Compare commits

..

70 Commits

Author SHA1 Message Date
Maurice c7fa676199 fix(planner): only route to multi-day transport endpoints on their pickup/drop-off days (#1210) 2026-06-16 18:53:00 +02:00
Maurice 1547258c0c docs(readme): refresh dashboard, costs and trip screenshots (#1208)
* docs(readme): refresh dashboard, costs and trip screenshots

* docs(readme): correct outdated info (React 19, NestJS, 20 languages, Costs rename, passkeys, AirTrail, notifications)
2026-06-16 16:59:25 +02:00
Maurice a1ad512064 fix(trips): keep the day-count field empty when cleared and validate it (#1204) (#1207) 2026-06-16 16:20:17 +02:00
Maurice 25324108cb Day plan: hotel travel times at start/end + login toggle polish (#1206)
* fix(login): use the shared toggle for the stay-signed-in option

* feat(planner): show hotel travel times at the start and end of a day

* fix(login): give the stay-signed-in toggle an accessible name and fix its test
2026-06-16 12:51:57 +02:00
jubnl 9f5d2f6488 fix(planner): scroll long place description/notes on mobile (#1195) (#1199)
The place details card (PlaceInspector) clipped long description/notes
with no way to scroll. The content area is a flex column whose children
(description/notes) had the default flex-shrink: 1, so once the card hit
its maxHeight cap they compressed to fit and their overflow:hidden clipped
the text instead of overflowing into a scroll region.

- Make the content area a bounded scroll region (flex: 1 1 auto,
  minHeight: 0, overflowY: auto, momentum + overscroll containment).
- Pin description/notes with flexShrink: 0 so they keep natural height and
  the card overflows into the scroll instead of clipping.
- Pin header/footer with flexShrink: 0 so they stay fixed while scrolling.
- Add wordBreak/overflowWrap to the description div to fix horizontal clip.
2026-06-16 08:39:39 +02:00
jubnl 40253d2fdf fix(places): fall back to search when autocomplete details lookup fails (#1192) (#1198)
Clicking an auto-suggest dropdown item did a second /maps/details lookup
that could fail (details kill-switch off, an overloaded OSM Overpass mirror
behind a proxy, or any upstream error), dead-ending on "Place search failed"
while the search button stayed reliable.

handleSelectSuggestion now treats a missing or coordinate-less details result
(or a thrown error) as a miss and falls back to the text-search path the search
button uses, applying the first result. The error toast only fires if the
fallback also returns nothing. Adds tests for the previously untested
suggestion-click path.
2026-06-16 08:14:01 +02:00
jubnl 910631c1ff fix(backup): restore from Docker, fail-fast on shadowed /app, bundle encryption key (#1193) (#1197)
* fix(backup): restore uploads through symlinked dir and bundle encryption key (#1193)

Restoring a backup inside Docker threw ERR_FS_CP_DIR_TO_NON_DIR because
/app/server/uploads is a symlink to the mounted /app/uploads volume and
cpSync (dereference:false) refuses to overwrite the symlink node with a
directory. The DB was swapped before this failing copy, so users saw
restored data but missing upload files (trip covers). Resolve the symlink
with realpathSync before copying so the merge targets the real directory;
no-op on a plain dir, so non-Docker behavior is unchanged.

Also bundle the at-rest encryption key (data/.encryption_key) into the
backup so a restore onto a different install can decrypt stored secrets
(API keys, MFA, SMTP/OIDC). Skipped when ENCRYPTION_KEY is provided via
env (the file is not the source of truth then). On restore the key is
swapped back if the archive carries one; a restart is required for the
in-memory key to take effect.

* fix(docker): fail fast when a volume shadows /app (#1193)

Mounting an old volume at /app hides the image's node_modules and dist,
so startup crashed with a cryptic "Cannot find module
'tsconfig-paths/register'". Add a CMD preflight that detects the missing
app files and exits with actionable guidance. Document in the README that
only /app/data and /app/uploads should be mounted, never /app.

* fix: ssrf test
2026-06-16 07:43:00 +02:00
jubnl 5b41cab898 chore(ssrf): include lookup error code in error message 2026-06-16 06:52:03 +02:00
jubnl bf969ee80d feat(auth): add "Remember me" checkbox to extend session lifetime (#1189)
Adds a "Remember me" checkbox to the login form (single responsive page,
covers mobile + desktop). Unchecked (default) issues the existing
SESSION_DURATION JWT with a browser-session cookie (no maxAge); checked
issues a longer-lived JWT plus a persistent cookie sized by the new
SESSION_DURATION_REMEMBER env var (default 30d). The choice is threaded
through the MFA verify leg so it survives the step-up.

Register/demo logins keep their current persistent behaviour.
2026-06-15 12:21:05 +02:00
Maurice 2d413c99cf build(deps): bump tsx's esbuild to 0.28.1 (GHSA-gv7w-rqvm-qjhr)
The production image's last image-scan finding was esbuild 0.28.0, pulled
in transitively by tsx. Pin tsx's esbuild to 0.28.1 (within tsx's ~0.28.0
range) to clear GHSA-gv7w-rqvm-qjhr. Lockfile-only; no runtime change.
2026-06-15 10:50:15 +02:00
Maurice 58c7bd831a build(docker): rebuild gosu with a current Go toolchain
Debian's apt gosu ships an old Go stdlib that the image CVE scan flags
(1 critical + several high, all in golang/stdlib). Build gosu from source
with a current Go toolchain and copy the static binary in instead; the
runtime behaviour is unchanged — gosu still drops root to node at startup.
2026-06-15 10:38:01 +02:00
Maurice 8d1e7dded0 ci(security): only fail Docker Scout on fixable CVEs
Add only-fixed so the scan no longer fails on vulnerabilities with no
upstream fix available (e.g. base-image OS packages), and only flags
actionable, fixable findings.
2026-06-15 10:21:39 +02:00
Maurice 127a92c8f5 Merge main into dev: back-merge wiki dev-env updates before the 3.1.0 release
# Conflicts:
#	wiki/Development-environment.md
2026-06-15 10:00:15 +02:00
jubnl 1ed00b67ad fix(pwa): persist offline storage + Mapbox offline policy (H8, H9) (#1184)
H8: prefetched tiles and file blobs could be evicted under storage pressure
(worsened by opaque tile responses inflating the quota ~7MB each), blanking the
offline map right when a traveler needs it. Request persistent storage at app
init so the browser exempts our caches from eviction. We deliberately keep tile
requests no-cors (a cors switch would break self-hosted/custom tile providers
without CORS headers), so persistence is the safe mitigation rather than
de-opaquing responses.

H9: Mapbox GL users had no offline map at all — no runtimeCaching matched the
Mapbox hosts. Add a StaleWhileRevalidate rule for api.mapbox.com /
*.tiles.mapbox.com so visited areas are available offline (best-effort; full
pre-download still requires the Leaflet renderer, now documented).

- new sync/persistentStorage.ts requestPersistentStorage(), called from main.tsx
- vite.config: mapbox-tiles SW cache rule
- MapViewAuto / tilePrefetcher comments document the offline-maps policy
- tests for the persist helper (granted / already-persisted / absent / rejects)
2026-06-15 09:33:35 +02:00
jubnl 4d072b4cb8 fix(realtime): correct assignment:created echo dedup (H11) (#1183)
When X-Idempotency/X-Socket-Id let an own-echo through, the assignment:created
dedup had two bugs: it keyed on place id, so (1) a legitimate second assignment
of a place already on the day was silently dropped, and (2) the temp-version
reconciliation matched place?.id === placeId, letting undefined === undefined
collapse place-less rows onto each other.

- dedup now keys on assignment id (exact-id duplicate -> no-op)
- temp (negative-id) optimistic rows are reconciled only when a real placeId
  matches, replacing just that row; a sibling temp of another place is untouched
- everything else appends, including a genuine 2nd assignment of the same place
- tests: 2nd-of-same-place kept, correct temp picked among siblings, place-less
  rows don't collapse

Note: the broader own-echo suppression relies on X-Socket-Id being sent; this
fixes the client-side fallback when an echo slips through.
2026-06-15 09:33:12 +02:00
jubnl 028e3e0a84 fix(server): lengthen idempotency key TTL to survive multi-day offline (H6) (#1182)
The nightly cleanup deleted idempotency keys older than 24h. The TREK client
replays queued mutations with their X-Idempotency-Key on reconnect, so a device
offline longer than a day had its keys GC'd before it returned — the replayed
POST was then treated as new and created a duplicate.

- raise the TTL to 30 days (DEFAULT_IDEMPOTENCY_TTL_SECONDS), overridable via
  IDEMPOTENCY_TTL_SECONDS
- extract purgeExpiredIdempotencyKeys(now, ttl, db) (mirrors cleanupOldBackups)
  with an injectable db, and have the cron job call it
- tests: 30-day default eviction, 25-day key retained (was dropped at 24h),
  env override

H7 (exactly-once across the lost-response window) is deferred: a correct fix
must store the response in the same DB transaction as the entity write. Doing
it in the generic interceptor (reserve-before-handler) cannot store the real
response body for the crash case, which would break the client's temp->real id
remapping on replay (mutationQueue.flush relies on the entity in the body). It
needs a per-service change and is tracked separately.
2026-06-15 09:32:42 +02:00
jubnl 39b5af790e fix(sync): re-hydrate active trip store on reconnect/online (H1) (#1181)
setRefetchCallback was dead code, so on reconnect the queue flushed and Dexie
re-seeded but the open trip's Zustand store was never refreshed — a
collaborator's edits made while we were offline didn't appear until navigating
away and back.

- new tripStore.hydrateActiveTrip(): silent refresh of the active trip's
  collaborative state (days/places/packing/todo/budget/reservations/files),
  no resetTrip and no isLoading toggle so there's no splash on reconnect
- syncTriggers wires setRefetchCallback to it (WS layer awaits the flush hook
  first) and re-hydrates open trips after the online-event syncAll; cleared on
  unregister
- websocket exposes getActiveTrips() for the online-event path
- tests: refetch wiring + ordering, silent hydrate without reset/splash
2026-06-15 09:32:28 +02:00
jubnl 1eb2cb8eb2 fix(store): reset and uniformly hydrate trip-scoped slices in loadTrip (H4, H5) (#1180)
loadTrip only replaced the first slice group, so budget/reservations/files
from a previous trip stayed visible after switching trips (data exposure on a
shared screen). Those three also loaded via separate tab-gated effects, so they
never hydrated offline for an unopened tab.

- resetTrip() clears every trip-scoped slice (keeps global tags/categories) and
  runs at the top of loadTrip, so a switch can't leak the prior trip's data
- loadTrip now hydrates budget/reservations/files through their repos alongside
  the rest (non-fatal catches), making offline hydration uniform
- useTripPlanner drops the redundant loadFiles + reservations/budget effects;
  tab-gated lazy reloads stay as on-demand refresh
- tests: cross-trip no-leak, uniform hydration, resetTrip
2026-06-15 09:25:28 +02:00
jubnl bcd2c8c959 fix(repo): fall back to Dexie when a network read fails (H2) (#1179)
Repos gated reads on raw navigator.onLine and the online branch had no
try/catch, so a captive portal or connected-but-no-internet (navigator.onLine
lying "true") threw a network error instead of serving the good cached copy —
blanking the trip even though Dexie held it.

- new onlineThenCache(onlineFn, cacheFn) helper: reads the cache when offline,
  and on a network-level failure (Axios error with no HTTP response). A genuine
  HTTP error (4xx/5xx — the server responded) is rethrown so callers still set
  error state / navigate, not masked by a stale cache.
- gates only on navigator.onLine, NOT the connectivity probe: the probe is a
  coarse global flag and one failed health check would otherwise divert every
  read to the (possibly empty) cache even when the request would succeed.
- every repo list/get read path routed through it (reads only — writes still
  go through the mutation queue so failures surface)
- tests: captive-portal fallback, HTTP-error rethrow, non-Axios rethrow
2026-06-15 09:25:11 +02:00
jubnl 5a9c14fc8e fix(db): scope, evict, and cap the offline blob cache (H3) (#1178)
Blob cache previously leaked forever: clearTripData omitted it, entries had
no trip discriminator, and there was no size/count bound, so file blobs
survived trip eviction and could starve the map-tile cache for quota.

- BlobCacheEntry gains tripId + bytes; Dexie v3 adds a tripId index with a
  backfill upgrade (legacy rows -> tripId -1, bytes from blob.size)
- clearTripData purges the trip's blobs in-transaction
- enforceBlobBudget() evicts oldest-by-cachedAt past 200 entries / 100 MB
- tripSyncManager threads tripId/bytes into puts and enforces the budget
2026-06-15 09:24:52 +02:00
jubnl 5500405f2f fix(security): stop cross-user offline data leak on shared devices (#1176)
Closes BLOCKER B4 — three reinforcing paths could serve one account's
cached data to the next user on a shared device:

- The Workbox 'api-data' cache keyed trip/user-scoped GETs by URL only
  (cookie-blind). Changed to NetworkOnly; offline reads come from the
  per-user IndexedDB cache via the repo layer instead.
- IndexedDB had no per-user scoping. The Dexie connection is now scoped
  per user (trek-offline-u<id>) behind a Proxy so the ~19 importers keep a
  stable binding; login opens the user DB, logout deletes it and returns
  to the anonymous DB.
- logout() was fire-and-forget and racy: background flush/syncAll could
  re-seed the DB after the wipe. It is now async and ordered — close an
  auth gate, unregister sync triggers, disconnect, clear caches, delete
  the user DB — and flush()/syncAll() bail when the gate is closed.
2026-06-15 07:58:20 +02:00
jubnl 0a794583d7 fix(maps): make offline tiles cover real trips (cap coherence + zoom-clamp) (#1177)
Closes BLOCKER B5 — the offline map was blank for most real trips:

- The Workbox 'map-tiles' cache held only 1000 entries while the prefetcher
  budgeted ~3413, so prefetched tiles were evicted on arrival. Both caps are
  now a coherent 12288 (~180 MB), kept in sync with cross-referencing comments.
- prefetchTilesForTrip skipped a trip entirely when its all-zooms estimate
  exceeded the cap, so region/road-trip bboxes got no tiles. Removed the
  all-or-nothing guard; prefetchTiles already fills zooms low→high and stops at
  the budget, so large trips now cache the zooms that fit instead of nothing.
2026-06-15 07:53:12 +02:00
jubnl 4188f67ab7 fix(sync): remap temp ids, prevent id collisions, surface failed mutations (#1175)
Closes three offline BLOCKERs from the PWA audit:

- B1: offline edits/deletes of an offline-created entity were lost. The
  negative temp id was baked into the PUT/DELETE url and never rewritten
  after the CREATE returned a real id, so dependents 404'd and were dropped.
  Dependents now carry a {id} placeholder + tempEntityId; flush builds a
  tempId->realId map and durably rewrites still-queued dependents on CREATE
  success (survives flush boundaries / reloads).
- B2: tempId = -(Date.now()) collided within a millisecond, overwriting an
  optimistic row. Replaced with a monotonic nextTempId() minter.
- B3: any 4xx marked the mutation failed with no rollback and no signal, and
  the badge ignored failed rows. Terminal failures now roll back the phantom
  optimistic CREATE; 401/408/425/429 are treated as retryable; failedCount()
  is surfaced in OfflineBanner (red pill) and OfflineTab.
2026-06-15 07:51:52 +02:00
jubnl 8077ffab34 fix(maps): bound place-photo cache growth (Wikimedia + Google) (#1174)
The place-photo cache (uploads/photos/google) grew unbounded: a Wikimedia
geosearch path cached full-res originals despite requesting a 400px thumb,
the writer applied no size guard, nothing reclaimed orphaned files, and
backups archived the whole re-derivable cache verbatim.

- Prefer the scaled `thumburl` over the full-res `info.url` in the Commons
  geosearch fallback.
- Downscale any cached image to <=800px JPEG via the existing jimp dep,
  with a safe fallback to the original bytes on decode failure.
- Add sweepOrphans() (orphaned meta rows + stray files) wired into the
  scheduler (startup + nightly), and removeIfUnreferenced() called on
  place delete for prompt reclamation.
- Exclude the re-derivable photo/trek caches from backups; restores
  self-heal as the cache dirs are recreated at startup.
2026-06-14 23:31:02 +02:00
Maurice 3e9626fce9 feat(places): enrich list-imported places via the Places API (#886) (#1161)
* feat(places): enrich list-imported places via the Places API (#886)

Google/Naver list imports only carry a name and coordinates, so the places open
as bare pins — the Maps tab jumps to coordinates, with no photo, address or
open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import
dialog, shown only when a Google Maps key is configured.

When enabled, after the (fast, unchanged) import the server runs a background
pass that re-resolves each place by name — biased to and validated against the
imported coordinates so a common-name search cannot overwrite the wrong place —
and fills the empty address/website/phone/photo columns plus the resolved
google_place_id, pushing each row over the live sync. Opening hours and the
proper Maps link then work on demand from the stored id.

Enrichment only fills empty fields, runs detached so a long list never blocks
the import, and no-ops when no key is configured.

* fix(places): use the ToggleSwitch component for the enrich toggle

Match the rest of the app — the import-enrichment opt-in used a raw checkbox;
swap it for the shared ToggleSwitch (text left, switch right) like the settings
toggles.
2026-06-14 00:54:11 +02:00
rossanorbr 3398da633b fix(planner): make route tools reachable in mobile day plan sheet (#1142)
* wiki: update dev env

* wiki: small precision in dev env

* fix(planner): make route tools reachable in mobile day plan sheet

On mobile, selecting a day closes the plan sheet immediately, so the
route tools footer (Route toggle / Optimize / routing profile) - gated
on the selected day - was never reachable. Desktop was unaffected.

- Add showRouteToolsWhenExpanded prop to DayPlanSidebar: when set,
  route tools render on any expanded day with 2+ assigned places
- Make handleOptimize accept an explicit dayId (defaulting to
  selectedDayId, preserving desktop behavior)
- Keep the distance/duration pill gated on the selected day, since
  routeInfo belongs to the selected day's calculated route
- Enable the prop on the mobile plan sheet in TripPlannerPage

* fix(planner): correct route-tools prop doc and dev-environment wiki

- Reword the showRouteToolsWhenExpanded JSDoc to list the controls the
  footer actually renders (Route toggle / Optimize / travel profile);
  there is no "Open in Google Maps" action in that block.
- Wiki: drop the non-existent server test:parity script, document the
  real shared i18n:parity checks, and fix the i18n note (the translation
  layer already lives in @trek/shared, it is not "upcoming").

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-06-13 15:24:27 +02:00
Maurice 31f99f0e4e Various fixes: 2FA autofocus, viewer-timezone times, duplicate place guard (#1159)
* fix(auth): autofocus the 2FA code input when the MFA step appears (#767)

* fix(notifications): show notification and admin times in the viewer timezone (#1149)

SQLite CURRENT_TIMESTAMP is UTC but the string has no Z, so the client parsed
it as local time. Normalize in-app notification created_at to ISO-UTC, and stop
forcing the admin user table to render in the server timezone.

* fix(places): warn before adding a duplicate place (#1152)

Manually adding a place did not check the existing pool, so the same POI could
land in Unplanned twice. Flag a likely duplicate by Google Place ID, name or
near-identical coordinates and require a confirming second click to add anyway.
2026-06-13 15:02:18 +02:00
Maurice 56655d53b4 AirTrail integration: import flights & two-way sync (#214) (#1158)
* feat(admin): register AirTrail as an integration addon

Off by default; toggle lives in Admin -> Addons with a Plane icon. The
per-user connection (URL + API key) follows in integration settings.

* feat(integrations): add per-user AirTrail connection

Settings -> Integrations gains an AirTrail section: instance URL + Bearer
API key (encrypted at rest via apiKeyCrypto), a self-signed-TLS opt-in and
a test-connection check. Served by a small Nest controller under
/api/integrations/airtrail, gated on the airtrail addon and SSRF-guarded.
The key is per-user, so it only ever returns that user's own flights.

* feat(transport): import flights from AirTrail

Adds an AirTrail Import button next to Manual Transport that lists the
user's AirTrail flights and highlights the ones inside the trip dates.
Selected flights become reservations linked to their AirTrail origin
(external_* columns), deduped against flights already in the trip, then
broadcast to every member. The mapping resolves airports, airport-local
times and flight metadata; the linkage is what the two-way sync rides on.

* feat(transport): badge AirTrail-linked flights as synced

Linked reservations show an 'AirTrail synced' badge, or 'no longer
synced' once the flight is gone from AirTrail.

* feat(transport): keep TREK and AirTrail flights in sync both ways

A scheduled poll reconciles each connected owner's flights: field edits
(detected by snapshot hash, since AirTrail has no updated_at) flow into
the linked reservation and broadcast live; a flight deleted in AirTrail
keeps the TREK row but stops syncing. Editing a linked flight in TREK
pushes back to AirTrail under the importer's credentials, preserving the
existing seat manifest; if the owner disconnected the link detaches so the
poll can't revert the local edit. Deleting in TREK never touches AirTrail.

* i18n(airtrail): add AirTrail strings across all locales

* test(airtrail): cover flight mapping, timezones and snapshot hashing

* fix(airtrail): reduce airline/aircraft objects to codes

The flight list/get response returns airline and aircraft as joined
objects ({icao, iata, name, ...}), not bare codes. Mapping them straight
through produced '[object Object]' titles and stored objects in metadata,
which crashed reservation rendering. Extract the ICAO/IATA code instead,
and title flights by their flight number.

* fix(airtrail): clear error on non-JSON responses, tolerate /api in URL

A misconfigured instance URL made AirTrail serve its SPA/login HTML, and
the raw JSON.parse failure surfaced as 'Unexpected token <'. Surface an
actionable message instead, and strip a pasted trailing /api so the base
URL still resolves.

* feat(transport): sync AirTrail edits on trip open, not just on the poll

Add a per-user on-demand sync (POST /integrations/airtrail/sync) triggered
when a connected user opens a trip, so AirTrail-side edits appear right away
instead of waiting up to a full poll cycle. Lower the background poll from 15
to 5 minutes as a safety net.

* fix(transport): refresh imported AirTrail flights without a reload

loadTrip doesn't fetch reservations, so a freshly imported flight only
appeared after a full page reload — use loadReservations instead. Also show
flight dates in the user's locale format (e.g. 13.06.2026) rather than the
raw ISO string.

* style(settings): align AirTrail connection with the photo-provider layout

Match the Immich section: stacked URL/key fields, a ToggleSwitch for
self-signed TLS, and a Save / Test-connection row with a status badge.

* feat(transport): add a seat field when editing flights

The transport editor only offered a seat field for trains; flights had
none even though imports store metadata.seat. Show and persist a seat for
flights too.

* style(transport): match the AirTrail button height to Manual Transport

* feat(transport): put the flight seat next to flight number and sync it to AirTrail

Move the seat from a standalone row to the per-leg flight details (beside
the flight number), stored per leg in metadata.legs[].seat with the first
leg mirrored to metadata.seat. On push, set the seat number on the user's
own AirTrail seat (the one with a userId), leaving co-passengers untouched;
import/poll read that same seat back.

* refactor(planner): move the AirTrail trip-open sync into useTripPlanner

Page containers must not own state/effects (lint:pages). Same logic,
relocated from the page into its data hook.

* test(db): pin the region-reconciliation test to its schema version

The test re-ran 'the last migration' assuming the reconciliation is last;
it no longer is once later migrations are appended. Pin to version 135 and
re-run from there (the appended migrations are idempotent).
2026-06-13 13:11:35 +02:00
jubnl f91721c73e fix(packing): respect per-item quantity in bulk import (#1157) 2026-06-13 03:23:37 +02:00
Maurice 0a58e3270b fix(packing): add more bag colors so sub-bags stop repeating (#1156)
The auto-assigned bag palette only had 8 colors, so the 9th bag reused the first one. Double it to 16 (keeping the existing 8 and their order) and keep the server and client lists in sync - both cycle BAG_COLORS[count % length].
2026-06-13 00:52:49 +02:00
Maurice e224befde7 Map/planner/dashboard polish and small community features (#1155)
* feat(planner): reorder days in a modal instead of a dropdown

The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged.

* feat(map): explore reliability, Mapbox popups + compass, region-biased search

POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out.

Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north.

/api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result.

* feat(dashboard): list-view and mobile polish

Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts.

Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar.

* feat: small community-requested options

Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields.

* test(shared): bump day-note subtitle limit assertion to 250

* test: align specs with the new search param order and archive label

Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.
2026-06-12 20:23:34 +02:00
Maurice f46cc8a98e Reorder whole days and insert a day (#589) (#1148)
* feat(days): reorder whole days and insert a day at a position

Adds reorderDays + insertDay to the day service and a PUT /days/reorder route
(plus an optional position on create). Day rows stay stable so a day's
assignments, notes, bookings and accommodations ride along by id; on a dated
trip the calendar dates stay pinned to their slots while the content moves
across them, and each booking's date is re-stamped onto its day's new date
(time-of-day preserved) so day_id stays consistent. Renumbering uses the
two-phase write to avoid the UNIQUE(trip_id, day_number) collision, and a move
that would invert an accommodation's check-in/out span is rejected.

* feat(planner): reorder days from a toolbar popup, and add days

A new toolbar button opens a popup listing the days; drag a row by its grip or
use the up/down arrows to reorder, and add a day from there. Reorders apply
optimistically with rollback and sync over WebSocket; the day headers are left
untouched, so the existing place drop-targets are unaffected.

* i18n: add day-reorder strings across all languages
2026-06-12 00:17:49 +02:00
Maurice 1378c95078 Explore places on the map, planner route fixes, and instance-wide Mapbox (#1147)
* feat(maps): add an OSM POI search endpoint (category within a viewport)

New /api/maps/pois queries OpenStreetMap via Overpass for places of a category
(restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design
— it never calls Google, even when a Google key is configured.

* feat(map): explore nearby places on the trip map (OSM category pill)

A floating, icon-only pill over the planner map lets you toggle a POI category and
see those OpenStreetMap places in the current view; clicking a marker opens the
add-place form pre-filled (name, address, website, phone). Single-select with a
'search this area' action after the map moves. Renders on both the Leaflet and
Mapbox maps, and can be turned off in settings (discussion #841).

* fix(planner): anchor timed places when optimising and route transports by location

- The day optimiser no longer reshuffles places that have a set time — they stay
  anchored to their time, like locked places.
- The route now uses a transport's departure/arrival location as a waypoint when it
  has one (e.g. a flight's airport), instead of breaking the route at every booking;
  transports without a location are ignored for routing but still show their leg's
  distance/duration under the booking.

* feat(admin): instance-wide Mapbox defaults in default user settings

Admins can set a shared Mapbox token (plus style, 3D and quality) as instance
defaults, so the whole instance can use Mapbox without each user pasting their own
key. Users without their own value inherit it via the existing admin-defaults
merge; the shared token is stored encrypted (discussion #920).
2026-06-11 23:42:16 +02:00
Maurice bb477645a3 Support multi-leg (layover) flights (#1146)
* feat(transport): support multi-leg (layover) flights in the booking form

A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER ->
HND) instead of a single departure/arrival pair. The route is entered as a list
of waypoints with a '+ add stop' button; each stop carries its own arrival and
departure time plus the airline/flight number of the segment leaving it, while
the whole booking keeps one price.

Stored without a schema change: the existing reservation_endpoints rows carry the
ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the
per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/
flight_number) mirrors the first and last leg, so a single-leg flight persists
exactly as before and legacy readers keep working.

* feat(planner): show each flight leg as its own day-plan entry, ordered by time

A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA ->
HND), each on its own day with its own times, instead of a single span. Each leg
is an addressable slot (reservation id + leg index) so places and notes can be
dropped into the layover gap between legs; the per-leg position is persisted in
metadata.legs[i].day_positions and survives a reload.

Day-plan items are now ordered chronologically: anything with a time (a place's
time, a flight leg, a timed note) sorts by that time, and untimed items inherit
the time of the item before them so they stay where they were placed.

* feat(planner): show the full multi-stop route in the bookings panel

The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of
just the first and last airport.

* feat(map): draw multi-leg flights as connected legs with a marker per airport

Both the Leaflet and Mapbox overlays now render a flight over all its waypoints:
one great-circle arc per leg and a marker at every airport, with the label
showing the full route and the summed distance. A single-leg flight is unchanged.

Also drops the floating stats badge that was drawn on transport arcs.

* fix(map): centre a clicked place above the bottom inspector panel

Selecting a place panned/flew it to the dead centre of the screen, where it sat
behind the detail card. Both overlays now bias the target into the visible area
above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox
passes the padding to flyTo).

* feat: show the full multi-stop flight route in PDF and calendar export

The PDF day list and the ICS export now render the whole route (FRA → BER → HND)
for a multi-leg flight instead of just the first and last airport, falling back to
the flat metadata for single-leg flights. The ICS keeps a single event per booking.

* feat(import): group connecting flight legs into one multi-leg booking

When a booking confirmation contains several flight legs sharing a PNR that
connect at the same airport with a short layover (under 24h), they are now
imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs)
instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two
separate bookings, and a single flight is unchanged.

* i18n: translate the new flight-route strings into all languages

* i18n: translate the Costs page into every language

The Budget → Costs rework left the new costs.* strings untranslated in every
non-English locale (they fell back to English). Translate them across all
supported languages.

* Revert "fix(map): centre a clicked place above the bottom inspector panel"

This reverts commit 0936103f04.
2026-06-11 22:17:14 +02:00
Maurice e65acb3de7 Fix a batch of reported bugs (#1145)
* fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137)

* fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129)

* fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119)

* fix(unraid): point the template at a PNG icon Unraid can render (#1073)

* fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069)

* fix(map): centre the selected pin in the visible map area above the bottom panel (#1125)

* fix(pdf): render persisted place-photo proxy URLs as images (#1130)

* fix(planner): show the selected place category in the edit form (#1134)

* fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132)
2026-06-11 13:31:43 +02:00
jubnl 3c040fab11 fix: miscellaneous bug fixes (#1139)
* fix(share): serve place thumbnails in shared trip links (#1100)

Google-sourced place photos are stored as image_url pointing at the
JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401
for an unauthenticated shared-trip viewer and render as broken images.

Rewrite place image_url values in the shared payload to a public,
token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and
add an unguarded SharedController route that validates the token and that
the place belongs to its trip before streaming the cached bytes. Mirrors
the existing JourneyPublicController precedent. No client changes needed.

* fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119)

Atlas sourced country and sub-national boundaries from Natural Earth's GitHub
`master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020
counties such as Oppland/Hordaland) and depicts some contested territory in
unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped
entirely.

- Country borders (admin0) now come from the geoBoundaries CGAZ composite;
  sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2
  codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes
  them into committed gzipped bundles under server/assets/atlas, read server-side at
  runtime (no network at boot, no GitHub CSP allowlist entry).
- New GET /addons/atlas/countries/geo serves the country layer; the client fetches
  it from the API instead of GitHub.
- A migration reconciles manually-marked visited_regions against the new bundle
  (valid code -> keep; region name still matches -> re-code; curated merge crosswalk
  for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and
  visited_countries hold only invariant alpha-2 country codes, so they are untouched.
- Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0.

Closes #1119

* fix(packing): make templates admin-only to create, usable by members

Creating a packing-list template was gated only by trip access, so any
trip member could create one from the Lists feature, while applying a
template silently failed for non-admins because the apply dropdown was
populated from the AdminGuard-protected /api/admin/packing-templates
endpoint.

- save-as-template now returns 403 for non-admins; the Save-as-Template
  button is hidden unless the user is an admin (both the TripPlanner
  toolbar and the inline packing header).
- add member-accessible GET /api/trips/:tripId/packing/templates so the
  apply dropdown lists templates for any trip member; client fetches
  from it instead of the admin endpoint.

Closes #1120
Closes #1121

* fix(packing): show bag tracking to non-admin members

The global Bag Tracking toggle was only readable via the admin-gated
GET /api/admin/bag-tracking, so non-admin trip members got 403 and the
weight fields, bag circles, and BAGS sidebar never rendered (#1124).

Surface the flag through the already-authenticated GET /api/addons
(loaded into the client addon store on app start for every user); the
packing hook reads it from the store instead of the admin endpoint. The
admin write path stays admin-gated and unchanged.
2026-06-09 16:02:37 +02:00
Maurice 49b3af8b0d feat: optimize routes around accommodation, confirm note deletions (#1123)
Optimize day routes around the accommodation

When a day has an accommodation set, the route optimizer now treats it as
the day's home base: it optimizes a loop that leaves the hotel and returns
to it, so the stop nearest the hotel comes first. On a transfer day -
checking out of one hotel and into another - the route runs from the first
hotel to the second instead.

The optimizer also gained a 2-opt pass on top of the nearest-neighbor
ordering, which removes the crossings the greedy pass used to leave behind.
A new display setting ("optimize route from accommodation", on by default)
lets you turn the anchoring off.

Confirm before deleting notes

Deleting a plan note or a collab note now asks for confirmation first. On
phones and tablets the edit and delete icons sit close together and were
easy to mis-tap, which deleted notes with no way back.
2026-06-07 12:52:06 +02:00
Maurice 093e069ccc Backend/frontend hardening & consistency cleanups (#1113)
* refactor(auth): session token validation and password-change consistency

* refactor(journey): entry field allow-list and public share-link consistency

* refactor(mcp): align tool authorization with the REST permission checks

* chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)
2026-06-06 16:37:03 +02:00
jubnl 070ef01328 chore: update kitinerary version 2026-06-05 19:26:34 +02:00
Maurice a876fb2634 feat: Passkey (WebAuthn) login (#1111)
* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle

Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers.

* feat(auth): passkey enrolment, login button + admin settings UI

PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action.

* i18n(auth): passkey strings across all locales

Add login/settings/admin passkey keys to en and all 19 translated locales.
2026-06-05 18:54:13 +02:00
Maurice 247433fb2a feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106)
* fix(journey): authorize reads of the journey share link

GET /api/journeys/:id/share-link now requires journey access (canAccessJourney),
matching the create/delete share-link routes and the get_journey_share_link MCP
tool. Returns no link when the caller lacks access to the journey.

* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile

Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/
Splitwise-style cost tracker: multiple payers per expense, equal split across
chosen members, settle-up with persisted history + undo, 12 fixed categories,
per-expense currency with live FX conversion to a user-set display currency
(Settings -> Display), and locale-correct money formatting. Adds a desktop and a
dedicated mobile layout. A migration backfills existing budget items (single
payer, split members, currency). Closes #551 (per-expense currency).

Also switches the app font to self-hosted Poppins (Geist for secondary subtext),
replacing the Google Fonts CDN dependency.

* fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge

- Dark mode used a warm oklch palette that read brownish; switch to the
  neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a
  subtle backdrop-blur glass on cards.
- Costs now uses the full available page width on desktop instead of a 1280px cap.
- Render the expense count next to the Expenses title as a badge.
- Adapt budget/journey unit tests to the new payer-based settlement model and the
  Costs rename (category default 'other', Costs tab/CostsPanel).

* fix(costs): drop the entry-count badge, always show row edit/delete actions

Removes the count badge next to the Expenses title and makes the per-row
edit/delete actions permanently visible (no longer hover-only) on desktop too.

* feat(costs): currency-native money formatting, custom select/date, rename addon to Costs

- Format every amount in its own currency convention (symbol position, grouping
  and decimal separators) regardless of app language, via a currency->locale map
  (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the
  app locale, so EUR showed the symbol in front under an English UI.
- Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the
  add/edit expense modal instead of the native <select>/<input type=date>.
- Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only;
  id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs.

* feat(auth): configurable session duration via SESSION_DURATION

Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling
how long a session stays valid before re-login. It drives both the trek_session
JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid
values warn at startup and fall back to the default (24h — unchanged). The MFA
challenge token and MCP OAuth tokens keep their own TTL.

Implements the request from discussion #946. Documented in the env-var wiki page,
.env.example and docker-compose.yml.
2026-06-05 01:38:25 +02:00
jubnl 6ef3c7ae6b feat(reservations): native booking-confirmation import via KDE KItinerary (#1102)
* feat(reservations): native booking-confirmation import via KDE KItinerary

Adds a two-step preview → confirm flow for importing booking emails,
PDFs, PKPass and HTML confirmations. The server invokes the KDE
kitinerary-extractor binary, maps JSON-LD schema.org output to TREK
reservation shapes, and persists via the existing createReservation
pipeline (accommodations, budget, places, WebSocket broadcasts).

- NestJS BookingImportModule: preview + confirm endpoints under
  /api/trips/:tripId/reservations/import/booking{,/confirm}
- KitineraryExtractorService: spawns the binary, filters stderr noise,
  handles QDateTime (@value) timezone-aware datetimes
- kitinerary-mapper: FlightReservation, TrainReservation, BusReservation,
  BoatReservation, LodgingReservation, FoodEstablishmentReservation,
  RentalCarReservation, EventReservation → typed preview items
- BookingImportService: auto-creates place rows; geocodes venues without
  coordinates via Nominatim (name+address → address → name fallback);
  resolves day IDs for accommodation linking
- BookingImportModal: drag-and-drop multi-file upload, preview cards
  with type icons, per-item exclude toggle, confirm step
- Shared Zod contracts: BookingImportPreviewItem, PreviewResponse,
  ConfirmRequest, ConfirmResponse — consumed by controller, service,
  API client and modal
- Dockerfile: node:24-trixie-slim runtime; amd64 downloads KDE static
  binary + locales; arm64 installs libkitinerary-bin + symlinks to
  fixed path; ENV KITINERARY_EXTRACTOR_PATH set for both arches
- /api/health/features exposes { bookingImport: boolean } so the UI
  hides the Import button when the binary is absent
- i18n keys (English), wiki docs, API.md, README one-liner

* i18n: add booking import translations for all 19 non-English locales

Adds 17 reservations.import.* keys and undo.importBooking to ar, br, cs,
de, es, fr, gr, hu, id, it, ja, ko, nl, pl, ru, tr, uk, zh, zh-TW.

* chore: enforce i18n parity

* docs(wiki): add KItinerary local setup instructions to dev environment guide
2026-06-04 20:40:57 +02:00
Maurice abe1c549bd feat(transport): add bus, taxi, bicycle, ferry and other transport types (#1105)
Closes #718. Adds five new transport reservation types alongside the
existing flight/train/car/cruise: bus, taxi, bicycle, ferry and a generic
'transport_other' catch-all. The new types are treated as first-class
transports everywhere — the transport modal, day plan, route calculation,
map overlays, file grouping and the PDF export — and are translated across
all 20 locales.

A dedicated 'transport_other' value is used for the catch-all so existing
'other' bookings are not reclassified as transport.
2026-06-04 20:39:11 +02:00
jubnl 10bea35a91 fix(journey): raise PhotoLightbox z-index above MobileEntryView (#1101) 2026-06-03 12:53:45 +02:00
Larinel a77ee4b4d5 fix(pwa): removed orientation from the manifest (#1058) 2026-06-01 22:08:43 +02:00
Maurice 9bec97fc19 Fix a batch of reported bugs: Atlas regions, planner overlays, imports, Safari modals (#1094)
* Start the Journey date picker week on Monday (#1078)

The Journey entry date picker started the week on Sunday (firstDow = getDay(), headers Su-first) while every other picker (CustomDateTimePicker, VacayCalendar) starts on Monday. Align it: Monday-first leading offset ((getDay()+6)%7) and Mo-first weekday headers.

* Fix Taiwan resolving to CN-TW in the Atlas country search (#1049)

natural-earth gives Taiwan ISO_A2='CN-TW' (a subdivision-style value) with ADM0_A3='TWN'. The dynamic A2_TO_A3 augmentation added 'CN-TW'->'TWN', which then overwrote the legitimate TWN->TW entry in the reverse map, so Taiwan's country option resolved to 'CN-TW' — unresolvable by Intl.DisplayNames (no name, broken flag, not searchable). Only augment A2_TO_A3 with real 2-letter codes.

* Drop empty leftover dateless days when a trip gets a shorter dated range (#1083)

generateDays kept all unused dateless placeholder days after switching to an explicit (shorter) date range, so day_count (COUNT(*) FROM days) stayed inflated. Delete the empty leftovers (no assignments/notes/accommodations) like the dateless path already does, while preserving any that still hold content. Adds TRIP-SVC-017.

* Render GPX and route overlays once the Mapbox style has loaded (#1036)

The GPX and route geojson effects ran before the map 'load' event had
attached their sources, so on the first paint they hit the early return
and never re-ran. Add mapReady to their dependencies so they fire again
the moment the sources exist.

* Convert HEIC trip and journey covers to JPEG before upload (#1085)

HEIC/HEIF covers coming straight off an iPhone could not be rendered in
the preview or stored as a usable image. Route both cover pickers through
normalizeImageFile, the same conversion the journal entry editor already
uses, so the file becomes a JPEG before it leaves the browser.

* Name GPX routes and tracks after their source file so multiple imports stick (#1054)

Unnamed routes and tracks all fell back to the same generic 'GPX Route' /
'GPX Track' label, so the name-based import dedup dropped every one after
the first - importing several files (or one file with several tracks) only
kept a single place. Derive the default name from the source filename with
an index suffix when a file holds more than one geometry, thread the
filename down through the controller, and let the import modal take more
than one file at a time. Adds PLACE-SVC-037/038.

* Namespace the modal backdrop class so content blockers stop hiding it (#1027)

Generic class names like .modal-backdrop sit on the cosmetic filter lists
that content blockers (1Blocker, EasyList Annoyances) ship, and get hidden
with display:none. The shared Modal - used by New Trip and Add Place -
carried that class, so Safari users running such a blocker saw the modal
silently fail to open with no error and no network request. Rename it to
.trek-modal-backdrop.

* Highlight GB regions by resolving England/Scotland/Wales/NI to finer admin-1 codes (#1067)

A zoom-8 reverse geocode of a UK place only resolves to the constituent
country (GB-ENG/SCT/WLS/NIR), but Natural Earth's admin-1 polygons for GB
are counties and boroughs (GB-LND, GB-MAN, GB-CON, ...). Those four codes
match no polygon, so places in England never highlighted in the Atlas
while CH/IT/NL/etc. worked. When a GB lookup lands on a constituent
country, re-resolve it at a finer zoom where Nominatim exposes the
county/borough code the polygons actually carry. Other countries keep the
exact zoom-8 behaviour. Adds ATLAS-UNIT-021.

* Surface the real place-search error instead of a generic toast (#1092)

When a place search or detail lookup fails, the backend already forwards the
upstream reason - including descriptive Google Places API messages such as
'Places API (New) has not been used in project ... or it is disabled'. The
planner discarded it and always showed 'Place search failed', so a key that
is mis-enabled, unbilled, or pointed at the legacy API instead of Places API
(New) looked like an unexplained silent failure. Show the server-provided
message when present, and stop the Atlas bucket-list search from swallowing
its error without a trace.

* Await the async cover normalization in the TripFormModal paste test (#1085)

handleCoverSelect now normalizes the pasted file before previewing it, so
URL.createObjectURL is called a microtask later. The assertion moves into
waitFor; a non-HEIC file still passes through unchanged.
2026-05-31 23:28:16 +02:00
Maurice 20791a29a7 Migrate TREK 3 to NestJS + React 19 (shared Zod contracts) (#1087)
* Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer

Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.

* Finish the NestJS migration — drop the legacy Express app

NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.

- Platform/transport routes extracted to nest/platform/platform.routes.ts
  and mounted before app.init() — Nest's router answers an unmatched
  request with a 404, so a route registered after init is never reached.
  The SPA fallback is a NotFoundException filter and the catch-all uses a
  RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
  gallery's Immich/Synology proxy), addons (GET /api/addons) and the
  cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
  so upload rejections keep their 400/413 { error } body and non-ASCII
  filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
  trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
  app; removed the now-meaningless Express-vs-Nest parity tests and a few
  orphaned client components.

* Restore the reset-password rate limit and fix copyTrip reservation links

Two correctness/security gaps the NestJS migration introduced:

- POST /api/auth/reset-password lost its per-IP rate limiter. Restore it
  (5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter)
  so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019.
- copyTripById did not copy reservations.end_day_id (a day reference — now
  remapped through dayMap like day_id) or needs_review, so a duplicated trip
  lost multi-day transport end-day links and reset the review flag.

* Clean up dead code, dedupe helpers, fix the reset-password contract

- Remove server exports orphaned by the Express removal: the immich
  album-link helpers, seven route-only service exports, getFileByIdFull;
  de-export internal-only helpers (utcSuffix).
- De-duplicate verifyTripAccess (9 identical copies -> services/tripAccess.ts)
  and avatarUrl (3 -> services/avatarUrl.ts); name the bcrypt cost
  (BCRYPT_COST) and the email regex (EMAIL_REGEX). Public API unchanged.
- resetPasswordRequestSchema declared `password`, but the client sends and
  the service reads `new_password` — rename it so the contract matches and
  the client types resolve.
- Make ATLAS-013 deterministic: stub the admin-1 GeoJSON download instead of
  fetching ~4600 features from GitHub during the test (it hung the suite).

* Make the client typecheck runnable (vitest/vite ambient types)

The client had no `typecheck` script and tsc couldn't even start (the
baseUrl deprecation errored out, same as server/shared already silence).
Add `ignoreDeprecations: "6.0"` to match the other workspaces, a `typecheck`
npm script, and a src/vite-env.d.ts referencing vite/client + vitest/globals
so tsc knows the test globals (describe/it/expect/vi). This turns ~3600
phantom "Cannot find name" errors into a real, measurable count (~590 actual
type errors remain, to be worked down). Type-only; no runtime change.

* Derive client domain types from the shared schema contracts

Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.

* chore(db): log swallowed errors in addon-disable migration + guard against destructive migrations

The migration that disables the legacy "memories" addon swallowed any
error in an empty catch, as did ~30 other catch blocks in the migration
runner (column adds, the journey rebuild, index probes). Replace each
silent catch with the existing console.warn('[migrations] ...') log so
failures are visible. Control flow is unchanged: every step stays
non-fatal, nothing new is thrown.

Add a static guardrail test that scans the migration source and fails
when a new destructive statement (DROP TABLE / DROP COLUMN / TRUNCATE /
DELETE FROM / ALTER ... DROP) appears outside a reviewed allowlist, and
when an empty/silent catch block is reintroduced. The existing
destructive statements are all legitimate table rebuilds or
bounded cleanups and are recorded in the allowlist with a reason.

* Re-check SSRF on every redirect hop when resolving short links

Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve.

* Reject WebSocket tokens minted before a password change

Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics.

* fix(i18n): guard locale key parity and finish the OAuth consent page strings

Every non-en locale now exposes the exact same flat key set as en. Keys that
had drifted out of sync are backfilled with the English source value (tagged
en-fallback) so t() resolves a real string instead of relying on the silent
runtime fallback; no existing translation was touched and no key was removed.

Add a parity test that imports each aggregated locale bundle and asserts its
key set matches en, with a diagnostic listing of any missing/extra keys. This
complements the file-level check in shared/scripts by guarding the merged
export the app actually serves.

Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded
English chrome strings now go through oauth.authorize.* keys (English source
in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged.

* Add semantic theme color tokens to Tailwind

Map the CSS theme variables from src/index.css (:root light / .dark dark) to named Tailwind utilities — bg-surface, text-content, border-edge, bg-accent and their variants. This gives components a Tailwind-native target for the theme colors so we can replace inline `style={{ ... 'var(--...)' }}` with utility classes without changing the rendered values.

* Surface silent store failures to the user and validate API responses in dev

Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev.

* Migrate static theme inline styles to Tailwind utilities and extract page sub-components

Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical.

* Remove the unrouted photos page and its dead photo components

PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays.

* Resolve the remaining client type errors and the trip.title navbar bug

Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change.

* Convert the remaining dynamic and hardcoded inline styles to Tailwind utilities

Second styling pass over the components and pages: move conditional theme colors into className ternaries (bg-accent / bg-surface-hover etc.), turn reused CSSProperties constants into className constants, and express static hardcoded hex/rgba colors as Tailwind arbitrary values so the exact rendered colour is preserved. Truly dynamic styling (computed geometry, gradients, multi-part shadows, data-driven colours, the undefined --sidebar/--nav layout vars) stays inline as it cannot be expressed as a static class. Updated three component tests that asserted the old inline active-state styles to assert the equivalent utility class instead.

Verified: client typecheck 0, full client suite green, and a live light/dark render check in the dev server confirms the semantic theme tokens resolve correctly (the earlier 'transparent popups' were a stale dev server that pre-dated the tailwind.config token addition, not a code issue).

* Add eslint flat-config for client and server and gate typecheck, lint and pages in CI

client and server had lint scripts but no eslint config (only shared was linted in CI). Add flat configs mirroring shared's stack (js + typescript-eslint recommended + eslint-config-prettier) plus the client's react-hooks/react-refresh plugins. Pre-existing patterns in this never-linted code (explicit any, require() in the CommonJS server, empty catches, exhaustive-deps) are set to 'warn' rather than 'error' so the gate passes at 0 errors without a repo-wide reformat — these can be ratcheted to errors over time. Wire blocking typecheck + lint + lint:pages steps into the client and server CI jobs (now that both typechecks are clean) and promote the server typecheck from informational to blocking.

* Decompose the remaining God Components into hooks, helpers and sub-components

FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted.

* Fix duplicate React keys in the file-assign place list

When a place is assigned to the same day more than once it appeared twice in a day's list, so the place-button key={p.id} collided and React warned about duplicate keys. Key by place id + render index so siblings stay unique. Pre-existing in the old FileManager; behaviour unchanged.

* Format the shared package and drop an unused import to satisfy the lint gate

The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass.

* Install all workspaces in the server CI job so SWC's native binary is present

The server vitest config transforms via unplugin-swc, which needs @swc/core's platform-specific native binary. A workspace-scoped 'npm ci --workspace server' skips that optional dependency, so vitest failed to load the config on the Linux runner. Use a full 'npm ci'.

* Re-resolve dependencies with npm install in the server CI job for SWC

Full 'npm ci' still skipped @swc/core's Linux native binary because the committed lockfile was generated on Windows and lacks the Linux optional-dep install metadata. 'npm install' re-resolves and fetches the platform-matching binary, which the server's unplugin-swc transform needs to load vitest.config.ts.

* Install @swc/core's Linux binary explicitly in the server CI job

Neither npm ci nor npm install fetched @swc/core-linux-x64-gnu on the Linux runner because the lockfile was generated on Windows and lacks the Linux optional-dep metadata. Add a step that installs the matching @swc/core-linux-x64-gnu version (no-save, no-lockfile) so unplugin-swc can load the server's vitest config.

* Use legacy-peer-deps when installing the SWC Linux binary in CI

The explicit @swc/core-linux-x64-gnu install re-resolved the tree and hit the pre-existing lucide-react/react-19 peer conflict that the lockfile was generated around. Add --legacy-peer-deps so the step matches the project's resolution and installs the binary.

* Keep the lockfile when installing the SWC binary so other deps stay pinned

Dropping --no-package-lock made npm re-resolve the whole tree and upgrade eslint, whose newer recommended config flagged no-useless-assignment as an error in the server lint step. Keep the lockfile so only @swc/core-linux-x64-gnu is added and every other dependency (incl. eslint) stays at its locked version.
2026-05-31 21:10:00 +02:00
Maurice 6d2dd37414 feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079)
* feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos

- Rework the mobile dashboard: cover hero, separate boarding-pass card,
  trimmed atlas (trips + days only), stacked widgets
- New floating bottom tab bar with a centred context-aware + button
  (new trip / place / journey / entry depending on the page)
- Move profile + notifications into a small top strip on the dashboard
- Desktop: glassmorphic tiles (light + dark), neutral dark palette,
  plain-text countdown module, real place photos in the boarding pass

* i18n(dashboard): translate new dashboard keys across all locales

Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy
dialog, aria labels, countdown) that were left as English placeholders,
plus the new startsIn/aria keys, for all 19 languages.

* feat(oidc): send PKCE (S256) in the OIDC login flow

The OIDC client now generates a code_verifier per login, sends the
S256 code_challenge on the authorize request and the code_verifier on
the token exchange. Works whether the provider has PKCE optional or
required (fixes login against providers that require PKCE, e.g. Pocket ID).
2026-05-27 23:19:03 +02:00
jufy111 0d2657ee37 feat: Updated border of map markers to reflect category color. (#1062) 2026-05-27 22:54:41 +02:00
Julien G. 0a8fb1f53b Merge branch 'feat/dashboard-rework' into dev 2026-05-27 17:53:46 +02:00
jubnl 2fe6657edd chore: enforce prettier & lint on shared package 2026-05-27 17:42:23 +02:00
jubnl 5f964b9524 chore: prettier + lint 2026-05-27 17:35:10 +02:00
Ahmet Yılmaz 8bda980028 i18n: complete Turkish (tr) translation (#1075)
Fill in the remaining ~2100 UI strings in shared/src/i18n/tr so Turkish
matches the English catalog. Brand names, URLs, and technical placeholders
are left untranslated by design.
2026-05-27 17:31:37 +02:00
Dimitris Kafetzis 831a4fd478 feat(i18n): add Greek translation (#1061) 2026-05-27 17:31:03 +02:00
Maurice 4ff4435f8b refactor(dashboard): replace hardcoded strings with i18n keys
Hero, atlas row, trip cards, filters, currency and timezone widgets now resolve all visible copy through t() instead of hardcoded English/German.
2026-05-26 23:25:51 +02:00
Maurice 69b699c9bf i18n(dashboard): sync all locales to one key set + German copy-dialog strings
Brings every locale's dashboard namespace to the same 149-key set (missing keys backfilled from English) and translates the previously English-only copy-trip dialog into German.
2026-05-26 23:25:50 +02:00
Maurice 98032fda0c feat(dashboard): boarding-pass hero, atlas row, live widgets + modal portal fix
Reworked dashboard layout: boarding-pass hero with hover + days-left countdown, atlas stats row with real flags, searchable currency widget, editable timezone widget, new-trip FAB. Modals now portal to document.body to avoid inheriting dashboard-scoped button/font styles.
2026-05-26 23:12:08 +02:00
Maurice e04ceeb1ee i18n(dashboard): dashboard keys across locales 2026-05-26 23:12:08 +02:00
Maurice e5000ff7dd feat(dashboard): upcoming reservations endpoint + travel-stats country/distance
Adds GET /api/reservations/upcoming for the dashboard widget, switches travel-stats to the same country source as Atlas (manual + place-derived, ISO codes), and a distance service for flown km.
2026-05-26 23:12:07 +02:00
Julien G. 126f2df21b chore: move i18n to shared package (#1066)
* chore: move i18n to shared package

* chore: move server translations to shared package and apply linter and prettier on entire shared package
2026-05-26 20:27:29 +02:00
Maurice 324d930ca3 remove route_calculation setting, always use OSRM routing (#1064)
The per-user route_calculation toggle was a second, hidden on/off layer
on top of the day footer's show-route button, and made it easy to end up
with straight-line routes for no obvious reason. Drop the setting
entirely: routing is always on, the footer toggle stays the single
switch. Old stored values are simply ignored (settings are key-value, no
migration needed).
2026-05-26 16:21:10 +02:00
Maurice e050814c42 feat(planner): real road routes (OSRM) with travel-time connectors (#1060)
* feat(planner): real road routes (OSRM) with travel-time connectors

Replace the straight-line "as the crow flies" route with real OSRM road
geometry (FOSSGIS routed-car/-foot) and an Apple-Maps style render
(blue casing under a lighter core) on both the Leaflet and Mapbox GL
maps. Routes are off by default and toggled per session, with a
driving/walking mode switch in the day footer.

Each day shows per-segment travel time/distance connectors between
places, computed from the OSRM legs and split at transport bookings.

Also redesigns the day header for visual consistency: vertical
number+weather capsule, name with a divider before the date, subtle
hotel/rental pills that stay on one line, and a hover-revealed 2x2
action square (edit / add transport / add note / collapse). Drops the
Google Maps button.

* test(planner): update route hook tests for calculateRouteWithLegs
2026-05-25 22:27:49 +02:00
Julien G. c130ed41be chore: fix monorepo build pipeline and migrate shared to built package (#1056)
* chore: fix monorepo build pipeline and migrate shared to built package

- Root package.json: add workspace scripts (dev, build, test, test:cov, test:e2e)
  that delegate to actual scripts in shared/server/client workspaces
- shared: add tsup build step (CJS + ESM dual output, .d.ts); consumers now import
  from the built dist instead of raw TS source via path aliases
- server: replace tsc-alias with tsconfig-paths (tsc-alias mangled node_modules
  paths); fix MCP SDK path aliases to point to root node_modules (../node_modules)
- server/scripts/dev.mjs: delay node --watch until tsc -w signals first-pass done,
  eliminating the spurious restart on every dev startup
- client/vite.config.js + vitest.config.ts: remove @trek/shared path alias (no longer
  needed now that shared is a proper package)
- Consolidate package-lock.json at the workspace root; drop per-workspace lock files

* chore: fix test script to reflect root package.json

* chore: add missing lint and prettier script in root package.json

* fix(ci): build shared before tests; fix vitest MCP SDK alias paths

vitest.config.ts aliases pointed at ./node_modules/ (server-local) but
packages are hoisted to the root node_modules/ in the npm workspace —
changed to ../node_modules/.

CI jobs now install and build shared before running server/client tests
so that @trek/shared's dist/ exists when vitest resolves the package.

* fix(docker): update Dockerfile and CI for monorepo workspace structure

Dockerfile:
- Add shared-builder stage that produces @trek/shared dist before
  client and server stages need it
- Each build stage carries root package.json + package-lock.json so npm
  can resolve @trek/shared as a workspace dependency
- Production stage installs via workspace context (npm ci --workspace=server
  --omit=dev) so node_modules/@trek/shared symlinks to shared/dist correctly
- Copy server/tsconfig.json into the image so tsconfig-paths/register can
  find the MCP SDK path aliases at runtime
- CMD cds into /app/server before starting node so tsconfig-paths baseUrl
  resolves and ../node_modules points to /app/node_modules
- Remove mkdir for /app/server (now a real dir); keep symlinks for uploads/data

docker.yml version-bump:
- Replace manual per-workspace cd+npm-version calls with single:
  npm version --workspaces --include-workspace-root --no-git-tag-version
  (mirrors the version:* scripts in root package.json)
- git add now references root package-lock.json; adds shared/package.json

.dockerignore: add shared/dist
package.json: fix version:prerelease preid (alpha → pre)

* fix(tests): use in-memory SQLite per worker in test mode

vitest pool:forks spawns parallel worker processes that all called
initDb() on the same data/travel.db, causing SQLite "database is locked"
and "duplicate column name" races.

When NODE_ENV=test each fork now gets an isolated :memory: DB so migrations
run independently with no file contention.

* chore(ci): add ACT guards to skip DockerHub steps in local act runs

act sets ACT=true automatically. Guards added:
- docker login: if: ${{ !env.ACT }}
- build outputs: type=docker (local load) when ACT, push-by-digest when CI
- digest export/upload: if: ${{ !env.ACT }}
- merge job: if: ${{ !env.ACT }}
- release-helm job (docker.yml): if: ${{ !env.ACT }}
- version-bump git push (docker.yml): wrapped in [ -z "$ACT" ] shell guard

Run locally with:
  ./bin/act -j build -W .github/workflows/docker.yml \
    -P ubuntu-latest=catthehacker/ubuntu:act-latest

* fix(ci): move ACT guards to step level; add guards to security.yml

env context is invalid in job-level if conditions — moved all ACT
guards down to individual steps. Also guards docker login + scout
in security.yml so act can run the build-only part of that workflow.

* fix(ci): skip git fetch and tag logic in act (no remote access in local containers)

* Revert "fix(ci): skip git fetch and tag logic in act (no remote access in local containers)"

This reverts commit 67cf290cda.

* Revert "fix(ci): move ACT guards to step level; add guards to security.yml"

This reverts commit f92b95e054.

* Revert "chore(ci): add ACT guards to skip DockerHub steps in local act runs"

This reverts commit 797183de08.

* fix(docker): add musl optional deps so alpine builds find native rollup/sharp binaries

npm prunes libc-constrained optional deps to the host libc (glibc) when
generating the lockfile, leaving no musl entry for Alpine containers.
Declaring the x64/arm64 musl variants as explicit root optionalDependencies
forces them into the lockfile so npm ci on Alpine can install them.

Covers shared-builder (tsup/rollup) and client-builder (vite/rollup + sharp
icon generation) for both linux/amd64 and linux/arm64 CI targets.

* fix(docker): copy client dist into server/public so the server resolves static files correctly

The server runs from /app/server and serves static files relative to that
directory, so the client build output must land at /app/server/public, not /app/public.
2026-05-25 21:44:58 +02:00
Maurice db5c403239 i18n: register Korean + add Ukrainian translation (#1055)
Korean translation by @ppuassi (#977) — now registered. Ukrainian by @JeffyOLOLO (#902) — lifted onto a clean branch. Both at full en.ts key parity (2258 keys).
2026-05-25 18:37:15 +02:00
SkyLostTR bd29fcb0c0 Add Turkish (tr) translation + language registry (#1029)
Turkish translation by @SkyLostTR, at full en.ts key parity, registered in supportedLanguages + TranslationContext.
2026-05-25 18:26:29 +02:00
sss3978 be71cae0d3 feat(i18n): add Japanese (ja) translation (#829)
Japanese translation by @soma3978, at full en.ts key parity, registered in supportedLanguages + TranslationContext.
2026-05-25 18:22:39 +02:00
ppuassi ee2089e81d feat(i18n): add Korean (ko) translation (#977)
Korean translation by @ppuassi, topped up to full en.ts key parity. Language registration follows separately.
2026-05-25 18:22:35 +02:00
gzor 352f94612d fix(packing): multiply item weight by quantity in bag/total weight calcs (#898)
Quantity now counts toward bag and total weights. Generalised to an itemWeight() helper used by every weight sum (bag totals + max, unassigned, grand total; sidebar + bag modal) with unit tests.
2026-05-25 17:59:54 +02:00
Maurice 0257e4e71e feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053)
First strangler migration (L1): /api/weather is served by a NestJS module.

- @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged).
- Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR.
- Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract.
- Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide.
- Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone.
2026-05-25 17:00:58 +02:00
Maurice 0b218d53b2 Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050)
Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in.

F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage.

Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).
2026-05-25 14:29:30 +02:00
65 changed files with 2956 additions and 11937 deletions
-2
View File
@@ -408,8 +408,6 @@ Caddy handles TLS and WebSockets automatically.
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.1.0
version: 3.0.22
description: Minimal Helm chart for TREK app
appVersion: "3.1.0"
appVersion: "3.0.22"
-6
View File
@@ -28,12 +28,6 @@ data:
{{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }}
{{- if .Values.env.SESSION_DURATION }}
SESSION_DURATION: {{ .Values.env.SESSION_DURATION | quote }}
{{- end }}
{{- if .Values.env.SESSION_DURATION_REMEMBER }}
SESSION_DURATION_REMEMBER: {{ .Values.env.SESSION_DURATION_REMEMBER | quote }}
{{- end }}
{{- if .Values.env.TRUST_PROXY }}
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
{{- end }}
-4
View File
@@ -34,10 +34,6 @@ env:
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true"
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# SESSION_DURATION: "24h"
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
# SESSION_DURATION_REMEMBER: "30d"
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
# TRUST_PROXY: "1"
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
# ALLOW_INTERNAL_NETWORK: "false"
+6 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "3.1.0",
"version": "3.0.22",
"private": true,
"type": "module",
"scripts": {
@@ -58,12 +58,11 @@
"@testing-library/user-event": "^14.6.1",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/leaflet": "^1.9.8",
"@types/node": "^25.9.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.9",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.18",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
@@ -81,8 +80,8 @@
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.16",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.9"
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0",
"vitest": "^3.2.4"
}
}
@@ -1,6 +1,6 @@
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import MarkdownToolbar from './MarkdownToolbar';
import React from 'react';
@@ -16,10 +16,10 @@ function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
}
describe('MarkdownToolbar', () => {
let onUpdate: Mock<(value: string) => void>;
let onUpdate: ReturnType<typeof vi.fn>;
beforeEach(() => {
onUpdate = vi.fn<(value: string) => void>();
onUpdate = vi.fn();
});
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
+15 -25
View File
@@ -31,29 +31,21 @@ const glMap = vi.hoisted(() => ({
vi.mock('mapbox-gl', () => ({
default: {
accessToken: '',
Map: vi.fn(function () {
return glMap
}),
Marker: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
}
}),
LngLatBounds: vi.fn(function () {
return { extend: vi.fn().mockReturnThis() }
}),
Map: vi.fn(() => glMap),
Marker: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
})),
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}
}),
Popup: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
})),
},
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
@@ -71,9 +63,7 @@ vi.mock('./locationMarkerMapbox', () => ({
}))
vi.mock('./reservationsMapbox', () => ({
ReservationMapboxOverlay: vi.fn(function () {
return { update: vi.fn() }
}),
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
}))
vi.mock('../../hooks/useGeolocation', () => ({
@@ -18,7 +18,7 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels } from '../../utils/dayOrder'
import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder'
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
@@ -407,29 +407,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}
if (cur.length >= 2) runs.push(cur)
// Hotel bookend legs: the drive from the day's accommodation to the first located
// waypoint of the day (morning) and from the last one back to it (evening). Only when
// the "optimize from accommodation" setting is on and the day has a hotel.
// Hotel bookend legs: the drive from the day's accommodation to the first
// located place (morning) and from the last place back to it (evening). Only
// when the "optimize from accommodation" setting is on and the day has a hotel,
// mirroring the range logic the optimizer itself uses (getAccommodationAnchors).
const day = days.find(d => d.id === selectedDayId)
const { morning: startHotel, evening: endHotel } =
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {}
const dayAccs = day && optimizeFromAccommodation !== false
? accommodations.filter(a => a.place_lat != null && a.place_lng != null && isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
: []
const checkOut = day ? dayAccs.find(a => a.end_day_id === day.id) : undefined
const checkIn = day ? dayAccs.find(a => a.start_day_id === day.id) : undefined
const transfer = !!(checkOut && checkIn && checkOut !== checkIn)
const startHotel = transfer ? checkOut : dayAccs[0]
const endHotel = transfer ? checkIn : dayAccs[0]
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
// Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel
// legs connect even when the day starts or ends with a booking rather than a place.
const wayPts: { lat: number; lng: number }[] = []
const placePts: { lat: number; lng: number }[] = []
for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
} else if (it.type === 'transport') {
const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId)
if (from) wayPts.push({ lat: from.lat, lng: from.lng })
if (to) wayPts.push({ lat: to.lat, lng: to.lng })
placePts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
}
}
const firstWay = wayPts[0]
const lastWay = wayPts[wayPts.length - 1]
const wantTop = !!(startHotel && firstWay)
const wantBottom = !!(endHotel && lastWay)
const firstPlace = placePts[0]
const lastPlace = placePts[placePts.length - 1]
const wantTop = !!(startHotel && firstPlace)
const wantBottom = !!(endHotel && lastPlace)
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
@@ -455,11 +456,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}
const hotel: { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } } = {}
if (wantTop) {
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay.lat, lng: firstWay.lng })
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstPlace.lat, lng: firstPlace.lng })
if (seg) hotel.top = { seg, name: hotelName(startHotel!) }
}
if (wantBottom) {
const seg = await legBetween({ lat: lastWay.lat, lng: lastWay.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number })
const seg = await legBetween({ lat: lastPlace.lat, lng: lastPlace.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number })
if (seg) hotel.bottom = { seg, name: hotelName(endHotel!) }
}
+6 -11
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
/**
* Live FX rates for the Costs panel, used to convert every amount into the user's
* display currency. Fetches api.frankfurter.dev (no key, already CSP-allowlisted
* display currency. Fetches exchangerate-api.com (no key, already CSP-allowlisted
* for the dashboard widget) for the given base and caches per base in memory +
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
* currency C converts to base as `amount / rates[C]`.
@@ -33,19 +33,14 @@ export function useExchangeRates(base: string) {
if (cached) setRates(cached.rates)
if (cached && Date.now() - cached.ts < TTL_MS) return
let cancelled = false
fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`)
fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(upper)}`)
.then(r => r.json())
.then((d: Array<{ quote?: string; rate?: number }>) => {
if (cancelled || !Array.isArray(d)) return
// Frankfurter omits the base's own self-rate, so seed it with `base = 1`.
const rates: Record<string, number> = { [upper]: 1 }
for (const r of d) {
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate
}
const entry = { rates, ts: Date.now() }
.then((d: { rates?: Record<string, number> }) => {
if (cancelled || !d?.rates) return
const entry = { rates: d.rates, ts: Date.now() }
mem.set(upper, entry)
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
setRates(rates)
setRates(d.rates)
})
.catch(() => { /* offline → keep cached/identity */ })
return () => { cancelled = true }
+2 -5
View File
@@ -20,11 +20,8 @@ beforeEach(() => {
} as any);
// Intercept CurrencyWidget's external fetch so it resolves before teardown
server.use(
http.get('https://api.frankfurter.dev/v2/rates', () => {
return HttpResponse.json([
{ date: '2026-06-16', base: 'EUR', quote: 'USD', rate: 1.08 },
{ date: '2026-06-16', base: 'EUR', quote: 'CHF', rate: 0.97 },
]);
http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => {
return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } });
}),
);
});
+2 -8
View File
@@ -461,15 +461,9 @@ function CurrencyTool(): React.ReactElement {
const [rates, setRates] = useState<Record<string, number> | null>(null)
const fetchRate = React.useCallback(() => {
fetch(`https://api.frankfurter.dev/v2/rates?base=${from}`)
fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
.then(r => r.json())
.then((d: Array<{ quote: string; rate: number }>) => {
if (!Array.isArray(d)) { setRates(null); return }
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
const map: Record<string, number> = { [from]: 1 }
for (const r of d) map[r.quote] = r.rate
setRates(map)
})
.then(d => setRates(d.rates ?? null))
.catch(() => setRates(null))
}, [from])
-2
View File
@@ -636,8 +636,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
assignments={assignments}
files={files}
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
onImport={() => setShowBookingImport(true)}
bookingImportAvailable={bookingImportAvailable}
onAirTrailImport={() => setShowAirTrailImport(true)}
airTrailAvailable={airTrailAvailable}
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
+1 -48
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import type { Day, Accommodation } from '../types'
import { getDayOrder, isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels } from './dayOrder'
import { getDayOrder, isDayInAccommodationRange, getAccommodationAnchors } from './dayOrder'
const days = [
{ id: 10, day_number: 1 },
@@ -70,51 +70,4 @@ describe('getAccommodationAnchors', () => {
const accs = [hotel({ start_day_id: 10, end_day_id: 30, place_lat: null, place_lng: null })]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({})
})
it('keeps morning/evening correct on a transfer day when the morning stay runs long (#887)', () => {
const accs = [
hotel({ start_day_id: 10, end_day_id: 30, place_lat: 1, place_lng: 1 }), // slept here, checks out later
hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 }), // check-in today
]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({
start: { lat: 1, lng: 1 },
end: { lat: 9, lng: 9 },
})
})
})
describe('getDayBookendHotels', () => {
it('returns nothing when the day has no accommodation', () => {
expect(getDayBookendHotels(days[1], days, [])).toEqual({})
})
it('bookends both ends with the single hotel on a normal stay day', () => {
const h = hotel({ start_day_id: 10, end_day_id: 30 })
const { morning, evening } = getDayBookendHotels(days[1], days, [h])
expect(morning).toBe(h)
expect(evening).toBe(h)
})
it('uses the checked-out hotel in the morning and the checked-in hotel in the evening on a transfer day', () => {
const out = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 })
const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 })
const { morning, evening } = getDayBookendHotels(days[1], days, [out, into])
expect(morning).toBe(out)
expect(evening).toBe(into)
})
it('still picks the slept-in hotel for the morning when its stay does not end on the transfer day (#887)', () => {
// The morning hotel runs long (checks out day 3) so it is not flagged as "checks out today";
// the old "ends today" rule collapsed both bookends onto the arriving hotel.
const stayed = hotel({ start_day_id: 10, end_day_id: 30, place_lat: 1, place_lng: 1 })
const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 })
const { morning, evening } = getDayBookendHotels(days[1], days, [stayed, into])
expect(morning).toBe(stayed)
expect(evening).toBe(into)
})
it('ignores accommodations without coordinates', () => {
const h = hotel({ place_lat: null, place_lng: null })
expect(getDayBookendHotels(days[1], days, [h])).toEqual({})
})
})
+15 -35
View File
@@ -3,36 +3,6 @@ import type { Day, Accommodation, RouteAnchors } from '../types'
export const getDayOrder = (day: Day, days: Day[]): number =>
day.day_number ?? days.indexOf(day)
// The two hotels that bookend a day: the one you woke up in (morning) and the one you sleep in
// tonight (evening). On a transfer day these differ; on any other day both are the single hotel.
// The morning hotel is keyed off "checked in on an earlier day and still in range" (i.e. you slept
// there) rather than "checks out today", so it stays correct when an overlapping or long stay does
// not end exactly on the transfer day.
export const getDayBookendHotels = (
day: Day,
days: Day[],
accommodations: Accommodation[],
): { morning?: Accommodation; evening?: Accommodation } => {
const inRange = accommodations.filter(a =>
a.place_lat != null && a.place_lng != null &&
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
)
if (inRange.length === 0) return {}
const dayOrd = getDayOrder(day, days)
const orderOf = (id: number) => {
const d = days.find(x => x.id === id)
return d ? getDayOrder(d, days) : dayOrd
}
const checkIn = inRange.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
const sleptHere = inRange.find(a => orderOf(a.start_day_id) < dayOrd) // the hotel you woke up in
return {
morning: sleptHere ?? checkIn ?? inRange[0],
evening: checkIn ?? sleptHere ?? inRange[0],
}
}
// Derives route anchors from the accommodation(s) active on a day. A single hotel is the day's home
// base, so the route is a loop that starts and ends there. A transfer day — checking out of one hotel
// and into another — instead runs from the morning hotel to the evening one.
@@ -41,12 +11,22 @@ export const getAccommodationAnchors = (
days: Day[],
accommodations: Accommodation[],
): RouteAnchors => {
const { morning, evening } = getDayBookendHotels(day, days, accommodations)
if (!morning || !evening) return {}
return {
start: { lat: morning.place_lat as number, lng: morning.place_lng as number },
end: { lat: evening.place_lat as number, lng: evening.place_lng as number },
const located = accommodations.filter(a =>
a.place_lat != null && a.place_lng != null &&
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
)
if (located.length === 0) return {}
const toAnchor = (a: Accommodation) => ({ lat: a.place_lat as number, lng: a.place_lng as number })
const checkOut = located.find(a => a.end_day_id === day.id) // the hotel you leave this morning
const checkIn = located.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
if (checkOut && checkIn && checkOut !== checkIn) {
return { start: toAnchor(checkOut), end: toAnchor(checkIn) }
}
const hotel = toAnchor(located[0])
return { start: hotel, end: hotel }
}
export const isDayInAccommodationRange = (
-1
View File
@@ -1,4 +1,3 @@
/// <reference types="node" />
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../helpers/msw/server';
-1
View File
@@ -23,7 +23,6 @@ services:
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
# - SESSION_DURATION=30d # How long users stay logged in (trek_session JWT + cookie maxAge). Accepts: 1h | 12h | 7d | 30d | 90d. Default: 24h
# - SESSION_DURATION_REMEMBER=30d # Session length when "Remember me" is ticked at login: longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Default: 30d
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
+2245 -5474
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "3.1.0",
"version": "3.0.22",
"workspaces": [
"client",
"server",
@@ -25,7 +25,7 @@
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
},
"devDependencies": {
"concurrently": "^10.0.3"
"concurrently": "^9.2.1"
},
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
"overrides": {
@@ -33,9 +33,9 @@
"react-dom": "19.2.6"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-musl": "4.62.0",
"@rollup/rollup-linux-arm64-musl": "4.62.0",
"@img/sharp-linuxmusl-x64": "0.35.1",
"@img/sharp-linuxmusl-arm64": "0.35.1"
"@rollup/rollup-linux-x64-musl": "4.60.4",
"@rollup/rollup-linux-arm64-musl": "4.60.4",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5"
}
}
}
-1
View File
@@ -9,7 +9,6 @@ NODE_ENV=development # development = development mode; production = production m
TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
# DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference (default: en)
# SESSION_DURATION=30d # How long users stay logged in — sets the trek_session JWT exp + cookie maxAge. Accepts 1h, 12h, 7d, 30d, 90d. Default: 24h
# SESSION_DURATION_REMEMBER=30d # Session length when "Remember me" is ticked at login — longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Default: 30d
# Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
# Note: browser/OS language is detected automatically first; this is the fallback when no match is found.
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
+13 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/server",
"version": "3.1.0",
"version": "3.0.22",
"main": "src/index.ts",
"scripts": {
"start": "node --require tsconfig-paths/register dist/index.js",
@@ -21,12 +21,13 @@
"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",
@@ -46,7 +47,6 @@
"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,9 +67,16 @@
},
"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",
@@ -87,19 +94,11 @@
"@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",
"@vitest/coverage-v8": "^3.2.4",
"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"
"vitest": "^3.2.4"
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -96,7 +96,7 @@ export function applyGlobalMiddleware(
"https://en.wikipedia.org", "https://commons.wikimedia.org",
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
],
+9 -9
View File
@@ -1,9 +1,10 @@
import { ADDON_IDS } from '../../addons';
import { db } from '../../db/database';
import { logError, logInfo } from '../auditLog';
import { broadcast } from '../../websocket';
import { isAddonEnabled } from '../adminService';
import { logError, logInfo } from '../auditLog';
import { ADDON_IDS } from '../../addons';
import { getReservation, getReservationWithJoins, updateReservation } from '../reservationService';
import { getAirtrailCredentials } from './airtrailService';
import {
AirtrailAuthError,
AirtrailCreds,
@@ -14,7 +15,6 @@ import {
saveFlight,
} from './airtrailClient';
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
import { getAirtrailCredentials } from './airtrailService';
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
export function syncGloballyEnabled(): boolean {
@@ -59,7 +59,7 @@ async function syncOwner(uid: number): Promise<number> {
if (err instanceof AirtrailAuthError) logError(`AirTrail sync: invalid API key for user ${uid}`);
return 0;
}
const byId = new Map(flights.map((f) => [String(f.id), f]));
const byId = new Map(flights.map(f => [String(f.id), f]));
const linked = db
.prepare(
@@ -145,15 +145,15 @@ function splitLocal(dt: string | null | undefined): { date: string | null; time:
}
function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
let meta: Record<string, any>;
let meta: Record<string, any> = {};
try {
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
} catch {
meta = {};
}
const endpoints: any[] = reservation.endpoints || [];
const fromEp = endpoints.find((e) => e.role === 'from');
const toEp = endpoints.find((e) => e.role === 'to');
const fromEp = endpoints.find(e => e.role === 'from');
const toEp = endpoints.find(e => e.role === 'to');
const fromCode = fromEp?.code || existing.from?.iata || existing.from?.icao || null;
const toCode = toEp?.code || existing.to?.iata || existing.to?.icao || null;
if (!fromCode || !toCode) return null;
@@ -164,7 +164,7 @@ function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): Airtra
// Preserve the existing seat manifest (an update replaces all seats); fall back
// to the key-owner placeholder so AirTrail attributes it to the connecting user.
const seats = (existing.seats ?? []).map((s) => ({
const seats = (existing.seats ?? []).map(s => ({
userId: s.userId,
guestName: s.guestName,
seat: s.seat,
@@ -179,7 +179,7 @@ function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): Airtra
// a userId), leaving any co-passenger seats untouched.
const seatNumber = typeof meta.seat === 'string' && meta.seat.trim() ? meta.seat.trim() : null;
if (seatNumber) {
const ownSeat = seats.find((s) => s.userId) ?? seats[0];
const ownSeat = seats.find(s => s.userId) ?? seats[0];
if (ownSeat) ownSeat.seatNumber = seatNumber;
}
+4 -11
View File
@@ -1,7 +1,7 @@
/**
* Live exchange rates for the Costs/Budget money conversion.
*
* Fetches from api.frankfurter.dev (no key, already CSP-allowlisted for the
* Fetches from exchangerate-api.com (no key, already CSP-allowlisted for the
* dashboard widget) and caches per base currency in-memory for a few hours so a
* settlement request never hammers the upstream. Rates are "units of X per 1
* base", so an amount in currency C converts to base as `amount / rates[C]`.
@@ -17,17 +17,10 @@ const inflight = new Map<string, Promise<Record<string, number> | null>>();
async function fetchRates(base: string): Promise<Record<string, number> | null> {
try {
const res = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(base)}`);
const res = await fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(base)}`);
if (!res.ok) return null;
// Frankfurter returns an array of { date, base, quote, rate } and omits the
// base's own self-rate, so seed the map with `base = 1` then index by quote.
const data = (await res.json()) as Array<{ quote?: string; rate?: number }>;
if (!Array.isArray(data)) return null;
const rates: Record<string, number> = { [base.toUpperCase()]: 1 };
for (const r of data) {
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate;
}
return Object.keys(rates).length > 1 ? rates : null;
const data = (await res.json()) as { rates?: Record<string, number> };
return data.rates && typeof data.rates === 'object' ? data.rates : null;
} catch {
return null;
}
File diff suppressed because it is too large Load Diff
+23 -46
View File
@@ -1,10 +1,9 @@
import { db } from '../db/database';
import { Jimp, JimpMime } from 'jimp';
import crypto from 'node:crypto';
import path from 'node:path';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import { Jimp, JimpMime } from 'jimp';
import { db } from '../db/database';
// Overridable for tests (mirrors the TREK_DB_FILE seam) so the suite never touches
// the real uploads tree.
@@ -27,9 +26,7 @@ const knownOnDisk = new Set<string>();
// Ensure upload dir exists once at startup — avoids sync FS calls inside put() on every write.
try {
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
} catch {
/* already exists */
}
} catch { /* already exists */ }
function filePath(placeId: string): string {
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
@@ -49,9 +46,9 @@ interface CachedPhoto {
}
export function get(placeId: string): CachedPhoto | null {
const row = db
.prepare('SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL')
.get(placeId) as { attribution: string | null } | undefined;
const row = db.prepare(
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL'
).get(placeId) as { attribution: string | null } | undefined;
if (!row) return null;
@@ -71,9 +68,9 @@ export function get(placeId: string): CachedPhoto | null {
}
export function getErrored(placeId: string): boolean {
const row = db
.prepare('SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL')
.get(placeId) as { error_at: number } | undefined;
const row = db.prepare(
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL'
).get(placeId) as { error_at: number } | undefined;
if (!row) return false;
return Date.now() - row.error_at < ERROR_TTL;
@@ -82,7 +79,7 @@ export function getErrored(placeId: string): boolean {
export function markError(placeId: string): void {
knownOnDisk.delete(placeId);
db.prepare(
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)',
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
).run(placeId, Date.now(), Date.now());
}
@@ -112,28 +109,21 @@ export async function put(placeId: string, bytes: Buffer, attribution: string |
knownOnDisk.add(placeId);
db.prepare(
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)',
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
).run(placeId, attribution, Date.now());
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution };
}
export function getInFlight(
placeId: string,
): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
return inFlight.get(placeId);
}
export function setInFlight(
placeId: string,
promise: Promise<{ filePath: string; attribution: string | null } | null>,
): void {
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
inFlight.set(placeId, promise);
promise
.finally(() => inFlight.delete(placeId))
.catch(() => {
/* awaiter logs; this .catch only prevents unhandledRejection */
});
.catch(() => { /* awaiter logs; this .catch only prevents unhandledRejection */ });
}
export function serveFilePath(placeId: string): string | null {
@@ -148,18 +138,14 @@ export function serveFilePath(placeId: string): string | null {
// Google place_id (the dedup key) or by the stable proxy URL stored in image_url
// (covers coords: pseudo-ids, which never have a google_place_id).
function isReferenced(placeId: string): boolean {
const row = db
.prepare('SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ? LIMIT 1')
.get(placeId, proxyUrl(placeId));
const row = db.prepare(
'SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ? LIMIT 1'
).get(placeId, proxyUrl(placeId));
return !!row;
}
function deleteEntry(placeId: string): void {
try {
fs.unlinkSync(filePath(placeId));
} catch {
/* already gone */
}
try { fs.unlinkSync(filePath(placeId)); } catch { /* already gone */ }
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
knownOnDisk.delete(placeId);
}
@@ -189,20 +175,11 @@ export function sweepOrphans(): number {
// Pass 2: files on disk that no surviving meta row maps to (e.g. left over from a
// crash between writeFile and the DB upsert, or a meta row deleted out-of-band).
let entries: string[];
try {
entries = fs.readdirSync(GOOGLE_PHOTO_DIR);
} catch {
entries = [];
}
let entries: string[] = [];
try { entries = fs.readdirSync(GOOGLE_PHOTO_DIR); } catch { entries = []; }
for (const entry of entries) {
if (!entry.endsWith('.jpg') || keepFiles.has(entry)) continue;
try {
fs.unlinkSync(path.join(GOOGLE_PHOTO_DIR, entry));
removed++;
} catch {
/* race */
}
try { fs.unlinkSync(path.join(GOOGLE_PHOTO_DIR, entry)); removed++; } catch { /* race */ }
}
return removed;
@@ -1,26 +0,0 @@
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> = {}): 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<AddonsService>);
expect(new AddonsController(svc).list()).toBe(feed);
expect(list).toHaveBeenCalledTimes(1);
expect(list).toHaveBeenCalledWith();
});
});
@@ -1,232 +0,0 @@
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<Record<string, unknown>> };
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<Record<string, unknown>> }).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');
});
});
@@ -8,7 +8,6 @@ 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;
@@ -122,114 +121,6 @@ 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<AdminService>)).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<AdminService>)).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<AdminService>)).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<AdminService>)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 });
expect(new AdminController(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial<AdminService>)).stats()).toEqual({ users: 3 });
expect(new AdminController(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial<AdminService>)).permissions()).toEqual({ a: 1 });
expect(new AdminController(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
expect(new AdminController(svc({ getOidcSettings: vi.fn().mockReturnValue({ issuer: 'x' }) } as Partial<AdminService>)).getOidc()).toEqual({ issuer: 'x' });
expect(new AdminController(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
expect(new AdminController(svc({ getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [] }) } as Partial<AdminService>)).getNotificationPrefs(user)).toEqual({ rows: [] });
expect(new AdminController(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listInvites()).toEqual({ invites: [{ id: 1 }] });
expect(new AdminController(svc({ getBagTracking: vi.fn().mockReturnValue({ enabled: false }) } as Partial<AdminService>)).getBagTracking()).toEqual({ enabled: false });
expect(new AdminController(svc({ getPlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesPhotos()).toEqual({ enabled: true });
expect(new AdminController(svc({ getPlacesAutocomplete: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesAutocomplete()).toEqual({ enabled: true });
expect(new AdminController(svc({ getPlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesDetails()).toEqual({ enabled: true });
expect(new AdminController(svc({ getCollabFeatures: vi.fn().mockReturnValue({ chat: false }) } as Partial<AdminService>)).getCollabFeatures()).toEqual({ chat: false });
expect(new AdminController(svc({ listPackingTemplates: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listPackingTemplates()).toEqual({ templates: [{ id: 1 }] });
expect(new AdminController(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
expect(new AdminController(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
expect(new AdminController(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
expect(new AdminController(svc({ getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<AdminService>)).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<AdminService>));
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<AdminService>));
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<AdminService>));
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<AdminService>)).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<AdminService>)).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<AdminService>)).updatePackingTemplate('3', {})).toEqual({ id: 3 });
expect(new AdminController(svc({ createTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).createTemplateCategory('3', { name: 'Tops' })).toEqual({ id: 4 });
expect(new AdminController(svc({ updateTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).updateTemplateCategory('3', '4', {})).toEqual({ id: 4 });
expect(new AdminController(svc({ deleteTemplateCategory: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateCategory('3', '4')).toEqual({ success: true });
expect(new AdminController(svc({ updateTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial<AdminService>)).updateTemplateItem('7', {})).toEqual({ id: 7 });
expect(new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateItem('7')).toEqual({ success: true });
expect(thrown(() => new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({ error: 'gone', status: 404 }) } as Partial<AdminService>)).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<AdminService>)).deleteMcpToken('2')).toEqual({ success: true });
expect(thrown(() => new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial<AdminService>)).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<AdminService>)).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<AdminService>));
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<AdminService>));
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;
@@ -241,23 +132,4 @@ 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<typeof vi.fn>).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<typeof vi.fn>).mockRejectedValueOnce('weird');
await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'weird' } });
});
});
+12 -250
View File
@@ -1,264 +1,26 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect } 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<unknown>): 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', () => {
vi.mocked(extractToken).mockReturnValue(null);
expect(thrown(() => guard.canActivate(context({ headers: {}, cookies: {} })))).toEqual({
status: 401,
body: { error: 'Access token required', code: 'AUTH_REQUIRED' },
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',
});
});
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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<typeof resolveAuthToggles>);
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<typeof resolveAuthToggles>);
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<string, { factory: (data: unknown, ctx: unknown) => 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' });
});
});
@@ -51,18 +51,6 @@ 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', () => {
@@ -116,71 +104,6 @@ describe('AuthPublicController', () => {
expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl());
@@ -244,88 +167,4 @@ describe('AuthController (authenticated)', () => {
const c = new AuthController(asvc({ changePassword: vi.fn() } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } });
expect(new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl()).updateSettings(user, {}))).toEqual({ status: 400, body: { error: 'Bad' } });
expect(new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) } as Partial<AuthService>), rl()).updateSettings(user, {})).toEqual({ success: true, user: { id: 1 } });
expect(thrown(() => new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) } as Partial<AuthService>), rl()).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
expect(new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) } as Partial<AuthService>), 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<AuthService>), rl()).deleteAvatar(user)).toEqual({ removed: true });
const listUsers = vi.fn().mockReturnValue([{ id: 1 }]);
expect(new AuthController(asvc({ listUsers } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
expect(new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
expect(thrown(() => new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } });
expect(new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl()).mfaDisable(user, {}, req))).toEqual({ status: 401, body: { error: 'Wrong' } });
const ok = new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial<AuthService>), 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<AuthService>), rl()).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
expect(thrown(() => new AuthController(asvc({ createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) } as Partial<AuthService>), 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<AuthService>), rl()).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
expect(new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial<AuthService>), 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<AuthService>), rl()).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
expect(new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) } as Partial<AuthService>), 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<AuthService>), rl()).avatar(user, { filename: 'b.png' } as Express.Multer.File)).toEqual({ avatar: '/b.png' });
});
});
@@ -3,31 +3,13 @@ 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. 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),
}));
// The controller imports the tmp-dir + size cap at module load.
vi.mock('../../../src/services/backupService', () => ({ getUploadTmpDir: () => '/tmp', MAX_BACKUP_UPLOAD_SIZE: 1024 }));
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;
@@ -104,17 +86,12 @@ 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<BackupService>)).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<BackupService>)).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<BackupService>)).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' } });
});
@@ -131,14 +108,6 @@ describe('BackupController', () => {
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad' }) } as Partial<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).create(user, req))).toEqual({ status: 500, body: { error: 'Error creating backup' } });
@@ -154,20 +123,6 @@ 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<BackupService>)).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<BackupService>)).updateAutoSettings(user, undefined as unknown as Record<string, unknown>, req);
expect(updateAutoSettings).toHaveBeenCalledWith({});
});
it('GET/PUT /auto-settings', () => {
expect(new BackupController(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial<BackupService>)).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<BackupService>)).updateAutoSettings(user, { enabled: true }, req);
@@ -183,50 +138,3 @@ 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);
});
});
@@ -42,75 +42,12 @@ 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,
settlement: vi.fn().mockReturnValue({ transfers: [] }),
} as Partial<BudgetService>);
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<BudgetService>);
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<BudgetService>);
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<BudgetService>);
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<BudgetService>);
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<BudgetService>);
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
});
});
describe('POST /', () => {
@@ -187,31 +124,6 @@ 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<BudgetService>);
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<BudgetService>);
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();
@@ -220,14 +132,6 @@ 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<BudgetService>);
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<BudgetService>);
expect(thrown(() => new BudgetController(missing).remove(user, '5', '9'))).toEqual({
@@ -1,165 +0,0 @@
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();
});
});
});
@@ -2,7 +2,6 @@ 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';
@@ -45,63 +44,6 @@ 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<DaysService>);
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<DaysService>);
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<DaysService>);
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<DaysService>);
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<DaysService>)).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } });
const update = vi.fn().mockReturnValue({ id: 9, title: 'T' });
@@ -1,6 +1,5 @@
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() {
@@ -32,89 +31,4 @@ 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' });
});
});
@@ -1,9 +1,6 @@
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) }));
@@ -126,19 +123,6 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
const s = fsvc({ getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<FilesService>);
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', () => {
@@ -163,62 +147,6 @@ 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', () => {
@@ -1,134 +0,0 @@
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');
});
});
@@ -1,65 +0,0 @@
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> = {}): 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();
});
});
@@ -144,54 +144,4 @@ 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();
});
});
@@ -76,8 +76,6 @@ describe('JourneyController', () => {
const c = new JourneyController(svc({ linkPhotoToEntry } as Partial<JourneyService>));
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<JourneyService>)).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', () => {
@@ -145,113 +143,6 @@ 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<JourneyService>)).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<JourneyService>);
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<JourneyService>);
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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>));
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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).remove(user, '9')).toEqual({ success: true });
expect(new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeTrip(user, '9', '2')).toEqual({ success: true });
expect(new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true });
expect(new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).deleteShareLink(user, '9')).toEqual({ success: true });
});
});
describe('JourneyPublicController', () => {
@@ -276,45 +167,6 @@ 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<JourneyService>);
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<JourneyService>);
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<JourneyService>);
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<JourneyService>);
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<JourneyService>);
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);
+1 -130
View File
@@ -50,67 +50,12 @@ 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', () => {
@@ -142,28 +87,12 @@ 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', () => {
@@ -209,30 +138,12 @@ describe('MapsController (parity with the legacy /api/maps route)', () => {
expect(photo).toHaveBeenCalledWith(3, 'coords:1,2', 1, 2, 'Spot');
});
it('maps a 4xx service error', async () => {
it('maps a 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', () => {
@@ -279,18 +190,6 @@ 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', () => {
@@ -321,39 +220,11 @@ 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');
});
});
});
-131
View File
@@ -1,131 +0,0 @@
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();
});
});
});
@@ -1,748 +0,0 @@
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> = {}): MemoriesService {
return { ...overrides } as unknown as MemoriesService;
}
type MockRes = Response & {
status: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
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);
});
});
});
@@ -1,195 +0,0 @@
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);
});
});
@@ -4,9 +4,6 @@ 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';
@@ -145,95 +142,6 @@ 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', () => {
@@ -247,21 +155,6 @@ 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);
@@ -273,45 +166,6 @@ 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', () => {
@@ -361,66 +215,4 @@ 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' } });
});
});
@@ -1,172 +0,0 @@
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]);
});
});
@@ -71,59 +71,6 @@ 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', () => {
@@ -184,145 +131,6 @@ 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', () => {
-158
View File
@@ -1,158 +0,0 @@
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);
});
});
@@ -67,35 +67,14 @@ 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<PackingService>);
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 (empty array)', () => {
it('400 when items is not a non-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();
@@ -124,46 +103,7 @@ 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<PackingService>);
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<PackingService>);
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<PackingService>);
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<PackingService>);
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({
@@ -171,77 +111,12 @@ 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<PackingService>);
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<PackingService>);
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<PackingService>);
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<PackingService>);
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<PackingService>);
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<PackingService>);
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<PackingService>);
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<PackingService>);
new PackingController(svc).setBagMembers(user, '5', '3', 'not-an-array');
expect(setBagMembers).toHaveBeenCalledWith('5', '3', []);
});
});
describe('templates', () => {
@@ -260,33 +135,6 @@ 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<PackingService>);
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<PackingService>);
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<PackingService>);
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<PackingService>);
@@ -314,24 +162,6 @@ 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<PackingService>);
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<PackingService>);
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();
+1 -31
View File
@@ -16,15 +16,12 @@ 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(),
listTemplates: vi.fn(), applyTemplate: vi.fn(), saveAsTemplate: vi.fn(), setBagMembers: vi.fn(), getCategoryAssignees: 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() {
@@ -58,7 +55,6 @@ 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');
@@ -75,31 +71,5 @@ 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' }) }),
);
});
});
});
@@ -85,63 +85,10 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
});
});
describe('POST /import/map', () => {
const file = { buffer: Buffer.from('<kml/>'), 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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({ status: 400, body: { error: 'List is empty' } });
@@ -150,26 +97,6 @@ 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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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', () => {
@@ -190,10 +117,8 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
});
});
it('GET /:id returns the place when found, 404 when missing', () => {
it('GET /:id 404 when missing', () => {
expect(thrown(() => new PlacesController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial<PlacesService>)).get(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } });
const s = svc({ get: vi.fn().mockReturnValue({ id: 9 }) } as Partial<PlacesService>);
expect(new PlacesController(s).get(user, '5', '9')).toEqual({ place: { id: 9 } });
});
it('PUT /:id 404 when missing, else updates + hooks', () => {
@@ -218,11 +143,4 @@ 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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
expect(await thrownAsync(() => new PlacesController(http).image(user, '5', '9'))).toEqual({ status: 429, body: { error: 'rate limited' } });
});
});
@@ -1,513 +0,0 @@
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<string, unknown>;
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<string, string>,
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<typeof build>) {
// 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<typeof vi.fn> };
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<typeof makeRes>) {
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' });
});
});
@@ -2,9 +2,6 @@ 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';
@@ -72,66 +69,4 @@ describe('SharedController', () => {
expect(thrown(() => new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue(null) } as Partial<ShareService>)).read('bad'))).toEqual({ status: 404, body: { error: 'Invalid or expired link' } });
expect(new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue({ trip: { id: 9 } }) } as Partial<ShareService>)).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<typeof vi.fn>; json: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn>; type: ReturnType<typeof vi.fn> };
}
beforeEach(() => createReadStream.mockReset());
function controller(path: string | null) {
return new SharedController(svc({ getSharedPlacePhotoPath: vi.fn().mockReturnValue(path) } as Partial<ShareService>));
}
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();
});
});
});
@@ -1,76 +0,0 @@
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');
});
});
+1 -121
View File
@@ -3,12 +3,10 @@ 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() }));
const { isDemoEmail } = vi.hoisted(() => ({ isDemoEmail: vi.fn(() => false) }));
vi.mock('../../../src/services/demo', () => ({ isDemoEmail }));
vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
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;
@@ -42,15 +40,6 @@ 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<TripsService>));
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' } });
@@ -68,34 +57,12 @@ 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<TripsService>)).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<TripsService>)).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<TripsService>)).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<TripsService>)).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<TripsService>);
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' } });
@@ -110,45 +77,6 @@ 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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
expect(() => new TripsController(s).update(user, '9', { title: 'b' }, req)).toThrow('boom');
});
});
describe('POST /:id/copy', () => {
@@ -176,14 +104,6 @@ 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<TripsService>);
expect(new TripsController(s).remove(user, '9', req)).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, undefined);
});
});
describe('members', () => {
@@ -202,25 +122,6 @@ 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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
@@ -250,20 +151,6 @@ 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', () => {
@@ -277,13 +164,6 @@ 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<TripsService>);
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<TripsService>);
expect(() => new TripsController(other).exportIcs(user, '9', makeRes())).toThrow('boom');
});
});
it('POST /:id/copy maps a copy failure to 500', () => {
@@ -53,12 +53,6 @@ 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);
@@ -76,12 +70,6 @@ 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();
});
-27
View File
@@ -22,31 +22,4 @@ 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('; ');
});
});
@@ -769,9 +769,7 @@ describe('BACKUP-042 restoreFromZip — integrity check fails', () => {
}),
close: vi.fn(),
};
DatabaseMock.mockImplementation(function () {
return fakeDbInstance;
});
DatabaseMock.mockReturnValue(fakeDbInstance);
const result = await restoreFromZip('/data/tmp/upload.zip');
@@ -805,9 +803,7 @@ describe('BACKUP-043 restoreFromZip — missing required table', () => {
}),
close: vi.fn(),
};
DatabaseMock.mockImplementation(function () {
return fakeDbInstance;
});
DatabaseMock.mockReturnValue(fakeDbInstance);
const result = await restoreFromZip('/data/tmp/upload.zip');
@@ -831,7 +827,7 @@ describe('BACKUP-044 restoreFromZip — Database constructor throws (invalid SQL
);
fsMock.rmSync.mockReturnValue(undefined);
DatabaseMock.mockImplementation(function () {
DatabaseMock.mockImplementation(() => {
throw new Error('file is not a database');
});
@@ -866,9 +862,7 @@ describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => {
}),
close: vi.fn(),
};
DatabaseMock.mockImplementation(function () {
return fakeDbInstance;
});
DatabaseMock.mockReturnValue(fakeDbInstance);
return fakeDbInstance;
}
@@ -1003,9 +997,7 @@ describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
}),
close: vi.fn(),
};
DatabaseMock.mockImplementation(function () {
return fakeDbInstance;
});
DatabaseMock.mockReturnValue(fakeDbInstance);
fsMock.existsSync.mockImplementation((p: string) => {
// travel.db present, extractedUploads present
@@ -1060,9 +1052,7 @@ describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
}),
close: vi.fn(),
};
DatabaseMock.mockImplementation(function () {
return fakeDbInstance;
});
DatabaseMock.mockReturnValue(fakeDbInstance);
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return true;
+1 -5
View File
@@ -24,11 +24,7 @@ export default defineConfig({
silent: false,
reporters: ['verbose'],
coverage: {
// 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',
provider: 'v8',
reporter: ['lcov', 'text'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
+18 -33
View File
@@ -1,57 +1,42 @@
{
"name": "@trek/shared",
"version": "3.1.0",
"version": "3.0.22",
"private": true,
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.cts",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./i18n": {
"import": {
"types": "./dist/i18n/index.d.mts",
"default": "./dist/i18n/index.mjs"
},
"require": {
"types": "./dist/i18n/index.d.cts",
"default": "./dist/i18n/index.cjs"
}
"types": "./dist/i18n/index.d.ts",
"import": "./dist/i18n/index.js",
"require": "./dist/i18n/index.cjs"
},
"./i18n/*": {
"import": {
"types": "./dist/i18n/*/index.d.mts",
"default": "./dist/i18n/*/index.mjs"
},
"require": {
"types": "./dist/i18n/*/index.d.cts",
"default": "./dist/i18n/*/index.cjs"
}
"types": "./dist/i18n/*/index.d.ts",
"import": "./dist/i18n/*/index.js",
"require": "./dist/i18n/*/index.cjs"
}
},
"typesVersions": {
"*": {
"i18n": [
"./dist/i18n/index.d.cts"
"./dist/i18n/index.d.ts"
],
"i18n/*": [
"./dist/i18n/*/index.d.cts"
"./dist/i18n/*/index.d.ts"
]
}
},
"scripts": {
"build": "tsdown",
"build:watch": "tsdown --watch --no-clean",
"build": "tsup",
"build:watch": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
@@ -74,9 +59,9 @@
"eslint-plugin-prettier": "^5.5.5",
"prettier": "3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"tsdown": "^0.22.2",
"tsup": "^8.5.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vitest": "^4.1.9"
"vitest": "^3.2.4"
}
}
@@ -1,4 +1,4 @@
import { defineConfig } from 'tsdown'
import { defineConfig } from 'tsup'
export default defineConfig({
// Root barrel + i18n metadata barrel + one entry per locale (lazy-load chunks)
@@ -6,8 +6,5 @@ export default defineConfig({
format: ['cjs', 'esm'],
dts: true,
clean: true,
deps: {
neverBundle: ['zod'],
},
target: false,
external: ['zod'],
})
-2
View File
@@ -41,8 +41,6 @@
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Auto-derived (true in production or when FORCE_HTTPS=true). Set to false to force session cookies over plain HTTP. Not recommended for production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production; off in development unless set. Required for FORCE_HTTPS." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="SESSION_DURATION" Target="SESSION_DURATION" Default="24h" Mode="" Description="How long a login session stays valid when 'Remember me' is unchecked (the default): trek_session JWT exp + a browser-session cookie cleared when the browser closes. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h." Type="Variable" Display="advanced" Required="false" Mask="false">24h</Config>
<Config Name="SESSION_DURATION_REMEMBER" Target="SESSION_DURATION_REMEMBER" Default="30d" Mode="" Description="Session length when 'Remember me' is ticked at login: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d." Type="Variable" Display="advanced" Required="false" Mask="false">30d</Config>
<!-- Initial Setup -->
<Config Name="ADMIN_EMAIL" Target="ADMIN_EMAIL" Default="admin@trek.local" Mode="" Description="Email for the first admin account created on initial boot. Has no effect once any user exists." Type="Variable" Display="always" Required="false" Mask="false">admin@trek.local</Config>
+1 -1
View File
@@ -31,7 +31,7 @@ The currency converter lets you quickly convert an amount between two currencies
You can also click the swap arrow to reverse source and target.
**Exchange rates** are fetched from [Frankfurter](https://frankfurter.dev) using the `https://api.frankfurter.dev/v2/rates?base={from}` endpoint. Rates are refreshed each time you change a currency or click the refresh icon.
**Exchange rates** are fetched from [exchangerate-api.com](https://www.exchangerate-api.com) using the `https://api.exchangerate-api.com/v4/latest/{from}` endpoint. Rates are refreshed each time you change a currency or click the refresh icon.
**Supported currencies:** 162 currencies are available in the selector, including all major fiat currencies (USD, EUR, GBP, JPY, etc.) and many minor ones.
+1 -1
View File
@@ -45,7 +45,7 @@ The Unraid template exposes the following fields in the container UI:
### Advanced Variables
Additional variables (`PORT`, `NODE_ENV`, `LOG_LEVEL`, `DEFAULT_LANGUAGE`, `FORCE_HTTPS`, `TRUST_PROXY`, `COOKIE_SECURE`, `ALLOW_INTERNAL_NETWORK`, `SESSION_DURATION`, `SESSION_DURATION_REMEMBER`, all OIDC variables, `MCP_RATE_LIMIT`, `MCP_MAX_SESSION_PER_USER`, `DEMO_MODE`) are available under **Advanced View** in the template editor.
Additional variables (`PORT`, `NODE_ENV`, `LOG_LEVEL`, `DEFAULT_LANGUAGE`, `FORCE_HTTPS`, `TRUST_PROXY`, `COOKIE_SECURE`, `ALLOW_INTERNAL_NETWORK`, all OIDC variables, `MCP_RATE_LIMIT`, `MCP_MAX_SESSION_PER_USER`, `DEMO_MODE`) are available under **Advanced View** in the template editor.
## Setting the Encryption Key