From 7266ad99ae8a33b4e9d652be53772765948c6407 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:36:39 +0200 Subject: [PATCH] Restore nest coverage to >=80% after the #1209 dep bump (istanbul provider + branch tests) (#1213) * fix(server): set oxc:false in vitest so the SWC transform survives the Vite 8 bump * fix(server): switch coverage to the istanbul provider (v8 under-reports branches on Vite 8 + Vitest 4) * test(nest): cover controller/service branches to clear the 80% coverage gate --- package-lock.json | 258 ++---- server/package.json | 21 +- .../tests/unit/nest/addons.controller.test.ts | 26 + server/tests/unit/nest/addons.service.test.ts | 232 ++++++ .../tests/unit/nest/admin.controller.test.ts | 128 +++ server/tests/unit/nest/auth-guard.test.ts | 262 +++++- .../tests/unit/nest/auth.controller.test.ts | 161 ++++ .../tests/unit/nest/backup.controller.test.ts | 96 ++- .../tests/unit/nest/budget.controller.test.ts | 98 ++- server/tests/unit/nest/budget.service.test.ts | 165 ++++ .../tests/unit/nest/days.controller.test.ts | 58 ++ .../tests/unit/nest/exception-filter.test.ts | 86 ++ .../tests/unit/nest/files.controller.test.ts | 72 ++ server/tests/unit/nest/files.service.test.ts | 134 ++++ .../tests/unit/nest/health.controller.test.ts | 65 ++ .../unit/nest/idempotency.interceptor.test.ts | 50 ++ .../unit/nest/journey.controller.test.ts | 148 ++++ .../tests/unit/nest/maps.controller.test.ts | 131 ++- server/tests/unit/nest/maps.service.test.ts | 131 +++ .../unit/nest/memories.controller.test.ts | 748 ++++++++++++++++++ .../tests/unit/nest/memories.service.test.ts | 195 +++++ .../tests/unit/nest/oauth.controller.test.ts | 208 +++++ server/tests/unit/nest/oauth.service.test.ts | 172 ++++ .../tests/unit/nest/oidc.controller.test.ts | 192 +++++ server/tests/unit/nest/oidc.service.test.ts | 158 ++++ .../unit/nest/packing.controller.test.ts | 172 +++- .../tests/unit/nest/packing.service.test.ts | 32 +- .../tests/unit/nest/places.controller.test.ts | 84 +- .../unit/nest/platform.controller.test.ts | 513 ++++++++++++ .../tests/unit/nest/share.controller.test.ts | 65 ++ server/tests/unit/nest/share.service.test.ts | 76 ++ .../tests/unit/nest/trips.controller.test.ts | 122 ++- server/tests/unit/nest/trips.service.test.ts | 12 + server/tests/unit/nest/zod-pipe.test.ts | 27 + server/vitest.config.ts | 6 +- 35 files changed, 4897 insertions(+), 207 deletions(-) create mode 100644 server/tests/unit/nest/addons.controller.test.ts create mode 100644 server/tests/unit/nest/addons.service.test.ts create mode 100644 server/tests/unit/nest/budget.service.test.ts create mode 100644 server/tests/unit/nest/files.service.test.ts create mode 100644 server/tests/unit/nest/health.controller.test.ts create mode 100644 server/tests/unit/nest/maps.service.test.ts create mode 100644 server/tests/unit/nest/memories.controller.test.ts create mode 100644 server/tests/unit/nest/memories.service.test.ts create mode 100644 server/tests/unit/nest/oauth.service.test.ts create mode 100644 server/tests/unit/nest/oidc.service.test.ts create mode 100644 server/tests/unit/nest/platform.controller.test.ts create mode 100644 server/tests/unit/nest/share.service.test.ts diff --git a/package-lock.json b/package-lock.json index 66ef48e0..fd41c463 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2475,9 +2475,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2495,9 +2492,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2515,9 +2509,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2535,9 +2526,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2554,9 +2542,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2573,9 +2558,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2593,9 +2575,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2619,9 +2598,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2645,9 +2621,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2671,9 +2644,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2696,9 +2666,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2721,9 +2688,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2896,6 +2860,16 @@ "node": ">=18" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jimp/core": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz", @@ -5050,9 +5024,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5070,9 +5041,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5090,9 +5058,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5110,9 +5075,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5130,9 +5092,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5150,9 +5109,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5402,7 +5358,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.62.0", @@ -5416,7 +5373,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.62.0", @@ -5430,7 +5388,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.62.0", @@ -5444,7 +5403,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.62.0", @@ -5458,7 +5418,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.62.0", @@ -5472,7 +5433,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.62.0", @@ -5482,14 +5444,12 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.62.0", @@ -5499,14 +5459,12 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.62.0", @@ -5516,14 +5474,12 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.62.0", @@ -5532,9 +5488,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5549,14 +5502,12 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.62.0", @@ -5566,14 +5517,12 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.62.0", @@ -5583,14 +5532,12 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.62.0", @@ -5600,14 +5547,12 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.62.0", @@ -5617,14 +5562,12 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.62.0", @@ -5634,14 +5577,12 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.62.0", @@ -5651,14 +5592,12 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.62.0", @@ -5668,14 +5607,12 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.62.0", @@ -5684,9 +5621,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5705,7 +5639,8 @@ "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.62.0", @@ -5719,7 +5654,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.62.0", @@ -5733,7 +5669,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.62.0", @@ -5747,7 +5684,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.62.0", @@ -5761,7 +5699,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.62.0", @@ -5775,7 +5714,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@simplewebauthn/browser": { "version": "13.3.0", @@ -5909,9 +5849,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5929,9 +5866,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5949,9 +5883,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5969,9 +5900,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5989,9 +5917,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6009,9 +5934,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7088,6 +7010,31 @@ } } }, + "node_modules/@vitest/coverage-istanbul": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.9.tgz", + "integrity": "sha512-4a7DsIwycTf4eYwEDtnMfMV8H80KSKH9PuMHhqL5SwPZzDyUKq2X/TPCVZ7NqIuSz7UbZckmEmkip6iZBI/gEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@istanbuljs/schema": "^0.1.3", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/trace-mapping": "0.3.31", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.9" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", @@ -12558,9 +12505,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12582,9 +12526,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12606,9 +12547,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12630,9 +12568,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -16923,9 +16858,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -16943,9 +16875,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -16963,9 +16892,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -16989,9 +16915,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -20432,6 +20355,7 @@ "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", + "@vitest/coverage-istanbul": "^4.1.9", "@vitest/coverage-v8": "^4.1.9", "eslint": "^9.18.0", "eslint-config-flat-gitignore": "^2.3.0", @@ -20886,9 +20810,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20906,9 +20827,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -20926,9 +20844,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20946,9 +20861,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20966,9 +20878,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20986,9 +20895,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/server/package.json b/server/package.json index a809101e..b2f19e67 100644 --- a/server/package.json +++ b/server/package.json @@ -21,13 +21,12 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { - "@trek/shared": "*", - "tsconfig-paths": "^4.2.0", "@modelcontextprotocol/sdk": "^1.28.0", "@nestjs/common": "^11.1.24", "@nestjs/core": "^11.1.24", "@nestjs/platform-express": "^11.1.24", "@simplewebauthn/server": "^13.1.2", + "@trek/shared": "*", "archiver": "^6.0.1", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.8.0", @@ -47,6 +46,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "semver": "^7.7.4", + "tsconfig-paths": "^4.2.0", "tsx": "^4.21.0", "typescript": "^6.0.2", "undici": "^7.0.0", @@ -67,16 +67,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "prettier": "^3.8.3", - "prettier-plugin-organize-imports": "^4.3.0", - "eslint": "^9.18.0", - "eslint-config-flat-gitignore": "^2.3.0", - "typescript-eslint": "^8.58.2", "@nestjs/testing": "^11.1.24", "@swc/core": "^1.15.40", + "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", @@ -94,9 +87,17 @@ "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", + "@vitest/coverage-istanbul": "^4.1.9", "@vitest/coverage-v8": "^4.1.9", + "eslint": "^9.18.0", + "eslint-config-flat-gitignore": "^2.3.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", "nodemon": "^3.1.0", + "prettier": "^3.8.3", + "prettier-plugin-organize-imports": "^4.3.0", "supertest": "^7.2.2", + "typescript-eslint": "^8.58.2", "tz-lookup": "^6.1.25", "unplugin-swc": "^1.5.9", "vitest": "^4.1.9" diff --git a/server/tests/unit/nest/addons.controller.test.ts b/server/tests/unit/nest/addons.controller.test.ts new file mode 100644 index 00000000..6fa34ae2 --- /dev/null +++ b/server/tests/unit/nest/addons.controller.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from 'vitest'; +import { AddonsController } from '../../../src/nest/addons/addons.controller'; +import type { AddonsService } from '../../../src/nest/addons/addons.service'; + +function makeService(overrides: Partial = {}): AddonsService { + return { + list: vi.fn().mockReturnValue({ collabFeatures: {}, bagTracking: false, addons: [] }), + ...overrides, + } as unknown as AddonsService; +} + +describe('AddonsController (parity with the legacy GET /api/addons route)', () => { + it('GET / delegates straight to the service and returns its feed', () => { + const feed = { + collabFeatures: { comments: true }, + bagTracking: true, + addons: [{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }], + }; + const list = vi.fn().mockReturnValue(feed); + const svc = makeService({ list } as Partial); + + expect(new AddonsController(svc).list()).toBe(feed); + expect(list).toHaveBeenCalledTimes(1); + expect(list).toHaveBeenCalledWith(); + }); +}); diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts new file mode 100644 index 00000000..8e6b107a --- /dev/null +++ b/server/tests/unit/nest/addons.service.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Three distinct prepare(...).all() reads (addons, photo_providers, photo_provider_fields). +// A single shared statement is reused, so .all() is fed result sets in call order. +const { dbMock } = vi.hoisted(() => { + const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() }; + return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } }; +}); +vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} })); + +const { getBagTracking, getCollabFeatures } = vi.hoisted(() => ({ + getBagTracking: vi.fn(() => ({ enabled: false })), + getCollabFeatures: vi.fn(() => ({})), +})); +vi.mock('../../../src/services/adminService', () => ({ getBagTracking, getCollabFeatures })); + +const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({})) })); +vi.mock('../../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig })); + +import { AddonsService } from '../../../src/nest/addons/addons.service'; + +function svc() { + return new AddonsService(); +} + +// Feed the three reads in order: addons, providers, fields. +function feedReads(addons: unknown[], providers: unknown[], fields: unknown[]) { + dbMock._stmt.all + .mockReturnValueOnce(addons) + .mockReturnValueOnce(providers) + .mockReturnValueOnce(fields); +} + +beforeEach(() => { + vi.clearAllMocks(); + dbMock._stmt.all.mockReturnValue([]); + getCollabFeatures.mockReturnValue({}); + getBagTracking.mockReturnValue({ enabled: false }); + getPhotoProviderConfig.mockReturnValue({}); +}); + +describe('AddonsService.list', () => { + it('returns the collab features and the bag-tracking flag from the admin service', () => { + getCollabFeatures.mockReturnValue({ comments: true }); + getBagTracking.mockReturnValue({ enabled: true }); + feedReads([], [], []); + + const res = svc().list(); + expect(res.collabFeatures).toEqual({ comments: true }); + expect(res.bagTracking).toBe(true); + expect(res.addons).toEqual([]); + }); + + it('coerces the addon enabled column to a boolean (both 1 and 0)', () => { + feedReads( + [ + { id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }, + { id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0 }, + ], + [], + [], + ); + + const res = svc().list(); + expect(res.addons).toEqual([ + { id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }, + { id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: false }, + ]); + }); + + it('maps a photo provider with no fields to an empty fields array (the || [] fallback)', () => { + feedReads( + [], + [{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }], + [], + ); + getPhotoProviderConfig.mockReturnValue({ baseUrl: 'http://x' }); + + const res = svc().list(); + expect(res.addons).toEqual([ + { + id: 'immich', + name: 'Immich', + type: 'photo_provider', + icon: 'image', + enabled: true, + config: { baseUrl: 'http://x' }, + fields: [], + }, + ]); + expect(getPhotoProviderConfig).toHaveBeenCalledWith('immich'); + }); + + it('coerces a disabled photo provider enabled flag to false', () => { + feedReads( + [], + [{ id: 'synology', name: 'Synology', icon: 'image', enabled: 0, sort_order: 1 }], + [], + ); + + const res = svc().list(); + expect((res.addons[0] as { enabled: boolean }).enabled).toBe(false); + }); + + it('groups multiple fields under their provider and keeps insertion order', () => { + feedReads( + [], + [{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }], + [ + { + provider_id: 'immich', + field_key: 'url', + label: 'URL', + input_type: 'text', + placeholder: 'https://', + hint: 'Base URL', + required: 1, + secret: 0, + settings_key: 'immich_url', + payload_key: 'url', + sort_order: 0, + }, + // Second field for the SAME provider exercises the `get(...) || []` truthy branch. + { + provider_id: 'immich', + field_key: 'token', + label: 'Token', + input_type: 'password', + placeholder: null, + hint: null, + required: 0, + secret: 1, + settings_key: null, + payload_key: null, + sort_order: 1, + }, + ], + ); + + const res = svc().list(); + const provider = res.addons[0] as { fields: Array> }; + expect(provider.fields).toEqual([ + { + key: 'url', + label: 'URL', + input_type: 'text', + placeholder: 'https://', + hint: 'Base URL', + required: true, + secret: false, + settings_key: 'immich_url', + payload_key: 'url', + sort_order: 0, + }, + { + key: 'token', + label: 'Token', + input_type: 'password', + placeholder: '', + hint: null, + required: false, + secret: true, + settings_key: null, + payload_key: null, + sort_order: 1, + }, + ]); + }); + + it('falls back placeholder→"", hint→null, settings/payload keys→null when columns are missing/empty', () => { + feedReads( + [], + [{ id: 'p', name: 'P', icon: 'i', enabled: 1, sort_order: 0 }], + [ + { + provider_id: 'p', + field_key: 'k', + label: 'L', + input_type: 'text', + // placeholder/hint/settings_key/payload_key omitted entirely (undefined) + required: 0, + secret: 0, + sort_order: 0, + }, + ], + ); + + const res = svc().list(); + const field = (res.addons[0] as { fields: Array> }).fields[0]; + expect(field).toMatchObject({ + placeholder: '', + hint: null, + settings_key: null, + payload_key: null, + }); + }); + + it('keeps fields belonging to other providers out of a provider with none of its own', () => { + // A field exists, but for a DIFFERENT provider than the one returned — exercises + // the `fieldsByProvider.get(p.id) || []` fallback while the map is non-empty. + feedReads( + [], + [{ id: 'has-none', name: 'X', icon: 'i', enabled: 1, sort_order: 0 }], + [ + { + provider_id: 'other', + field_key: 'k', + label: 'L', + input_type: 'text', + required: 0, + secret: 0, + sort_order: 0, + }, + ], + ); + + const res = svc().list(); + expect((res.addons[0] as { fields: unknown[] }).fields).toEqual([]); + }); + + it('concatenates regular addons before the photo providers', () => { + feedReads( + [{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }], + [{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }], + [], + ); + + const res = svc().list(); + expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['atlas', 'immich']); + expect((res.addons[1] as { type: string }).type).toBe('photo_provider'); + }); +}); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index 69c38337..2296c785 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -8,6 +8,7 @@ vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mock import { AdminController } from '../../../src/nest/admin/admin.controller'; import type { AdminService } from '../../../src/nest/admin/admin.service'; import { writeAudit } from '../../../src/services/auditLog'; +import { send as sendNotification } from '../../../src/services/notificationService'; import type { User } from '../../../src/types'; const user = { id: 1, role: 'admin', email: 'admin@example.test' } as User; @@ -121,6 +122,114 @@ describe('AdminController addons + sessions + jwt + defaults', () => { }); }); +describe('AdminController error envelope fallbacks', () => { + it('ok() defaults to 400 when the error envelope omits a status', () => { + expect(thrown(() => new AdminController(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } }); + }); + + it('updateOidc defaults to 400 when the service error omits a status', () => { + expect(thrown(() => new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'nope' }) } as Partial)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'nope' } }); + }); + + it('updateOidc audits issuer_set=false when no issuer is supplied', () => { + expect(new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial)).updateOidc(user, {}, req)).toEqual({ success: true }); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.oidc_update', details: { issuer_set: false } })); + }); +}); + +describe('AdminController read-only getters', () => { + it('return service values verbatim', () => { + expect(new AdminController(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 }); + expect(new AdminController(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial)).stats()).toEqual({ users: 3 }); + expect(new AdminController(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial)).permissions()).toEqual({ a: 1 }); + expect(new AdminController(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial)).auditLog({})).toEqual({ entries: [] }); + expect(new AdminController(svc({ getOidcSettings: vi.fn().mockReturnValue({ issuer: 'x' }) } as Partial)).getOidc()).toEqual({ issuer: 'x' }); + expect(new AdminController(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial)).versionCheck()).resolves.toEqual({ current: '1' }); + expect(new AdminController(svc({ getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [] }) } as Partial)).getNotificationPrefs(user)).toEqual({ rows: [] }); + expect(new AdminController(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listInvites()).toEqual({ invites: [{ id: 1 }] }); + expect(new AdminController(svc({ getBagTracking: vi.fn().mockReturnValue({ enabled: false }) } as Partial)).getBagTracking()).toEqual({ enabled: false }); + expect(new AdminController(svc({ getPlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesPhotos()).toEqual({ enabled: true }); + expect(new AdminController(svc({ getPlacesAutocomplete: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesAutocomplete()).toEqual({ enabled: true }); + expect(new AdminController(svc({ getPlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesDetails()).toEqual({ enabled: true }); + expect(new AdminController(svc({ getCollabFeatures: vi.fn().mockReturnValue({ chat: false }) } as Partial)).getCollabFeatures()).toEqual({ chat: false }); + expect(new AdminController(svc({ listPackingTemplates: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listPackingTemplates()).toEqual({ templates: [{ id: 1 }] }); + expect(new AdminController(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] }); + expect(new AdminController(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); + expect(new AdminController(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] }); + expect(new AdminController(svc({ getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial)).getDefaultUserSettings()).toEqual({ theme: 'dark' }); + }); + + it('setNotificationPrefs persists then returns the refreshed matrix', () => { + const setAdminPreferences = vi.fn(); + const c = new AdminController(svc({ setAdminPreferences, getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [1] }) } as Partial)); + expect(c.setNotificationPrefs(user, { x: 1 })).toEqual({ rows: [1] }); + expect(setAdminPreferences).toHaveBeenCalledWith(user.id, { x: 1 }); + }); + + it('githubReleases falls back to default paging when no query is given', async () => { + const getGithubReleases = vi.fn().mockResolvedValue([{ tag: 'v1' }]); + const c = new AdminController(svc({ getGithubReleases } as Partial)); + await expect(c.githubReleases()).resolves.toEqual([{ tag: 'v1' }]); + expect(getGithubReleases).toHaveBeenCalledWith('10', '1'); + await c.githubReleases('5', '2'); + expect(getGithubReleases).toHaveBeenLastCalledWith('5', '2'); + }); +}); + +describe('AdminController feature toggles + audit', () => { + it('bag-tracking updates and audits', () => { + const c = new AdminController(svc({ updateBagTracking: vi.fn().mockReturnValue({ enabled: true }) } as Partial)); + expect(c.updateBagTracking(user, { enabled: true }, req)).toEqual({ enabled: true }); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.bag_tracking' })); + }); + + it('places-autocomplete: 400 on a non-boolean, else updates + audits', () => { + expect(thrown(() => new AdminController(svc()).updatePlacesAutocomplete(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } }); + expect(new AdminController(svc({ updatePlacesAutocomplete: vi.fn().mockReturnValue({ enabled: false }) } as Partial)).updatePlacesAutocomplete(user, { enabled: false }, req)).toEqual({ enabled: false }); + }); + + it('places-details: 400 on a non-boolean, else updates + audits', () => { + expect(thrown(() => new AdminController(svc()).updatePlacesDetails(user, { enabled: 1 }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } }); + expect(new AdminController(svc({ updatePlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).updatePlacesDetails(user, { enabled: true }, req)).toEqual({ enabled: true }); + }); +}); + +describe('AdminController packing template sub-routes', () => { + it('update/delete templates, categories and items map errors + return success', () => { + expect(new AdminController(svc({ updatePackingTemplate: vi.fn().mockReturnValue({ id: 3 }) } as Partial)).updatePackingTemplate('3', {})).toEqual({ id: 3 }); + expect(new AdminController(svc({ createTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).createTemplateCategory('3', { name: 'Tops' })).toEqual({ id: 4 }); + expect(new AdminController(svc({ updateTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).updateTemplateCategory('3', '4', {})).toEqual({ id: 4 }); + expect(new AdminController(svc({ deleteTemplateCategory: vi.fn().mockReturnValue({}) } as Partial)).deleteTemplateCategory('3', '4')).toEqual({ success: true }); + expect(new AdminController(svc({ updateTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial)).updateTemplateItem('7', {})).toEqual({ id: 7 }); + expect(new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({}) } as Partial)).deleteTemplateItem('7')).toEqual({ success: true }); + expect(thrown(() => new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({ error: 'gone', status: 404 }) } as Partial)).deleteTemplateItem('9'))).toEqual({ status: 404, body: { error: 'gone' } }); + }); +}); + +describe('AdminController tokens + sessions', () => { + it('mcp token + oauth session deletes return success and map errors', () => { + expect(new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial)).deleteMcpToken('2')).toEqual({ success: true }); + expect(thrown(() => new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial)).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } }); + expect(thrown(() => new AdminController(svc({ revokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) } as Partial)).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } }); + }); +}); + +describe('AdminController default-user-settings error path', () => { + it('400 with an Error message when setAdminUserDefaults throws an Error', () => { + const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw new Error('bad default'); }) } as Partial)); + expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'bad default' } }); + }); + + it('400 stringifies a non-Error throw', () => { + const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw 'plain string'; }) } as Partial)); + expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'plain string' } }); + }); + + it('400 when the body is null', () => { + expect(thrown(() => new AdminController(svc()).setDefaultUserSettings(user, null, req))).toEqual({ status: 400, body: { error: 'Object body required' } }); + }); +}); + describe('AdminController dev test-notification', () => { it('404 outside development', async () => { delete process.env.NODE_ENV; @@ -132,4 +241,23 @@ describe('AdminController dev test-notification', () => { const res = await new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' }); expect(res).toEqual({ success: true }); }); + + it('applies notification defaults when the body is empty', async () => { + process.env.NODE_ENV = 'development'; + const res = await new AdminController(svc()).devTestNotification(user, {}); + expect(res).toEqual({ success: true }); + expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ event: 'trip_reminder', scope: 'user', targetId: user.id })); + }); + + it('maps an Error from the notification service to 400', async () => { + process.env.NODE_ENV = 'development'; + (sendNotification as unknown as ReturnType).mockRejectedValueOnce(new Error('send failed')); + await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'send failed' } }); + }); + + it('stringifies a non-Error notification failure to 400', async () => { + process.env.NODE_ENV = 'development'; + (sendNotification as unknown as ReturnType).mockRejectedValueOnce('weird'); + await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'weird' } }); + }); }); diff --git a/server/tests/unit/nest/auth-guard.test.ts b/server/tests/unit/nest/auth-guard.test.ts index 2a95aef8..0f5223d4 100644 --- a/server/tests/unit/nest/auth-guard.test.ts +++ b/server/tests/unit/nest/auth-guard.test.ts @@ -1,26 +1,264 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HttpException } from '@nestjs/common'; +import type { Request } from 'express'; + +vi.mock('../../../src/middleware/auth', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() })); +vi.mock('../../../src/services/authService', () => ({ resolveAuthToggles: vi.fn() })); +vi.mock('../../../src/services/cookie', () => ({ setAuthCookie: vi.fn() })); +vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/services/passkeyService', () => ({ + passkeyRegisterOptions: vi.fn(), + passkeyRegisterVerify: vi.fn(), + passkeyLoginOptions: vi.fn(), + passkeyLoginVerify: vi.fn(), + listPasskeys: vi.fn(), + renamePasskey: vi.fn(), + deletePasskey: vi.fn(), +})); + import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard'; +import { CookieAuthGuard } from '../../../src/nest/auth/cookie-auth.guard'; +import { OptionalJwtGuard } from '../../../src/nest/auth/optional-jwt.guard'; +import { AdminGuard } from '../../../src/nest/auth/admin.guard'; +import { PasskeyEnabledGuard } from '../../../src/nest/auth/passkey-enabled.guard'; +import { PasskeyController } from '../../../src/nest/auth/passkey.controller'; +import { RateLimitService } from '../../../src/nest/auth/rate-limit.service'; +import { CurrentUser } from '../../../src/nest/auth/current-user.decorator'; +import { extractToken, verifyJwtAndLoadUser } from '../../../src/middleware/auth'; +import { resolveAuthToggles } from '../../../src/services/authService'; +import { setAuthCookie } from '../../../src/services/cookie'; +import { writeAudit } from '../../../src/services/auditLog'; +import * as passkey from '../../../src/services/passkeyService'; +import type { User } from '../../../src/types'; + +const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User; function context(req: unknown) { return { switchToHttp: () => ({ getRequest: () => req }) } as never; } +function thrown(fn: () => unknown): { status: number; body: unknown } { + try { fn(); } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected throw'); +} +async function thrownAsync(fn: () => Promise): Promise<{ status: number; body: unknown }> { + try { await fn(); } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected throw'); +} + +beforeEach(() => vi.clearAllMocks()); describe('JwtAuthGuard', () => { const guard = new JwtAuthGuard(); it('rejects with the legacy 401 { error, code } when no token is present', () => { - let thrown: unknown; - try { - guard.canActivate(context({ headers: {}, cookies: {} })); - } catch (e) { - thrown = e; - } - expect(thrown).toBeInstanceOf(HttpException); - expect((thrown as HttpException).getStatus()).toBe(401); - expect((thrown as HttpException).getResponse()).toEqual({ - error: 'Access token required', - code: 'AUTH_REQUIRED', + vi.mocked(extractToken).mockReturnValue(null); + expect(thrown(() => guard.canActivate(context({ headers: {}, cookies: {} })))).toEqual({ + status: 401, + body: { error: 'Access token required', code: 'AUTH_REQUIRED' }, }); }); + + it('rejects an invalid/expired token (verify returns null)', () => { + vi.mocked(extractToken).mockReturnValue('tok'); + vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null); + expect(thrown(() => guard.canActivate(context({ headers: {} })))).toEqual({ + status: 401, + body: { error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, + }); + }); + + it('attaches the loaded user and allows a valid token through', () => { + const req: Record = { headers: {} }; + vi.mocked(extractToken).mockReturnValue('tok'); + vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user); + expect(guard.canActivate(context(req))).toBe(true); + expect(req.user).toBe(user); + }); +}); + +describe('CookieAuthGuard', () => { + const guard = new CookieAuthGuard(); + + it('401s when the trek_session cookie is missing', () => { + expect(thrown(() => guard.canActivate(context({ cookies: {} })))).toEqual({ + status: 401, + body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, + }); + // and when there is no cookies object at all + expect(thrown(() => guard.canActivate(context({})))).toEqual({ + status: 401, + body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, + }); + }); + + it('401s when the cookie token fails verification', () => { + vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null); + expect(thrown(() => guard.canActivate(context({ cookies: { trek_session: 'tok' } })))).toEqual({ + status: 401, + body: { error: 'Invalid or expired session', code: 'AUTH_REQUIRED' }, + }); + }); + + it('attaches the user and allows a valid cookie session through', () => { + const req: Record = { cookies: { trek_session: 'tok' } }; + vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user); + expect(guard.canActivate(context(req))).toBe(true); + expect(req.user).toBe(user); + }); +}); + +describe('OptionalJwtGuard', () => { + const guard = new OptionalJwtGuard(); + + it('always allows; sets req.user to null when no token', () => { + const req: Record = { headers: {} }; + vi.mocked(extractToken).mockReturnValue(null); + expect(guard.canActivate(context(req))).toBe(true); + expect(req.user).toBeNull(); + expect(verifyJwtAndLoadUser).not.toHaveBeenCalled(); + }); + + it('sets req.user to null when a token verifies to nothing', () => { + const req: Record = { headers: {} }; + vi.mocked(extractToken).mockReturnValue('tok'); + vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null); + expect(guard.canActivate(context(req))).toBe(true); + expect(req.user).toBeNull(); + }); + + it('populates req.user from a valid token', () => { + const req: Record = { headers: {} }; + vi.mocked(extractToken).mockReturnValue('tok'); + vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user); + expect(guard.canActivate(context(req))).toBe(true); + expect(req.user).toBe(user); + }); +}); + +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' } }); + }); + + it('allows an admin through', () => { + expect(guard.canActivate(context({ user: { role: 'admin' } }))).toBe(true); + }); +}); + +describe('PasskeyEnabledGuard', () => { + const guard = new PasskeyEnabledGuard(); + + it('404s when passkey_login is off', () => { + vi.mocked(resolveAuthToggles).mockReturnValue({ passkey_login: false } as ReturnType); + expect(thrown(() => guard.canActivate())).toEqual({ status: 404, body: { error: 'Passkey login is not enabled' } }); + }); + + it('allows when passkey_login is on', () => { + vi.mocked(resolveAuthToggles).mockReturnValue({ passkey_login: true } as ReturnType); + expect(guard.canActivate()).toBe(true); + }); +}); + +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) {} } + (CurrentUser() as ParameterDecorator)(Target.prototype, 'handler', 0); + const meta = Reflect.getMetadata('__routeArguments__', Target, 'handler') as Record User | undefined }>; + return Object.values(meta)[0].factory; + } + + it('resolves the authenticated user from the request', () => { + expect(paramFactory()(undefined, context({ user }))).toBe(user); + }); + + it('returns undefined when no user is attached', () => { + expect(paramFactory()(undefined, context({}))).toBeUndefined(); + }); +}); + +describe('PasskeyController', () => { + const req = { ip: '9.9.9.9' } as Request; + const res = {} as never; + function rl(): RateLimitService { return new RateLimitService(); } + + it('register/options maps a service error, else returns the options', async () => { + vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ error: 'Incorrect password', status: 401 }); + expect(await thrownAsync(() => new PasskeyController(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); + vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ options: { challenge: 'c' } as never }); + expect(await new PasskeyController(rl()).registerOptions(user, { password: 'p' }, req)).toEqual({ challenge: 'c' }); + }); + + it('register/verify maps a service error, else audits and returns the credential', async () => { + vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ error: 'Verification failed', status: 400 } as never); + expect(await thrownAsync(() => new PasskeyController(rl()).registerVerify(user, {}, req))).toEqual({ status: 400, body: { error: 'Verification failed' } }); + vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ credential: { id: 'cr' } } as never); + expect(await new PasskeyController(rl()).registerVerify(user, {}, req)).toEqual({ success: true, credential: { id: 'cr' } }); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_register' })); + }); + + it('login/options maps a service error, else returns the options', async () => { + vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ error: 'Not configured', status: 503 } as never); + expect(await thrownAsync(() => new PasskeyController(rl()).loginOptions(req))).toEqual({ status: 503, body: { error: 'Not configured' } }); + vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'd' } } as never); + expect(await new PasskeyController(rl()).loginOptions(req)).toEqual({ challenge: 'd' }); + }); + + it('login/verify audits a failure then maps the error, padding latency', async () => { + vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ error: 'No match', status: 401, auditAction: 'user.login_fail', auditUserId: null } as never); + expect(await thrownAsync(() => new PasskeyController(rl()).loginVerify({}, req, res))).toEqual({ status: 401, body: { error: 'No match' } }); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_fail' })); + }, 10000); + + it('login/verify sets the session cookie and audits login on success', async () => { + vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ token: 'tk', user, auditUserId: 1 } as never); + expect(await new PasskeyController(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' } })); + }, 10000); + + it('credentials: list, rename (error + success), delete (error + success)', () => { + vi.mocked(passkey.listPasskeys).mockReturnValue([{ id: 'a' }]); + expect(new PasskeyController(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] }); + + vi.mocked(passkey.renamePasskey).mockReturnValue({ error: 'Not found', status: 404 }); + expect(thrown(() => new PasskeyController(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } }); + vi.mocked(passkey.renamePasskey).mockReturnValue({ success: true }); + expect(new PasskeyController(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true }); + + vi.mocked(passkey.deletePasskey).mockReturnValue({ error: 'Incorrect password', status: 401 }); + expect(thrown(() => new PasskeyController(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); + vi.mocked(passkey.deletePasskey).mockReturnValue({ success: true }); + expect(new PasskeyController(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true }); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_delete' })); + }); + + it('throttles registration and login ceremonies once the bucket is exhausted', async () => { + const s = new RateLimitService(); + const now = Date.now(); + for (let i = 0; i < 5; i++) s.check('mfa', '9.9.9.9', 5, 15 * 60 * 1000, now); + expect(await thrownAsync(() => new PasskeyController(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++) s2.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now); + expect(await thrownAsync(() => new PasskeyController(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 () => { + vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'z' } } as never); + const noIp = {} as Request; + expect(await new PasskeyController(rl()).loginOptions(noIp)).toEqual({ challenge: 'z' }); + }); }); diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index 84ad7923..faef3ee5 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -51,6 +51,18 @@ describe('RateLimitService', () => { expect(s.check('mfa', 'ip', 2, 1000, 20)).toBe(true); // different bucket expect(s.check('login', 'ip', 2, 1000, 2000)).toBe(true); // window elapsed -> reset }); + + it('reset clears a single named bucket, and reset() clears all of them', () => { + const s = rl(); + s.check('login', 'ip', 1, 1000, 0); // login bucket now at its cap + s.check('mfa', 'ip', 1, 1000, 0); // mfa bucket now at its cap + expect(s.check('login', 'ip', 1, 1000, 0)).toBe(false); + s.reset('login'); // only the login bucket + expect(s.check('login', 'ip', 1, 1000, 0)).toBe(true); + expect(s.check('mfa', 'ip', 1, 1000, 0)).toBe(false); // mfa untouched + s.reset(); // everything + expect(s.check('mfa', 'ip', 1, 1000, 0)).toBe(true); + }); }); describe('AuthPublicController', () => { @@ -104,6 +116,71 @@ describe('AuthPublicController', () => { expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial), rl()).resetPassword({}, req)).toEqual({ success: true }); }); + it('app-config forwards the optional user (present and absent)', () => { + const getAppConfig = vi.fn().mockReturnValue({ version: '3' }); + const c = new AuthPublicController(asvc({ getAppConfig } as Partial), rl()); + expect(c.appConfig({ user } as unknown as Request)).toEqual({ version: '3' }); + expect(getAppConfig).toHaveBeenLastCalledWith(user); + expect(c.appConfig({} as Request)).toEqual({ version: '3' }); + expect(getAppConfig).toHaveBeenLastCalledWith(undefined); + }); + + it('invite maps a service error', () => { + const c = new AuthPublicController(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial), rl()); + expect(thrown(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } }); + }); + + it('login takes the mfa-required branch and never sets a cookie', async () => { + const setAuthCookie = vi.fn(); + const c = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt', auditAction: 'user.login_mfa' }), setAuthCookie } as Partial), rl()); + expect(await c.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' }); + expect(setAuthCookie).not.toHaveBeenCalled(); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_mfa' })); + }, 10000); + + it('forgot-password: non-issued reason and a delivery failure both still return ok', async () => { + // Non-issued (unknown email / throttled): audits the reason, no email sent. + const sendNever = vi.fn(); + const skip = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'not_found', userId: null }), sendPasswordResetEmail: sendNever } as Partial), rl()); + expect(await skip.forgotPassword({ email: 'x@y.z' }, req)).toEqual({ ok: true }); + expect(sendNever).not.toHaveBeenCalled(); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_request', details: { reason: 'not_found' } })); + // Issued but the mailer throws: swallowed, audited as failed, still ok. + const boom = vi.fn().mockRejectedValue(new Error('smtp')); + const fail = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail: boom } as Partial), rl()); + expect(await fail.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true }); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ details: { delivered: 'failed' } })); + }, 10000); + + it('forgot-password ignores a non-string email body', async () => { + const requestPasswordReset = vi.fn().mockReturnValue({ reason: 'not_found', userId: null }); + const c = new AuthPublicController(asvc({ requestPasswordReset } as Partial), rl()); + expect(await c.forgotPassword({ email: 42 } as { email?: unknown }, req)).toEqual({ ok: true }); + expect(requestPasswordReset).toHaveBeenCalledWith('', expect.any(String)); + }, 10000); + + it('reset-password 429 once the dedicated reset bucket is exhausted', () => { + const s = rl(); + const now = Date.now(); + for (let i = 0; i < 5; i++) s.check('reset', '9.9.9.9', 5, 15 * 60 * 1000, now); + const c = new AuthPublicController(asvc({ resetPassword: vi.fn() } as Partial), s); + expect(thrown(() => c.resetPassword({}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + }); + + it('mfa/verify-login maps a service error', () => { + const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial), rl()); + expect(thrown(() => c.verifyMfaLogin({}, req, res))).toEqual({ status: 401, body: { error: 'Bad code' } }); + }); + + it('demo-login + register + invite throw 429 when the login bucket is exhausted', () => { + const s = rl(); + const now = Date.now(); + for (let i = 0; i < 10; i++) s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now); + const c = new AuthPublicController(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial), s); + expect(thrown(() => c.register({}, req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(thrown(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + }); + it('mfa/verify-login sets cookie + audits; logout clears cookie', () => { const setAuthCookie = vi.fn(); const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial), rl()); @@ -167,4 +244,88 @@ describe('AuthController (authenticated)', () => { const c = new AuthController(asvc({ changePassword: vi.fn() } as Partial), s); expect(thrown(() => c.changePassword(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); + + it('change-password refreshes this device cookie when the service returns a token', () => { + const setAuthCookie = vi.fn(); + const c = new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial), rl()); + expect(c.changePassword(user, {}, req, res)).toEqual({ success: true }); + expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req); + }); + + it('delete-account maps error, else audits and succeeds', () => { + expect(thrown(() => new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } }); + expect(new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial), rl()).deleteAccount(user, req)).toEqual({ success: true }); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.account_delete' })); + }); + + it('maps-key + api-keys pass straight through to the service', () => { + const updateMapsKey = vi.fn().mockReturnValue({ success: true }); + expect(new AuthController(asvc({ updateMapsKey } as Partial), rl()).mapsKey(user, { maps_api_key: 'k' })).toEqual({ success: true }); + expect(updateMapsKey).toHaveBeenCalledWith(1, 'k'); + const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 }); + expect(new AuthController(asvc({ updateApiKeys } as Partial), rl()).apiKeys(user, { a: 1 })).toEqual({ ok: 1 }); + }); + + it('update-settings + get-settings map errors, else return their payloads', () => { + expect(thrown(() => new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) } as Partial), rl()).updateSettings(user, {}))).toEqual({ status: 400, body: { error: 'Bad' } }); + expect(new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) } as Partial), rl()).updateSettings(user, {})).toEqual({ success: true, user: { id: 1 } }); + expect(thrown(() => new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) } as Partial), rl()).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } }); + expect(new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) } as Partial), rl()).getSettings(user)).toEqual({ settings: { theme: 'dark' } }); + }); + + it('delete-avatar + users + travel-stats delegate to the service', async () => { + const deleteAvatar = vi.fn().mockResolvedValue({ removed: true }); + expect(await new AuthController(asvc({ deleteAvatar } as Partial), rl()).deleteAvatar(user)).toEqual({ removed: true }); + const listUsers = vi.fn().mockReturnValue([{ id: 1 }]); + expect(new AuthController(asvc({ listUsers } as Partial), rl()).users(user)).toEqual({ users: [{ id: 1 }] }); + expect(listUsers).toHaveBeenCalledWith(1); + const getTravelStats = vi.fn().mockReturnValue({ countries: 3 }); + expect(new AuthController(asvc({ getTravelStats } as Partial), rl()).travelStats(user)).toEqual({ countries: 3 }); + }); + + it('validate-keys maps error, else returns the maps/weather payload', async () => { + expect(await thrownAsync(() => new AuthController(asvc({ validateKeys: vi.fn().mockResolvedValue({ error: 'fail', status: 502 }) } as Partial), rl()).validateKeys(user))).toEqual({ status: 502, body: { error: 'fail' } }); + const ok = new AuthController(asvc({ validateKeys: vi.fn().mockResolvedValue({ maps: true, weather: false, maps_details: { ok: 1 } }) } as Partial), rl()); + expect(await ok.validateKeys(user)).toEqual({ maps: true, weather: false, maps_details: { ok: 1 } }); + }); + + it('app-settings get maps error, else returns data; put maps error, else audits', () => { + expect(thrown(() => new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } }); + expect(new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial), rl()).getAppSettings(user)).toEqual({ x: 1 }); + expect(thrown(() => new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } }); + expect(new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true }); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' })); + }); + + it('mfa/setup maps a service error before ever awaiting the QR promise', async () => { + const c = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ error: 'already on', status: 409 }) } as Partial), rl()); + expect(await thrownAsync(() => c.mfaSetup(user))).toEqual({ status: 409, body: { error: 'already on' } }); + }); + + it('mfa/enable + mfa/disable map errors', () => { + expect(thrown(() => new AuthController(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } }); + expect(thrown(() => new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial), rl()).mfaDisable(user, {}, req))).toEqual({ status: 401, body: { error: 'Wrong' } }); + const ok = new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial), rl()); + expect(ok.mfaDisable(user, {}, req)).toEqual({ success: true, mfa_enabled: false }); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' })); + }); + + it('mcp-tokens list + create error + delete error/success', () => { + expect(new AuthController(asvc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) } as Partial), rl()).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] }); + expect(thrown(() => new AuthController(asvc({ createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) } as Partial), rl()).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } }); + expect(thrown(() => new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) } as Partial), rl()).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } }); + expect(new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial), rl()).deleteMcpToken(user, 'tid')).toEqual({ success: true }); + }); + + it('ws-token maps error, else returns the token', () => { + expect(thrown(() => new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) } as Partial), rl()).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } }); + expect(new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) } as Partial), rl()).wsToken(user)).toEqual({ token: 'ws' }); + }); + + it('avatar saves when not in demo mode (env present but email is not a demo email)', async () => { + process.env.DEMO_MODE = 'true'; + vi.mocked(isDemoEmail).mockReturnValue(false); + const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/b.png' }); + expect(await new AuthController(asvc({ saveAvatar } as Partial), rl()).avatar(user, { filename: 'b.png' } as Express.Multer.File)).toEqual({ avatar: '/b.png' }); + }); }); diff --git a/server/tests/unit/nest/backup.controller.test.ts b/server/tests/unit/nest/backup.controller.test.ts index a21dcf03..2e6c4746 100644 --- a/server/tests/unit/nest/backup.controller.test.ts +++ b/server/tests/unit/nest/backup.controller.test.ts @@ -3,13 +3,31 @@ import { HttpException } from '@nestjs/common'; import type { Request, Response } from 'express'; vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') })); -// The controller imports the tmp-dir + size cap at module load. -vi.mock('../../../src/services/backupService', () => ({ getUploadTmpDir: () => '/tmp', MAX_BACKUP_UPLOAD_SIZE: 1024 })); +// The controller imports the tmp-dir + size cap at module load. The thin +// BackupService wrapper forwards every call straight into this module, so the +// mock also stubs the delegated functions for the wrapper tests below. +vi.mock('../../../src/services/backupService', () => ({ + getUploadTmpDir: () => '/tmp', + MAX_BACKUP_UPLOAD_SIZE: 1024, + BACKUP_RATE_WINDOW: 3600000, + listBackups: vi.fn().mockReturnValue([{ filename: 'svc.zip' }]), + createBackup: vi.fn().mockResolvedValue({ filename: 'svc.zip', size: 5 }), + restoreFromZip: vi.fn().mockResolvedValue({ success: true }), + getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: false }, timezone: 'UTC' }), + updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }), + deleteBackup: vi.fn(), + isValidBackupFilename: vi.fn().mockReturnValue(true), + backupFilePath: vi.fn().mockReturnValue('/data/backups/svc.zip'), + backupFileExists: vi.fn().mockReturnValue(true), + checkRateLimit: vi.fn().mockReturnValue(true), +})); import { BackupController } from '../../../src/nest/backup/backup.controller'; +import { BackupService as RealBackupService } from '../../../src/nest/backup/backup.service'; import { AdminGuard } from '../../../src/nest/auth/admin.guard'; import type { BackupService } from '../../../src/nest/backup/backup.service'; import { writeAudit } from '../../../src/services/auditLog'; +import * as backupSvc from '../../../src/services/backupService'; import type { User } from '../../../src/types'; const user = { id: 1, role: 'admin', email: 'a@example.test' } as User; @@ -86,12 +104,17 @@ describe('BackupController', () => { it('POST /restore maps the service status, else audits', async () => { expect(await thrownAsync(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).restore(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } }); + expect(await thrownAsync(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).restore(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } }); expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad zip' }) } as Partial)).restore(user, 'x.zip', req))).toEqual({ status: 422, body: { error: 'bad zip' } }); const res = await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial)).restore(user, 'x.zip', req); expect(res).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.restore', resource: 'x.zip' })); }); + it('POST /restore falls back to status 400 when the service omits one', async () => { + expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, error: 'nope' }) } as Partial)).restore(user, 'x.zip', req))).toEqual({ status: 400, body: { error: 'nope' } }); + }); + it('POST /upload-restore 400 without a file, cleans up the tmp file', async () => { expect(await thrownAsync(() => new BackupController(svc()).uploadRestore(user, undefined, req))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); }); @@ -108,6 +131,14 @@ describe('BackupController', () => { expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad' }) } as Partial)).uploadRestore(user, file, req))).toEqual({ status: 422, body: { error: 'bad' } }); }); + it('POST /upload-restore falls back to a default name and maps unexpected errors to 500', async () => { + const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File; + expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial)).uploadRestore(user, file, req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } }); + const ok = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File; + await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial)).uploadRestore(user, ok, req); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'upload.zip' })); + }); + it('maps unexpected service errors to 500 (create, restore, auto-settings)', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); expect(await thrownAsync(() => new BackupController(svc({ createBackup: vi.fn().mockRejectedValue(new Error('disk')) } as Partial)).create(user, req))).toEqual({ status: 500, body: { error: 'Error creating backup' } }); @@ -123,6 +154,20 @@ describe('BackupController', () => { expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: 'parse fail' }); }); + it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + process.env.NODE_ENV = 'production'; + const r = thrown(() => new BackupController(svc({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) } as Partial)).updateAutoSettings(user, {}, req)); + expect(r.status).toBe(500); + expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: undefined }); + }); + + it('PUT /auto-settings tolerates a missing body', () => { + const updateAutoSettings = vi.fn().mockReturnValue({ enabled: false, interval: 'weekly', keep_days: 30 }); + new BackupController(svc({ updateAutoSettings } as Partial)).updateAutoSettings(user, undefined as unknown as Record, req); + expect(updateAutoSettings).toHaveBeenCalledWith({}); + }); + it('GET/PUT /auto-settings', () => { expect(new BackupController(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' }); const res = new BackupController(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial)).updateAutoSettings(user, { enabled: true }, req); @@ -138,3 +183,50 @@ describe('BackupController', () => { expect(deleteBackup).toHaveBeenCalledWith('x.zip'); }); }); + +describe('BackupService (wrapper)', () => { + const wrapper = new RealBackupService(); + + it('forwards every call straight to the legacy backup service', async () => { + expect(wrapper.listBackups()).toEqual([{ filename: 'svc.zip' }]); + expect(backupSvc.listBackups).toHaveBeenCalled(); + + await expect(wrapper.createBackup()).resolves.toEqual({ filename: 'svc.zip', size: 5 }); + expect(backupSvc.createBackup).toHaveBeenCalled(); + + await expect(wrapper.restoreFromZip('/tmp/a.zip')).resolves.toEqual({ success: true }); + expect(backupSvc.restoreFromZip).toHaveBeenCalledWith('/tmp/a.zip'); + + expect(wrapper.getAutoSettings()).toEqual({ settings: { enabled: false }, timezone: 'UTC' }); + expect(backupSvc.getAutoSettings).toHaveBeenCalled(); + + expect(wrapper.updateAutoSettings({ enabled: true })).toEqual({ enabled: true, interval: 'daily', keep_days: 7 }); + expect(backupSvc.updateAutoSettings).toHaveBeenCalledWith({ enabled: true }); + + wrapper.deleteBackup('svc.zip'); + expect(backupSvc.deleteBackup).toHaveBeenCalledWith('svc.zip'); + + expect(wrapper.isValidBackupFilename('svc.zip')).toBe(true); + expect(backupSvc.isValidBackupFilename).toHaveBeenCalledWith('svc.zip'); + + expect(wrapper.backupFilePath('svc.zip')).toBe('/data/backups/svc.zip'); + expect(backupSvc.backupFilePath).toHaveBeenCalledWith('svc.zip'); + + expect(wrapper.backupFileExists('svc.zip')).toBe(true); + expect(backupSvc.backupFileExists).toHaveBeenCalledWith('svc.zip'); + + expect(wrapper.checkRateLimit('ip', 3, 1000)).toBe(true); + expect(backupSvc.checkRateLimit).toHaveBeenCalledWith('ip', 3, 1000); + }); + + it('exposes the legacy rate window', () => { + expect(wrapper.rateWindow).toBe(backupSvc.BACKUP_RATE_WINDOW); + }); +}); + +describe('BackupModule', () => { + it('wires the controller and service together', async () => { + const { BackupModule } = await import('../../../src/nest/backup/backup.module'); + expect(new BackupModule()).toBeInstanceOf(BackupModule); + }); +}); diff --git a/server/tests/unit/nest/budget.controller.test.ts b/server/tests/unit/nest/budget.controller.test.ts index 5a4639f6..ce2f6ad2 100644 --- a/server/tests/unit/nest/budget.controller.test.ts +++ b/server/tests/unit/nest/budget.controller.test.ts @@ -42,12 +42,75 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou }); it('GET /summary/per-person + /settlement delegate', () => { + const settlement = vi.fn().mockReturnValue({ transfers: [] }); const svc = makeService({ perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]), - settlement: vi.fn().mockReturnValue({ transfers: [] }), + settlement, } as Partial); expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] }); expect(new BudgetController(svc).settlement(user, '5')).toEqual({ transfers: [] }); + expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR'); + }); + + it('GET /settlement forwards the base query and the trip currency', () => { + const settlement = vi.fn().mockReturnValue({ transfers: [] }); + const svc = makeService({ + verifyTripAccess: vi.fn().mockReturnValue({ id: 5, user_id: 1, currency: 'USD' }), + settlement, + } as Partial); + new BudgetController(svc).settlement(user, '5', 'GBP'); + expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD'); + }); + + describe('settlements ledger', () => { + it('GET /settlements lists', () => { + const svc = makeService({ listSettlements: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); + expect(new BudgetController(svc).listSettlements(user, '5')).toEqual({ settlements: [{ id: 1 }] }); + }); + + it('POST /settlements 403 without budget_edit', () => { + const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) }); + expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({ + status: 403, body: { error: 'No permission' }, + }); + }); + + it('POST /settlements 400 when a field is missing', () => { + const svc = makeService(); + expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2 }))).toEqual({ + status: 400, body: { error: 'from_user_id, to_user_id and amount are required' }, + }); + expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, amount: 5 }))).toEqual({ + status: 400, body: { error: 'from_user_id, to_user_id and amount are required' }, + }); + expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { to_user_id: 2, amount: 5 }))).toEqual({ + status: 400, body: { error: 'from_user_id, to_user_id and amount are required' }, + }); + }); + + it('POST /settlements creates and broadcasts (amount 0 is allowed)', () => { + const createSettlement = vi.fn().mockReturnValue({ id: 3, amount: 0 }); + const broadcast = vi.fn(); + const svc = makeService({ createSettlement, broadcast } as Partial); + const res = new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 0 }, 'sock'); + expect(res).toEqual({ settlement: { id: 3, amount: 0 } }); + expect(createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 0 }, user.id); + expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock'); + }); + + it('DELETE /settlements/:id 404 when missing', () => { + const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(false) } as Partial); + expect(thrown(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({ + status: 404, body: { error: 'Settlement not found' }, + }); + }); + + it('DELETE /settlements/:id success broadcasts the numeric id', () => { + const broadcast = vi.fn(); + const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(true), broadcast } as Partial); + expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true }); + expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock'); + }); }); describe('POST /', () => { @@ -124,6 +187,31 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou }); }); + describe('PUT /:id/payers', () => { + it('400 when payers is not an array', () => { + expect(thrown(() => new BudgetController(makeService()).setPayers(user, '5', '9', 'nope'))).toEqual({ + status: 400, body: { error: 'payers must be an array' }, + }); + }); + + it('404 when the item is missing', () => { + const svc = makeService({ setPayers: vi.fn().mockReturnValue(null) } as Partial); + expect(thrown(() => new BudgetController(svc).setPayers(user, '5', '9', [{ user_id: 2, amount: 10 }]))).toEqual({ + status: 404, body: { error: 'Budget item not found' }, + }); + }); + + it('sets payers and broadcasts budget:updated', () => { + const setPayers = vi.fn().mockReturnValue({ id: 9, payers: [{ user_id: 2, amount: 10 }] }); + const broadcast = vi.fn(); + const svc = makeService({ setPayers, broadcast } as Partial); + const res = new BudgetController(svc).setPayers(user, '5', '9', [{ user_id: 2, amount: 10 }], 'sock'); + expect(res).toEqual({ item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } }); + expect(setPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]); + expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } }, 'sock'); + }); + }); + it('PUT /:id/members/:userId/paid toggles + broadcasts normalised paid flag', () => { const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 1 }); const broadcast = vi.fn(); @@ -132,6 +220,14 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 1 }, 'sock'); }); + it('PUT /:id/members/:userId/paid broadcasts paid: 0 when toggled off', () => { + const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 0 }); + const broadcast = vi.fn(); + const svc = makeService({ toggleMemberPaid, broadcast } as Partial); + new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', false, 'sock'); + expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 0 }, 'sock'); + }); + it('DELETE /:id 404 when missing, success otherwise', () => { const missing = makeService({ remove: vi.fn().mockReturnValue(false) } as Partial); expect(thrown(() => new BudgetController(missing).remove(user, '5', '9'))).toEqual({ diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts new file mode 100644 index 00000000..cf931f6b --- /dev/null +++ b/server/tests/unit/nest/budget.service.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the data + side-effect dependencies the wrapper reaches into directly. +const { dbMock } = vi.hoisted(() => { + const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() }; + return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } }; +}); +vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} })); + +const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast })); + +const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) })); +vi.mock('../../../src/services/permissions', () => ({ checkPermission })); + +const { getRates } = vi.hoisted(() => ({ getRates: vi.fn() })); +vi.mock('../../../src/services/exchangeRateService', () => ({ getRates })); + +const { budget } = vi.hoisted(() => ({ + budget: { + verifyTripAccess: vi.fn(), + listBudgetItems: vi.fn(), + getPerPersonSummary: vi.fn(), + calculateSettlement: vi.fn(), + createBudgetItem: vi.fn(), + updateBudgetItem: vi.fn(), + deleteBudgetItem: vi.fn(), + updateMembers: vi.fn(), + toggleMemberPaid: vi.fn(), + setItemPayers: vi.fn(), + listSettlements: vi.fn(), + createSettlement: vi.fn(), + deleteSettlement: vi.fn(), + reorderBudgetItems: vi.fn(), + reorderBudgetCategories: vi.fn(), + }, +})); +vi.mock('../../../src/services/budgetService', () => budget); + +import { BudgetService } from '../../../src/nest/budget/budget.service'; + +function svc() { + return new BudgetService(); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +describe('BudgetService', () => { + it('verifyTripAccess delegates to the legacy service', () => { + budget.verifyTripAccess.mockReturnValue({ id: 5, user_id: 2 }); + expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 }); + expect(budget.verifyTripAccess).toHaveBeenCalledWith('5', 2); + }); + + it('canEdit forwards the ownership flag when the user owns the trip', () => { + checkPermission.mockReturnValue(true); + expect(svc().canEdit({ user_id: 1 } as never, { id: 1, role: 'user' } as never)).toBe(true); + expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 1, 1, false); + }); + + it('canEdit marks the user as a guest when they do not own the trip', () => { + checkPermission.mockReturnValue(false); + expect(svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never)).toBe(false); + expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 2, 1, true); + }); + + it('broadcast forwards to the websocket helper', () => { + svc().broadcast('5', 'budget:created', { item: { id: 1 } }, 'sock'); + expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock'); + }); + + it('list / perPersonSummary delegate', () => { + budget.listBudgetItems.mockReturnValue([{ id: 1 }]); + expect(svc().list('5')).toEqual([{ id: 1 }]); + budget.getPerPersonSummary.mockReturnValue([{ userId: 1 }]); + expect(svc().perPersonSummary('5')).toEqual([{ userId: 1 }]); + }); + + describe('settlement', () => { + it('upper-cases the explicit base and forwards the rates', async () => { + getRates.mockResolvedValue({ USD: 1.1 }); + budget.calculateSettlement.mockReturnValue({ transfers: [] }); + await svc().settlement('5', 'usd', 'EUR'); + expect(getRates).toHaveBeenCalledWith('USD'); + expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' }); + }); + + it('falls back to the trip currency when no base is given', async () => { + getRates.mockResolvedValue(null); + await svc().settlement('5', undefined, 'gbp'); + expect(getRates).toHaveBeenCalledWith('GBP'); + expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' }); + }); + + it('falls back to EUR when neither base nor trip currency is present', async () => { + getRates.mockResolvedValue(null); + await svc().settlement('5', undefined, ''); + expect(getRates).toHaveBeenCalledWith('EUR'); + expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' }); + }); + }); + + it('create / update / remove / members / paid / payers delegate', () => { + svc().create('5', { name: 'Hotel' } as never); + expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Hotel' }); + svc().update('9', '5', { name: 'X' }); + expect(budget.updateBudgetItem).toHaveBeenCalledWith('9', '5', { name: 'X' }); + svc().remove('9', '5'); + expect(budget.deleteBudgetItem).toHaveBeenCalledWith('9', '5'); + svc().updateMembers('9', '5', [2, 3]); + expect(budget.updateMembers).toHaveBeenCalledWith('9', '5', [2, 3]); + svc().toggleMemberPaid('9', '5', '2', true); + expect(budget.toggleMemberPaid).toHaveBeenCalledWith('9', '5', '2', true); + svc().setPayers('9', '5', [{ user_id: 2, amount: 10 }]); + expect(budget.setItemPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]); + }); + + it('settlement ledger + reorder delegate', () => { + svc().listSettlements('5'); + expect(budget.listSettlements).toHaveBeenCalledWith('5'); + svc().createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3); + expect(budget.createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3); + svc().deleteSettlement('7', '5'); + expect(budget.deleteSettlement).toHaveBeenCalledWith('7', '5'); + svc().reorderItems('5', [3, 1]); + expect(budget.reorderBudgetItems).toHaveBeenCalledWith('5', [3, 1]); + svc().reorderCategories('5', ['food', 'fun']); + expect(budget.reorderBudgetCategories).toHaveBeenCalledWith('5', ['food', 'fun']); + }); + + describe('syncReservationPrice', () => { + it('returns early when the reservation is not found', () => { + dbMock._stmt.get.mockReturnValueOnce(undefined); + svc().syncReservationPrice('5', 42, 250, 'sock'); + expect(dbMock._stmt.run).not.toHaveBeenCalled(); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('merges into existing metadata and broadcasts reservation:updated', () => { + dbMock._stmt.get + .mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }) // lookup + .mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload + svc().syncReservationPrice('5', 42, 250, 'sock'); + const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string); + expect(writtenMeta).toEqual({ vendor: 'ACME', price: '250' }); + expect(broadcast).toHaveBeenCalledWith('5', 'reservation:updated', { reservation: { id: 42, metadata: '{"vendor":"ACME","price":"250"}' } }, 'sock'); + }); + + it('starts from an empty object when the reservation has no metadata', () => { + dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: null }).mockReturnValueOnce({ id: 42 }); + svc().syncReservationPrice('5', 42, 99, undefined); + const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string); + expect(writtenMeta).toEqual({ price: '99' }); + }); + + it('swallows errors so a sync failure never breaks the budget update', () => { + dbMock.prepare.mockImplementationOnce(() => { throw new Error('db gone'); }); + expect(() => svc().syncReservationPrice('5', 42, 250, 'sock')).not.toThrow(); + expect(broadcast).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/tests/unit/nest/days.controller.test.ts b/server/tests/unit/nest/days.controller.test.ts index d64d1051..7edda513 100644 --- a/server/tests/unit/nest/days.controller.test.ts +++ b/server/tests/unit/nest/days.controller.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; import { DaysController } from '../../../src/nest/days/days.controller'; import { DayNotesController } from '../../../src/nest/days/day-notes.controller'; +import { DayReorderError } from '../../../src/services/dayService'; import type { DaysService } from '../../../src/nest/days/days.service'; import type { DayNotesService } from '../../../src/nest/days/day-notes.service'; import type { User } from '../../../src/types'; @@ -44,6 +45,63 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)' expect(broadcast).toHaveBeenCalledWith('5', 'day:created', { day: { id: 9 } }, 'sock'); }); + it('POST / 404 when the trip is not accessible', () => { + const svc = daysSvc({ verifyTripAccess: vi.fn().mockReturnValue(null) }); + expect(thrown(() => new DaysController(svc).create(user, '5', {}))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + }); + + it('POST / with a position inserts + broadcasts day:reordered', () => { + const insert = vi.fn().mockReturnValue({ id: 12 }); const create = vi.fn(); const broadcast = vi.fn(); + const svc = daysSvc({ insert, create, broadcast } as Partial); + expect(new DaysController(svc).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } }); + expect(insert).toHaveBeenCalledWith('5', 0); + expect(create).not.toHaveBeenCalled(); + expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { day: { id: 12 } }, 'sock'); + }); + + describe('PUT /reorder', () => { + it('404 when the trip is not accessible', () => { + const svc = daysSvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) }); + expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] }))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + }); + + it('403 without day_edit', () => { + const svc = daysSvc({ canEdit: vi.fn().mockReturnValue(false) }); + expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'No permission' } }); + }); + + it('400 when orderedIds is missing', () => { + expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', {}))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } }); + }); + + it('400 when orderedIds is not an array', () => { + expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', { orderedIds: 'nope' as never }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } }); + }); + + it('maps a DayReorderError to 400 with its message', () => { + const reorder = vi.fn(() => { throw new DayReorderError('orderedIds must be a permutation of the trip day ids.'); }); + const svc = daysSvc({ reorder } as Partial); + expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [9] }))).toEqual({ + status: 400, body: { error: 'orderedIds must be a permutation of the trip day ids.' }, + }); + }); + + it('rethrows a non-DayReorderError unchanged', () => { + const boom = new Error('db is down'); + const reorder = vi.fn(() => { throw boom; }); + const svc = daysSvc({ reorder } as Partial); + expect(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] })).toThrow(boom); + }); + + it('reorders and broadcasts day:reordered', () => { + const reorder = vi.fn(); const broadcast = vi.fn(); + const svc = daysSvc({ reorder, broadcast } as Partial); + expect(new DaysController(svc).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true }); + expect(reorder).toHaveBeenCalledWith('5', [2, 1]); + expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { orderedIds: [2, 1] }, 'sock'); + }); + }); + it('PUT /:id 404 when the day is missing, else updates', () => { expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial)).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } }); const update = vi.fn().mockReturnValue({ id: 9, title: 'T' }); diff --git a/server/tests/unit/nest/exception-filter.test.ts b/server/tests/unit/nest/exception-filter.test.ts index 264a24a1..75c0fe9b 100644 --- a/server/tests/unit/nest/exception-filter.test.ts +++ b/server/tests/unit/nest/exception-filter.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; +import { MulterError } from 'multer'; import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter'; function mockHost() { @@ -31,4 +32,89 @@ describe('TrekExceptionFilter', () => { expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }); }); + + it('maps a multer LIMIT_FILE_SIZE error to 413 with the multer message', () => { + const { res, host } = mockHost(); + filter.catch(new MulterError('LIMIT_FILE_SIZE', 'avatar'), host); + expect(res.status).toHaveBeenCalledWith(413); + expect(res.json).toHaveBeenCalledWith({ error: 'File too large' }); + }); + + it('maps any other multer error to 400 with the multer message', () => { + const { res, host } = mockHost(); + const err = new MulterError('LIMIT_UNEXPECTED_FILE', 'avatar'); + filter.catch(err, host); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: err.message }); + }); + + it('normalises a Nest-shaped { statusCode, message, error } body to { error }', () => { + const { res, host } = mockHost(); + filter.catch(new HttpException({ statusCode: 400, message: 'Validation failed', error: 'Bad Request' }, 400), host); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Validation failed' }); + }); + + it('joins an array message into a single string', () => { + const { res, host } = mockHost(); + filter.catch(new HttpException({ message: ['too short', 'required'] }, 400), host); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'too short, required' }); + }); + + it('falls back to obj.error when an object body has no message', () => { + const { res, host } = mockHost(); + filter.catch(new HttpException({ statusCode: 400, error: 'Bad Request' }, 400), host); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Bad Request' }); + }); + + it("uses 'Error' when an object body carries neither message nor error", () => { + const { res, host } = mockHost(); + filter.catch(new HttpException({ statusCode: 400 }, 400), host); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Error' }); + }); + + it('hides 5xx object-body details behind Internal server error', () => { + const { res, host } = mockHost(); + filter.catch(new HttpException({ message: 'secret stack detail' }, 503), host); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }); + }); + + it('maps a plain error with statusCode to that status (4xx exposes message)', () => { + const { res, host } = mockHost(); + filter.catch({ statusCode: 400, message: 'Only images are allowed' }, host); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Only images are allowed' }); + }); + + it('honours a plain error status field when statusCode is absent', () => { + const { res, host } = mockHost(); + filter.catch({ status: 404, message: 'Not here' }, host); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Not here' }); + }); + + it("uses 'Error' for a 4xx plain error with no message", () => { + const { res, host } = mockHost(); + filter.catch({ statusCode: 422 }, host); + expect(res.status).toHaveBeenCalledWith(422); + expect(res.json).toHaveBeenCalledWith({ error: 'Error' }); + }); + + it('hides a 5xx string-body HttpException behind Internal server error', () => { + const { res, host } = mockHost(); + filter.catch(new HttpException('database exploded', 500), host); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }); + }); + + it('treats a null exception as a 500', () => { + const { res, host } = mockHost(); + filter.catch(null, host); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }); + }); }); diff --git a/server/tests/unit/nest/files.controller.test.ts b/server/tests/unit/nest/files.controller.test.ts index 029a41f5..20020555 100644 --- a/server/tests/unit/nest/files.controller.test.ts +++ b/server/tests/unit/nest/files.controller.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { Request, Response } from 'express'; +import os from 'os'; +import path from 'path'; +import fs from 'fs'; vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) })); @@ -123,6 +126,19 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route const s = fsvc({ getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); expect(new FilesController(s).links(user, '5', '9')).toEqual({ links: [{ id: 1 }] }); }); + + it('the trash + link routes all reject without file_delete / file_edit', async () => { + const denied = () => fsvc({ can: vi.fn().mockReturnValue(false) }); + await expect(new FilesController(denied()).permanent(user, '5', '9')).rejects.toMatchObject({ status: 403 }); + expect(thrown(() => new FilesController(denied()).restore(user, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(thrown(() => new FilesController(denied()).link(user, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(thrown(() => new FilesController(denied()).unlink(user, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } }); + }); + + it('GET /:id/links 404 without trip access', () => { + const s = fsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) }); + expect(thrown(() => new FilesController(s).links(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + }); }); describe('FilesDownloadController', () => { @@ -147,6 +163,62 @@ describe('FilesDownloadController', () => { expect(thrown(() => new FilesDownloadController(dsvc({ getFileById: vi.fn().mockReturnValue(undefined) })).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); expect(thrown(() => new FilesDownloadController(dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: '/x', safe: false }) })).download(req, res, '5', '9'))).toEqual({ status: 403, body: { error: 'Forbidden' } }); }); + + it('404 when the safe path is gone from disk', () => { + const missing = path.join(os.tmpdir(), `trek-no-such-${Date.now()}.pdf`); + const s = dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: missing, safe: true }) }); + expect(thrown(() => new FilesDownloadController(s).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); + }); + + it('streams a regular file via sendFile with an explicit root', () => { + const real = path.join(os.tmpdir(), `trek-dl-${Date.now()}.pdf`); + fs.writeFileSync(real, 'x'); + try { + const sendFile = vi.fn(); + const localRes = { setHeader: vi.fn(), sendFile } as unknown as Response; + const s = dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }) }); + new FilesDownloadController(s).download(req, localRes, '5', '9'); + expect(sendFile).toHaveBeenCalledWith(path.basename(real), { root: path.dirname(real) }); + expect(localRes.setHeader).not.toHaveBeenCalled(); + } finally { + fs.unlinkSync(real); + } + }); + + it('serves a .pkpass inline with the Wallet MIME type and the original name', () => { + const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpass`); + fs.writeFileSync(real, 'x'); + try { + const setHeader = vi.fn(); + const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response; + const s = dsvc({ + getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: 'BoardingPass.pkpass' }), + resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }), + }); + new FilesDownloadController(s).download(req, localRes, '5', '9'); + expect(setHeader).toHaveBeenCalledWith('Content-Type', 'application/vnd.apple.pkpass'); + expect(setHeader).toHaveBeenCalledWith('Content-Disposition', 'inline; filename="BoardingPass.pkpass"'); + } finally { + fs.unlinkSync(real); + } + }); + + it('falls back to the resolved basename when a .pkpass has no original name', () => { + const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpass`); + fs.writeFileSync(real, 'x'); + try { + const setHeader = vi.fn(); + const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response; + const s = dsvc({ + getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: null }), + resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }), + }); + new FilesDownloadController(s).download(req, localRes, '5', '9'); + expect(setHeader).toHaveBeenCalledWith('Content-Disposition', `inline; filename="${path.basename(real)}"`); + } finally { + fs.unlinkSync(real); + } + }); }); describe('PhotosController', () => { diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts new file mode 100644 index 00000000..a65a1102 --- /dev/null +++ b/server/tests/unit/nest/files.service.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request } from 'express'; + +// Mock the side-effect dependencies the wrapper reaches into directly. +const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast })); + +const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) })); +vi.mock('../../../src/services/permissions', () => ({ checkPermission })); + +const { svc } = vi.hoisted(() => ({ + svc: { + verifyTripAccess: vi.fn(), + authenticateDownload: vi.fn(), + resolveFilePath: vi.fn(), + listFiles: vi.fn(), + getFileById: vi.fn(), + getDeletedFile: vi.fn(), + createFile: vi.fn(), + updateFile: vi.fn(), + toggleStarred: vi.fn(), + softDeleteFile: vi.fn(), + restoreFile: vi.fn(), + permanentDeleteFile: vi.fn(), + emptyTrash: vi.fn(), + createFileLink: vi.fn(), + deleteFileLink: vi.fn(), + getFileLinks: vi.fn(), + }, +})); +vi.mock('../../../src/services/fileService', () => svc); + +import { FilesService } from '../../../src/nest/files/files.service'; +import type { User } from '../../../src/types'; + +function service() { + return new FilesService(); +} + +beforeEach(() => vi.clearAllMocks()); + +describe('FilesService (thin wrapper around the legacy fileService)', () => { + it('verifyTripAccess delegates to the legacy service', () => { + svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 2 }); + expect(service().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 }); + expect(svc.verifyTripAccess).toHaveBeenCalledWith('5', 2); + }); + + it('can forwards the ownership flag when the user owns the trip', () => { + checkPermission.mockReturnValue(true); + const user = { id: 1, role: 'user' } as User; + expect(service().can('file_edit', { user_id: 1 } as never, user)).toBe(true); + expect(checkPermission).toHaveBeenCalledWith('file_edit', 'user', 1, 1, false); + }); + + it('can marks the user as a guest when they do not own the trip', () => { + checkPermission.mockReturnValue(false); + const user = { id: 1, role: 'user' } as User; + expect(service().can('file_upload', { user_id: 2 } as never, user)).toBe(false); + expect(checkPermission).toHaveBeenCalledWith('file_upload', 'user', 2, 1, true); + }); + + it('broadcast forwards to the websocket helper', () => { + service().broadcast('5', 'file:created', { file: { id: 1 } }, 'sock'); + expect(broadcast).toHaveBeenCalledWith('5', 'file:created', { file: { id: 1 } }, 'sock'); + }); + + it('authenticateDownload / resolveFilePath delegate', () => { + const req = { headers: {} } as Request; + svc.authenticateDownload.mockReturnValue({ userId: 7 }); + expect(service().authenticateDownload(req)).toEqual({ userId: 7 }); + expect(svc.authenticateDownload).toHaveBeenCalledWith(req); + + svc.resolveFilePath.mockReturnValue({ resolved: '/a/b.pdf', safe: true }); + expect(service().resolveFilePath('b.pdf')).toEqual({ resolved: '/a/b.pdf', safe: true }); + expect(svc.resolveFilePath).toHaveBeenCalledWith('b.pdf'); + }); + + it('the read helpers delegate', () => { + svc.listFiles.mockReturnValue([{ id: 1 }]); + expect(service().listFiles('5', true)).toEqual([{ id: 1 }]); + expect(svc.listFiles).toHaveBeenCalledWith('5', true); + + svc.getFileById.mockReturnValue({ id: 9 }); + expect(service().getFileById('9', '5')).toEqual({ id: 9 }); + expect(svc.getFileById).toHaveBeenCalledWith('9', '5'); + + svc.getDeletedFile.mockReturnValue({ id: 9 }); + expect(service().getDeletedFile('9', '5')).toEqual({ id: 9 }); + expect(svc.getDeletedFile).toHaveBeenCalledWith('9', '5'); + + svc.getFileLinks.mockReturnValue([{ id: 1 }]); + expect(service().getFileLinks('9')).toEqual([{ id: 1 }]); + expect(svc.getFileLinks).toHaveBeenCalledWith('9'); + }); + + it('the mutating helpers delegate', () => { + const file = { filename: 'a.pdf' } as Express.Multer.File; + svc.createFile.mockReturnValue({ id: 9 }); + expect(service().createFile('5', file, 1, { description: 'd' })).toEqual({ id: 9 }); + expect(svc.createFile).toHaveBeenCalledWith('5', file, 1, { description: 'd' }); + + svc.updateFile.mockReturnValue({ id: 9 }); + const current = { id: 9 } as never; + expect(service().updateFile('9', current, { description: 'x' })).toEqual({ id: 9 }); + expect(svc.updateFile).toHaveBeenCalledWith('9', current, { description: 'x' }); + + svc.toggleStarred.mockReturnValue({ id: 9, starred: 1 }); + expect(service().toggleStarred('9', 0)).toEqual({ id: 9, starred: 1 }); + expect(svc.toggleStarred).toHaveBeenCalledWith('9', 0); + + service().softDeleteFile('9'); + expect(svc.softDeleteFile).toHaveBeenCalledWith('9'); + + svc.restoreFile.mockReturnValue({ id: 9 }); + expect(service().restoreFile('9')).toEqual({ id: 9 }); + expect(svc.restoreFile).toHaveBeenCalledWith('9'); + + const trashed = { id: 9 } as never; + service().permanentDeleteFile(trashed); + expect(svc.permanentDeleteFile).toHaveBeenCalledWith(trashed); + + svc.emptyTrash.mockReturnValue(3); + expect(service().emptyTrash('5')).toBe(3); + expect(svc.emptyTrash).toHaveBeenCalledWith('5'); + + svc.createFileLink.mockReturnValue([{ id: 1 }]); + expect(service().createFileLink('9', { reservation_id: 2 })).toEqual([{ id: 1 }]); + expect(svc.createFileLink).toHaveBeenCalledWith('9', { reservation_id: 2 }); + + service().deleteFileLink('3', '9'); + expect(svc.deleteFileLink).toHaveBeenCalledWith('3', '9'); + }); +}); diff --git a/server/tests/unit/nest/health.controller.test.ts b/server/tests/unit/nest/health.controller.test.ts new file mode 100644 index 00000000..a18d9a75 --- /dev/null +++ b/server/tests/unit/nest/health.controller.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest'; +import { HealthController } from '../../../src/nest/health/health.controller'; +import { HealthService } from '../../../src/nest/health/health.service'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import type { User } from '../../../src/types'; + +const user = { id: 1, role: 'user', email: 'u@example.test' } as User; + +function makeService(overrides: Partial = {}): HealthService { + return { + info: vi.fn().mockReturnValue({ runtime: 'nestjs', diInjected: true, userCount: 0 }), + ...overrides, + } as unknown as HealthService; +} + +describe('HealthController (foundation smoke endpoints under /api/_nest)', () => { + it('GET /health merges ok:true with the service info', () => { + const svc = makeService({ + info: vi.fn().mockReturnValue({ runtime: 'nestjs', diInjected: true, userCount: 7 }), + }); + expect(new HealthController(svc).getHealth()).toEqual({ + ok: true, + runtime: 'nestjs', + diInjected: true, + userCount: 7, + }); + }); + + it('GET /me returns the authenticated user as-is', () => { + const svc = makeService(); + expect(new HealthController(svc).me(user)).toBe(user); + }); + + it('POST /echo wraps the validated body', () => { + const svc = makeService(); + expect(new HealthController(svc).echo({ name: 'Maurice' })).toEqual({ + youSent: { name: 'Maurice' }, + }); + }); +}); + +describe('HealthService.info (shared SQLite connection proof)', () => { + function makeDb(get: () => unknown): DatabaseService { + return { get: vi.fn(get) } as unknown as DatabaseService; + } + + it('returns the real user count when the row resolves', () => { + const service = new HealthService(makeDb(() => ({ n: 42 }))); + expect(service.info()).toEqual({ + runtime: 'nestjs', + diInjected: true, + userCount: 42, + }); + }); + + it('falls back to null when the row is undefined', () => { + const service = new HealthService(makeDb(() => undefined)); + expect(service.info().userCount).toBeNull(); + }); + + it('falls back to null when the count column is null', () => { + const service = new HealthService(makeDb(() => ({ n: null }))); + expect(service.info().userCount).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/idempotency.interceptor.test.ts b/server/tests/unit/nest/idempotency.interceptor.test.ts index b5c5d432..31d4e491 100644 --- a/server/tests/unit/nest/idempotency.interceptor.test.ts +++ b/server/tests/unit/nest/idempotency.interceptor.test.ts @@ -144,4 +144,54 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle res.json({ error: 'bad' }); expect(run).not.toHaveBeenCalled(); }); + + it('does not cache a body that exceeds the 256 KiB cap', async () => { + const run = vi.fn(); + const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run }); + const res = makeRes(); + const big = { blob: 'x'.repeat(300 * 1024) }; + const h = handler(big); + await lastValueFrom( + new IdempotencyInterceptor(db).intercept( + ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res), + h, + ), + ); + res.statusCode = 200; + res.json(big); + expect(run).not.toHaveBeenCalled(); + }); + + it('swallows a storage failure so the response still succeeds', async () => { + const run = vi.fn(() => { + throw new Error('db is locked'); + }); + const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run }); + const res = makeRes(); + const h = handler({ ok: true }); + await lastValueFrom( + new IdempotencyInterceptor(db).intercept( + ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res), + h, + ), + ); + res.statusCode = 201; + const returned = res.json({ ok: true }); + expect(run).toHaveBeenCalledTimes(1); + expect(returned).toEqual({ ok: true }); + }); + + it('treats a PATCH as a mutating method', async () => { + const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run: vi.fn() }); + const res = makeRes(); + const h = handler('done'); + await lastValueFrom( + new IdempotencyInterceptor(db).intercept( + ctx({ method: 'PATCH', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories/1', user: { id: 1 } }, res), + h, + ), + ); + expect(db.get).toHaveBeenCalled(); + expect(h.handle).toHaveBeenCalled(); + }); }); diff --git a/server/tests/unit/nest/journey.controller.test.ts b/server/tests/unit/nest/journey.controller.test.ts index f8a6b55f..48b699ae 100644 --- a/server/tests/unit/nest/journey.controller.test.ts +++ b/server/tests/unit/nest/journey.controller.test.ts @@ -76,6 +76,8 @@ describe('JourneyController', () => { const c = new JourneyController(svc({ linkPhotoToEntry } as Partial)); expect(c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 }); expect(linkPhotoToEntry).toHaveBeenCalledWith(3, 5, 1); + // accepts the canonical journey_photo_id, 403 when the service refuses + expect(thrown(() => new JourneyController(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial)).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); it('unlink photo (204) maps 404; delete photo 404 then unlinks file', () => { @@ -143,6 +145,113 @@ describe('JourneyController', () => { await new JourneyController(noOptIn).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {}); expect(uploadToImmich).toHaveBeenCalledTimes(1); // only the opted-in upload above }); + + it('entry photo upload: 400 no files, 403 when nothing added, swallows immich errors and empty ids', async () => { + expect(await thrownAsync(() => new JourneyController(svc()).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' } }); + expect(await thrownAsync(() => new JourneyController(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial)).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + + // opted in but the immich upload throws → best-effort, the local photo still wins + const setPhotoProvider = vi.fn(); + const blowsUp = svc({ addPhoto: vi.fn().mockReturnValue({ id: 8 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockRejectedValue(new Error('immich down')), setPhotoProvider } as Partial); + expect(await new JourneyController(blowsUp).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' })).toEqual({ photos: [{ id: 8 }] }); + expect(setPhotoProvider).not.toHaveBeenCalled(); + + // opted in but immich returns a falsy id → no provider stamping + const noId = svc({ addPhoto: vi.fn().mockReturnValue({ id: 9 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockResolvedValue(''), setPhotoProvider } as Partial); + expect(await new JourneyController(noId).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] }); + }); + + it('provider-photos batch passes the passphrase through when present', () => { + const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 }); + new JourneyController(svc({ addProviderPhoto } as Partial)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' }); + expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret'); + // single-photo success path + expect(new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 }); + }); + + it('PATCH photos: 404 then returns the updated photo', () => { + expect(thrown(() => new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial)).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } }); + expect(new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial)).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 }); + }); + + it('DELETE photo unlinks the file when a path exists', () => { + const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => undefined); + try { + expect(new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial)).deletePhoto(user, '7')).toEqual({ success: true }); + expect(unlinkSpy).toHaveBeenCalledTimes(1); + // a vanished file is swallowed + unlinkSpy.mockImplementationOnce(() => { throw new Error('ENOENT'); }); + expect(new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial)).deletePhoto(user, '8')).toEqual({ success: true }); + } finally { + unlinkSpy.mockRestore(); + } + }); + + it('gallery provider-photos: batch (with passphrase), single 400/403, success', () => { + const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 }); + const batch = new JourneyController(svc({ addProviderPhotoToGallery } as Partial)); + expect(batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 }); + expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw'); + expect(thrown(() => new JourneyController(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } }); + expect(thrown(() => new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } }); + expect(new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 }); + }); + + it('DELETE gallery photo: 404, then unlinks the file when present', () => { + expect(thrown(() => new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial)).deleteGalleryPhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } }); + // no file_path → nothing to unlink, returns void + expect(new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial)).deleteGalleryPhoto(user, '7')).toBeUndefined(); + const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => undefined); + try { + new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }) } as Partial)).deleteGalleryPhoto(user, '8'); + expect(unlinkSpy).toHaveBeenCalledTimes(1); + unlinkSpy.mockImplementationOnce(() => { throw new Error('ENOENT'); }); + expect(new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }) } as Partial)).deleteGalleryPhoto(user, '9')).toBeUndefined(); + } finally { + unlinkSpy.mockRestore(); + } + }); + + it('PATCH /:id returns the updated journey on success', () => { + expect(new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial)).update(user, '9', { title: 'x' })).toEqual({ id: 9 }); + }); + + it('cover upload: 400 without file, 404 when the journey is gone, else returns the journey', () => { + expect(thrown(() => new JourneyController(svc()).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); + expect(thrown(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial)).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Journey not found' } }); + const updateJourney = vi.fn().mockReturnValue({ id: 9, cover_image: 'journey/c.jpg' }); + expect(new JourneyController(svc({ updateJourney } as Partial)).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File)).toEqual({ id: 9, cover_image: 'journey/c.jpg' }); + expect(updateJourney).toHaveBeenCalledWith(9, 1, { cover_image: 'journey/c.jpg' }); + }); + + it('DELETE /:id and trips/contributors success paths', () => { + expect(new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial)).remove(user, '9')).toEqual({ success: true }); + expect(new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial)).removeTrip(user, '9', '2')).toEqual({ success: true }); + expect(new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial)).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true }); + expect(new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial)).removeContributor(user, '9', '2')).toEqual({ success: true }); + }); + + it('addContributor defaults the role to viewer when omitted', () => { + const addContributor = vi.fn().mockReturnValue(true); + new JourneyController(svc({ addContributor } as Partial)).addContributor(user, '9', { user_id: 2 }); + expect(addContributor).toHaveBeenCalledWith(9, 1, 2, 'viewer'); + }); + + it('createEntry returns the entry when the journey exists', () => { + expect(new JourneyController(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 }); + }); + + it('reorderEntries succeeds for a numeric array', () => { + expect(new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial)).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true }); + }); + + it('preferences returns the result on success', () => { + expect(new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true }); + }); + + it('deleteShareLink returns success when removed', () => { + expect(new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial)).deleteShareLink(user, '9')).toEqual({ success: true }); + }); }); describe('JourneyPublicController', () => { @@ -167,6 +276,45 @@ describe('JourneyPublicController', () => { expect(streamImmichAsset).toHaveBeenCalledWith({}, 5, 'a1', 'original', 5); }); + it('photo proxy streams thumbnails too', async () => { + const streamPhoto = vi.fn().mockResolvedValue(undefined); + const s = svc({ validateShareTokenForPhoto: vi.fn().mockReturnValue({ ownerId: 3 }), streamPhoto } as Partial); + await new JourneyPublicController(s).photo('tok', '7', 'thumbnail', {} as Response); + expect(streamPhoto).toHaveBeenCalledWith({}, 3, 7, 'thumbnail'); + }); + + it('legacy photo proxy: synology streams, and a failure becomes a 404 json', async () => { + const streamSynologyAsset = vi.fn().mockResolvedValue(undefined); + const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }), streamSynologyAsset } as Partial); + await new JourneyPublicController(s).legacyPhoto('tok', 'synology', 'a1', '2', 'thumbnail', {} as Response); + expect(streamSynologyAsset).toHaveBeenCalledWith({}, 5, 5, 'a1', 'thumbnail'); + + const status = vi.fn().mockReturnThis(); + const json = vi.fn(); + const res = { status, json } as unknown as Response; + const failing = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 0 }), streamSynologyAsset: vi.fn().mockRejectedValue(new Error('no synology')) } as Partial); + await new JourneyPublicController(failing).legacyPhoto('tok', 'synology', 'a1', '6', 'original', res); + expect(status).toHaveBeenCalledWith(404); + expect(json).toHaveBeenCalledWith({ error: 'Provider not supported' }); + }); + + it('legacy photo proxy: falls back to the path ownerId when the token has none', async () => { + const streamImmichAsset = vi.fn().mockResolvedValue(undefined); + const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 0 }), streamImmichAsset } as Partial); + await new JourneyPublicController(s).legacyPhoto('tok', 'immich', 'a1', '8', 'original', {} as Response); + expect(streamImmichAsset).toHaveBeenCalledWith({}, 8, 'a1', 'original', 8); + }); + + it('legacy photo proxy: local provider 404s when the resolved file does not exist', async () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false); + try { + const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }) } as Partial); + expect(await thrownAsync(() => new JourneyPublicController(s).legacyPhoto('tok', 'local', 'gone.jpg', '2', 'thumbnail', {} as Response))).toEqual({ status: 404, body: { error: 'Not found' } }); + } finally { + existsSpy.mockRestore(); + } + }); + it('legacy photo proxy: local provider cannot escape uploads/journey via a traversal asset id', async () => { // Pretend any path exists so we can inspect exactly what would be served. const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); diff --git a/server/tests/unit/nest/maps.controller.test.ts b/server/tests/unit/nest/maps.controller.test.ts index 6ed91b7b..d4b98306 100644 --- a/server/tests/unit/nest/maps.controller.test.ts +++ b/server/tests/unit/nest/maps.controller.test.ts @@ -50,12 +50,67 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { expect(search).toHaveBeenCalledWith(3, 'berlin', 'de', undefined); }); + it('400 on a malformed locationBias (non-finite lat/lng)', async () => { + const search = vi.fn(); + const bad = { lat: NaN, lng: 2 }; + expect(await thrown(() => makeController({ search }).search(user, 'x', 'de', bad))).toEqual({ + status: 400, body: { error: 'Invalid locationBias: lat and lng must be finite numbers' }, + }); + expect(search).not.toHaveBeenCalled(); + }); + + it('forwards a valid locationBias to the service', async () => { + const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' }); + const bias = { lat: 1, lng: 2, radius: 5000 }; + await makeController({ search }).search(user, 'x', 'de', bias); + expect(search).toHaveBeenCalledWith(3, 'x', 'de', bias); + }); + it('maps a service error to its status + message', async () => { const search = vi.fn().mockRejectedValue(withError(429, 'Rate limited')); expect(await thrown(() => makeController({ search }).search(user, 'x'))).toEqual({ status: 429, body: { error: 'Rate limited' }, }); }); + + it('defaults a non-Error rejection to 500 + the fallback message', async () => { + const search = vi.fn().mockRejectedValue('boom'); + expect(await thrown(() => makeController({ search }).search(user, 'x'))).toEqual({ + status: 500, body: { error: 'Search error' }, + }); + }); + }); + + describe('GET /pois', () => { + it('400 when category is missing', async () => { + const pois = vi.fn(); + expect(await thrown(() => makeController({ pois }).pois(undefined, '1', '2', '3', '4'))).toEqual({ + status: 400, body: { error: 'A category is required' }, + }); + expect(pois).not.toHaveBeenCalled(); + }); + + it('400 when the bbox has a non-finite value', async () => { + const pois = vi.fn(); + expect(await thrown(() => makeController({ pois }).pois('cafe', 'x', '2', '3', '4'))).toEqual({ + status: 400, body: { error: 'A valid bbox (south, west, north, east) is required' }, + }); + expect(pois).not.toHaveBeenCalled(); + }); + + it('delegates a valid request with a parsed numeric bbox', async () => { + const pois = vi.fn().mockResolvedValue({ places: [] }); + const res = await makeController({ pois }).pois('cafe', '1', '2', '3', '4'); + expect(res).toEqual({ places: [] }); + expect(pois).toHaveBeenCalledWith('cafe', { south: 1, west: 2, north: 3, east: 4 }); + }); + + it('maps a service error, defaulting to 500', async () => { + const pois = vi.fn().mockRejectedValue(new Error('Overpass down')); + expect(await thrown(() => makeController({ pois }).pois('cafe', '1', '2', '3', '4'))).toEqual({ + status: 500, body: { error: 'Overpass down' }, + }); + }); }); describe('POST /autocomplete', () => { @@ -87,12 +142,28 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { }); }); + it('400 when locationBias is missing the high corner', async () => { + const c = makeController({ autocompleteDisabled: () => false }); + const bad = { low: { lat: 1, lng: 2 } } as never; + expect(await thrown(() => c.autocomplete(user, 'be', undefined, bad))).toEqual({ + status: 400, body: { error: 'Invalid locationBias: low and high must have finite lat and lng' }, + }); + }); + it('delegates a valid request', async () => { const autocomplete = vi.fn().mockResolvedValue({ suggestions: [], source: 'osm' }); const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } }; await makeController({ autocompleteDisabled: () => false, autocomplete }).autocomplete(user, 'be', 'en', bias); expect(autocomplete).toHaveBeenCalledWith(3, 'be', 'en', bias); }); + + it('maps a service error', async () => { + const autocomplete = vi.fn().mockRejectedValue(withError(503, 'Upstream down')); + const c = makeController({ autocompleteDisabled: () => false, autocomplete }); + expect(await thrown(() => c.autocomplete(user, 'be'))).toEqual({ + status: 503, body: { error: 'Upstream down' }, + }); + }); }); describe('GET /details/:placeId', () => { @@ -138,12 +209,30 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { expect(photo).toHaveBeenCalledWith(3, 'coords:1,2', 1, 2, 'Spot'); }); - it('maps a service error', async () => { + it('maps a 4xx service error', async () => { const photo = vi.fn().mockRejectedValue(withError(404, 'No photo available')); expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({ status: 404, body: { error: 'No photo available' }, }); }); + + it('logs and maps a 5xx service error', async () => { + const photo = vi.fn().mockRejectedValue(withError(502, 'Upstream failed')); + expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({ + status: 502, body: { error: 'Upstream failed' }, + }); + expect(console.error).toHaveBeenCalledWith('Place photo error:', expect.any(Error)); + }); + + it('defaults a status-less error to 500 and parses NaN coords', async () => { + const photo = vi.fn().mockRejectedValue(new Error('Error fetching photo')); + expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1'))).toEqual({ + status: 500, body: { error: 'Error fetching photo' }, + }); + const [, , lat, lng] = photo.mock.calls[0]; + expect(Number.isNaN(lat)).toBe(true); + expect(Number.isNaN(lng)).toBe(true); + }); }); describe('GET /place-photo/:placeId/bytes', () => { @@ -190,6 +279,18 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' }); }); + + it('does not re-send a 404 when the stream errors after headers were flushed', () => { + let onError: () => void = () => {}; + const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() }; + createReadStream.mockReturnValue(stream); + const res = makeRes(); + (res as { headersSent: boolean }).headersSent = true; + makeController({ photoBytesPath: () => '/cache/p1.jpg' }).placePhotoBytes('p1', res); + onError(); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + }); }); describe('GET /reverse', () => { @@ -220,11 +321,39 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { expect(await makeController({ resolveUrl }).resolveUrl('https://maps.app.goo.gl/x')).toEqual({ lat: 1, lng: 2, name: null, address: null }); }); + it('400 when url is not a string', async () => { + expect(await thrown(() => makeController({}).resolveUrl(42 as unknown as string))).toEqual({ + status: 400, body: { error: 'URL is required' }, + }); + }); + it('maps a service error, defaulting to 400', async () => { const resolveUrl = vi.fn().mockRejectedValue(new Error('Failed to resolve URL')); expect(await thrown(() => makeController({ resolveUrl }).resolveUrl('bad'))).toEqual({ status: 400, body: { error: 'Failed to resolve URL' }, }); }); + + it('honours an explicit status on the thrown error', async () => { + const resolveUrl = vi.fn().mockRejectedValue(withError(422, 'Unsupported link')); + expect(await thrown(() => makeController({ resolveUrl }).resolveUrl('bad'))).toEqual({ + status: 422, body: { error: 'Unsupported link' }, + }); + }); + + it('falls back to the default message when a non-Error is thrown', async () => { + const resolveUrl = vi.fn().mockRejectedValue('nope'); + expect(await thrown(() => makeController({ resolveUrl }).resolveUrl('bad'))).toEqual({ + status: 400, body: { error: 'Failed to resolve URL' }, + }); + }); + }); + + describe('GET /reverse', () => { + it('forwards lang through to the service', async () => { + const reverse = vi.fn().mockResolvedValue({ name: null, address: null }); + await makeController({ reverse }).reverse('1', '2', 'fr'); + expect(reverse).toHaveBeenCalledWith('1', '2', 'fr'); + }); }); }); diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts new file mode 100644 index 00000000..25cda7f2 --- /dev/null +++ b/server/tests/unit/nest/maps.service.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { maps } = vi.hoisted(() => ({ + maps: { + searchPlaces: vi.fn(), + autocompletePlaces: vi.fn(), + getPlaceDetails: vi.fn(), + getPlaceDetailsExpanded: vi.fn(), + getPlacePhoto: vi.fn(), + reverseGeocode: vi.fn(), + resolveGoogleMapsUrl: vi.fn(), + searchOverpassPois: vi.fn(), + }, +})); +vi.mock('../../../src/services/mapsService', () => maps); + +const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() })); +vi.mock('../../../src/services/placePhotoCache', () => ({ serveFilePath })); + +import { MapsService } from '../../../src/nest/maps/maps.service'; +import type { DatabaseService } from '../../../src/nest/database/database.service'; + +/** A DatabaseService stub whose get() returns the row the test wants. */ +function makeDb(row?: { value: string }) { + const get = vi.fn(() => row); + const db = { get } as unknown as DatabaseService; + return { db, get }; +} + +function svc(row?: { value: string }) { + return new MapsService(makeDb(row).db); +} + +beforeEach(() => vi.clearAllMocks()); + +describe('MapsService', () => { + describe('kill-switch settings reads', () => { + it('reports a switch disabled when the stored value is exactly "false"', () => { + expect(svc({ value: 'false' }).autocompleteDisabled()).toBe(true); + expect(svc({ value: 'false' }).detailsDisabled()).toBe(true); + expect(svc({ value: 'false' }).photosDisabled()).toBe(true); + }); + + it('reports enabled when the value is "true"', () => { + expect(svc({ value: 'true' }).autocompleteDisabled()).toBe(false); + expect(svc({ value: 'true' }).detailsDisabled()).toBe(false); + expect(svc({ value: 'true' }).photosDisabled()).toBe(false); + }); + + it('reports enabled when the setting row is absent', () => { + expect(svc(undefined).autocompleteDisabled()).toBe(false); + expect(svc(undefined).detailsDisabled()).toBe(false); + expect(svc(undefined).photosDisabled()).toBe(false); + }); + + it('queries the matching app_settings key', () => { + const { db, get } = makeDb({ value: 'true' }); + const s = new MapsService(db); + s.autocompleteDisabled(); + expect(get).toHaveBeenCalledWith(expect.stringContaining('app_settings'), 'places_autocomplete_enabled'); + s.detailsDisabled(); + expect(get).toHaveBeenCalledWith(expect.any(String), 'places_details_enabled'); + s.photosDisabled(); + expect(get).toHaveBeenCalledWith(expect.any(String), 'places_photos_enabled'); + }); + }); + + describe('delegation to the legacy maps service', () => { + it('search forwards userId, query, lang and bias', () => { + maps.searchPlaces.mockResolvedValue({ places: [], source: 'osm' }); + const bias = { lat: 1, lng: 2, radius: 5 }; + svc().search(3, 'berlin', 'de', bias); + expect(maps.searchPlaces).toHaveBeenCalledWith(3, 'berlin', 'de', bias); + }); + + it('search works without optional args', () => { + svc().search(3, 'berlin'); + expect(maps.searchPlaces).toHaveBeenCalledWith(3, 'berlin', undefined, undefined); + }); + + it('autocomplete forwards through', () => { + const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } }; + svc().autocomplete(3, 'be', 'en', bias); + expect(maps.autocompletePlaces).toHaveBeenCalledWith(3, 'be', 'en', bias); + }); + + it('details forwards through', () => { + svc().details(3, 'p1', 'de'); + expect(maps.getPlaceDetails).toHaveBeenCalledWith(3, 'p1', 'de'); + }); + + it('detailsExpanded forwards refresh through', () => { + svc().detailsExpanded(3, 'p1', 'de', true); + expect(maps.getPlaceDetailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true); + }); + + it('photo forwards coords and name through', () => { + svc().photo(3, 'p1', 1.5, 2.5, 'Spot'); + expect(maps.getPlacePhoto).toHaveBeenCalledWith(3, 'p1', 1.5, 2.5, 'Spot'); + }); + + it('reverse forwards through', () => { + svc().reverse('1', '2', 'de'); + expect(maps.reverseGeocode).toHaveBeenCalledWith('1', '2', 'de'); + }); + + it('resolveUrl forwards through', () => { + svc().resolveUrl('https://maps.app.goo.gl/x'); + expect(maps.resolveGoogleMapsUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/x'); + }); + + it('pois forwards category and bbox through', () => { + const bbox = { south: 1, west: 2, north: 3, east: 4 }; + svc().pois('cafe', bbox); + expect(maps.searchOverpassPois).toHaveBeenCalledWith('cafe', bbox); + }); + }); + + describe('photoBytesPath', () => { + it('returns the cached file path from placePhotoCache', () => { + serveFilePath.mockReturnValue('/cache/p1.jpg'); + expect(svc().photoBytesPath('p1')).toBe('/cache/p1.jpg'); + expect(serveFilePath).toHaveBeenCalledWith('p1'); + }); + + it('returns null when nothing is cached', () => { + serveFilePath.mockReturnValue(null); + expect(svc().photoBytesPath('p1')).toBeNull(); + }); + }); +}); diff --git a/server/tests/unit/nest/memories.controller.test.ts b/server/tests/unit/nest/memories.controller.test.ts new file mode 100644 index 00000000..362a6f73 --- /dev/null +++ b/server/tests/unit/nest/memories.controller.test.ts @@ -0,0 +1,748 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Response } from 'express'; +import type { Request } from 'express'; +import { UnifiedMemoriesController } from '../../../src/nest/memories/unified.controller'; +import { ImmichMemoriesController } from '../../../src/nest/memories/immich.controller'; +import { SynologyMemoriesController } from '../../../src/nest/memories/synology.controller'; +import type { MemoriesService } from '../../../src/nest/memories/memories.service'; +import type { User } from '../../../src/types'; + +const { getClientIp } = vi.hoisted(() => ({ getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/services/auditLog', () => ({ getClientIp })); + +const user = { id: 7, role: 'user', email: 'u@example.test' } as User; + +function makeService(overrides: Partial = {}): MemoriesService { + return { ...overrides } as unknown as MemoriesService; +} + +type MockRes = Response & { + status: ReturnType; + json: ReturnType; + statusCode: number; +}; + +function makeRes(): MockRes { + const res = { + statusCode: 200, + status: vi.fn(function (this: unknown, c: number) { + (res as { statusCode: number }).statusCode = c; + return res; + }), + json: vi.fn(function () { + return res; + }), + }; + return res as unknown as MockRes; +} + +// ───────────────────────────────────────────────────────────────────────────── +describe('UnifiedMemoriesController (parity with /api/integrations/memories/unified)', () => { + describe('GET /trips/:tripId/photos', () => { + it('returns the photos on success', () => { + const svc = makeService({ listTripPhotos: vi.fn().mockReturnValue({ data: [{ id: 1 }] }) }); + const res = makeRes(); + new UnifiedMemoriesController(svc).listPhotos(user, '5', res); + expect(svc.listTripPhotos).toHaveBeenCalledWith('5', 7); + expect(res.json).toHaveBeenCalledWith({ photos: [{ id: 1 }] }); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('maps the error envelope to its status + message', () => { + const svc = makeService({ listTripPhotos: vi.fn().mockReturnValue({ error: { status: 404, message: 'Trip not found' } }) }); + const res = makeRes(); + new UnifiedMemoriesController(svc).listPhotos(user, '5', res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Trip not found' }); + }); + }); + + describe('POST /trips/:tripId/photos', () => { + it('defaults shared to true and selections to [] when both are absent', async () => { + const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 3 } }); + const svc = makeService({ addTripPhotos }); + const res = makeRes(); + await new UnifiedMemoriesController(svc).addPhotos(user, '5', {}, 'sock', res); + expect(addTripPhotos).toHaveBeenCalledWith('5', 7, true, [], 'sock'); + expect(res.json).toHaveBeenCalledWith({ success: true, added: 3 }); + }); + + it('coerces a falsy shared flag and forwards an array of selections', async () => { + const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 0 } }); + const svc = makeService({ addTripPhotos }); + const selections = [{ provider: 'immich', asset_ids: ['a'] }]; + await new UnifiedMemoriesController(svc).addPhotos(user, '5', { shared: 0, selections }, 'sock', makeRes()); + expect(addTripPhotos).toHaveBeenCalledWith('5', 7, false, selections, 'sock'); + }); + + it('ignores a non-array selections payload', async () => { + const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 0 } }); + const svc = makeService({ addTripPhotos }); + await new UnifiedMemoriesController(svc).addPhotos(user, '5', { selections: 'nope', shared: true }, 'sock', makeRes()); + expect(addTripPhotos).toHaveBeenCalledWith('5', 7, true, [], 'sock'); + }); + + it('maps the error envelope', async () => { + const svc = makeService({ addTripPhotos: vi.fn().mockResolvedValue({ error: { status: 403, message: 'No access' } }) }); + const res = makeRes(); + await new UnifiedMemoriesController(svc).addPhotos(user, '5', {}, 'sock', res); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'No access' }); + }); + }); + + describe('PUT /trips/:tripId/photos/sharing', () => { + it('coerces photo_id to a number and forwards shared', async () => { + const setTripPhotoSharing = vi.fn().mockResolvedValue({ data: {} }); + const svc = makeService({ setTripPhotoSharing }); + const res = makeRes(); + await new UnifiedMemoriesController(svc).setSharing(user, '5', { photo_id: '9', shared: true }, res); + expect(setTripPhotoSharing).toHaveBeenCalledWith('5', 7, 9, true); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + + it('maps the error envelope', async () => { + const svc = makeService({ setTripPhotoSharing: vi.fn().mockResolvedValue({ error: { status: 404, message: 'Photo not found' } }) }); + const res = makeRes(); + await new UnifiedMemoriesController(svc).setSharing(user, '5', { photo_id: '9', shared: false }, res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Photo not found' }); + }); + }); + + describe('DELETE /trips/:tripId/photos', () => { + it('removes the photo on success', () => { + const removeTripPhoto = vi.fn().mockReturnValue({ data: {} }); + const svc = makeService({ removeTripPhoto }); + const res = makeRes(); + new UnifiedMemoriesController(svc).removePhoto(user, '5', { photo_id: 11 }, res); + expect(removeTripPhoto).toHaveBeenCalledWith('5', 7, 11); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + + it('maps the error envelope', () => { + const svc = makeService({ removeTripPhoto: vi.fn().mockReturnValue({ error: { status: 404, message: 'Photo not found' } }) }); + const res = makeRes(); + new UnifiedMemoriesController(svc).removePhoto(user, '5', { photo_id: 11 }, res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Photo not found' }); + }); + }); + + describe('GET /trips/:tripId/album-links', () => { + it('returns the links on success', () => { + const svc = makeService({ listTripAlbumLinks: vi.fn().mockReturnValue({ data: [{ id: 'l1' }] }) }); + const res = makeRes(); + new UnifiedMemoriesController(svc).listAlbumLinks(user, '5', res); + expect(res.json).toHaveBeenCalledWith({ links: [{ id: 'l1' }] }); + }); + + it('maps the error envelope', () => { + const svc = makeService({ listTripAlbumLinks: vi.fn().mockReturnValue({ error: { status: 404, message: 'Trip not found' } }) }); + const res = makeRes(); + new UnifiedMemoriesController(svc).listAlbumLinks(user, '5', res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Trip not found' }); + }); + }); + + describe('POST /trips/:tripId/album-links', () => { + it('forwards a coerced passphrase when present', () => { + const createTripAlbumLink = vi.fn().mockReturnValue({ data: {} }); + const svc = makeService({ createTripAlbumLink }); + const res = makeRes(); + new UnifiedMemoriesController(svc).createAlbumLink( + user, + '5', + { provider: 'synologyphotos', album_id: 'a1', album_name: 'Trip', passphrase: 123 }, + res, + ); + expect(createTripAlbumLink).toHaveBeenCalledWith('5', 7, 'synologyphotos', 'a1', 'Trip', '123'); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + + it('passes undefined when the passphrase is absent or empty', () => { + const createTripAlbumLink = vi.fn().mockReturnValue({ data: {} }); + const svc = makeService({ createTripAlbumLink }); + new UnifiedMemoriesController(svc).createAlbumLink(user, '5', { provider: 'immich', album_id: 'a1', album_name: 'Trip', passphrase: '' }, makeRes()); + expect(createTripAlbumLink).toHaveBeenCalledWith('5', 7, 'immich', 'a1', 'Trip', undefined); + }); + + it('maps the error envelope', () => { + const svc = makeService({ createTripAlbumLink: vi.fn().mockReturnValue({ error: { status: 400, message: 'Invalid provider' } }) }); + const res = makeRes(); + new UnifiedMemoriesController(svc).createAlbumLink(user, '5', {}, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid provider' }); + }); + }); + + describe('DELETE /trips/:tripId/album-links/:linkId', () => { + it('removes the link on success', () => { + const removeAlbumLink = vi.fn().mockReturnValue({ data: {} }); + const svc = makeService({ removeAlbumLink }); + const res = makeRes(); + new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res); + expect(removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + + it('maps the error envelope', () => { + const svc = makeService({ removeAlbumLink: vi.fn().mockReturnValue({ error: { status: 404, message: 'Link not found' } }) }); + const res = makeRes(); + new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Link not found' }); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +describe('ImmichMemoriesController (parity with /api/integrations/memories/immich)', () => { + describe('GET /settings', () => { + it('delegates to the service', () => { + const immichGetConnectionSettings = vi.fn().mockReturnValue({ immich_url: 'u' }); + const svc = makeService({ immichGetConnectionSettings }); + expect(new ImmichMemoriesController(svc).getSettings(user)).toEqual({ immich_url: 'u' }); + expect(immichGetConnectionSettings).toHaveBeenCalledWith(7); + }); + }); + + describe('PUT /settings', () => { + const req = {} as Request; + + it('400 when the save fails', async () => { + const svc = makeService({ immichSaveSettings: vi.fn().mockResolvedValue({ success: false, error: 'Bad URL' }) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).putSettings(user, { immich_url: 'x', immich_api_key: 'k' }, req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Bad URL' }); + }); + + it('applies auto_upload when it is a boolean and returns success', async () => { + const immichSaveSettings = vi.fn().mockResolvedValue({ success: true }); + const immichSetAutoUpload = vi.fn(); + const svc = makeService({ immichSaveSettings, immichSetAutoUpload }); + const res = makeRes(); + await new ImmichMemoriesController(svc).putSettings(user, { immich_url: 'x', immich_api_key: 'k', auto_upload: true }, req, res); + expect(immichSaveSettings).toHaveBeenCalledWith(7, 'x', 'k', '1.2.3.4'); + expect(immichSetAutoUpload).toHaveBeenCalledWith(7, true); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + + it('skips auto_upload when it is not a boolean', async () => { + const immichSaveSettings = vi.fn().mockResolvedValue({ success: true }); + const immichSetAutoUpload = vi.fn(); + const svc = makeService({ immichSaveSettings, immichSetAutoUpload }); + await new ImmichMemoriesController(svc).putSettings(user, { auto_upload: 'yes' as unknown as boolean }, req, makeRes()); + expect(immichSetAutoUpload).not.toHaveBeenCalled(); + }); + + it('returns the warning when the save carries one', async () => { + const svc = makeService({ immichSaveSettings: vi.fn().mockResolvedValue({ success: true, warning: 'Unverified TLS' }) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).putSettings(user, {}, req, res); + expect(res.json).toHaveBeenCalledWith({ success: true, warning: 'Unverified TLS' }); + }); + }); + + describe('GET /status', () => { + it('delegates to the service', async () => { + const svc = makeService({ immichGetConnectionStatus: vi.fn().mockResolvedValue({ connected: true }) }); + await expect(new ImmichMemoriesController(svc).getStatus(user)).resolves.toEqual({ connected: true }); + }); + }); + + describe('POST /test', () => { + it('short-circuits to a 200 envelope when url is missing', async () => { + const immichTestConnection = vi.fn(); + const svc = makeService({ immichTestConnection }); + expect(await new ImmichMemoriesController(svc).test({ immich_api_key: 'k' })).toEqual({ connected: false, error: 'URL and API key required' }); + expect(immichTestConnection).not.toHaveBeenCalled(); + }); + + it('short-circuits when the api key is missing', async () => { + const immichTestConnection = vi.fn(); + const svc = makeService({ immichTestConnection }); + expect(await new ImmichMemoriesController(svc).test({ immich_url: 'u' })).toEqual({ connected: false, error: 'URL and API key required' }); + expect(immichTestConnection).not.toHaveBeenCalled(); + }); + + it('delegates when both are present', async () => { + const immichTestConnection = vi.fn().mockResolvedValue({ connected: true }); + const svc = makeService({ immichTestConnection }); + expect(await new ImmichMemoriesController(svc).test({ immich_url: 'u', immich_api_key: 'k' })).toEqual({ connected: true }); + expect(immichTestConnection).toHaveBeenCalledWith('u', 'k'); + }); + }); + + describe('GET /browse', () => { + it('returns the buckets on success', async () => { + const svc = makeService({ immichBrowseTimeline: vi.fn().mockResolvedValue({ buckets: [{ id: 'b' }] }) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).browse(user, res); + expect(res.json).toHaveBeenCalledWith({ buckets: [{ id: 'b' }] }); + }); + + it('maps the error with its status', async () => { + const svc = makeService({ immichBrowseTimeline: vi.fn().mockResolvedValue({ error: 'Not connected', status: 412 }) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).browse(user, res); + expect(res.status).toHaveBeenCalledWith(412); + expect(res.json).toHaveBeenCalledWith({ error: 'Not connected' }); + }); + }); + + describe('POST /search', () => { + it('clamps page to >=1 and size to <=200 and defaults both', async () => { + const immichSearchPhotos = vi.fn().mockResolvedValue({ assets: [{ id: 'a' }], hasMore: true }); + const svc = makeService({ immichSearchPhotos }); + const res = makeRes(); + await new ImmichMemoriesController(svc).search(user, { from: 'f', to: 't' }, res); + expect(immichSearchPhotos).toHaveBeenCalledWith(7, 'f', 't', 1, 50); + expect(res.json).toHaveBeenCalledWith({ assets: [{ id: 'a' }], hasMore: true }); + }); + + it('floors a sub-1 page to 1 and caps an oversized size at 200', async () => { + const immichSearchPhotos = vi.fn().mockResolvedValue({}); + const svc = makeService({ immichSearchPhotos }); + await new ImmichMemoriesController(svc).search(user, { page: 0, size: 9999 }, makeRes()); + expect(immichSearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 1, 200); + }); + + it('honours an explicit page and size within range', async () => { + const immichSearchPhotos = vi.fn().mockResolvedValue({}); + const svc = makeService({ immichSearchPhotos }); + await new ImmichMemoriesController(svc).search(user, { page: 3, size: 25 }, makeRes()); + expect(immichSearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 3, 25); + }); + + it('defaults assets to [] and hasMore to false when omitted', async () => { + const svc = makeService({ immichSearchPhotos: vi.fn().mockResolvedValue({}) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).search(user, {}, res); + expect(res.json).toHaveBeenCalledWith({ assets: [], hasMore: false }); + }); + + it('maps the error envelope', async () => { + const svc = makeService({ immichSearchPhotos: vi.fn().mockResolvedValue({ error: 'down', status: 502 }) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).search(user, {}, res); + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json).toHaveBeenCalledWith({ error: 'down' }); + }); + }); + + describe('GET /assets/:tripId/:assetId/:ownerId/info', () => { + it('400 on an invalid asset id', async () => { + const immichIsValidAssetId = vi.fn().mockReturnValue(false); + const svc = makeService({ immichIsValidAssetId }); + const res = makeRes(); + await new ImmichMemoriesController(svc).assetInfo(user, '5', 'bad', '2', res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid asset ID' }); + }); + + it('403 when access is denied', async () => { + const svc = makeService({ + immichIsValidAssetId: vi.fn().mockReturnValue(true), + canAccessUserPhoto: vi.fn().mockReturnValue(false), + }); + const res = makeRes(); + await new ImmichMemoriesController(svc).assetInfo(user, '5', 'a', '2', res); + expect(svc.canAccessUserPhoto).toHaveBeenCalledWith(7, 2, '5', 'a', 'immich'); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' }); + }); + + it('maps a service error after the guards pass', async () => { + const svc = makeService({ + immichIsValidAssetId: vi.fn().mockReturnValue(true), + canAccessUserPhoto: vi.fn().mockReturnValue(true), + immichGetAssetInfo: vi.fn().mockResolvedValue({ error: 'Asset gone', status: 404 }), + }); + const res = makeRes(); + await new ImmichMemoriesController(svc).assetInfo(user, '5', 'a', '2', res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Asset gone' }); + }); + + it('returns the asset data on success', async () => { + const svc = makeService({ + immichIsValidAssetId: vi.fn().mockReturnValue(true), + canAccessUserPhoto: vi.fn().mockReturnValue(true), + immichGetAssetInfo: vi.fn().mockResolvedValue({ data: { id: 'a', takenAt: 't' } }), + }); + const res = makeRes(); + await new ImmichMemoriesController(svc).assetInfo(user, '5', 'a', '2', res); + expect(res.json).toHaveBeenCalledWith({ id: 'a', takenAt: 't' }); + }); + }); + + describe('GET /assets/.../thumbnail + /original', () => { + it('thumbnail: 400 on invalid id', async () => { + const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(false) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).assetThumbnail(user, '5', 'bad', '2', res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('thumbnail: 403 when access denied', async () => { + const svc = makeService({ + immichIsValidAssetId: vi.fn().mockReturnValue(true), + canAccessUserPhoto: vi.fn().mockReturnValue(false), + }); + const res = makeRes(); + await new ImmichMemoriesController(svc).assetThumbnail(user, '5', 'a', '2', res); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('thumbnail: streams with kind=thumbnail when allowed', async () => { + const immichStreamAsset = vi.fn().mockResolvedValue(undefined); + const svc = makeService({ + immichIsValidAssetId: vi.fn().mockReturnValue(true), + canAccessUserPhoto: vi.fn().mockReturnValue(true), + immichStreamAsset, + }); + const res = makeRes(); + await new ImmichMemoriesController(svc).assetThumbnail(user, '5', 'a', '2', res); + expect(immichStreamAsset).toHaveBeenCalledWith(res, 7, 'a', 'thumbnail', 2); + }); + + it('original: 400 on invalid id', async () => { + const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(false) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).assetOriginal(user, '5', 'bad', '2', res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('original: 403 when access denied', async () => { + const svc = makeService({ + immichIsValidAssetId: vi.fn().mockReturnValue(true), + canAccessUserPhoto: vi.fn().mockReturnValue(false), + }); + const res = makeRes(); + await new ImmichMemoriesController(svc).assetOriginal(user, '5', 'a', '2', res); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('original: streams with kind=original when allowed', async () => { + const immichStreamAsset = vi.fn().mockResolvedValue(undefined); + const svc = makeService({ + immichIsValidAssetId: vi.fn().mockReturnValue(true), + canAccessUserPhoto: vi.fn().mockReturnValue(true), + immichStreamAsset, + }); + const res = makeRes(); + await new ImmichMemoriesController(svc).assetOriginal(user, '5', 'a', '2', res); + expect(immichStreamAsset).toHaveBeenCalledWith(res, 7, 'a', 'original', 2); + }); + }); + + describe('GET /albums + /albums/:albumId/photos', () => { + it('albums: returns the list on success', async () => { + const svc = makeService({ immichListAlbums: vi.fn().mockResolvedValue({ albums: [{ id: 'a' }] }) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).albums(user, res); + expect(res.json).toHaveBeenCalledWith({ albums: [{ id: 'a' }] }); + }); + + it('albums: maps the error envelope', async () => { + const svc = makeService({ immichListAlbums: vi.fn().mockResolvedValue({ error: 'nope', status: 500 }) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).albums(user, res); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'nope' }); + }); + + it('albumPhotos: returns the assets on success', async () => { + const svc = makeService({ immichGetAlbumPhotos: vi.fn().mockResolvedValue({ assets: [{ id: 'p' }] }) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).albumPhotos(user, 'al1', res); + expect(svc.immichGetAlbumPhotos).toHaveBeenCalledWith(7, 'al1'); + expect(res.json).toHaveBeenCalledWith({ assets: [{ id: 'p' }] }); + }); + + it('albumPhotos: maps the error envelope', async () => { + const svc = makeService({ immichGetAlbumPhotos: vi.fn().mockResolvedValue({ error: 'gone', status: 404 }) }); + const res = makeRes(); + await new ImmichMemoriesController(svc).albumPhotos(user, 'al1', res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'gone' }); + }); + }); + + describe('POST /trips/:tripId/album-links/:linkId/sync', () => { + it('maps the error envelope without broadcasting', async () => { + const broadcast = vi.fn(); + const svc = makeService({ immichSyncAlbumAssets: vi.fn().mockResolvedValue({ error: 'Link gone', status: 404 }), broadcast }); + const res = makeRes(); + await new ImmichMemoriesController(svc).sync(user, '5', 'l1', 'sock', res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Link gone' }); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('broadcasts when at least one asset was added', async () => { + const broadcast = vi.fn(); + const svc = makeService({ immichSyncAlbumAssets: vi.fn().mockResolvedValue({ added: 2, total: 10 }), broadcast }); + const res = makeRes(); + await new ImmichMemoriesController(svc).sync(user, '5', 'l1', 'sock', res); + expect(res.json).toHaveBeenCalledWith({ success: true, added: 2, total: 10 }); + expect(broadcast).toHaveBeenCalledWith('5', 'memories:updated', { userId: 7 }, 'sock'); + }); + + it('does not broadcast when nothing was added', async () => { + const broadcast = vi.fn(); + const svc = makeService({ immichSyncAlbumAssets: vi.fn().mockResolvedValue({ added: 0, total: 10 }), broadcast }); + const res = makeRes(); + await new ImmichMemoriesController(svc).sync(user, '5', 'l1', 'sock', res); + expect(res.json).toHaveBeenCalledWith({ success: true, added: 0, total: 10 }); + expect(broadcast).not.toHaveBeenCalled(); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +describe('SynologyMemoriesController (parity with /api/integrations/memories/synologyphotos)', () => { + describe('GET /settings + /status', () => { + it('settings: returns the data on success', async () => { + const svc = makeService({ synologyGetSettings: vi.fn().mockResolvedValue({ success: true, data: { synology_url: 'u' } }) }); + const res = makeRes(); + await new SynologyMemoriesController(svc).getSettings(user, res); + expect(res.json).toHaveBeenCalledWith({ synology_url: 'u' }); + }); + + it('settings: maps the error envelope', async () => { + const svc = makeService({ synologyGetSettings: vi.fn().mockResolvedValue({ success: false, error: { status: 500, message: 'DB error' } }) }); + const res = makeRes(); + await new SynologyMemoriesController(svc).getSettings(user, res); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'DB error' }); + }); + + it('status: delegates', async () => { + const svc = makeService({ synologyGetStatus: vi.fn().mockResolvedValue({ success: true, data: { connected: true } }) }); + const res = makeRes(); + await new SynologyMemoriesController(svc).getStatus(user, res); + expect(res.json).toHaveBeenCalledWith({ connected: true }); + }); + }); + + describe('PUT /settings', () => { + it('400 when the url is missing', async () => { + const synologyUpdateSettings = vi.fn(); + const svc = makeService({ synologyUpdateSettings }); + const res = makeRes(); + await new SynologyMemoriesController(svc).putSettings(user, { synology_username: 'admin' }, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'URL and username are required' }); + expect(synologyUpdateSettings).not.toHaveBeenCalled(); + }); + + it('400 when the username is missing', async () => { + const synologyUpdateSettings = vi.fn(); + const svc = makeService({ synologyUpdateSettings }); + const res = makeRes(); + await new SynologyMemoriesController(svc).putSettings(user, { synology_url: 'http://nas' }, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(synologyUpdateSettings).not.toHaveBeenCalled(); + }); + + it('delegates with trimmed values and the boolean skip-ssl flag (true keyword)', async () => { + const synologyUpdateSettings = vi.fn().mockResolvedValue({ success: true, data: {} }); + const svc = makeService({ synologyUpdateSettings }); + const res = makeRes(); + await new SynologyMemoriesController( + svc, + ).putSettings(user, { synology_url: ' http://nas ', synology_username: ' admin ', synology_password: ' pw ', synology_skip_ssl: 'true' }, res); + expect(synologyUpdateSettings).toHaveBeenCalledWith(7, 'http://nas', 'admin', 'pw', true); + expect(res.json).toHaveBeenCalledWith({}); + }); + + it('treats a literal-true skip-ssl flag as true and other values as false', async () => { + const synologyUpdateSettings = vi.fn().mockResolvedValue({ success: true, data: {} }); + const svc = makeService({ synologyUpdateSettings }); + await new SynologyMemoriesController(svc).putSettings(user, { synology_url: 'u', synology_username: 'a', synology_skip_ssl: true }, makeRes()); + expect(synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', true); + + const svc2 = makeService({ synologyUpdateSettings: vi.fn().mockResolvedValue({ success: true, data: {} }) }); + await new SynologyMemoriesController(svc2).putSettings(user, { synology_url: 'u', synology_username: 'a', synology_skip_ssl: 'no' }, makeRes()); + expect(svc2.synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', false); + }); + }); + + describe('POST /test', () => { + it('reports a single missing field with "is required"', async () => { + const synologyTestConnection = vi.fn(); + const svc = makeService({ synologyTestConnection }); + const res = makeRes(); + await new SynologyMemoriesController(svc).test(user, { synology_url: 'u', synology_username: 'a' }, res); + expect(res.json).toHaveBeenCalledWith({ connected: false, error: 'Password is required' }); + expect(synologyTestConnection).not.toHaveBeenCalled(); + }); + + it('reports multiple missing fields with "are required"', async () => { + const svc = makeService({ synologyTestConnection: vi.fn() }); + const res = makeRes(); + await new SynologyMemoriesController(svc).test(user, {}, res); + expect(res.json).toHaveBeenCalledWith({ connected: false, error: 'URL, Username, Password are required' }); + }); + + it('delegates when every field is present (otp + skip-ssl forwarded)', async () => { + const synologyTestConnection = vi.fn().mockResolvedValue({ success: true, data: { connected: true } }); + const svc = makeService({ synologyTestConnection }); + const res = makeRes(); + await new SynologyMemoriesController( + svc, + ).test(user, { synology_url: 'u', synology_username: 'a', synology_password: 'p', synology_otp: '123', synology_skip_ssl: true }, res); + expect(synologyTestConnection).toHaveBeenCalledWith(7, 'u', 'a', 'p', '123', true); + expect(res.json).toHaveBeenCalledWith({ connected: true }); + }); + }); + + describe('GET /albums + /albums/:albumId/photos', () => { + it('albums: delegates', async () => { + const svc = makeService({ synologyListAlbums: vi.fn().mockResolvedValue({ success: true, data: { albums: [] } }) }); + const res = makeRes(); + await new SynologyMemoriesController(svc).albums(user, res); + expect(res.json).toHaveBeenCalledWith({ albums: [] }); + }); + + it('albumPhotos: forwards a coerced passphrase when present', async () => { + const synologyGetAlbumPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); + const svc = makeService({ synologyGetAlbumPhotos }); + const res = makeRes(); + await new SynologyMemoriesController(svc).albumPhotos(user, 'al1', 'secret', res); + expect(synologyGetAlbumPhotos).toHaveBeenCalledWith(7, 'al1', 'secret'); + expect(res.json).toHaveBeenCalledWith({ assets: [] }); + }); + + it('albumPhotos: passes undefined when the passphrase query is absent', async () => { + const synologyGetAlbumPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); + const svc = makeService({ synologyGetAlbumPhotos }); + await new SynologyMemoriesController(svc).albumPhotos(user, 'al1', undefined, makeRes()); + expect(synologyGetAlbumPhotos).toHaveBeenCalledWith(7, 'al1', undefined); + }); + }); + + describe('POST /trips/:tripId/album-links/:linkId/sync', () => { + it('delegates and unwraps the success envelope', async () => { + const synologySyncAlbumLink = vi.fn().mockResolvedValue({ success: true, data: { added: 1, total: 2 } }); + const svc = makeService({ synologySyncAlbumLink }); + const res = makeRes(); + await new SynologyMemoriesController(svc).sync(user, '5', 'l1', 'sock', res); + expect(synologySyncAlbumLink).toHaveBeenCalledWith(7, '5', 'l1', 'sock'); + expect(res.json).toHaveBeenCalledWith({ added: 1, total: 2 }); + }); + }); + + describe('POST /search', () => { + it('uses the default offset/limit when nothing is provided', async () => { + const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); + const svc = makeService({ synologySearchPhotos }); + await new SynologyMemoriesController(svc).search(user, {}, makeRes()); + expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 0, 100); + }); + + it('forwards from/to and uses size as the limit when size > 0', async () => { + const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); + const svc = makeService({ synologySearchPhotos }); + await new SynologyMemoriesController(svc).search(user, { from: '2024-01-01', to: '2024-02-01', size: 30 }, makeRes()); + expect(synologySearchPhotos).toHaveBeenCalledWith(7, '2024-01-01', '2024-02-01', 0, 30); + }); + + it('derives the offset from a 1-based page using the limit', async () => { + const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); + const svc = makeService({ synologySearchPhotos }); + await new SynologyMemoriesController(svc).search(user, { page: 3, limit: 20 }, makeRes()); + // page-1 = 2, offset = 2 * 20 = 40 + expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 40, 20); + }); + + it('keeps the explicit offset when page resolves to <= 0', async () => { + const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); + const svc = makeService({ synologySearchPhotos }); + await new SynologyMemoriesController(svc).search(user, { page: 1, offset: 5, limit: 10 }, makeRes()); + expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 5, 10); + }); + + it('falls back to defaults when numeric fields are non-finite', async () => { + const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); + const svc = makeService({ synologySearchPhotos }); + await new SynologyMemoriesController(svc).search(user, { offset: 'x', limit: 'y', page: 'z', size: 'q' }, makeRes()); + expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 0, 100); + }); + }); + + describe('GET /assets/:tripId/:photoId/:ownerId/info', () => { + it('403 when access is denied', async () => { + const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(false) }); + const res = makeRes(); + await new SynologyMemoriesController(svc).assetInfo(user, '5', 'p1', '2', undefined, res); + expect(svc.canAccessUserPhoto).toHaveBeenCalledWith(7, 2, '5', 'p1', 'synologyphotos'); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: "You don't have access to this photo" }); + }); + + it('delegates with the coerced passphrase when access is granted', async () => { + const synologyGetAssetInfo = vi.fn().mockResolvedValue({ success: true, data: { id: 'p1' } }); + const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyGetAssetInfo }); + const res = makeRes(); + await new SynologyMemoriesController(svc).assetInfo(user, '5', 'p1', '2', 'secret', res); + expect(synologyGetAssetInfo).toHaveBeenCalledWith(7, 'p1', 2, 'secret'); + expect(res.json).toHaveBeenCalledWith({ id: 'p1' }); + }); + + it('passes undefined passphrase when the query is absent', async () => { + const synologyGetAssetInfo = vi.fn().mockResolvedValue({ success: true, data: { id: 'p1' } }); + const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyGetAssetInfo }); + await new SynologyMemoriesController(svc).assetInfo(user, '5', 'p1', '2', undefined, makeRes()); + expect(synologyGetAssetInfo).toHaveBeenCalledWith(7, 'p1', 2, undefined); + }); + }); + + describe('GET /assets/:tripId/:photoId/:ownerId/:kind', () => { + it('400 on an invalid kind', async () => { + const synologyStreamAsset = vi.fn(); + const svc = makeService({ synologyStreamAsset }); + const res = makeRes(); + await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'preview', undefined, undefined, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid asset kind' }); + expect(synologyStreamAsset).not.toHaveBeenCalled(); + }); + + it('403 when access is denied for a valid kind', async () => { + const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(false) }); + const res = makeRes(); + await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'thumbnail', undefined, undefined, res); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: "You don't have access to this photo" }); + }); + + it('streams a thumbnail, defaulting size to "sm" when omitted', async () => { + const synologyStreamAsset = vi.fn().mockResolvedValue(undefined); + const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyStreamAsset }); + const res = makeRes(); + await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'thumbnail', undefined, undefined, res); + expect(synologyStreamAsset).toHaveBeenCalledWith(res, 7, 2, 'p1', 'thumbnail', 'sm', undefined); + }); + + it('keeps a whitelisted size and forwards the passphrase for an original', async () => { + const synologyStreamAsset = vi.fn().mockResolvedValue(undefined); + const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyStreamAsset }); + const res = makeRes(); + await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'original', 'xl', 'secret', res); + expect(synologyStreamAsset).toHaveBeenCalledWith(res, 7, 2, 'p1', 'original', 'xl', 'secret'); + }); + + it('coerces a non-whitelisted size back to "sm"', async () => { + const synologyStreamAsset = vi.fn().mockResolvedValue(undefined); + const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyStreamAsset }); + const res = makeRes(); + await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'thumbnail', 'huge', undefined, res); + expect(synologyStreamAsset).toHaveBeenCalledWith(res, 7, 2, 'p1', 'thumbnail', 'sm', undefined); + }); + }); +}); diff --git a/server/tests/unit/nest/memories.service.test.ts b/server/tests/unit/nest/memories.service.test.ts new file mode 100644 index 00000000..36bb3f37 --- /dev/null +++ b/server/tests/unit/nest/memories.service.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// The MemoriesService is a thin pass-through over the legacy services/memories/* +// helpers. Mock each legacy module so we can assert the wrapper forwards every +// argument unchanged (and exercise the optional-param call sites). + +const unified = vi.hoisted(() => ({ + listTripPhotos: vi.fn(() => ({ data: [] })), + listTripAlbumLinks: vi.fn(() => ({ data: [] })), + createTripAlbumLink: vi.fn(() => ({ data: {} })), + removeAlbumLink: vi.fn(() => ({ data: {} })), + addTripPhotos: vi.fn(async () => ({ data: { added: 0 } })), + removeTripPhoto: vi.fn(() => ({ data: {} })), + setTripPhotoSharing: vi.fn(async () => ({ data: {} })), +})); +vi.mock('../../../src/services/memories/unifiedService', () => unified); + +const immich = vi.hoisted(() => ({ + getConnectionSettings: vi.fn(() => ({})), + saveImmichSettings: vi.fn(async () => ({ success: true })), + setImmichAutoUpload: vi.fn(), + testConnection: vi.fn(async () => ({ connected: true })), + getConnectionStatus: vi.fn(async () => ({ connected: true })), + browseTimeline: vi.fn(async () => ({ buckets: [] })), + searchPhotos: vi.fn(async () => ({ assets: [] })), + streamImmichAsset: vi.fn(async () => undefined), + listAlbums: vi.fn(async () => ({ albums: [] })), + getAlbumPhotos: vi.fn(async () => ({ assets: [] })), + syncAlbumAssets: vi.fn(async () => ({ added: 0, total: 0 })), + getAssetInfo: vi.fn(async () => ({ data: {} })), + isValidAssetId: vi.fn(() => true), +})); +vi.mock('../../../src/services/memories/immichService', () => immich); + +const synology = vi.hoisted(() => ({ + getSynologySettings: vi.fn(async () => ({ success: true, data: {} })), + updateSynologySettings: vi.fn(async () => ({ success: true, data: {} })), + getSynologyStatus: vi.fn(async () => ({ success: true, data: {} })), + testSynologyConnection: vi.fn(async () => ({ success: true, data: {} })), + listSynologyAlbums: vi.fn(async () => ({ success: true, data: {} })), + getSynologyAlbumPhotos: vi.fn(async () => ({ success: true, data: {} })), + syncSynologyAlbumLink: vi.fn(async () => ({ success: true, data: {} })), + searchSynologyPhotos: vi.fn(async () => ({ success: true, data: {} })), + getSynologyAssetInfo: vi.fn(async () => ({ success: true, data: {} })), + streamSynologyAsset: vi.fn(async () => undefined), +})); +vi.mock('../../../src/services/memories/synologyService', () => synology); + +const helpers = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn(() => true) })); +vi.mock('../../../src/services/memories/helpersService', () => helpers); + +const ws = vi.hoisted(() => ({ broadcast: vi.fn() })); +vi.mock('../../../src/websocket', () => ws); + +import { MemoriesService } from '../../../src/nest/memories/memories.service'; + +const res = {} as import('express').Response; + +describe('MemoriesService (delegation wrapper over services/memories/*)', () => { + let svc: MemoriesService; + + beforeEach(() => { + vi.clearAllMocks(); + svc = new MemoriesService(); + }); + + it('access check + broadcast forward verbatim', () => { + helpers.canAccessUserPhoto.mockReturnValue(false); + expect(svc.canAccessUserPhoto(1, 2, '5', 'a', 'immich')).toBe(false); + expect(helpers.canAccessUserPhoto).toHaveBeenCalledWith(1, 2, '5', 'a', 'immich'); + + svc.broadcast('5', 'memories:updated', { userId: 1 }, 'sock'); + expect(ws.broadcast).toHaveBeenCalledWith('5', 'memories:updated', { userId: 1 }, 'sock'); + }); + + it('broadcast forwards an absent socket id as undefined', () => { + svc.broadcast('5', 'memories:updated', { userId: 1 }); + expect(ws.broadcast).toHaveBeenCalledWith('5', 'memories:updated', { userId: 1 }, undefined); + }); + + it('unified methods delegate', async () => { + svc.listTripPhotos('5', 7); + expect(unified.listTripPhotos).toHaveBeenCalledWith('5', 7); + + const selections = [{ provider: 'immich', asset_ids: ['a'] }]; + await svc.addTripPhotos('5', 7, true, selections, 'sock'); + expect(unified.addTripPhotos).toHaveBeenCalledWith('5', 7, true, selections, 'sock'); + + await svc.setTripPhotoSharing('5', 7, 9, false); + expect(unified.setTripPhotoSharing).toHaveBeenCalledWith('5', 7, 9, false); + + svc.removeTripPhoto('5', 7, 9); + expect(unified.removeTripPhoto).toHaveBeenCalledWith('5', 7, 9); + + svc.listTripAlbumLinks('5', 7); + expect(unified.listTripAlbumLinks).toHaveBeenCalledWith('5', 7); + + svc.removeAlbumLink('5', 'l1', 7); + expect(unified.removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7); + }); + + it('createTripAlbumLink forwards a passphrase when present and omits it when absent', () => { + svc.createTripAlbumLink('5', 7, 'immich', 'a1', 'Trip', 'secret'); + expect(unified.createTripAlbumLink).toHaveBeenCalledWith('5', 7, 'immich', 'a1', 'Trip', 'secret'); + + svc.createTripAlbumLink('5', 7, 'immich', 'a1', 'Trip'); + expect(unified.createTripAlbumLink).toHaveBeenLastCalledWith('5', 7, 'immich', 'a1', 'Trip', undefined); + }); + + it('immich methods delegate', async () => { + svc.immichGetConnectionSettings(7); + expect(immich.getConnectionSettings).toHaveBeenCalledWith(7); + + await svc.immichSaveSettings(7, 'u', 'k', '1.2.3.4'); + expect(immich.saveImmichSettings).toHaveBeenCalledWith(7, 'u', 'k', '1.2.3.4'); + + svc.immichSetAutoUpload(7, true); + expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(7, true); + + await svc.immichGetConnectionStatus(7); + expect(immich.getConnectionStatus).toHaveBeenCalledWith(7); + + await svc.immichTestConnection('u', 'k'); + expect(immich.testConnection).toHaveBeenCalledWith('u', 'k'); + + await svc.immichBrowseTimeline(7); + expect(immich.browseTimeline).toHaveBeenCalledWith(7); + + await svc.immichSearchPhotos(7, 'f', 't', 2, 50); + expect(immich.searchPhotos).toHaveBeenCalledWith(7, 'f', 't', 2, 50); + + expect(svc.immichIsValidAssetId('abc')).toBe(true); + expect(immich.isValidAssetId).toHaveBeenCalledWith('abc'); + + await svc.immichGetAssetInfo(7, 'a', 2); + expect(immich.getAssetInfo).toHaveBeenCalledWith(7, 'a', 2); + + await svc.immichStreamAsset(res, 7, 'a', 'thumbnail', 2); + expect(immich.streamImmichAsset).toHaveBeenCalledWith(res, 7, 'a', 'thumbnail', 2); + + await svc.immichListAlbums(7); + expect(immich.listAlbums).toHaveBeenCalledWith(7); + + await svc.immichGetAlbumPhotos(7, 'al1'); + expect(immich.getAlbumPhotos).toHaveBeenCalledWith(7, 'al1'); + + await svc.immichSyncAlbumAssets('5', 'l1', 7, 'sock'); + expect(immich.syncAlbumAssets).toHaveBeenCalledWith('5', 'l1', 7, 'sock'); + }); + + it('synology methods delegate', async () => { + await svc.synologyGetSettings(7); + expect(synology.getSynologySettings).toHaveBeenCalledWith(7); + + await svc.synologyUpdateSettings(7, 'u', 'a', 'p', true); + expect(synology.updateSynologySettings).toHaveBeenCalledWith(7, 'u', 'a', 'p', true); + + await svc.synologyGetStatus(7); + expect(synology.getSynologyStatus).toHaveBeenCalledWith(7); + + await svc.synologyTestConnection(7, 'u', 'a', 'p', '123', false); + expect(synology.testSynologyConnection).toHaveBeenCalledWith(7, 'u', 'a', 'p', '123', false); + + await svc.synologyListAlbums(7); + expect(synology.listSynologyAlbums).toHaveBeenCalledWith(7); + + await svc.synologySyncAlbumLink(7, '5', 'l1', 'sock'); + expect(synology.syncSynologyAlbumLink).toHaveBeenCalledWith(7, '5', 'l1', 'sock'); + + await svc.synologySearchPhotos(7, 'f', 't', 0, 100); + expect(synology.searchSynologyPhotos).toHaveBeenCalledWith(7, 'f', 't', 0, 100); + }); + + it('synology album-photos forwards a passphrase when present and omits it when absent', async () => { + await svc.synologyGetAlbumPhotos(7, 'al1', 'secret'); + expect(synology.getSynologyAlbumPhotos).toHaveBeenCalledWith(7, 'al1', 'secret'); + + await svc.synologyGetAlbumPhotos(7, 'al1'); + expect(synology.getSynologyAlbumPhotos).toHaveBeenLastCalledWith(7, 'al1', undefined); + }); + + it('synology asset-info + stream forward a passphrase when present and omit it when absent', async () => { + await svc.synologyGetAssetInfo(7, 'p1', 2, 'secret'); + expect(synology.getSynologyAssetInfo).toHaveBeenCalledWith(7, 'p1', 2, 'secret'); + + await svc.synologyGetAssetInfo(7, 'p1', 2); + expect(synology.getSynologyAssetInfo).toHaveBeenLastCalledWith(7, 'p1', 2, undefined); + + await svc.synologyStreamAsset(res, 7, 2, 'p1', 'thumbnail', 'sm', 'secret'); + expect(synology.streamSynologyAsset).toHaveBeenCalledWith(res, 7, 2, 'p1', 'thumbnail', 'sm', 'secret'); + + await svc.synologyStreamAsset(res, 7, 2, 'p1', 'original', 'xl'); + expect(synology.streamSynologyAsset).toHaveBeenLastCalledWith(res, 7, 2, 'p1', 'original', 'xl', undefined); + }); +}); diff --git a/server/tests/unit/nest/oauth.controller.test.ts b/server/tests/unit/nest/oauth.controller.test.ts index 6d833d74..3b6be649 100644 --- a/server/tests/unit/nest/oauth.controller.test.ts +++ b/server/tests/unit/nest/oauth.controller.test.ts @@ -4,6 +4,9 @@ import type { Request, Response } from 'express'; vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logWarn: vi.fn() })); +import { getClientIp } from '../../../src/services/auditLog'; +const getClientIpMock = vi.mocked(getClientIp); + import { OauthPublicController } from '../../../src/nest/oauth/oauth-public.controller'; import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller'; import { RateLimitService } from '../../../src/nest/auth/rate-limit.service'; @@ -142,6 +145,95 @@ describe('OauthPublicController /token', () => { new OauthPublicController(osvc(), s).token(reqWith({ client_id: 'c' }), res); expect(res.statusCode).toBe(429); }); + + it('falls back to {} when the body is not an object', () => { + const res = makeRes(); + new OauthPublicController(osvc(), rl()).token({ ip: '7.7.7.7', body: 'not-an-object' } as unknown as Request, res); + // no client_id in the {} fallback -> 401 + expect(res.statusCode).toBe(401); + expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' }); + }); + + it('authorization_code: invalid client secret writes an audit + 401', () => { + const res = makeRes(); + new OauthPublicController(osvc({ + consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }), + authenticateClient: vi.fn().mockReturnValue(null), + }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res); + expect(res.statusCode).toBe(401); + expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' }); + }); + + it('refresh_token: invalid_client maps to its specific 401 message', () => { + const res = makeRes(); + new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl()) + .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res); + expect(res.statusCode).toBe(401); + expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' }); + }); + + it('refresh_token: defaults the status to 400 when the service omits it', () => { + const res = makeRes(); + new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant' }) }), rl()) + .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res); + expect(res.statusCode).toBe(400); + }); + + it('client_credentials: 401 when the client cannot be authenticated', () => { + const res = makeRes(); + new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + .token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res); + expect(res.statusCode).toBe(401); + expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' }); + }); + + it('client_credentials: honours a valid requested scope subset', () => { + const res = makeRes(); + const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' }); + new OauthPublicController(osvc({ + authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }), + issueClientCredentialsToken, + }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a' }), res); + expect(res.body).toEqual({ access_token: 'cc_at' }); + expect(issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['a'], expect.any(String)); + }); + + it('client_credentials: derives the audience from an explicit resource', () => { + const res = makeRes(); + const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' }); + new OauthPublicController(osvc({ + authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }), + issueClientCredentialsToken, + }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', resource: 'https://aud/' }), res); + // trailing slashes are trimmed, not the mcpSafeUrl fallback + expect(issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['a'], 'https://aud'); + }); + + it('logs a dash for a missing ip on the authorization_code client-auth failure', () => { + getClientIpMock.mockReturnValueOnce(undefined); + const res = makeRes(); + new OauthPublicController(osvc({ + consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }), + authenticateClient: vi.fn().mockReturnValue(null), + }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res); + expect(res.statusCode).toBe(401); + }); + + it('logs a dash for a missing ip on the refresh invalid_client failure', () => { + getClientIpMock.mockReturnValueOnce(undefined); + const res = makeRes(); + new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl()) + .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res); + expect(res.statusCode).toBe(401); + }); + + it('logs a dash for a missing ip on the client_credentials auth failure', () => { + getClientIpMock.mockReturnValueOnce(undefined); + const res = makeRes(); + new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + .token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res); + expect(res.statusCode).toBe(401); + }); }); describe('OauthPublicController /userinfo + /revoke', () => { @@ -155,6 +247,21 @@ describe('OauthPublicController /userinfo + /revoke', () => { expect(r2.body).toEqual({ sub: '1', email: 'a@b.c', email_verified: true, preferred_username: 'u' }); }); + it('userinfo: 404 empty when MCP is disabled', () => { + const res = makeRes(); + new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).userinfo('Bearer tok', res); + expect(res.statusCode).toBe(404); + expect(res.ended).toBe(true); + }); + + it('userinfo: 401 with the error challenge when the token is unknown', () => { + const res = makeRes(); + new OauthPublicController(osvc({ getUserByAccessToken: vi.fn().mockReturnValue(null) }), rl()).userinfo('Bearer tok', res); + expect(res.statusCode).toBe(401); + expect(res.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP", error="invalid_token"'); + expect(res.body).toEqual({ error: 'invalid_token' }); + }); + it('revoke: 400 without token/client, always 200 once authenticated', () => { const r1 = makeRes(); new OauthPublicController(osvc(), rl()).revoke({ ip: '1', body: { client_id: 'c' } } as Request, r1); @@ -166,6 +273,45 @@ describe('OauthPublicController /userinfo + /revoke', () => { expect(r2.body).toEqual({}); expect(revokeToken).toHaveBeenCalled(); }); + + it('revoke: 404 empty when MCP is disabled', () => { + const res = makeRes(); + new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).revoke({ ip: '1', body: {} } as Request, res); + expect(res.statusCode).toBe(404); + expect(res.ended).toBe(true); + }); + + it('revoke: 429 when the per-ip bucket is exhausted', () => { + const s = rl(); + for (let i = 0; i < 10; i++) s.check('oauth_revoke', '1', 10, 60000, Date.now()); + const res = makeRes(); + new OauthPublicController(osvc(), s).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res); + expect(res.statusCode).toBe(429); + }); + + it('revoke: falls back to a default ip key and {} body when both are missing', () => { + const res = makeRes(); + new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken: vi.fn() }), rl()) + .revoke({ body: undefined } as unknown as Request, res); + // body fell back to {} -> token/client missing -> 400 + expect(res.statusCode).toBe(400); + }); + + it('revoke: 401 when the client credentials are invalid', () => { + const res = makeRes(); + new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + .revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res); + expect(res.statusCode).toBe(401); + expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' }); + }); + + it('revoke: logs a dash for a missing ip on the invalid-client failure', () => { + getClientIpMock.mockReturnValueOnce(undefined); + const res = makeRes(); + new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + .revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res); + expect(res.statusCode).toBe(401); + }); }); describe('OauthApiController', () => { @@ -215,4 +361,66 @@ describe('OauthApiController', () => { expect(thrown(() => new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: false, error: 'invalid_scope', error_description: 'bad' }) }), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req))).toEqual({ status: 400, body: { error: 'invalid_scope', error_description: 'bad' } }); expect(thrown(() => new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: null }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue(null) }), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req))).toEqual({ status: 503, body: { error: 'server_error', error_description: 'Authorization server is temporarily unavailable' } }); }); + + it('validate: 429 when the per-ip bucket is exhausted', () => { + const s = rl(); + for (let i = 0; i < 30; i++) s.check('oauth_validate', '1.2.3.4', 30, 60000, Date.now()); + const res = makeRes2(); + expect(thrown(() => new OauthApiController(osvc(), s).validate({ ...req } as Request, {}, res))).toEqual({ + status: 429, + body: { error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' }, + }); + }); + + it('validate: falls back to the "unknown" rate-limit key when req.ip is absent', () => { + const res = makeRes2(); + const out = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true }) }), rl()) + .validate({ user: undefined } as unknown as Request, {}, res); + expect(out).toEqual({ valid: true, loginRequired: true }); + }); + + it('validate: forwards the resource + returns the raw result for a logged-in user', () => { + const res = makeRes2(); + const validateAuthorizeRequest = vi.fn().mockReturnValue({ valid: true, scopes: ['s'] }); + const out = new OauthApiController(osvc({ validateAuthorizeRequest }), rl()) + .validate({ ...req, user: { id: 9 } } as unknown as Request, { resource: 'https://r' }, res); + expect(out).toEqual({ valid: true, scopes: ['s'] }); + expect(validateAuthorizeRequest).toHaveBeenCalledWith(expect.objectContaining({ resource: 'https://r' }), 9); + }); + + it('authorize: 403 when MCP is disabled', () => { + expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()) + .authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req))) + .toEqual({ status: 403, body: { error: 'MCP is not enabled' } }); + }); + + it('authorize: carries the state through both the denied and approved redirects', () => { + const denied = new OauthApiController(osvc(), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req); + expect((denied as { redirect: string }).redirect).toContain('state=xyz'); + + const svc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: 'https://aud' }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue('the_code') }); + const ok = new OauthApiController(svc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req); + expect((ok as { redirect: string }).redirect).toContain('code=the_code'); + expect((ok as { redirect: string }).redirect).toContain('state=xyz'); + }); + + it('client/session errors default the status to 400 when the service omits it', () => { + expect(thrown(() => new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).createClient(user, { name: 'X', allowed_scopes: ['a'] }, req))) + .toEqual({ status: 400, body: { error: 'bad' } }); + expect(thrown(() => new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).rotateClient(user, 'c1', req))) + .toEqual({ status: 400, body: { error: 'bad' } }); + expect(thrown(() => new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).deleteClient(user, 'c1', req))) + .toEqual({ status: 404, body: { error: 'not_found' } }); + expect(thrown(() => new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).deleteClient(user, 'c1', req))) + .toEqual({ status: 400, body: { error: 'bad' } }); + expect(thrown(() => new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).revokeSession(user, '1', req))) + .toEqual({ status: 404, body: { error: 'not_found' } }); + expect(thrown(() => new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).revokeSession(user, '1', req))) + .toEqual({ status: 400, body: { error: 'bad' } }); + }); + + it('sessions: 403 when MCP is off on the list', () => { + expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).listSessions(user))) + .toEqual({ status: 403, body: { error: 'MCP is not enabled' } }); + }); }); diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts new file mode 100644 index 00000000..3b1447b8 --- /dev/null +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// The Nest service is a thin wrapper that forwards to the legacy oauthService +// plus the addon/notification helpers. Mock those and assert the delegation. +const { oauth } = vi.hoisted(() => ({ + oauth: { + consumeAuthCode: vi.fn(), + authenticateClient: vi.fn(), + verifyPKCE: vi.fn(), + issueTokens: vi.fn(), + issueClientCredentialsToken: vi.fn(), + refreshTokens: vi.fn(), + revokeToken: vi.fn(), + getUserByAccessToken: vi.fn(), + validateAuthorizeRequest: vi.fn(), + saveConsent: vi.fn(), + createAuthCode: vi.fn(), + listOAuthClients: vi.fn(), + createOAuthClient: vi.fn(), + rotateOAuthClientSecret: vi.fn(), + deleteOAuthClient: vi.fn(), + listOAuthSessions: vi.fn(), + revokeSession: vi.fn(), + }, +})); +vi.mock('../../../src/services/oauthService', () => oauth); + +const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn() })); +vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled })); + +const { getMcpSafeUrl } = vi.hoisted(() => ({ getMcpSafeUrl: vi.fn() })); +vi.mock('../../../src/services/notifications', () => ({ getMcpSafeUrl })); + +import { OauthService } from '../../../src/nest/oauth/oauth.service'; +import { ADDON_IDS } from '../../../src/addons'; + +function svc() { return new OauthService(); } + +beforeEach(() => vi.clearAllMocks()); + +describe('OauthService', () => { + it('mcpEnabled checks the MCP addon flag', () => { + isAddonEnabled.mockReturnValue(true); + expect(svc().mcpEnabled()).toBe(true); + expect(isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.MCP); + isAddonEnabled.mockReturnValue(false); + expect(svc().mcpEnabled()).toBe(false); + }); + + it('mcpSafeUrl forwards to the notifications helper', () => { + getMcpSafeUrl.mockReturnValue('https://safe'); + expect(svc().mcpSafeUrl()).toBe('https://safe'); + expect(getMcpSafeUrl).toHaveBeenCalled(); + }); + + it('consumeAuthCode delegates', () => { + oauth.consumeAuthCode.mockReturnValue({ clientId: 'c' }); + expect(svc().consumeAuthCode('code')).toEqual({ clientId: 'c' }); + expect(oauth.consumeAuthCode).toHaveBeenCalledWith('code'); + }); + + it('authenticateClient delegates with both args', () => { + oauth.authenticateClient.mockReturnValue({ id: 'c' }); + expect(svc().authenticateClient('c', 'secret')).toEqual({ id: 'c' }); + expect(oauth.authenticateClient).toHaveBeenCalledWith('c', 'secret'); + }); + + it('verifyPKCE delegates', () => { + oauth.verifyPKCE.mockReturnValue(true); + expect(svc().verifyPKCE('v', 'ch')).toBe(true); + expect(oauth.verifyPKCE).toHaveBeenCalledWith('v', 'ch'); + }); + + it('issueTokens forwards the full argument list', () => { + oauth.issueTokens.mockReturnValue({ access_token: 'at' }); + expect(svc().issueTokens('c', 1, ['s'], null, 'aud')).toEqual({ access_token: 'at' }); + expect(oauth.issueTokens).toHaveBeenCalledWith('c', 1, ['s'], null, 'aud'); + }); + + it('issueClientCredentialsToken forwards the full argument list', () => { + oauth.issueClientCredentialsToken.mockReturnValue({ access_token: 'cc' }); + expect(svc().issueClientCredentialsToken('c', 1, ['s'], 'aud')).toEqual({ access_token: 'cc' }); + expect(oauth.issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['s'], 'aud'); + }); + + it('refreshTokens forwards the full argument list', () => { + oauth.refreshTokens.mockReturnValue({ tokens: { access_token: 'new' } }); + expect(svc().refreshTokens('rt', 'c', 's', '1.2.3.4')).toEqual({ tokens: { access_token: 'new' } }); + expect(oauth.refreshTokens).toHaveBeenCalledWith('rt', 'c', 's', '1.2.3.4'); + }); + + it('revokeToken forwards the full argument list', () => { + svc().revokeToken('t', 'c', undefined, '1.2.3.4'); + expect(oauth.revokeToken).toHaveBeenCalledWith('t', 'c', undefined, '1.2.3.4'); + }); + + it('getUserByAccessToken delegates', () => { + oauth.getUserByAccessToken.mockReturnValue({ user: { id: 1 } }); + expect(svc().getUserByAccessToken('tok')).toEqual({ user: { id: 1 } }); + expect(oauth.getUserByAccessToken).toHaveBeenCalledWith('tok'); + }); + + it('validateAuthorizeRequest delegates with the user id', () => { + oauth.validateAuthorizeRequest.mockReturnValue({ valid: true }); + const params = { response_type: 'code' } as never; + expect(svc().validateAuthorizeRequest(params, 5)).toEqual({ valid: true }); + expect(oauth.validateAuthorizeRequest).toHaveBeenCalledWith(params, 5); + }); + + it('saveConsent forwards the full argument list', () => { + svc().saveConsent('c', 1, ['s'], '1.2.3.4'); + expect(oauth.saveConsent).toHaveBeenCalledWith('c', 1, ['s'], '1.2.3.4'); + }); + + it('createAuthCode forwards the params object', () => { + oauth.createAuthCode.mockReturnValue('the_code'); + const p = { clientId: 'c', userId: 1, redirectUri: 'u', scopes: ['s'], resource: null, codeChallenge: 'cc', codeChallengeMethod: 'S256' } as const; + expect(svc().createAuthCode(p)).toBe('the_code'); + expect(oauth.createAuthCode).toHaveBeenCalledWith(p); + }); + + it('listOAuthClients delegates', () => { + oauth.listOAuthClients.mockReturnValue([{ id: 'c1' }]); + expect(svc().listOAuthClients(1)).toEqual([{ id: 'c1' }]); + expect(oauth.listOAuthClients).toHaveBeenCalledWith(1); + }); + + it('createOAuthClient forwards the full argument list', () => { + oauth.createOAuthClient.mockReturnValue({ client_id: 'c1' }); + expect(svc().createOAuthClient(1, 'CLI', ['https://cb'], ['a'], '1.2.3.4', { allowsClientCredentials: true })).toEqual({ client_id: 'c1' }); + expect(oauth.createOAuthClient).toHaveBeenCalledWith(1, 'CLI', ['https://cb'], ['a'], '1.2.3.4', { allowsClientCredentials: true }); + }); + + it('rotateOAuthClientSecret delegates', () => { + oauth.rotateOAuthClientSecret.mockReturnValue({ client_secret: 'new' }); + expect(svc().rotateOAuthClientSecret(1, 'c1', '1.2.3.4')).toEqual({ client_secret: 'new' }); + expect(oauth.rotateOAuthClientSecret).toHaveBeenCalledWith(1, 'c1', '1.2.3.4'); + }); + + it('deleteOAuthClient delegates', () => { + oauth.deleteOAuthClient.mockReturnValue({}); + expect(svc().deleteOAuthClient(1, 'c1', '1.2.3.4')).toEqual({}); + expect(oauth.deleteOAuthClient).toHaveBeenCalledWith(1, 'c1', '1.2.3.4'); + }); + + it('listOAuthSessions delegates', () => { + oauth.listOAuthSessions.mockReturnValue([{ id: 1 }]); + expect(svc().listOAuthSessions(1)).toEqual([{ id: 1 }]); + expect(oauth.listOAuthSessions).toHaveBeenCalledWith(1); + }); + + it('revokeSession delegates', () => { + oauth.revokeSession.mockReturnValue({}); + expect(svc().revokeSession(1, 7, '1.2.3.4')).toEqual({}); + expect(oauth.revokeSession).toHaveBeenCalledWith(1, 7, '1.2.3.4'); + }); +}); + +describe('OauthModule', () => { + it('wires the public + api controllers and the providers', async () => { + const { OauthModule } = await import('../../../src/nest/oauth/oauth.module'); + const { OauthPublicController } = await import('../../../src/nest/oauth/oauth-public.controller'); + const { OauthApiController } = await import('../../../src/nest/oauth/oauth-api.controller'); + const { OauthService: Svc } = await import('../../../src/nest/oauth/oauth.service'); + const { RateLimitService } = await import('../../../src/nest/auth/rate-limit.service'); + + const controllers = Reflect.getMetadata('controllers', OauthModule); + const providers = Reflect.getMetadata('providers', OauthModule); + expect(controllers).toEqual([OauthPublicController, OauthApiController]); + expect(providers).toEqual([Svc, RateLimitService]); + }); +}); diff --git a/server/tests/unit/nest/oidc.controller.test.ts b/server/tests/unit/nest/oidc.controller.test.ts index dcd3b85c..9f200ea5 100644 --- a/server/tests/unit/nest/oidc.controller.test.ts +++ b/server/tests/unit/nest/oidc.controller.test.ts @@ -71,6 +71,59 @@ describe('OidcController /login', () => { expect(res.redirectedTo).toContain('code_challenge=cc'); expect(res.redirectedTo).toContain('code_challenge_method=S256'); }); + + it('400 when a non-HTTPS issuer is used in production', async () => { + process.env.NODE_ENV = 'production'; + const res = makeRes(); + await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).login(req, res); + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: 'OIDC issuer must use HTTPS in production' }); + }); + + it('allows a non-HTTPS issuer outside production', async () => { + process.env.NODE_ENV = 'development'; + const res = makeRes(); + await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).login(req, res); + expect(res.redirect).toHaveBeenCalled(); + }); + + it('500 when APP_URL is not configured', async () => { + const res = makeRes(); + await new OidcController(svc({ getAppUrl: vi.fn().mockReturnValue('') })).login(req, res); + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: 'APP_URL is not configured. OIDC cannot be used.' }); + }); + + it('passes the invite token from the query into createState', async () => { + const res = makeRes(); + const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' }); + const reqInvite = { query: { invite: 'tok123' }, headers: {} } as unknown as Request; + await new OidcController(svc({ createState })).login(reqInvite, res); + expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', 'tok123'); + }); + + it('trims a trailing slash off APP_URL when building the redirect uri', async () => { + const res = makeRes(); + const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' }); + await new OidcController(svc({ getAppUrl: vi.fn().mockReturnValue('https://app///'), createState })).login(req, res); + expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', undefined); + }); + + it('500 when discovery throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const res = makeRes(); + await new OidcController(svc({ discover: vi.fn().mockRejectedValue(new Error('boom')) })).login(req, res); + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: 'OIDC login failed' }); + }); + + it('500 logs a non-Error rejection without crashing', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const res = makeRes(); + await new OidcController(svc({ discover: vi.fn().mockRejectedValue('plain string') })).login(req, res); + expect(res.statusCode).toBe(500); + expect(spy).toHaveBeenCalledWith('[OIDC] Login error:', 'plain string'); + }); }); describe('OidcController /callback', () => { @@ -131,6 +184,145 @@ describe('OidcController /callback', () => { await c.callback('c', 's', undefined, reqCb('s'), res); expect(res.redirectedTo).toBe('https://app/login?oidc_error=subject_mismatch'); }); + + it('redirects invalid_state when there is no bound state cookie at all', async () => { + const res = makeRes(); + const reqNoCookie = { query: {}, headers: {}, cookies: {} } as unknown as Request; + await new OidcController(svc()).callback('c', 's', undefined, reqNoCookie, res); + expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state'); + }); + + it('tolerates a request with no cookies object', async () => { + const res = makeRes(); + const reqNoCookies = { query: {}, headers: {} } as unknown as Request; + await new OidcController(svc()).callback('c', 's', undefined, reqNoCookies, res); + expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state'); + }); + + it('redirects not_configured when the config disappears mid-flow', async () => { + const res = makeRes(); + await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue(null) })).callback('c', 's', undefined, reqCb('s'), res); + expect(res.redirectedTo).toBe('https://app/login?oidc_error=not_configured'); + }); + + it('redirects issuer_not_https when a non-HTTPS issuer is used in production', async () => { + process.env.NODE_ENV = 'production'; + const res = makeRes(); + await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).callback('c', 's', undefined, reqCb('s'), res); + expect(res.redirectedTo).toBe('https://app/login?oidc_error=issuer_not_https'); + }); + + it('redirects token_failed when the token exchange is not ok', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const res = makeRes(); + await new OidcController(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: false, _status: 401 }) })).callback('c', 's', undefined, reqCb('s'), res); + expect(res.redirectedTo).toBe('https://app/login?oidc_error=token_failed'); + }); + + it('redirects token_failed when the access token is missing', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const res = makeRes(); + await new OidcController(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true }) })).callback('c', 's', undefined, reqCb('s'), res); + expect(res.redirectedTo).toBe('https://app/login?oidc_error=token_failed'); + }); + + it('redirects id_token_invalid when verification fails with a reason', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const res = makeRes(); + await new OidcController(svc({ + exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }), + verifyIdToken: vi.fn().mockResolvedValue({ ok: false, error: 'bad_signature' }), + })).callback('c', 's', undefined, reqCb('s'), res); + expect(res.redirectedTo).toBe('https://app/login?oidc_error=id_token_invalid'); + }); + + it('redirects id_token_invalid when verification fails without an error field', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const res = makeRes(); + await new OidcController(svc({ + exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }), + verifyIdToken: vi.fn().mockResolvedValue({ ok: false }), + })).callback('c', 's', undefined, reqCb('s'), res); + expect(res.redirectedTo).toBe('https://app/login?oidc_error=id_token_invalid'); + }); + + it('falls back to config.issuer when the discovery doc has no issuer', async () => { + const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }); + const res = makeRes(); + await new OidcController(svc({ + discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui' }), + exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }), + verifyIdToken, + getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }), + findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }), + })).callback('c', 's', undefined, reqCb('s'), res); + // doc.issuer absent → (doc.issuer ?? '') is '' → falls back to config.issuer + expect(verifyIdToken).toHaveBeenCalledWith('it', expect.anything(), 'c', 'https://idp'); + expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac'); + }); + + it('strips trailing slashes off the discovery doc issuer before verifying', async () => { + const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }); + const res = makeRes(); + await new OidcController(svc({ + discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp/' }), + exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }), + verifyIdToken, + getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }), + findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }), + })).callback('c', 's', undefined, reqCb('s'), res); + expect(verifyIdToken).toHaveBeenCalledWith('it', expect.anything(), 'c', 'https://idp'); + }); + + it('redirects no_email when the userinfo has no email', async () => { + const res = makeRes(); + await new OidcController(svc({ + exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }), + verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }), + getUserInfo: vi.fn().mockResolvedValue({ sub: 'u1' }), + })).callback('c', 's', undefined, reqCb('s'), res); + expect(res.redirectedTo).toBe('https://app/login?oidc_error=no_email'); + }); + + it('accepts when userinfo omits sub (no cross-check to run)', async () => { + const res = makeRes(); + await new OidcController(svc({ + exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }), + verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }), + getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c' }), + findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }), + })).callback('c', 's', undefined, reqCb('s'), res); + expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac'); + }); + + it('accepts when the id_token claims have a non-string sub (cross-check skipped)', async () => { + const res = makeRes(); + await new OidcController(svc({ + exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }), + verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 12345 } }), + getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'something-else' }), + findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }), + })).callback('c', 's', undefined, reqCb('s'), res); + expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac'); + }); + + it('surfaces a findOrCreateUser provisioning error', async () => { + const res = makeRes(); + await new OidcController(svc({ + exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }), + verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }), + getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }), + findOrCreateUser: vi.fn().mockReturnValue({ error: 'registration_disabled' }), + })).callback('c', 's', undefined, reqCb('s'), res); + expect(res.redirectedTo).toBe('https://app/login?oidc_error=registration_disabled'); + }); + + it('redirects server_error when the flow throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const res = makeRes(); + await new OidcController(svc({ discover: vi.fn().mockRejectedValue(new Error('network down')) })).callback('c', 's', undefined, reqCb('s'), res); + expect(res.redirectedTo).toBe('https://app/login?oidc_error=server_error'); + }); }); describe('OidcController /exchange', () => { diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts new file mode 100644 index 00000000..ae198a1f --- /dev/null +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; + +// The Nest service is a thin pass-through to the legacy OIDC helpers plus a few +// adjacent service modules. Mock each one and assert the wrapper forwards every +// argument and returns whatever the legacy function hands back. +const { oidc } = vi.hoisted(() => ({ + oidc: { + getOidcConfig: vi.fn(), + discover: vi.fn(), + createState: vi.fn(), + consumeState: vi.fn(), + exchangeCodeForToken: vi.fn(), + verifyIdToken: vi.fn(), + getUserInfo: vi.fn(), + findOrCreateUser: vi.fn(), + touchLastLogin: vi.fn(), + generateToken: vi.fn(), + createAuthCode: vi.fn(), + consumeAuthCode: vi.fn(), + frontendUrl: vi.fn(), + }, +})); +vi.mock('../../../src/services/oidcService', () => oidc); + +const { getAppUrl } = vi.hoisted(() => ({ getAppUrl: vi.fn() })); +vi.mock('../../../src/services/notifications', () => ({ getAppUrl })); + +const { resolveAuthToggles } = vi.hoisted(() => ({ resolveAuthToggles: vi.fn() })); +vi.mock('../../../src/services/authService', () => ({ resolveAuthToggles })); + +const { setAuthCookie } = vi.hoisted(() => ({ setAuthCookie: vi.fn() })); +vi.mock('../../../src/services/cookie', () => ({ setAuthCookie })); + +import { OidcService } from '../../../src/nest/oidc/oidc.service'; + +let s: OidcService; +beforeEach(() => { + vi.clearAllMocks(); + s = new OidcService(); +}); + +describe('OidcService', () => { + it('oidcLoginEnabled reads the resolved auth toggle', () => { + resolveAuthToggles.mockReturnValue({ oidc_login: true }); + expect(s.oidcLoginEnabled()).toBe(true); + resolveAuthToggles.mockReturnValue({ oidc_login: false }); + expect(s.oidcLoginEnabled()).toBe(false); + }); + + it('getOidcConfig delegates to the legacy helper', () => { + const cfg = { issuer: 'https://idp' }; + oidc.getOidcConfig.mockReturnValue(cfg); + expect(s.getOidcConfig()).toBe(cfg); + }); + + it('getAppUrl delegates to notifications.getAppUrl', () => { + getAppUrl.mockReturnValue('https://app'); + expect(s.getAppUrl()).toBe('https://app'); + }); + + it('discover forwards the issuer and discovery url', () => { + const doc = { authorization_endpoint: 'https://idp/auth' }; + oidc.discover.mockReturnValue(doc); + expect(s.discover('https://idp', 'https://idp/.well-known')).toBe(doc); + expect(oidc.discover).toHaveBeenCalledWith('https://idp', 'https://idp/.well-known'); + }); + + it('discover works without a discovery url', () => { + oidc.discover.mockReturnValue('doc'); + expect(s.discover('https://idp')).toBe('doc'); + expect(oidc.discover).toHaveBeenCalledWith('https://idp', undefined); + }); + + it('createState forwards the redirect uri and invite token', () => { + const st = { state: 'st', codeChallenge: 'cc' }; + oidc.createState.mockReturnValue(st); + expect(s.createState('https://app/cb', 'inv')).toBe(st); + expect(oidc.createState).toHaveBeenCalledWith('https://app/cb', 'inv'); + }); + + it('createState works without an invite token', () => { + oidc.createState.mockReturnValue({ state: 'st', codeChallenge: 'cc' }); + s.createState('https://app/cb'); + expect(oidc.createState).toHaveBeenCalledWith('https://app/cb', undefined); + }); + + it('consumeState forwards the state', () => { + oidc.consumeState.mockReturnValue({ redirectUri: 'r', codeVerifier: 'v' }); + expect(s.consumeState('st')).toEqual({ redirectUri: 'r', codeVerifier: 'v' }); + expect(oidc.consumeState).toHaveBeenCalledWith('st'); + }); + + it('exchangeCodeForToken spreads all arguments through', () => { + oidc.exchangeCodeForToken.mockReturnValue({ _ok: true }); + const doc = { token_endpoint: 'https://idp/token' } as never; + expect(s.exchangeCodeForToken(doc, 'code', 'redir', 'cid', 'secret', 'verifier')).toEqual({ _ok: true }); + expect(oidc.exchangeCodeForToken).toHaveBeenCalledWith(doc, 'code', 'redir', 'cid', 'secret', 'verifier'); + }); + + it('verifyIdToken spreads all arguments through', () => { + oidc.verifyIdToken.mockReturnValue({ ok: true }); + const doc = { issuer: 'https://idp' } as never; + expect(s.verifyIdToken('id_token', doc, 'cid', 'https://idp')).toEqual({ ok: true }); + expect(oidc.verifyIdToken).toHaveBeenCalledWith('id_token', doc, 'cid', 'https://idp'); + }); + + it('getUserInfo forwards the endpoint and access token', () => { + oidc.getUserInfo.mockReturnValue({ email: 'a@b.c' }); + expect(s.getUserInfo('https://idp/ui', 'at')).toEqual({ email: 'a@b.c' }); + expect(oidc.getUserInfo).toHaveBeenCalledWith('https://idp/ui', 'at'); + }); + + it('findOrCreateUser spreads all arguments through', () => { + const result = { user: { id: 1 } }; + oidc.findOrCreateUser.mockReturnValue(result); + const info = { email: 'a@b.c' } as never; + const cfg = { issuer: 'https://idp' } as never; + expect(s.findOrCreateUser(info, cfg, 'inv')).toBe(result); + expect(oidc.findOrCreateUser).toHaveBeenCalledWith(info, cfg, 'inv'); + }); + + it('touchLastLogin forwards the user id', () => { + s.touchLastLogin(42); + expect(oidc.touchLastLogin).toHaveBeenCalledWith(42); + }); + + it('generateToken forwards the user', () => { + oidc.generateToken.mockReturnValue('jwt'); + expect(s.generateToken({ id: 7 })).toBe('jwt'); + expect(oidc.generateToken).toHaveBeenCalledWith({ id: 7 }); + }); + + it('createAuthCode forwards the token', () => { + oidc.createAuthCode.mockReturnValue('ac'); + expect(s.createAuthCode('jwt')).toBe('ac'); + expect(oidc.createAuthCode).toHaveBeenCalledWith('jwt'); + }); + + it('consumeAuthCode forwards the code', () => { + oidc.consumeAuthCode.mockReturnValue({ token: 'jwt' }); + expect(s.consumeAuthCode('ac')).toEqual({ token: 'jwt' }); + expect(oidc.consumeAuthCode).toHaveBeenCalledWith('ac'); + }); + + it('frontendUrl forwards the path', () => { + oidc.frontendUrl.mockReturnValue('https://app/login'); + expect(s.frontendUrl('/login')).toBe('https://app/login'); + expect(oidc.frontendUrl).toHaveBeenCalledWith('/login'); + }); + + it('setAuthCookie forwards res, token and req to the cookie helper', () => { + const res = {} as Response; + const req = {} as Request; + s.setAuthCookie(res, 'jwt', req); + expect(setAuthCookie).toHaveBeenCalledWith(res, 'jwt', req); + }); +}); diff --git a/server/tests/unit/nest/packing.controller.test.ts b/server/tests/unit/nest/packing.controller.test.ts index 9d813c72..eb413b80 100644 --- a/server/tests/unit/nest/packing.controller.test.ts +++ b/server/tests/unit/nest/packing.controller.test.ts @@ -67,14 +67,35 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r }); }); + it('GET / lists items for the trip (success path)', () => { + const listItems = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]); + const svc = makeService({ listItems } as Partial); + expect(new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }, { id: 2 }] }); + expect(listItems).toHaveBeenCalledWith('5'); + }); + describe('POST /import', () => { - it('400 when items is not a non-empty array', () => { + it('400 when items is not a non-empty array (empty array)', () => { const svc = makeService(); expect(thrown(() => new PackingController(svc).importItems(user, '5', []))).toEqual({ status: 400, body: { error: 'items must be a non-empty array' }, }); }); + it('400 when items is not an array at all (non-array branch)', () => { + const svc = makeService(); + expect(thrown(() => new PackingController(svc).importItems(user, '5', 'nope'))).toEqual({ + status: 400, body: { error: 'items must be a non-empty array' }, + }); + }); + + it('403 without packing_edit permission', () => { + const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) }); + expect(thrown(() => new PackingController(svc).importItems(user, '5', [{ name: 'a' }]))).toEqual({ + status: 403, body: { error: 'No permission' }, + }); + }); + it('imports and broadcasts per item', () => { const bulkImport = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]); const broadcast = vi.fn(); @@ -103,7 +124,46 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r }); }); + describe('PUT /reorder', () => { + it('reorders the items and reports success', () => { + const reorderItems = vi.fn(); + const svc = makeService({ reorderItems } as Partial); + expect(new PackingController(svc).reorder(user, '5', [3, 1, 2])).toEqual({ success: true }); + expect(reorderItems).toHaveBeenCalledWith('5', [3, 1, 2]); + }); + + it('403 without packing_edit permission', () => { + const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) }); + expect(thrown(() => new PackingController(svc).reorder(user, '5', [1]))).toEqual({ + status: 403, body: { error: 'No permission' }, + }); + }); + }); + + describe('DELETE /:id (remove)', () => { + it('404 when the item is missing', () => { + const svc = makeService({ deleteItem: vi.fn().mockReturnValue(false) } as Partial); + expect(thrown(() => new PackingController(svc).remove(user, '5', '9'))).toEqual({ + status: 404, body: { error: 'Item not found' }, + }); + }); + + it('deletes the item and broadcasts', () => { + const deleteItem = vi.fn().mockReturnValue(true); + const broadcast = vi.fn(); + const svc = makeService({ deleteItem, broadcast } as Partial); + expect(new PackingController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true }); + expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock'); + }); + }); + describe('bags', () => { + it('GET /bags lists bags for the trip', () => { + const listBags = vi.fn().mockReturnValue([{ id: 3, name: 'Carry-on' }]); + const svc = makeService({ listBags } as Partial); + expect(new PackingController(svc).listBags(user, '5')).toEqual({ bags: [{ id: 3, name: 'Carry-on' }] }); + }); + it('400 on bag create with blank name', () => { const svc = makeService(); expect(thrown(() => new PackingController(svc).createBag(user, '5', { name: ' ' }))).toEqual({ @@ -111,12 +171,77 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r }); }); + it('400 on bag create with no name at all (optional-chain short-circuit)', () => { + const svc = makeService(); + expect(thrown(() => new PackingController(svc).createBag(user, '5', {}))).toEqual({ + status: 400, body: { error: 'Name is required' }, + }); + }); + + it('creates a bag and broadcasts', () => { + const createBag = vi.fn().mockReturnValue({ id: 3, name: 'Carry-on' }); + const broadcast = vi.fn(); + const svc = makeService({ createBag, broadcast } as Partial); + expect(new PackingController(svc).createBag(user, '5', { name: 'Carry-on', color: '#fff' }, 'sock')).toEqual({ + bag: { id: 3, name: 'Carry-on' }, + }); + expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-created', { bag: { id: 3, name: 'Carry-on' } }, 'sock'); + }); + it('404 on bag update when missing', () => { const svc = makeService({ updateBag: vi.fn().mockReturnValue(null) } as Partial); expect(thrown(() => new PackingController(svc).updateBag(user, '5', '3', { name: 'X' }))).toEqual({ status: 404, body: { error: 'Bag not found' }, }); }); + + it('updates a bag, forwards changed keys and broadcasts', () => { + const updateBag = vi.fn().mockReturnValue({ id: 3, name: 'X' }); + const broadcast = vi.fn(); + const svc = makeService({ updateBag, broadcast } as Partial); + new PackingController(svc).updateBag(user, '5', '3', { name: 'X', color: '#000' }, 'sock'); + expect(updateBag).toHaveBeenCalledWith('5', '3', expect.objectContaining({ name: 'X', color: '#000' }), ['name', 'color']); + expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-updated', { bag: { id: 3, name: 'X' } }, 'sock'); + }); + + it('404 on bag delete when missing', () => { + const svc = makeService({ deleteBag: vi.fn().mockReturnValue(false) } as Partial); + expect(thrown(() => new PackingController(svc).deleteBag(user, '5', '3'))).toEqual({ + status: 404, body: { error: 'Bag not found' }, + }); + }); + + it('deletes a bag and broadcasts', () => { + const deleteBag = vi.fn().mockReturnValue(true); + const broadcast = vi.fn(); + const svc = makeService({ deleteBag, broadcast } as Partial); + expect(new PackingController(svc).deleteBag(user, '5', '3', 'sock')).toEqual({ success: true }); + expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-deleted', { bagId: 3 }, 'sock'); + }); + + it('404 on set-members when the bag is missing', () => { + const svc = makeService({ setBagMembers: vi.fn().mockReturnValue(null) } as Partial); + expect(thrown(() => new PackingController(svc).setBagMembers(user, '5', '3', [1, 2]))).toEqual({ + status: 404, body: { error: 'Bag not found' }, + }); + }); + + it('sets bag members and broadcasts (array branch)', () => { + const setBagMembers = vi.fn().mockReturnValue([{ user_id: 1 }, { user_id: 2 }]); + const broadcast = vi.fn(); + const svc = makeService({ setBagMembers, broadcast } as Partial); + const res = new PackingController(svc).setBagMembers(user, '5', '3', [1, 2], 'sock'); + expect(res).toEqual({ members: [{ user_id: 1 }, { user_id: 2 }] }); + expect(setBagMembers).toHaveBeenCalledWith('5', '3', [1, 2]); + expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-members-updated', { bagId: 3, members: [{ user_id: 1 }, { user_id: 2 }] }, 'sock'); + }); + + it('coerces non-array members to an empty list (ternary else branch)', () => { + const setBagMembers = vi.fn().mockReturnValue([]); + const svc = makeService({ setBagMembers } as Partial); + new PackingController(svc).setBagMembers(user, '5', '3', 'not-an-array'); + expect(setBagMembers).toHaveBeenCalledWith('5', '3', []); + }); }); describe('templates', () => { @@ -135,6 +260,33 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r }); }); + it('applies a template, broadcasts the added items and reports the count', () => { + const applyTemplate = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }, { id: 3 }]); + const broadcast = vi.fn(); + const svc = makeService({ applyTemplate, broadcast } as Partial); + const res = new PackingController(svc).applyTemplate(user, '5', 't1', 'sock'); + expect(res).toEqual({ items: [{ id: 1 }, { id: 2 }, { id: 3 }], count: 3 }); + expect(broadcast).toHaveBeenCalledWith('5', 'packing:template-applied', { items: [{ id: 1 }, { id: 2 }, { id: 3 }] }, 'sock'); + }); + + it('400 when an admin saves a template with no name (whitespace)', () => { + const saveAsTemplate = vi.fn(); + const svc = makeService({ saveAsTemplate } as Partial); + expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5', ' '))).toEqual({ + status: 400, body: { error: 'Template name is required' }, + }); + expect(saveAsTemplate).not.toHaveBeenCalled(); + }); + + it('400 when an admin saves a template with no name at all (optional-chain)', () => { + const saveAsTemplate = vi.fn(); + const svc = makeService({ saveAsTemplate } as Partial); + expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5'))).toEqual({ + status: 400, body: { error: 'Template name is required' }, + }); + expect(saveAsTemplate).not.toHaveBeenCalled(); + }); + it('403 when a non-admin tries to save a template', () => { const saveAsTemplate = vi.fn(); const svc = makeService({ saveAsTemplate } as Partial); @@ -162,6 +314,24 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r }); describe('category assignees', () => { + it('GET /category-assignees returns the assignee list for an accessible trip', () => { + const getCategoryAssignees = vi.fn().mockReturnValue([{ category: 'Clothes', user_id: 2 }]); + const svc = makeService({ getCategoryAssignees } as Partial); + expect(new PackingController(svc).categoryAssignees(user, '5')).toEqual({ + assignees: [{ category: 'Clothes', user_id: 2 }], + }); + expect(getCategoryAssignees).toHaveBeenCalledWith('5'); + }); + + it('decodes the URI-encoded category name before forwarding', () => { + const updateCategoryAssignees = vi.fn().mockReturnValue([]); + const broadcast = vi.fn(); + const notifyTagged = vi.fn(); + const svc = makeService({ updateCategoryAssignees, broadcast, notifyTagged } as Partial); + new PackingController(svc).updateCategoryAssignees(user, '5', 'Toys%20%26%20Games', [2]); + expect(updateCategoryAssignees).toHaveBeenCalledWith('5', 'Toys & Games', [2]); + }); + it('updates assignees, broadcasts and fires the tag notification', () => { const updateCategoryAssignees = vi.fn().mockReturnValue([{ user_id: 2 }]); const broadcast = vi.fn(); diff --git a/server/tests/unit/nest/packing.service.test.ts b/server/tests/unit/nest/packing.service.test.ts index 933612a3..7006481b 100644 --- a/server/tests/unit/nest/packing.service.test.ts +++ b/server/tests/unit/nest/packing.service.test.ts @@ -16,12 +16,15 @@ const { pk } = vi.hoisted(() => ({ pk: { verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(), deleteItem: vi.fn(), bulkImport: vi.fn(), listBags: vi.fn(), createBag: vi.fn(), updateBag: vi.fn(), deleteBag: vi.fn(), - applyTemplate: vi.fn(), saveAsTemplate: vi.fn(), setBagMembers: vi.fn(), getCategoryAssignees: vi.fn(), + listTemplates: vi.fn(), applyTemplate: vi.fn(), saveAsTemplate: vi.fn(), setBagMembers: vi.fn(), getCategoryAssignees: vi.fn(), updateCategoryAssignees: vi.fn(), reorderItems: vi.fn(), }, })); vi.mock('../../../src/services/packingService', () => pk); +const { send } = vi.hoisted(() => ({ send: vi.fn(() => Promise.resolve()) })); +vi.mock('../../../src/services/notificationService', () => ({ send })); + import { PackingService } from '../../../src/nest/packing/packing.service'; function svc() { @@ -55,6 +58,7 @@ describe('PackingService (wrapper delegation + helpers)', () => { s.updateBag('5', '2', { name: 'B' } as never, ['name']); expect(pk.updateBag).toHaveBeenCalledWith('5', '2', { name: 'B' }, ['name']); s.deleteBag('5', '2'); expect(pk.deleteBag).toHaveBeenCalledWith('5', '2'); s.setBagMembers('5', '2', [1, 2]); expect(pk.setBagMembers).toHaveBeenCalledWith('5', '2', [1, 2]); + s.listTemplates(); expect(pk.listTemplates).toHaveBeenCalled(); s.applyTemplate('5', 't1'); expect(pk.applyTemplate).toHaveBeenCalledWith('5', 't1'); s.saveAsTemplate('5', 1, 'Tpl'); expect(pk.saveAsTemplate).toHaveBeenCalledWith('5', 1, 'Tpl'); s.getCategoryAssignees('5'); expect(pk.getCategoryAssignees).toHaveBeenCalledWith('5'); @@ -71,5 +75,31 @@ describe('PackingService (wrapper delegation + helpers)', () => { it('fires the notification when users are tagged (fire-and-forget, no throw)', () => { expect(() => svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2, 3])).not.toThrow(); }); + + it('queries the trip title and dispatches the notification with the resolved title', async () => { + dbMock._stmt.get.mockReturnValue({ title: 'Iceland 2026' }); + svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2, 3]); + // Flush the dynamic import().then microtask chain. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(dbMock.prepare).toHaveBeenCalledWith('SELECT title FROM trips WHERE id = ?'); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'packing_tagged', + actorId: 1, + scope: 'trip', + targetId: 5, + params: expect.objectContaining({ trip: 'Iceland 2026', actor: 'a@b.c', category: 'Clothes', tripId: '5' }), + }), + ); + }); + + it('falls back to "Untitled" when the trip row is missing (?? / default branch)', async () => { + dbMock._stmt.get.mockReturnValue(undefined); + svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ params: expect.objectContaining({ trip: 'Untitled' }) }), + ); + }); }); }); diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index cf68f644..492b419a 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -85,10 +85,63 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou }); }); + describe('POST /import/map', () => { + const file = { buffer: Buffer.from(''), originalname: 'm.kml' } as Express.Multer.File; + it('400 without a file', async () => { + expect(await thrownAsync(() => new PlacesController(svc()).importMap(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); + }); + it('403 without place_edit (permission runs before the file check)', async () => { + const importMapFile = vi.fn(); + const s = svc({ canEdit: vi.fn().mockReturnValue(false), importMapFile } as Partial); + expect(await thrownAsync(() => new PlacesController(s).importMap(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(importMapFile).not.toHaveBeenCalled(); + }); + it('400 when both import types are disabled', async () => { + expect(await thrownAsync(() => new PlacesController(svc()).importMap(user, '5', file, { importPoints: 'false', importPaths: 'false' }))).toEqual({ + status: 400, body: { error: 'No import types selected' }, + }); + }); + it('400 when the map file has no Placemarks (and carries the summary through)', async () => { + const summary = { totalPlacemarks: 0 }; + const s = svc({ importMapFile: vi.fn().mockResolvedValue({ places: [], summary }) } as Partial); + expect(await thrownAsync(() => new PlacesController(s).importMap(user, '5', file, {}))).toEqual({ + status: 400, body: { error: 'No valid Placemarks found in map file', summary }, + }); + }); + it('imports, broadcasts per place + returns the service result', async () => { + const broadcast = vi.fn(); + const result = { places: [{ id: 1 }, { id: 2 }], summary: { totalPlacemarks: 2 }, count: 2 }; + const s = svc({ importMapFile: vi.fn().mockResolvedValue(result), broadcast } as Partial); + expect(await new PlacesController(s).importMap(user, '5', file, {}, 'sock')).toEqual(result); + expect(broadcast).toHaveBeenCalledTimes(2); + expect(broadcast).toHaveBeenCalledWith('5', 'place:created', { place: { id: 1 } }, 'sock'); + }); + it('passes a missing summary through (no zero-placemark guard) and still imports', async () => { + const result = { places: [{ id: 7 }] }; + const s = svc({ importMapFile: vi.fn().mockResolvedValue(result), broadcast: vi.fn() } as Partial); + expect(await new PlacesController(s).importMap(user, '5', file, {})).toEqual(result); + }); + it('wraps a thrown Error from the service in a 400 with its message', async () => { + const s = svc({ importMapFile: vi.fn().mockRejectedValue(new Error('bad kml')) } as Partial); + expect(await thrownAsync(() => new PlacesController(s).importMap(user, '5', file, {}))).toEqual({ status: 400, body: { error: 'bad kml' } }); + }); + it('falls back to a generic 400 message for a non-Error rejection', async () => { + const s = svc({ importMapFile: vi.fn().mockRejectedValue('boom') } as Partial); + expect(await thrownAsync(() => new PlacesController(s).importMap(user, '5', file, {}))).toEqual({ status: 400, body: { error: 'Failed to import map file' } }); + }); + it('re-throws an HttpException raised inside the try untouched', async () => { + const s = svc({ importMapFile: vi.fn().mockRejectedValue(new HttpException({ error: 'teapot' }, 418)) } as Partial); + expect(await thrownAsync(() => new PlacesController(s).importMap(user, '5', file, {}))).toEqual({ status: 418, body: { error: 'teapot' } }); + }); + }); + describe('POST /import/google-list + naver-list', () => { it('400 without a url', async () => { expect(await thrownAsync(() => new PlacesController(svc()).importGoogle(user, '5', undefined))).toEqual({ status: 400, body: { error: 'URL is required' } }); }); + it('400 when url is the wrong type (not a string)', async () => { + expect(await thrownAsync(() => new PlacesController(svc()).importNaver(user, '5', 123))).toEqual({ status: 400, body: { error: 'URL is required' } }); + }); it('maps a service { error, status } to the same response', async () => { const s = svc({ importGoogleList: vi.fn().mockResolvedValue({ error: 'List is empty', status: 400 }) } as Partial); expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({ status: 400, body: { error: 'List is empty' } }); @@ -97,6 +150,26 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou const s = svc({ importNaverList: vi.fn().mockResolvedValue({ places: [{ id: 1 }], listName: 'Trip', skipped: 2 }), broadcast: vi.fn() } as Partial); expect(await new PlacesController(s).importNaver(user, '5', 'http://x')).toEqual({ places: [{ id: 1 }], count: 1, listName: 'Trip', skipped: 2 }); }); + it('forwards the enrich flag + userId and broadcasts each imported place', async () => { + const importGoogleList = vi.fn().mockResolvedValue({ places: [{ id: 1 }, { id: 2 }], listName: 'L', skipped: 0 }); + const broadcast = vi.fn(); + const s = svc({ importGoogleList, broadcast } as Partial); + expect(await new PlacesController(s).importGoogle(user, '5', 'http://x', 'true', 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, listName: 'L', skipped: 0 }); + expect(importGoogleList).toHaveBeenCalledWith('5', 'http://x', { enrich: true, userId: 1 }); + expect(broadcast).toHaveBeenCalledTimes(2); + }); + it('wraps a thrown Error in the provider-specific 400 (Google)', async () => { + const s = svc({ importGoogleList: vi.fn().mockRejectedValue(new Error('network down')) } as Partial); + expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({ + status: 400, body: { error: 'Failed to import Google Maps list. Make sure the list is shared publicly.' }, + }); + }); + it('wraps a non-Error rejection in the provider-specific 400 (Naver)', async () => { + const s = svc({ importNaverList: vi.fn().mockRejectedValue('weird') } as Partial); + expect(await thrownAsync(() => new PlacesController(s).importNaver(user, '5', 'http://x'))).toEqual({ + status: 400, body: { error: 'Failed to import Naver Maps list. Make sure the list is shared publicly.' }, + }); + }); }); describe('POST /bulk-delete', () => { @@ -117,8 +190,10 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou }); }); - it('GET /:id 404 when missing', () => { + it('GET /:id returns the place when found, 404 when missing', () => { expect(thrown(() => new PlacesController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial)).get(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } }); + const s = svc({ get: vi.fn().mockReturnValue({ id: 9 }) } as Partial); + expect(new PlacesController(s).get(user, '5', '9')).toEqual({ place: { id: 9 } }); }); it('PUT /:id 404 when missing, else updates + hooks', () => { @@ -143,4 +218,11 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou const e = svc({ searchImage: vi.fn().mockResolvedValue({ error: 'No key', status: 400 }) } as Partial); expect(await thrownAsync(() => new PlacesController(e).image(user, '5', '9'))).toEqual({ status: 400, body: { error: 'No key' } }); }); + + it('GET /:id/image turns an unexpected throw into a 500, but re-throws an HttpException as-is', async () => { + const boom = svc({ searchImage: vi.fn().mockRejectedValue(new Error('Unsplash down')) } as Partial); + expect(await thrownAsync(() => new PlacesController(boom).image(user, '5', '9'))).toEqual({ status: 500, body: { error: 'Error searching for image' } }); + const http = svc({ searchImage: vi.fn().mockRejectedValue(new HttpException({ error: 'rate limited' }, 429)) } as Partial); + expect(await thrownAsync(() => new PlacesController(http).image(user, '5', '9'))).toEqual({ status: 429, body: { error: 'rate limited' } }); + }); }); diff --git a/server/tests/unit/nest/platform.controller.test.ts b/server/tests/unit/nest/platform.controller.test.ts new file mode 100644 index 00000000..73f52287 --- /dev/null +++ b/server/tests/unit/nest/platform.controller.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NotFoundException } from '@nestjs/common'; + +// --- hoisted mock fns so the vi.mock factories can reference them ----------------- +const h = vi.hoisted(() => ({ + verifyJwtAndLoadUser: vi.fn(), + isAddonEnabled: vi.fn(), + getMcpSafeUrl: vi.fn(() => 'https://trek.example.test'), + dbPrepare: vi.fn(), + existsSync: vi.fn(), + // SDK middleware spies — each returns a tagged handler so we can identify which + // app.use call received it. + metaRouter: vi.fn(), + authorizeHandler: vi.fn(), + registerHandler: vi.fn(), + mcpHandler: vi.fn(), +})); + +vi.mock('../../../src/middleware/auth', () => ({ verifyJwtAndLoadUser: h.verifyJwtAndLoadUser })); +vi.mock('../../../src/db/database', () => ({ db: { prepare: h.dbPrepare } })); +vi.mock('../../../src/mcp', () => ({ mcpHandler: h.mcpHandler })); +vi.mock('../../../src/mcp/oauthProvider', () => ({ trekOAuthProvider: {}, trekClientsStore: {} })); +vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: h.isAddonEnabled })); +vi.mock('../../../src/services/notifications', () => ({ getMcpSafeUrl: h.getMcpSafeUrl })); + +// SDK router/handler factories return distinct tagged middleware so we never hit +// real new URL(...) wiring during registration. +vi.mock('@modelcontextprotocol/sdk/server/auth/router', () => ({ + mcpAuthMetadataRouter: vi.fn(() => h.metaRouter), +})); +vi.mock('@modelcontextprotocol/sdk/server/auth/handlers/authorize', () => ({ + authorizationHandler: vi.fn(() => h.authorizeHandler), +})); +vi.mock('@modelcontextprotocol/sdk/server/auth/handlers/register', () => ({ + clientRegistrationHandler: vi.fn(() => h.registerHandler), +})); + +vi.mock('node:fs', async (orig) => { + const real = (await orig()) as Record; + return { ...real, default: { ...(real.default as object), existsSync: h.existsSync }, existsSync: h.existsSync }; +}); + +import { + applyPlatformUploads, + applyPlatformTransport, + applyPlatformSpa, + applyPlatformStatic, +} from '../../../src/nest/platform/platform.routes'; +import { SpaFallbackFilter } from '../../../src/nest/platform/spa-fallback.filter'; + +// Tagged sentinel for express.static — we only need to know it was registered on +// the right path, not run it. +vi.mock('express', async () => { + const staticFn = vi.fn(() => 'STATIC' as unknown); + const fn: unknown = () => ({}); + Object.assign(fn as object, { static: staticFn }); + return { default: fn, static: staticFn }; +}); + +type Handler = (...args: unknown[]) => unknown; + +/** + * A fake express.Application that records every route/middleware registration so + * individual handlers can be pulled out and exercised in isolation. + */ +function fakeApp() { + const calls: Array<{ method: string; path?: string; handlers: Handler[] }> = []; + const record = (method: string) => (...args: unknown[]) => { + if (typeof args[0] === 'string' || args[0] instanceof RegExp) { + calls.push({ method, path: String(args[0]), handlers: args.slice(1) as Handler[] }); + } else { + calls.push({ method, handlers: args as Handler[] }); + } + }; + const app = { + use: record('use'), + get: record('get'), + post: record('post'), + delete: record('delete'), + } as never; + return { app, calls }; +} + +function makeRes() { + const res = { + statusCode: 200, + body: undefined as unknown, + headers: {} as Record, + status: vi.fn(function (this: typeof res, c: number) { this.statusCode = c; return this; }), + json: vi.fn(function (this: typeof res, b: unknown) { this.body = b; return this; }), + send: vi.fn(function (this: typeof res, b: unknown) { this.body = b; return this; }), + end: vi.fn(function (this: typeof res) { return this; }), + sendFile: vi.fn(function (this: typeof res, p: string) { this.body = `FILE:${p}`; return this; }), + setHeader: vi.fn(function (this: typeof res, k: string, v: string) { this.headers[k] = v; return this; }), + }; + return res; +} + +beforeEach(() => { + vi.clearAllMocks(); + h.getMcpSafeUrl.mockReturnValue('https://trek.example.test'); +}); + +describe('applyPlatformUploads', () => { + it('registers the static avatar/cover/journey mounts + the files block', () => { + const { app, calls } = fakeApp(); + applyPlatformUploads(app); + const paths = calls.filter((c) => c.method === 'use').map((c) => c.path); + expect(paths).toEqual( + expect.arrayContaining(['/uploads/avatars', '/uploads/covers', '/uploads/journey', '/uploads/files']), + ); + }); + + it('the /uploads/files block always answers 401', () => { + const { app, calls } = fakeApp(); + applyPlatformUploads(app); + const filesBlock = calls.find((c) => c.path === '/uploads/files')!.handlers[0]; + const res = makeRes(); + filesBlock({}, res); + expect(res.statusCode).toBe(401); + expect(res.body).toBe('Authentication required'); + }); + + describe('GET /uploads/photos/:filename', () => { + function photoHandler() { + const { app, calls } = fakeApp(); + applyPlatformUploads(app); + return calls.find((c) => c.method === 'get' && c.path === '/uploads/photos/:filename')!.handlers[0]; + } + + it('403 when the resolved path escapes the photos dir', () => { + // basename() strips the traversal, but feed a name that resolves outside by + // stubbing path indirectly is hard — instead exercise the existsSync 404 etc. + // The startsWith guard is defensive; cover it via a filename of '..'. + const handler = photoHandler(); + const res = makeRes(); + // path.basename('..') === '..' -> join(photos,'..') resolves to uploads -> not under photos + handler({ params: { filename: '..' }, headers: {}, query: {} }, res); + expect(res.statusCode).toBe(403); + expect(res.body).toBe('Forbidden'); + }); + + it('404 when the file does not exist', () => { + h.existsSync.mockReturnValue(false); + const res = makeRes(); + photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: {} }, res); + expect(res.statusCode).toBe(404); + expect(res.body).toBe('Not found'); + }); + + it('401 when no token is supplied', () => { + h.existsSync.mockReturnValue(true); + const res = makeRes(); + photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: {} }, res); + expect(res.statusCode).toBe(401); + expect(res.body).toBe('Authentication required'); + }); + + it('serves the file for a valid JWT session (Bearer header)', () => { + h.existsSync.mockReturnValue(true); + h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 }); + const res = makeRes(); + photoHandler()( + { params: { filename: 'a.jpg' }, headers: { authorization: 'Bearer jwt123' }, query: {} }, + res, + ); + expect(h.verifyJwtAndLoadUser).toHaveBeenCalledWith('jwt123'); + expect(String(res.body)).toContain('FILE:'); + }); + + it('reads the token from the query string when there is no Bearer header', () => { + h.existsSync.mockReturnValue(true); + h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 }); + const res = makeRes(); + photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'qtok' } }, res); + expect(h.verifyJwtAndLoadUser).toHaveBeenCalledWith('qtok'); + expect(String(res.body)).toContain('FILE:'); + }); + + it('401 when the token is not a session and the photo row is missing', () => { + h.existsSync.mockReturnValue(true); + h.verifyJwtAndLoadUser.mockReturnValue(null); + h.dbPrepare.mockReturnValue({ get: vi.fn().mockReturnValue(undefined) }); + const res = makeRes(); + photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share1' } }, res); + expect(res.statusCode).toBe(401); + }); + + it('401 when a share token does not cover the photo trip', () => { + h.existsSync.mockReturnValue(true); + h.verifyJwtAndLoadUser.mockReturnValue(null); + const photoStmt = { get: vi.fn().mockReturnValue({ trip_id: 7 }) }; + const shareStmt = { get: vi.fn().mockReturnValue({ trip_id: 8 }) }; + h.dbPrepare.mockImplementationOnce(() => photoStmt).mockImplementationOnce(() => shareStmt); + const res = makeRes(); + photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share1' } }, res); + expect(res.statusCode).toBe(401); + }); + + it('401 when there is no matching share token at all', () => { + h.existsSync.mockReturnValue(true); + h.verifyJwtAndLoadUser.mockReturnValue(null); + const photoStmt = { get: vi.fn().mockReturnValue({ trip_id: 7 }) }; + const shareStmt = { get: vi.fn().mockReturnValue(undefined) }; + h.dbPrepare.mockImplementationOnce(() => photoStmt).mockImplementationOnce(() => shareStmt); + const res = makeRes(); + photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share1' } }, res); + expect(res.statusCode).toBe(401); + }); + + it('serves the file when the share token covers the photo trip', () => { + h.existsSync.mockReturnValue(true); + h.verifyJwtAndLoadUser.mockReturnValue(null); + const photoStmt = { get: vi.fn().mockReturnValue({ trip_id: 7 }) }; + const shareStmt = { get: vi.fn().mockReturnValue({ trip_id: 7 }) }; + h.dbPrepare.mockImplementationOnce(() => photoStmt).mockImplementationOnce(() => shareStmt); + const res = makeRes(); + photoHandler()( + { params: { filename: 'a.jpg' }, headers: { authorization: 'Bearer share1' }, query: {} }, + res, + ); + expect(String(res.body)).toContain('FILE:'); + }); + }); +}); + +describe('applyPlatformTransport', () => { + function build() { + const { app, calls } = fakeApp(); + applyPlatformTransport(app); + return calls; + } + + it('GET /api/health sets no-store and returns ok', () => { + const calls = build(); + const health = calls.find((c) => c.method === 'get' && c.path === '/api/health')!.handlers[0]; + const res = makeRes(); + health({}, res); + expect(res.headers['Cache-Control']).toBe('no-store, must-revalidate'); + expect(res.body).toEqual({ status: 'ok' }); + }); + + describe('the /.well-known metadata middleware', () => { + function wellKnownMw(calls: ReturnType) { + // first app.use with no path, registered right after /api/health + return calls.find((c) => c.method === 'use' && c.path === undefined)!.handlers[0]; + } + + it('404s a /.well-known path when MCP is disabled', () => { + h.isAddonEnabled.mockReturnValue(false); + const mw = wellKnownMw(build()); + const res = makeRes(); + const next = vi.fn(); + mw({ path: '/.well-known/oauth-authorization-server' }, res, next); + expect(res.statusCode).toBe(404); + expect(next).not.toHaveBeenCalled(); + }); + + it('delegates to the SDK meta router for a non-well-known path', () => { + h.isAddonEnabled.mockReturnValue(true); + const mw = wellKnownMw(build()); + const res = makeRes(); + const next = vi.fn(); + mw({ path: '/anything' }, res, next); + expect(h.metaRouter).toHaveBeenCalled(); + }); + + it('delegates to the SDK meta router for a well-known path when MCP is enabled', () => { + h.isAddonEnabled.mockReturnValue(true); + const mw = wellKnownMw(build()); + const res = makeRes(); + const next = vi.fn(); + mw({ path: '/.well-known/oauth-authorization-server' }, res, next); + expect(h.metaRouter).toHaveBeenCalled(); + }); + }); + + it('GET /.well-known/openid-configuration returns AS metadata + userinfo_endpoint', () => { + const calls = build(); + const handler = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0]; + const res = makeRes(); + handler({}, res); + const body = res.body as { issuer: string; userinfo_endpoint: string }; + expect(body.issuer).toBe('https://trek.example.test'); + expect(body.userinfo_endpoint).toBe('https://trek.example.test/oauth/userinfo'); + }); + + it('trims trailing slashes off the configured base URL', () => { + h.getMcpSafeUrl.mockReturnValue('https://trek.example.test///'); + const calls = build(); + const handler = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0]; + const res = makeRes(); + handler({}, res); + expect((res.body as { issuer: string }).issuer).toBe('https://trek.example.test'); + }); + + describe('GET /.well-known/oauth-protected-resource (flat)', () => { + function handler() { + return build().find((c) => c.method === 'get' && c.path === '/.well-known/oauth-protected-resource')!.handlers[0]; + } + + it('404 when MCP is disabled', () => { + h.isAddonEnabled.mockReturnValue(false); + const res = makeRes(); + handler()({}, res); + expect(res.statusCode).toBe(404); + }); + + it('returns the PRM document when MCP is enabled', () => { + h.isAddonEnabled.mockReturnValue(true); + const res = makeRes(); + handler()({}, res); + const body = res.body as { resource: string; authorization_servers: string[] }; + expect(body.resource).toBe('https://trek.example.test/mcp'); + expect(body.authorization_servers).toEqual(['https://trek.example.test']); + }); + }); + + describe('mcpAddonGate (used on /oauth/authorize + /oauth/register)', () => { + function gate() { + // The gate is the first handler on the /oauth/authorize use registration. + return build().find((c) => c.method === 'use' && c.path === '/oauth/authorize')!.handlers[0]; + } + + it('404 when MCP is disabled', () => { + h.isAddonEnabled.mockReturnValue(false); + const res = makeRes(); + const next = vi.fn(); + gate()({}, res, next); + expect(res.statusCode).toBe(404); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next() when MCP is enabled', () => { + h.isAddonEnabled.mockReturnValue(true); + const res = makeRes(); + const next = vi.fn(); + gate()({}, res, next); + expect(next).toHaveBeenCalled(); + }); + }); + + it('wires the SDK authorize + register handlers behind the gate', () => { + const calls = build(); + const authorize = calls.find((c) => c.path === '/oauth/authorize')!; + const register = calls.find((c) => c.path === '/oauth/register')!; + expect(authorize.handlers).toContain(h.authorizeHandler); + expect(register.handlers).toContain(h.registerHandler); + }); + + it('mounts the MCP handler on POST/GET/DELETE /mcp', () => { + const calls = build(); + expect(calls.find((c) => c.method === 'post' && c.path === '/mcp')!.handlers[0]).toBe(h.mcpHandler); + expect(calls.find((c) => c.method === 'get' && c.path === '/mcp')!.handlers[0]).toBe(h.mcpHandler); + expect(calls.find((c) => c.method === 'delete' && c.path === '/mcp')!.handlers[0]).toBe(h.mcpHandler); + }); + + describe('the terminal /.well-known JSON-404 middleware', () => { + function mw() { + // The pathless app.use registered after the /mcp routes. + const calls = build(); + const pathless = calls.filter((c) => c.method === 'use' && c.path === undefined); + // first pathless = meta router; second = the JSON 404. + return pathless[1].handlers[0]; + } + + it('404 JSON for an unhandled /.well-known path', () => { + const res = makeRes(); + const next = vi.fn(); + mw()({ path: '/.well-known/unknown' }, res, next); + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: 'not_found' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next() for any non-well-known path', () => { + const res = makeRes(); + const next = vi.fn(); + mw()({ path: '/dashboard' }, res, next); + expect(next).toHaveBeenCalled(); + }); + }); + + it('the /oauth/consent middleware relaxes COOP then continues', () => { + const calls = build(); + const mw = calls.find((c) => c.method === 'use' && c.path === '/oauth/consent')!.handlers[0]; + const res = makeRes(); + const next = vi.fn(); + mw({}, res, next); + expect(res.headers['Cross-Origin-Opener-Policy']).toBe('unsafe-none'); + expect(next).toHaveBeenCalled(); + }); + + it('caches the OAuth metadata + SDK router across requests (lazy init runs once)', async () => { + const router = await import('@modelcontextprotocol/sdk/server/auth/router'); + const calls = build(); + const openid = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0]; + h.getMcpSafeUrl.mockClear(); + openid({}, makeRes()); + openid({}, makeRes()); + // getMcpSafeUrl is only consulted on the first lazy build of the metadata. + expect(h.getMcpSafeUrl).toHaveBeenCalledTimes(1); + + // Trigger the meta router lazy build twice; the SDK factory runs once. + const metaMw = calls.find((c) => c.method === 'use' && c.path === undefined)!.handlers[0]; + h.isAddonEnabled.mockReturnValue(true); + metaMw({ path: '/x' }, makeRes(), vi.fn()); + metaMw({ path: '/y' }, makeRes(), vi.fn()); + expect(router.mcpAuthMetadataRouter).toHaveBeenCalledTimes(1); + }); +}); + +describe('applyPlatformStatic', () => { + const original = process.env.NODE_ENV; + afterEach(() => { process.env.NODE_ENV = original; }); + + it('is a no-op outside production', () => { + process.env.NODE_ENV = 'development'; + const { app, calls } = fakeApp(); + applyPlatformStatic(app); + expect(calls).toHaveLength(0); + }); + + it('serves the built client statics in production', () => { + process.env.NODE_ENV = 'production'; + const { app, calls } = fakeApp(); + applyPlatformStatic(app); + expect(calls.some((c) => c.method === 'use')).toBe(true); + }); + + it('the static setHeaders callback adds no-cache for index.html only', async () => { + process.env.NODE_ENV = 'production'; + const expressMod = (await import('express')).default as unknown as { static: ReturnType }; + expressMod.static.mockClear(); + const { app } = fakeApp(); + applyPlatformStatic(app); + const opts = expressMod.static.mock.calls[0][1] as { setHeaders: (res: unknown, p: string) => void }; + const indexRes = makeRes(); + opts.setHeaders(indexRes, '/some/index.html'); + expect(indexRes.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate'); + const assetRes = makeRes(); + opts.setHeaders(assetRes, '/some/app.js'); + expect(assetRes.headers['Cache-Control']).toBeUndefined(); + }); +}); + +describe('applyPlatformSpa', () => { + const original = process.env.NODE_ENV; + afterEach(() => { process.env.NODE_ENV = original; }); + + it('only serves statics (no catch-all) outside production', () => { + process.env.NODE_ENV = 'development'; + const { app, calls } = fakeApp(); + applyPlatformSpa(app); + expect(calls.some((c) => c.method === 'get' && c.path === '/.*/' )).toBe(false); + }); + + it('registers the index.html catch-all in production', () => { + process.env.NODE_ENV = 'production'; + const { app, calls } = fakeApp(); + applyPlatformSpa(app); + const catchAll = calls.find((c) => c.method === 'get'); + expect(catchAll).toBeDefined(); + const res = makeRes(); + catchAll!.handlers[0]({}, res); + expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate'); + expect(String(res.body)).toContain('FILE:'); + expect(String(res.body)).toContain('index.html'); + }); +}); + +describe('SpaFallbackFilter', () => { + const original = process.env.NODE_ENV; + afterEach(() => { process.env.NODE_ENV = original; }); + + function host(req: { method: string }, res: ReturnType) { + return { switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }) } as never; + } + + it('serves index.html for an unmatched GET in production', () => { + process.env.NODE_ENV = 'production'; + const res = makeRes(); + new SpaFallbackFilter().catch(new NotFoundException('nope'), host({ method: 'GET' }, res)); + expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate'); + expect(String(res.body)).toContain('index.html'); + }); + + it('keeps the JSON 404 envelope for a non-GET miss in production', () => { + process.env.NODE_ENV = 'production'; + const res = makeRes(); + new SpaFallbackFilter().catch(new NotFoundException('gone'), host({ method: 'POST' }, res)); + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: 'gone' }); + }); + + it('keeps the JSON 404 envelope outside production even for GET', () => { + process.env.NODE_ENV = 'development'; + const res = makeRes(); + new SpaFallbackFilter().catch(new NotFoundException('missing'), host({ method: 'GET' }, res)); + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: 'missing' }); + }); + + it('falls back to Not Found when the exception has no message', () => { + process.env.NODE_ENV = 'development'; + const res = makeRes(); + const exc = new NotFoundException(); + // force an empty message so the || branch is taken + Object.defineProperty(exc, 'message', { value: '' }); + new SpaFallbackFilter().catch(exc, host({ method: 'GET' }, res)); + expect(res.body).toEqual({ error: 'Not Found' }); + }); +}); diff --git a/server/tests/unit/nest/share.controller.test.ts b/server/tests/unit/nest/share.controller.test.ts index f193d9ca..9277ea1b 100644 --- a/server/tests/unit/nest/share.controller.test.ts +++ b/server/tests/unit/nest/share.controller.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { Response } from 'express'; +const { createReadStream } = vi.hoisted(() => ({ createReadStream: vi.fn() })); +vi.mock('node:fs', () => ({ createReadStream })); + import { TripShareController, SharedController } from '../../../src/nest/share/share.controller'; import type { ShareService } from '../../../src/nest/share/share.service'; import type { User } from '../../../src/types'; @@ -69,4 +72,66 @@ describe('SharedController', () => { expect(thrown(() => new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue(null) } as Partial)).read('bad'))).toEqual({ status: 404, body: { error: 'Invalid or expired link' } }); expect(new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue({ trip: { id: 9 } }) } as Partial)).read('tok')).toEqual({ trip: { id: 9 } }); }); + + describe('place-photo proxy', () => { + function photoRes() { + const r = { + statusCode: 200, + headersSent: false, + status: vi.fn(function (this: unknown, c: number) { (r as { statusCode: number }).statusCode = c; return r; }), + json: vi.fn(), + set: vi.fn(), + type: vi.fn(), + }; + return r as unknown as Response & { status: ReturnType; json: ReturnType; set: ReturnType; type: ReturnType }; + } + + beforeEach(() => createReadStream.mockReset()); + + function controller(path: string | null) { + return new SharedController(svc({ getSharedPlacePhotoPath: vi.fn().mockReturnValue(path) } as Partial)); + } + + it('404 without streaming when the photo is not cached for the token', () => { + const res = photoRes(); + controller(null).placePhotoBytes('tok', 'p1', res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' }); + expect(createReadStream).not.toHaveBeenCalled(); + }); + + it('streams the cached file with image/jpeg + an immutable cache header on a hit', () => { + const stream = { on: vi.fn().mockReturnThis(), pipe: vi.fn() }; + createReadStream.mockReturnValue(stream); + const res = photoRes(); + controller('/cache/p1.jpg').placePhotoBytes('tok', 'p1', res); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=2592000, immutable'); + expect(res.type).toHaveBeenCalledWith('image/jpeg'); + expect(createReadStream).toHaveBeenCalledWith('/cache/p1.jpg'); + expect(stream.pipe).toHaveBeenCalledWith(res); + }); + + it('falls back to 404 when the read stream errors before headers were sent', () => { + let onError: () => void = () => {}; + const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() }; + createReadStream.mockReturnValue(stream); + const res = photoRes(); + controller('/cache/p1.jpg').placePhotoBytes('tok', 'p1', res); + onError(); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' }); + }); + + it('does not re-send a 404 when the stream errors after headers were flushed', () => { + let onError: () => void = () => {}; + const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() }; + createReadStream.mockReturnValue(stream); + const res = photoRes(); + (res as { headersSent: boolean }).headersSent = true; + controller('/cache/p1.jpg').placePhotoBytes('tok', 'p1', res); + onError(); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts new file mode 100644 index 00000000..3cac956b --- /dev/null +++ b/server/tests/unit/nest/share.service.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// The wrapper delegates to legacy helpers; mock them so no real DB is loaded. +const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); +vi.mock('../../../src/db/database', () => ({ canAccessTrip, closeDb: () => {}, reinitialize: () => {} })); + +const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); +vi.mock('../../../src/services/permissions', () => ({ checkPermission })); + +const { share } = vi.hoisted(() => ({ + share: { + createOrUpdateShareLink: vi.fn(), + getShareLink: vi.fn(), + deleteShareLink: vi.fn(), + getSharedTripData: vi.fn(), + getSharedPlacePhotoPath: vi.fn(), + }, +})); +vi.mock('../../../src/services/shareService', () => share); + +import { ShareService } from '../../../src/nest/share/share.service'; +import type { User } from '../../../src/types'; + +function svc() { + return new ShareService(); +} + +beforeEach(() => vi.clearAllMocks()); + +describe('ShareService', () => { + it('verifyTripAccess delegates to canAccessTrip', () => { + canAccessTrip.mockReturnValue({ id: 5, user_id: 2 }); + expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 }); + expect(canAccessTrip).toHaveBeenCalledWith('5', 2); + }); + + it('canManage forwards the ownership flag when the user owns the trip', () => { + checkPermission.mockReturnValue(true); + const trip = { user_id: 1 } as never; + const user = { id: 1, role: 'user' } as User; + expect(svc().canManage(trip, user)).toBe(true); + expect(checkPermission).toHaveBeenCalledWith('share_manage', 'user', 1, 1, false); + }); + + it('canManage marks the user as a guest when they do not own the trip', () => { + checkPermission.mockReturnValue(false); + const trip = { user_id: 2 } as never; + const user = { id: 1, role: 'user' } as User; + expect(svc().canManage(trip, user)).toBe(false); + expect(checkPermission).toHaveBeenCalledWith('share_manage', 'user', 2, 1, true); + }); + + it('createOrUpdate delegates to the legacy share service', () => { + share.createOrUpdateShareLink.mockReturnValue({ token: 't', created: true }); + const perms = { share_map: true }; + expect(svc().createOrUpdate('5', 2, perms)).toEqual({ token: 't', created: true }); + expect(share.createOrUpdateShareLink).toHaveBeenCalledWith('5', 2, perms); + }); + + it('get / remove / getSharedTripData / getSharedPlacePhotoPath delegate', () => { + share.getShareLink.mockReturnValue({ token: 't' }); + expect(svc().get('5')).toEqual({ token: 't' }); + expect(share.getShareLink).toHaveBeenCalledWith('5'); + + svc().remove('5'); + expect(share.deleteShareLink).toHaveBeenCalledWith('5'); + + share.getSharedTripData.mockReturnValue({ trip: { id: 9 } }); + expect(svc().getSharedTripData('tok')).toEqual({ trip: { id: 9 } }); + expect(share.getSharedTripData).toHaveBeenCalledWith('tok'); + + share.getSharedPlacePhotoPath.mockReturnValue('/cache/p1.jpg'); + expect(svc().getSharedPlacePhotoPath('tok', 'p1')).toBe('/cache/p1.jpg'); + expect(share.getSharedPlacePhotoPath).toHaveBeenCalledWith('tok', 'p1'); + }); +}); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index c4ab96a5..74c30e85 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -3,10 +3,12 @@ import { HttpException } from '@nestjs/common'; import type { Request } from 'express'; vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() })); -vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) })); +const { isDemoEmail } = vi.hoisted(() => ({ isDemoEmail: vi.fn(() => false) })); +vi.mock('../../../src/services/demo', () => ({ isDemoEmail })); import { TripsController } from '../../../src/nest/trips/trips.controller'; import type { TripsService } from '../../../src/nest/trips/trips.service'; +import { NotFoundError, ValidationError } from '../../../src/services/tripService'; import type { User } from '../../../src/types'; const user = { id: 1, role: 'user', email: 'u@example.test' } as User; @@ -40,6 +42,15 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(list).toHaveBeenCalledWith(1, 1); }); + it('GET / defaults the archived flag to 0 when not "1"', () => { + const list = vi.fn().mockReturnValue([]); + const c = new TripsController(svc({ list } as Partial)); + c.list(user, undefined); + expect(list).toHaveBeenLastCalledWith(1, 0); + c.list(user, '0'); + expect(list).toHaveBeenLastCalledWith(1, 0); + }); + describe('POST / (create)', () => { it('403 without trip_create, 400 without title', () => { expect(thrown(() => new TripsController(svc({ can: vi.fn().mockReturnValue(false) })).create(user, { title: 'T' }, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); @@ -57,12 +68,34 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { status: 400, body: { error: 'End date must be after start date' }, }); }); + + it('infers start_date from end_date (-6 days) and parses day_count', () => { + const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); + new TripsController(svc({ create } as Partial)).create(user, { title: 'T', end_date: '2026-07-07', day_count: '40' }, req); + expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ start_date: '2026-07-01', end_date: '2026-07-07', day_count: 40 })); + }); + + it('clamps a non-numeric day_count to the default of 7', () => { + const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); + new TripsController(svc({ create } as Partial)).create(user, { title: 'T', day_count: 'abc' }, req); + expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: 7 })); + }); + + it('logs the reminder when reminderDays is set', () => { + const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 3 }); + expect(new TripsController(svc({ create } as Partial)).create(user, { title: 'T' }, req)).toEqual({ trip: { id: 9 } }); + }); }); it('GET /:id 404 when missing', () => { expect(thrown(() => new TripsController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); + it('GET /:id returns the trip when present', () => { + const s = svc({ get: vi.fn().mockReturnValue({ id: 9 }) } as Partial); + expect(new TripsController(s).get(user, '9')).toEqual({ trip: { id: 9 } }); + }); + describe('PUT /:id', () => { it('404 when no access; 403 on archive without trip_archive', () => { expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).update(user, '9', {}, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); @@ -77,6 +110,45 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(new TripsController(s).update(user, '9', { title: 'b' }, req, 'sock')).toEqual({ trip: { id: 9 } }); expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9 } }, 'sock'); }); + + it('403 on cover_image without trip_cover_upload', () => { + const s = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'trip_cover_upload') }); + expect(thrown(() => new TripsController(s).update(user, '9', { cover_image: '/x.jpg' }, req))).toEqual({ status: 403, body: { error: 'No permission to change cover image' } }); + }); + + it('403 on an edit field without trip_edit', () => { + const s = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'trip_edit') }); + expect(thrown(() => new TripsController(s).update(user, '9', { title: 'b' }, req))).toEqual({ status: 403, body: { error: 'No permission to edit this trip' } }); + }); + + it('admin edit logs the owner and reminder changes', () => { + const update = vi.fn().mockReturnValue({ + updatedTrip: { id: 9 }, changes: { title: { oldValue: 'a', newValue: 'b' } }, newTitle: 'b', + ownerEmail: 'owner@x.y', isAdminEdit: true, newReminder: 5, oldReminder: 0, + }); + const s = svc({ update } as Partial); + expect(new TripsController(s).update(user, '9', { title: 'b' }, req)).toEqual({ trip: { id: 9 } }); + }); + + it('logs when a reminder is removed', () => { + const update = vi.fn().mockReturnValue({ + updatedTrip: { id: 9 }, changes: {}, newTitle: 'b', newReminder: 0, oldReminder: 5, + }); + const s = svc({ update } as Partial); + expect(new TripsController(s).update(user, '9', { reminder_days: 0 }, req)).toEqual({ trip: { id: 9 } }); + }); + + it('maps a NotFoundError to 404 and a ValidationError to 400', () => { + const nf = svc({ update: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) } as Partial); + expect(thrown(() => new TripsController(nf).update(user, '9', { title: 'b' }, req))).toEqual({ status: 404, body: { error: 'gone' } }); + const ve = svc({ update: vi.fn().mockImplementation(() => { throw new ValidationError('bad'); }) } as Partial); + expect(thrown(() => new TripsController(ve).update(user, '9', { title: 'b' }, req))).toEqual({ status: 400, body: { error: 'bad' } }); + }); + + it('re-throws an unknown error from update', () => { + const s = svc({ update: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); + expect(() => new TripsController(s).update(user, '9', { title: 'b' }, req)).toThrow('boom'); + }); }); describe('POST /:id/copy', () => { @@ -104,6 +176,14 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(new TripsController(s).remove(user, '9', req, 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, 'sock'); }); + + it('admin delete logs the owner', () => { + const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: true, ownerEmail: 'owner@x.y' }); + const broadcast = vi.fn(); + const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 2 }), remove, broadcast } as Partial); + expect(new TripsController(s).remove(user, '9', req)).toEqual({ success: true }); + expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, undefined); + }); }); describe('members', () => { @@ -122,6 +202,25 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(notifyInvite).toHaveBeenCalledWith('9', user, 2, 'T', 'bob@x.y'); }); + it('POST 404 without trip access', () => { + const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) }); + expect(thrown(() => new TripsController(s).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + }); + + it('POST maps NotFoundError to 404, ValidationError to 400, re-throws others', () => { + const nf = svc({ addMember: vi.fn().mockImplementation(() => { throw new NotFoundError('no user'); }) } as Partial); + expect(thrown(() => new TripsController(nf).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'no user' } }); + const ve = svc({ addMember: vi.fn().mockImplementation(() => { throw new ValidationError('already a member'); }) } as Partial); + expect(thrown(() => new TripsController(ve).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 400, body: { error: 'already a member' } }); + const other = svc({ addMember: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); + expect(() => new TripsController(other).addMember(user, '9', 'bob@x.y')).toThrow('boom'); + }); + + it('DELETE 404 without trip access', () => { + const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) }); + expect(thrown(() => new TripsController(s).removeMember(user, '9', '2'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + }); + it('DELETE self needs no permission; removing others needs member_manage', () => { const removeMember = vi.fn(); const s = svc({ can: vi.fn().mockReturnValue(false), removeMember } as Partial); @@ -151,6 +250,20 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(deleteOldCover).toHaveBeenCalledWith('/old.jpg'); expect(updateCoverImage).toHaveBeenCalledWith('9', '/uploads/covers/abc.jpg'); }); + + it('403 in demo mode for a demo account', () => { + const prev = process.env.DEMO_MODE; + process.env.DEMO_MODE = 'true'; + isDemoEmail.mockReturnValueOnce(true); + try { + expect(thrown(() => new TripsController(svc()).cover(user, '9', file))).toEqual({ + status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, + }); + } finally { + if (prev === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = prev; + } + }); }); describe('GET /:id/export.ics', () => { @@ -164,6 +277,13 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="trip.ics"'); expect(res.send).toHaveBeenCalledWith('BEGIN:VCALENDAR'); }); + + it('maps a NotFoundError from the export to 404 and re-throws others', () => { + const nf = svc({ exportICS: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) } as Partial); + expect(thrown(() => new TripsController(nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } }); + const other = svc({ exportICS: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); + expect(() => new TripsController(other).exportIcs(user, '9', makeRes())).toThrow('boom'); + }); }); it('POST /:id/copy maps a copy failure to 500', () => { diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index de1feb68..b0daaa9a 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -53,6 +53,12 @@ describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => s.exportICS('9'); expect(tripSvc.exportICS).toHaveBeenCalledWith('9'); }); + it('canAccessTrip delegates to the db helper', () => { + canAccessTrip.mockReturnValueOnce({ user_id: 7 }); + expect(svc().canAccessTrip('9', 7)).toEqual({ user_id: 7 }); + expect(canAccessTrip).toHaveBeenCalledWith('9', 7); + }); + it('can() delegates to checkPermission; broadcast forwards', () => { svc().can('trip_edit', 'user', 1, 1, false); expect(checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 1, 1, false); @@ -70,6 +76,12 @@ describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => expect(result).toMatchObject({ trip: { user_id: 1 }, days: [1], places: [], members: [{ id: 1 }] }); }); + it('bundle tolerates a null member list', () => { + tripSvc.listMembers.mockReturnValueOnce({ owner: { id: 1 }, members: null }); + const result = svc().bundle('9', { user_id: 1 }); + expect(result).toMatchObject({ members: [{ id: 1 }] }); + }); + it('notifyInvite is fire-and-forget (no throw)', () => { expect(() => svc().notifyInvite('9', { id: 1, email: 'a@b.c' } as never, 2, 'T', 'b@x.y')).not.toThrow(); }); diff --git a/server/tests/unit/nest/zod-pipe.test.ts b/server/tests/unit/nest/zod-pipe.test.ts index b6b774a2..230c5dc5 100644 --- a/server/tests/unit/nest/zod-pipe.test.ts +++ b/server/tests/unit/nest/zod-pipe.test.ts @@ -22,4 +22,31 @@ describe('ZodValidationPipe', () => { expect((thrown as HttpException).getStatus()).toBe(400); expect((thrown as HttpException).getResponse()).toHaveProperty('error'); }); + + it("labels a root-level (empty path) issue as 'body'", () => { + const rootPipe = new ZodValidationPipe(z.string()); + let thrown: unknown; + try { + rootPipe.transform(123, meta); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(HttpException); + const body = (thrown as HttpException).getResponse() as { error: string }; + expect(body.error).toMatch(/^body: /); + }); + + it('joins multiple issues with a semicolon', () => { + const multiPipe = new ZodValidationPipe(z.object({ a: z.string(), b: z.number() })); + let thrown: unknown; + try { + multiPipe.transform({ a: 1, b: 'x' }, meta); + } catch (e) { + thrown = e; + } + const body = (thrown as HttpException).getResponse() as { error: string }; + expect(body.error).toContain('a: '); + expect(body.error).toContain('b: '); + expect(body.error).toContain('; '); + }); }); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 660e71f3..1abeedcc 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -24,7 +24,11 @@ export default defineConfig({ silent: false, reporters: ['verbose'], coverage: { - provider: 'v8', + // Vite 8 + Vitest 4 made the sourcemap-based `v8` provider under-report branch + // coverage on the SWC/decorator-transformed output (it dropped to ~68% even + // though every test passes). `istanbul` instruments the source directly, so + // coverage is measured independently of the transform pipeline. + provider: 'istanbul', reporter: ['lcov', 'text'], reportsDirectory: './coverage', include: ['src/**/*.ts'],