Compare commits

..

66 Commits

Author SHA1 Message Date
Paul Nothaft a7885846ac chore(stable): release 3.45.7 (#881)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-27 07:58:07 +00:00
Paul Nothaft d868aac703 fix(security): close 5 Trivy alerts — postcss/tar bumps + drop npm from the runtime image (stable) (#879)
* fix(security): close the 5 open Trivy alerts — dep bumps + drop npm from the runtime image

Backend deps:
- postcss 8.5.10 -> 8.5.18 (CVE-2026-45623, GHSA-r28c-9q8g-f849; the pin
  exists to force sanitize-html's transitive copy onto a fixed version)
- tar pin/override >=7.5.16 -> >=7.5.21, resolves 7.5.22
  (GHSA-r292-9mhp-454m)

Runtime image:
- Remove the npm CLI from the final stage instead of upgrading it: npm's
  bundled node_modules ship tar 7.5.19 and brace-expansion 5.0.7 (no npm
  release bundles the fixed versions — checked 11.18.0 and 12.0.1), and
  npm never runs in production. wait-for-db.sh now invokes the migration
  runners via node directly. This ends the recurring npm-bundled-CVE
  alert class; the previous 'npm install -g npm@11' line was itself a
  patch for the last batch. (stable)

* fix(restore): run post-restore migrations via node — the image ships no npm

restoreService still shelled out to 'npm run migrate:safe' after a
restore; with npm removed from the runtime image that would ENOENT into
the non-fatal catch, silently leaving a restored older backup on a
schema behind the running code until the next container restart. Invoke
migrations/run-migrations-safe.js through node directly, matching
wait-for-db.sh. The PR #596 source-contract test now pins the new
invocation. (stable)
2026-07-27 09:54:43 +02:00
Paul Nothaft 577b7fa6ae chore(stable): release 3.45.6 (#877)
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
2026-07-27 07:10:18 +00:00
Paul Nothaft a27c705e39 fix(backup): make backup settings actually apply (#871) (stable) (#875)
* fix(backup): make backup settings actually apply (#871) (stable)

- Wire the What-to-Backup toggles into the walker: honor
  backup_include_thumbnails / backup_include_photos (opt-out,
  default ON) and accept the UI's backup_include_archives spelling
  for the archived gate (the engine expected _archived, so the
  Archives checkbox silently never worked).
- Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that
  node-postgres returns as a string, and the S3 path concatenated it
  onto the byte counter; coerce to Number at the source.
- Compute the real next scheduled run (cron-parser) and return it as
  nextBackup; the UI read a field the API never sent and rendered a
  hardcoded 'Not scheduled'. A named schedule label now beats the
  stray default cron the UI always sent, which silently turned
  weekly schedules into daily 03:00 runs.
- Never back up filesystem noise (.nfs* silly-renames, .DS_Store,
  Thumbs.db) and honor backup_exclude_patterns in the walker
  (previously rsync-only).
- Remove the compression/encryption toggles from the configuration
  UI: no backend implementation exists, and collecting an encryption
  passphrase while uploading plaintext is a false promise.

* fix(backup): close the review gaps in the settings wiring (stable)

- The UI's backup_include_archives now beats the migration-seeded
  backup_include_archived: every install has the singular key seeded
  true, so the alias-only-when-absent lookup made unchecking Archives
  a no-op.
- rsync destinations now receive the de-selected What-to-Backup paths
  and the noise filters as anchored --exclude args; previously rsync
  synced the whole storage root and the walker's selection only shaped
  the manifest, which then misreported what was actually transferred.
- Escape regex metacharacters in the walker's glob matcher: '.nfs*'
  compiled to /^.nfs.*$/ whose leading dot matched any character, so
  files like anfs-photo.jpg were silently dropped from backups.
- The Backup Coverage report now uses the same gate as the walker
  (new 'skipped-by-setting' status) instead of re-implementing it
  without the opt-out toggles and the archives alias.

* fix(backup): make the coverage diagnostics agree with the walker

- The coverage table shows the alias-aware flag value the gate actually
  used, instead of the seeded backup_include_archived shadowed by the
  UI's plural key (true next to a 'Gated off' badge).
- skipped-by-setting paths are now counted in the coverage summary
  (backend, TS contract, summary card, EN/DE locales) so the totals
  reconcile again when Photos or Thumbnails is unchecked.
- The form's thumbnail default now matches the backend's never-saved
  fallback (include): the checkbox no longer shows 'off' while
  thumbnails are being backed up, and saving an unrelated setting no
  longer flips the backup scope. (stable)

* fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display

- Saving a named schedule no longer wipes the stored custom cron: the
  backend already prefers the label, so the cron field stays inert for
  named schedules and is preserved for switching back to Custom. A
  custom schedule now validates the 5-field expression before saving
  (the backend silently fell back to daily 02:00 on a blank value).
- resolveExcludedBackupPaths now also returns rows disabled via
  include_in_default, so rsync excludes them; the enabled-only loader
  hid them and rsync transferred their contents anyway.
- The coverage table normalizes flag values like the walker does —
  Boolean('false') displayed true beside a gated-off badge. (stable)
2026-07-27 09:06:50 +02:00
Paul Nothaft b0e9145bba chore(stable): release 3.45.5 (#873)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-26 18:41:48 +00:00
Paul Nothaft 39696d42fe fix(security): bump backend deps to close all 14 open Trivy code-scanning alerts (stable) (#870)
* fix(security): bump backend deps to close all open Trivy code-scanning alerts (stable)

- axios 1.16.0 -> 1.18.1 (GHSA-gcfj-64vw-6mp9 high + 10 medium advisories)
- sharp 0.34.3 -> 0.35.3 (GHSA-f88m-g3jw-g9cj, inherited libvips CVEs)
- mailparser 3.9.9 -> 3.9.14 (pulls linkify-it 5.0.2, CVE-2026-59887)
- brace-expansion override >=5.0.6 -> >=5.0.7 (CVE-2026-13149)
- body-parser 1.20.4 -> 1.20.6 via lockfile refresh (CVE-2026-12590)

* fix(images): migrate removed sharp failOnError option and enforce Node >=20.9 (stable)

sharp 0.35 drops the deprecated failOnError constructor option, so
recoverably corrupt images would start failing upload validation and
thumbnail generation; use the failOn: 'none' equivalent instead.

sharp 0.35 also requires Node >=20.9: declare it in engines and make
picpeak-setup.sh compare the full version instead of only the major,
so native installs on Node 20.3-20.8 upgrade instead of breaking.

* fix(setup): align the Node floor with the whole dependency tree and gate native updates (stable)

html-to-text@10 needs Node >=20.19 and the glob/minimatch family excludes
Node 21, so declare engines as ^20.19.0 || >=22 and enforce the same range
in picpeak-setup.sh. Also run install_nodejs at the start of
update_native_installation so existing native installs on an old Node get
upgraded before the service is stopped, instead of restarting broken.

* fix(setup): make the update-path Node gate actually work (stable)

--update dispatches before detect_os, so install_nodejs saw an empty
PACKAGE_MANAGER, matched no install branch, and reported success on the
old runtime. Detect the OS on demand and re-verify the installed version
afterwards, failing loudly (before the service is stopped) when the
runtime still misses the engines range, e.g. a Node 21 that package
managers refuse to downgrade.
2026-07-26 20:38:16 +02:00
Paul Nothaft 50f5ca1d5b fix(security): read the password-complexity key the settings UI writes (stable) (#844)
* fix(security): read the password-complexity key the settings UI writes

The settings UI saves the admin's complexity choice as
security_password_complexity (useSettingsState.ts prefixes security_ to
password_complexity), but getPasswordComplexitySettings() queried
security_password_complexity_level — written by nothing — so the setting
was silently ignored and password validation always used the 'moderate'
default. Spotted in the filpgame fork (their main, 2026-07-14).

* fix(security): accept the Postgres json-column shape of the complexity value (codex review of #843)

On SQLite the TEXT column returns the JSON-stringified value
('"very_strong"'), but on Postgres (production default) setting_value
is a json column and arrives already decoded ('very_strong') — the bare
JSON.parse threw and the outer catch silently fell back to 'moderate'
again. Parse with fallback, mirroring getAppSetting's documented
pattern; test now covers both driver shapes + the empty-value default.
2026-07-19 20:04:35 +02:00
Paul Nothaft 11b6490e4c chore(stable): release 3.45.4 (#831)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-17 19:43:45 +00:00
Paul Nothaft 1cff576439 Merge pull request #829 from PicPeak/fix/hero-logo-visible-null-validation-stable
fix(events): accept hero_logo_visible: null on create/update (#822) (stable)
2026-07-17 21:39:26 +02:00
Paul Nothaft 8978acdb49 fix(events): accept hero_logo_visible: null on create/update (#822)
hero_logo_visible is nullable — null means "inherit the global
branding_logo_display_hero toggle" (#756, migration 152). But the create and
update validators used `.optional()` without `{ nullable: true }`, which only
skips `undefined`; an explicit `null` still ran `.isBoolean()` and failed with
HTTP 400 "Invalid value". Saving an event with `hero_logo_visible: null` (the
inherit state the frontend sends) was rejected on v3.45.2.

- Both routes: `body('hero_logo_visible').optional({ nullable: true }).isBoolean()`,
  matching the already-correct `hero_logo_size` rule next to it.
- Create handler: guard on `!= null` instead of `!== undefined` so an explicit
  null stores NULL (inherit) rather than being coerced to 0/false by
  formatBoolean on SQLite. The update handler already did `=== null ? null`.

Left hero_logo_position on plain `.optional()` on purpose: its column is NOT
NULL (no inherit migration) and its handler always resolves to a concrete value
via `|| brandingDefaults`, so null is genuinely invalid there — allowing it
would trade the 400 for a 500.

Adds smoke tests: PUT accepts hero_logo_visible: null and stores NULL; a
non-boolean value is still rejected.
2026-07-17 21:14:12 +02:00
Paul Nothaft 0d8123ed4a chore(stable): release 3.45.3 (#827)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-17 19:08:55 +00:00
Paul Nothaft db1d28a75b Merge pull request #825 from PicPeak/fix/update-instructions-production-compose-stable
fix(update): target docker-compose.production.yml in dashboard update steps + gate mailhog (stable)
2026-07-17 21:03:21 +02:00
Paul Nothaft 64bcd0ab9f fix(update): target docker-compose.production.yml in dashboard update steps
Production installs use docker-compose.production.yml (the README's documented
path, pinned GHCR images, no dev services), but the dashboard's update
instructions emitted bare `docker compose pull` / `up -d`. Bare `docker compose`
operates on docker-compose.yml — a different, build-based stack — so a
production user who followed the steps:
  - never pulled/recreated their real containers (stayed on the old version,
    e.g. stuck on 3.44.0 after "updating" to 3.45.2), and
  - started the dev-only mailhog service that docker-compose.yml defines
    (reported restart-looping).

The backend runs inside a container and can't stat the host's compose files, but
docker-compose.production.yml passes PICPEAK_RELEASE_CHANNEL into the backend env
and docker-compose.yml does not. detectEnvironment() now derives
isProductionCompose from it, and the Docker update steps prepend
`-f docker-compose.production.yml` when set. The non-production branch keeps the
bare commands but the warning now tells users to add `-f docker-compose.production.yml`
if they installed with it.

Also gates the mailhog service in docker-compose.yml behind a `dev` compose
profile so a plain `docker compose up -d` never starts it (opt in with
`docker compose --profile dev up -d`). Nothing depends on it (SMTP_HOST comes
from .env), so gating is safe. Verified: `docker compose config` lists mailhog
only with `--profile dev`; production compose is unchanged.

Adds unit tests for the production-vs-default command generation.
2026-07-17 20:56:18 +02:00
Paul Nothaft 1d48f59fe1 chore(stable): release 3.45.2 (#819)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-17 07:34:35 +00:00
Paul Nothaft e37d1fac58 Merge pull request #818 from PicPeak/fix/legacy-events-router-bola-stable
fix(security): remove unguarded legacy /api/events router on stable (GHSA-4j34-x562-5vfq)
2026-07-17 09:29:24 +02:00
Paul Nothaft 9ee3ff45d0 fix(security): remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq)
The legacy gallery router mounted at /api/events exposed create/list/update/
delete/extend guarded by adminAuth ALONE — no requirePermission, no
requireEventOwnership. adminAuth only checks the token is a valid type:'admin'
session, which every back-office role holds, down to read-only `viewer`. So any
non-super-admin account could:
  - GET /api/events → every gallery's bcrypt password_hash, share_token, and
    client name/email (the list handler selects * and mapEventForApi keeps
    those columns),
  - PUT /api/events/:id → reset any gallery's password (full takeover),
  - DELETE /api/events/:id → delete any gallery,
all bypassing the per-photographer ownership isolation the canonical
/api/admin/events router enforces. Affects any instance with more than the
single super_admin.

Fix: remove the legacy router entirely (mount + require + src/routes/events.js).
It was a superseded duplicate of /api/admin/events and unused by the frontend
EXCEPT for one live route — POST /:id/extend (the "Extend expiration" UI action,
which hit /api/events/:id/extend via the api client's /api base). That route is
migrated to the canonical mount as POST /api/admin/events/:id/extend with the
same guards as every other gallery mutation (adminAuth + requirePermission
('events.edit') + requireEventOwnership), and the frontend is repointed to it.
Behaviour of the extend itself is unchanged (expires_at + reactivate).

Verified end-to-end on a booted instance: /api/events (all methods) now 404;
/api/admin/events/:id/extend returns 401 unauth, 200 for the owner, 403 for a
non-owning editor; the full login→create→extend flow works. Adds a regression
test pinning the router removal and the extend ownership check.
2026-07-17 09:18:28 +02:00
Paul Nothaft 5453152f1c chore(stable): release 3.45.1 (#815)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-16 11:44:00 +00:00
Paul Nothaft b416baec5c Merge pull request #812 from PicPeak/fix/security-advisories-backend-stable
fix(security): close 4 open security advisories on stable (backup takeover, share-login bypass, ZIP slip, chunked-upload traversal)
2026-07-16 13:37:43 +02:00
Paul Nothaft 38ddd70c12 Merge pull request #809 from PicPeak/fix/docker-image-os-cves-stable
chore(security): close 21 frontend image CVEs on stable — nginx 1.30 base + apk cache-bust
2026-07-16 13:37:40 +02:00
Paul Nothaft b00a16159e fix(security): harden .picpeak restore operator-preservation (GHSA-qxfx follow-up)
The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):

- MFA hijack: reinject wrote back only password_hash/is_active/
  must_change_password, leaving a crafted backup's two_factor_* on the
  operator's row — it could strip or replace their second factor. The email-
  matched row is now updated with the operator's full AUTH set (login identity,
  password, and all two_factor_* columns). Relationship/audit FKs (role_id,
  created_by) are deliberately NOT forced from the snapshot: on a cross-instance
  restore those pre-restore ids may be absent from the backup and would dangle
  the FK (SQLite rolls back at commit); the restored row keeps its own valid
  values.

- Cross-instance restore rollback / FK safety: reinject matched only by email,
  so a backup shipping a different admin with the default `admin` username hit
  UNIQUE(username) and rolled the whole restore back; email and username could
  even collide on two different rows. Reconciliation is now non-destructive:
  the email-matching row is updated in place (id preserved → restored FKs like
  events.created_by stay valid); any different row holding the operator's
  username is RENAMED, not deleted (deletion would fire ON DELETE actions /
  dangle references); only when no row has the operator's email is a fresh row
  inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
  left the Postgres identity sequence unadvanced, so a sequence-based insert
  could collide).

- Stale session after restore: admin_users ids shift on restore, but the
  operator's live JWT is bound only to decoded.id (IP logged not enforced; the
  backup controls password_changed_at). The route now revokes the token (result
  checked and logged) and clears the admin cookie; the client redirects to a
  fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
  guarantee.

Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.

Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
2026-07-16 12:31:28 +02:00
Paul Nothaft dcfcb67f9b fix(security): sanitize chunked-upload filename (GHSA-pc72-jf53-w28j)
The chunked video upload stored req.body.filename unmodified and later built
the merged path as path.join(tempDir, uploadMeta.filename). path.join does not
neutralise '../', so a filename like '../../uploads/logos/evil.svg' escaped the
temp dir on merge and overwrote arbitrary files. Requires admin with
photos.upload.

Fix: path.basename() the client filename in initializeUpload() and reject
names that collapse to nothing. Adds a regression test.
2026-07-16 10:56:15 +02:00
Paul Nothaft cde0b465a9 fix(security): reject ZIP-slip entries in archive/backup restore (GHSA-jfhw-fj23-fx6x)
node-stream-zip's extract(null, root) writes each entry to path.join(root,
entry.name) without neutralising '../', so a crafted archive entry named
'../../uploads/logos/evil.svg' escaped the target dir and overwrote arbitrary
files (logos, .env, route files → RCE on source deploys). Requires admin with
archives.restore.

Adds assertZipEntriesWithin() to utils/safePath.js — a lexical containment
check run on the entry list BEFORE extract() — and guards both extract sinks:
adminArchives.js (the reported route) and picpeakImportService.js (the sibling
.picpeak import, same sink). Adds unit tests for traversal, absolute-path, and
sibling-prefix entries.
2026-07-16 10:56:15 +02:00
Paul Nothaft 28f69e4bf3 fix(security): share-login must not bypass gallery password (GHSA-9hmx-68vc-qpqw)
POST /auth/gallery/share-login validated only the 128-bit share token and then
minted a full type:'gallery' access token regardless of require_password —
computing requiresPassword at the end only to echo it, never enforce it. Anyone
holding a gallery's share link could read and download every photo in a
password-protected gallery via a direct API call, no password needed.

Fix: compute requiresPassword before minting; for a password-protected gallery
return { requires_password: true } with NO token and NO cookie. The client then
goes through /gallery/verify, which does bcrypt.compare the password. The public
(no-password) auto-login path is unchanged. The frontend already falls through
to the password prompt when share-login returns no token/event.

Adds route regression test covering the bypass, the public path, and bad tokens.
2026-07-16 10:56:15 +02:00
Paul Nothaft 1cf82d81a7 fix(security): preserve current admin on .picpeak restore (GHSA-qxfx-4493-4v8f)
adminAuth populates req.admin, not req.user, so currentAdminId was always
undefined in the /api/admin/picpeak/import handler. reinjectCurrentAdmin()
then had no account to preserve and the admin_users table was fully replaced
by the uploaded backup — a crafted .picpeak let any admin with backup.restore
take over every admin account (critical). One-line fix: pass req.admin.id.

Closes GHSA-qxfx-4493-4v8f and its duplicate GHSA-pjp6-jcrj-3cr5.
2026-07-16 10:56:15 +02:00
Paul Nothaft ae98e7ad74 chore(security): close 21 frontend image CVEs — nginx 1.30 base + apk cache-bust
The frontend image kept shipping vulnerable OS packages (nginx 1.28.3-r1,
curl/libcurl 8.19.0, c-ares 1.34.6) despite the apk upgrade line, for two
independent reasons:

1. The runtime stage's apk upgrade layer was cached indefinitely — the
   CACHEBUST build-arg CI passes (github.run_number) was only declared in
   the builder stage, and ARGs don't cross stage boundaries. Both
   Dockerfiles now redeclare CACHEBUST in the runtime stage and consume it
   in the apk RUN, so every build re-runs the upgrade and picks up current
   Alpine security updates.

2. nginx itself can never upgrade via apk on the nginx.org-based image:
   the bundled nginx-module-* packages pin the exact nginx version, so
   Alpine's patched 1.28.3-r4 is unreachable (verified empirically —
   apk add --upgrade nginx is a silent no-op). nginx fixes must come via
   the base tag, so bump to nginx:1.30-alpine (current stable, 1.30.4 on
   Alpine 3.24, same nginx.org conf.d layout — drop-in).

Verified: local image build scans clean with Trivy (0 OS findings, was 21);
container serves /health, SPA fallback, and BRAND_TITLE envsubst as non-root
nginx user.

Closes code-scanning alerts 371-374, 376-392 (nginx HTTP/2 & module CVEs,
curl CVE-2026-5773/-6276 + 6 medium, c-ares CVE-2026-33630).
2026-07-16 10:30:42 +02:00
Paul Nothaft caa9fe5d56 chore(stable): release 3.45.0 (#777)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-09 11:22:46 +00:00
Paul Nothaft c6e61f64ba Merge pull request #775 from PicPeak/ci/release-please-target-stable-on-stable
ci(release): cut the real v3.45.0 stable (target-branch: stable)
2026-07-09 13:13:38 +02:00
Paul Nothaft 3ec0451cbb ci(release): pin target-branch: stable so release-please cuts the real v3.45.0
Triggers the correct stable release from the stable branch (manifest
3.44.0 -> 3.45.0). Same fix as #774 (which fixes it on main for future
promotes); merging this to stable is what re-runs release-please
correctly for the promote that mis-fired as v2.7.0.
2026-07-09 11:40:44 +02:00
Paul Nothaft edac463ec3 Merge pull request #771 from PicPeak/release/3.83.0-merge-from-beta
chore(release): promote beta → stable (v3.83.0 line)
2026-07-08 20:42:43 +02:00
Paul Nothaft 2d3537f61c ci: run the Tests workflow on stable-targeted PRs (unblock this promote)
Same one-liner as #772 — adds stable to tests.yml push/pull_request
filters so the required backend/frontend checks report on this PR
instead of hanging on 'Expected — Waiting for status to be reported'.
2026-07-08 20:29:41 +02:00
Paul Nothaft 6025b3194d chore(release): align README/DEPLOYMENT_GUIDE with main (promote content) 2026-07-08 20:01:48 +02:00
Paul Nothaft 8713ab7f60 chore(release): keep stable manifest (3.44.0) + CHANGELOG for release-please-stable 2026-07-08 20:00:13 +02:00
Paul Nothaft 8994901e4a chore(release): promote beta → stable (v3.83.0 line)
Merge main (v3.83.0-beta.0) into stable to cut the next stable release.
Conflicts resolved toward main (the promoted code); stable release-control
files (manifest, CHANGELOG) restored separately.
2026-07-08 19:59:55 +02:00
Paul Nothaft b86669f1e1 Merge pull request #569 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.44.0
2026-05-27 21:51:33 +02:00
github-actions[bot] 80296282e8 chore(main): release 3.44.0 2026-05-27 19:50:15 +00:00
Paul Nothaft 5551c89bda Merge pull request #568 from the-luap/release/3.55.0-merge-from-beta
chore(release): promote beta → main as v3.55.0
2026-05-27 21:48:31 +02:00
Paul Nothaft dbde67c0fa Merge branch 'main' into release/3.55.0-merge-from-beta
Resolves 6 file conflicts arising from main carrying 7 weeks of
stable-channel work (security backports, release-please cuts, README
rewrite #281) that hadn't been forward-merged into beta.

Resolution per file:

- backend/package.json + package-lock.json — kept beta's version.
  Beta is the superset; it intentionally drops `handlebars` (PR #367
  removed the runtime require; the dep was the source of 2 criticals
  + 8 highs). Security-pinned versions (axios 1.15.2, nodemailer ^8,
  i18next-http-backend ^3.0.2, multer ^2.0.2, tar >=7.5.13) already
  match across both branches — no security regression.
- frontend/package.json + package-lock.json — kept beta's version.
  Superset of main (adds marked, @types/node, i18next-cli, memfs,
  i18n CLI scripts). Same security versions on both sides.
- README.md — kept main's version. PR #281 was an explicit cleanup
  ("shorter, cleaner, less AI-sounding"); beta had grown the file by
  326 lines ad-hoc during the freeze. Preserving the rewrite.
- CHANGELOG.md — kept main's version. Release-please regenerates from
  conventional commits on its next stable cut, so beta's accumulated
  entries will roll into the new v3.55.0 release block automatically.

Auto-merged files carrying main's session-invalidation fix (#245)
flowed cleanly into beta's versions — sessionTimeout.js, adminAuth.js,
and the test files all merged without conflict, meaning beta had
already absorbed equivalent changes by independent paths.

CI on the underlying merge state was green on PR #568 prior to this
resolution; will re-run automatically on push.
2026-05-27 21:45:32 +02:00
Paul Nothaft 067e460a4d Merge pull request #413 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.43.1
2026-05-07 20:09:30 +02:00
github-actions[bot] 3678193ae2 chore(main): release 3.43.1 2026-05-07 12:36:13 +00:00
Paul Nothaft 74eacbc78f Merge pull request #412 from the-luap/security/cve-backport-3.42.2
fix(security): backport 18 dependency CVE patches from beta (3.42.2 stable)
2026-05-07 14:35:47 +02:00
Paul Nothaft 37bf894412 fix(security): patch 18 dependency CVEs (axios + transitives + nodemailer + i18next-http-backend)
Closes the open Trivy code-scanning alerts for app-side dependencies.
The npm-bundled CVEs in /usr/local/lib/node_modules/npm (picomatch,
brace-expansion, ip-address inside the Node image itself) are deferred
to a separate Node-base-image PR — they're build-environment-side and
need their own compatibility testing.

| Package | From | To | CVEs cleared |
|---|---|---|---|
| axios (backend + frontend) | 1.14.0 | 1.15.2 | CVE-2026-42264 (HIGH), CVE-2026-42043 (HIGH), CVE-2026-42035 (HIGH), CVE-2026-42033 (HIGH), CVE-2026-42044, CVE-2026-42042, CVE-2026-42041, CVE-2026-42040, CVE-2026-42039, CVE-2026-42038, CVE-2026-42037, CVE-2026-42036, CVE-2026-42034, CVE-2026-40175, CVE-2025-62718 |
| nodemailer (backend) | ^7.0.13 | ^8.0.5 | GHSA-vvjj-xcjg-gr5g, GHSA-c7w3-x93f-qmm8 |
| i18next-http-backend (frontend) | ^3.0.2 | ^3.0.5 | CVE-2026-41691 |
| uuid (backend) | ^11.1.0 | ^11.1.1 | CVE-2026-41907 |
| postcss (frontend, devDep) | ^8.4.21 | ^8.5.10 | CVE-2026-41305 |

For transitives whose direct parents haven't released a version that
picks up the patched range, pinned via npm overrides:

| Package | Min | CVE |
|---|---|---|
| follow-redirects (backend + frontend) | >=1.16.0 | GHSA-r4q5-vmmm-2653 |
| fast-xml-parser (backend) | >=5.7.0 | CVE-2026-41650 |
| @tootallnate/once (backend) | >=3.0.1 | CVE-2026-3449 |
| ip-address (backend) | >=10.1.1 | CVE-2026-42338 |

PR #268 originally pinned axios to 1.14.0 to avoid a supply-chain
attack on a specific compromised version range. The 1.15.x series
are post-incident upstream releases — clean. Confirmed with the
maintainer before bumping.

* `npx tsc --noEmit` (frontend) — clean
* `npx vite build` (frontend) — clean (~4s, existing bundle-size
  warning, not new)
* Backend module-load smoke test — all critical modules load
  (`auth`, `adminAuth` middleware, `emailProcessor`, `recaptcha`,
  `storage`) with the new axios + nodemailer
* Lockfile re-verification — every targeted CVE now resolves to
  the patched version range

* npm-bundled CVEs inside `/usr/local/lib/node_modules/npm/` —
  picomatch CVE-2026-33671 (HIGH), CVE-2026-33672, brace-expansion
  CVE-2026-33750, ip-address (npm-internal) CVE-2026-42338. These
  live in the Node base image and require a Node base image bump
  with its own compatibility testing — separate PR.

Targeting `beta` so the bumps go through the normal release-please
flow before promotion to `main`.
2026-05-07 14:28:53 +02:00
Paul Nothaft 506b5c3dc4 Merge pull request #408 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.43.0
2026-05-07 13:00:20 +02:00
github-actions[bot] ab6db37326 chore(main): release 3.43.0 2026-05-07 10:59:36 +00:00
Paul Nothaft eb2ce290a7 Merge pull request #407 from the-luap/release/3.42.1-merge-from-beta
chore(release): promote beta → main as v3.42.1
2026-05-07 12:56:13 +02:00
Paul Nothaft 8a4c1a7c0a chore(release): promote beta → main as v3.42.1
Stable release promoting the entire `beta` channel to `main`. Brings
~300 commits of features, fixes, and infrastructure improvements that
have been baked on the beta channel since v2.6.5.

## Major themes since v2.6.5

* Multi-administrator support with RBAC (super admin / admin / editor)
* Async upload pipeline (background worker pool for sharp/ffmpeg/EXIF/
  watermark/webhooks; bytes-on-wire returns 202)
* Self-hosted webfonts (filesystem-driven scanner; replaces Google Fonts
  CDN; GDPR-compliant)
* 8-token CI palette + force color mode (full theming across admin and
  public site, with WCAG-safe contrast helpers)
* Native multi-arch Docker images (Apple Silicon + ARM64 Linux native)
* Native S3 storage backend (S3 + S3-compatible providers)
* Comprehensive video support (MP4/WebM/MOV upload, stream, play)
* Outbound webhooks for event/photo lifecycle (HMAC-signed)
* Gallery layout overhaul (decoupled header style, banner option,
  theme-aware skeletons, lazy-loaded folder tree picker)
* Multilingual email templates (EN/DE/NL/PT/RU translations table)
* Bulk operations (delete with password gate, archive)
* Photo dimensions backfill (true masonry layout)
* Customer client access (review area before guest share)
* Image security (devtools detection, watermarking, right-click,
  secure thumbnails)

## Notable bug fixes from beta

* `/auth/session` symmetry — three rounds of fixes (#350, #355, #363,
  #398) for the admin-login redirect-loop family
* Email template renderer: handle {{#if}} conditionals, fix CSS leak in
  plain-text fallback, gate publish-from-draft password placeholder,
  gate external_url in public response
* Caller/template variable drift across gallery_created,
  expiration_warning, archive_complete, gallery_expired
* Full-URL gallery_link in all email types (was path-only in 3 sites)
* ffmpeg/ffprobe via apk for Alpine compatibility (was glibc-bundled)
* Admin events search and counters not bounded to first 100 (#346)

## Conflict resolution notes

* `README.md` — kept main's leaner v2.6.5 rewrite (#281); added a
  Contributors section adapted from PR #393.
* `DEPLOYMENT_GUIDE.md` — beta version (more recent, includes External
  Media docs already backported to main).
* `CHANGELOG.md` — new 3.42.1 entry leads, beta's 3.x history follows,
  main's 2.x entries appended below a divider so the historical chain
  is preserved.
* `package.json` (backend + frontend) — beta's structure with version
  bumped from `3.42.1-beta.0` → `3.42.1`.
* `package-lock.json` (backend + frontend) — regenerated via
  `npm install --package-lock-only`.
* `.release-please-manifest.json` — bumped from `2.6.5` → `3.42.1` so
  the next release-please run on main starts from the correct base.

## Pre-flight checks

* Frontend `tsc --noEmit` — clean
* Frontend `vite build` — clean (~3.5s, 2.6 MB main chunk; existing
  warning about chunking, not new)
* Backend `npm test` — pre-existing failures in 6 integration suites
  (DB-fixture-dependent, not regressions)
* Frontend `vitest` — pre-existing failures in
  ThemeCustomizerEnhanced.test.tsx (missing QueryClientProvider after
  PR #390 added useQuery; not a regression of this merge)

The pre-existing test failures are tracked as separate follow-ups and
do not block this release promotion.
2026-05-07 12:47:45 +02:00
Paul Nothaft 4d3836fb2e Merge pull request #282 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m49s
Build and Push Docker Images / build-frontend (push) Failing after 3m46s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.6.5
2026-04-08 13:18:17 +02:00
github-actions[bot] 75499992eb chore(main): release 2.6.5 2026-04-08 11:15:26 +00:00
Paul Nothaft 62643f241b Merge pull request #281 from the-luap/docs/readme-rewrite-main
docs: rewrite README — shorter, cleaner
2026-04-08 13:15:07 +02:00
Paul Nothaft 64f606152f docs: rewrite README — shorter, cleaner, less AI-sounding
Rewrote from 350 lines to ~130 lines. Removed emoji-heavy headings,
marketing fluff, redundant sections, and the AI disclosure. Collapsed
screenshots into details tags. Kept all essential info: demo, features,
quick start, comparison, tech stack, docs links.
2026-04-08 13:14:57 +02:00
Paul Nothaft e2a698e892 Merge pull request #277 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m25s
Build and Push Docker Images / build-frontend (push) Failing after 3m24s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.6.4
2026-04-08 09:39:15 +02:00
github-actions[bot] d1d71dba25 chore(main): release 2.6.4 2026-04-08 07:14:32 +00:00
Paul Nothaft bb81fa5f4b Merge pull request #276 from the-luap/fix/main-lockfile-sync
fix: sync backend package-lock.json for security deps
2026-04-08 09:14:16 +02:00
Paul Nothaft 03e19893b3 fix: sync backend package-lock.json with security dep updates
The lock file was not committed with PR #275, causing npm ci to fail
in Docker builds. Regenerate to match the updated package.json overrides.
2026-04-08 09:14:06 +02:00
Paul Nothaft 279314e4b7 Merge pull request #275 from the-luap/security/fix-dep-vulnerabilities-main
security: fix 20 dependency vulnerabilities (backport)
2026-04-08 09:05:56 +02:00
Paul Nothaft 730912a3f4 security: fix 20 dependency vulnerabilities (backport to main)
Same fixes as beta PR #274. Updates handlebars, nodemailer, tar,
fast-xml-parser, brace-expansion, path-to-regexp, and lodash to
address 20 GitHub code scanning alerts.
2026-04-08 09:05:48 +02:00
Paul Nothaft ff9fb64e75 Merge pull request #273 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m26s
Build and Push Docker Images / build-frontend (push) Failing after 3m28s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.6.3
2026-04-07 20:40:47 +02:00
github-actions[bot] 9cbbe74051 chore(main): release 2.6.3 2026-04-07 18:40:34 +00:00
Paul Nothaft 2e1c71c1ab Merge pull request #272 from the-luap/docs/external-media-library-270
docs: add External Media Library section to deployment guide (#270)
2026-04-07 20:40:11 +02:00
Paul Nothaft f6ca713a6e docs: add External Media Library section to deployment guide (#270)
Add the missing "External Media Library" chapter to DEPLOYMENT_GUIDE.md
that was referenced in the TOC but never written. Covers configuration,
Docker volume mounting, folder structure, usage workflow, limitations,
and troubleshooting.

Closes #270
2026-04-07 19:52:02 +02:00
Paul Nothaft 197cd8e1e0 Merge pull request #268 from the-luap/security/pin-axios-main
security: pin axios to 1.14.0 — supply chain attack prevention
2026-04-05 18:41:54 +02:00
Paul Nothaft 681b440381 security: pin axios to 1.14.0 to prevent supply chain attack
Axios versions 1.14.1 and 0.30.4 were compromised on March 31, 2026
with a RAT dropper attributed to North Korean threat actor. Pin to
exact 1.14.0 (latest safe release) to prevent resolution to compromised
versions. See https://github.com/axios/axios/issues/10604
2026-04-05 18:41:45 +02:00
Paul Nothaft 3daeac9e53 Merge pull request #246 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m32s
Build and Push Docker Images / build-frontend (push) Failing after 3m34s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.6.2
2026-03-16 22:37:56 +01:00
github-actions[bot] 7febba2d9c chore(main): release 2.6.2 2026-03-16 21:37:35 +00:00
Paul Nothaft 0a3a53763c Merge pull request #245 from the-luap/fix/security-session-invalidation-main
fix(security): token invalidation on password change, session timeout enforcement
2026-03-16 22:37:16 +01:00
Paul Nothaft 85a60a2dc7 fix(security): invalidate tokens on password change, enforce session timeout, fix role update
- Set password_changed_at when changing password via adminAuth route so
  existing JWT tokens are rejected by the auth middleware check
- Enforce session timeout on first request with unseen tokens by checking
  token iat against configured timeout (prevents bypass after server restart)
- Convert camelCase roleId/isActive to snake_case role_id/is_active in
  frontend updateUser service (fixes silent role update failures)

Resolves GHSA-rqg3-47p5-vgwg
2026-03-16 22:36:52 +01:00
Paul Nothaft e74e73a3a0 Merge pull request #231 from the-luap/i18n/ru-missing-keys
i18n: add missing Russian translations for thumbnails and photo dimensions
2026-03-15 20:01:15 +01:00
1060 changed files with 16163 additions and 146911 deletions
+1 -32
View File
@@ -5,10 +5,7 @@
.env.*
docker-compose*.yml
.DS_Store
# **/ so backend/node_modules and frontend/node_modules are excluded too —
# the root-context Dockerfile.aio COPYs those directories and must get its
# deps from its builder stages, never from the host checkout.
**/node_modules
node_modules
npm-debug.log
coverage
.nyc_output
@@ -21,31 +18,3 @@ storage/events/archived/*
storage/thumbnails/*
data/*.db
logs/*
# Dockerfile.aio builds from the REPOSITORY ROOT and Docker reads only this
# file — backend/.dockerignore is never consulted — so the unprefixed rules
# above miss backend/data, backend/logs and backend/storage. A checkout that has
# been used to run PicPeak would otherwise bake its database, photos, logs and
# SETUP_TOKEN into a published image layer.
# backend/data wholesale, not a suffix list. It holds only runtime state and is
# gitignored in full (.gitignore: `data/`), while suffix rules kept letting real
# secrets through: a used checkout here carries ADMIN_CREDENTIALS.txt alongside
# the database, plus -journal files and any DATABASE_PATH that does not end in
# .db. Any of those in a published layer is a credential leak.
backend/data
backend/logs
backend/storage
# Same root-context trap, one level deeper: the `.env`, `.env.*` and `data/*.db`
# rules above are unanchored only in appearance — Docker matches them against the
# path from the build context, so they catch `./.env` and never `backend/.env`.
# A checkout that has been used to run PicPeak locally keeps its JWT_SECRET,
# DB_PASSWORD and SMTP credentials there, and `COPY backend/ .` puts the file at
# /app/.env in the published layer. Match at any depth instead, the way
# **/node_modules above already does.
**/.env
**/.env.*
**/*.db
**/*.db-journal
**/*.sqlite*
frontend/dist
+15 -165
View File
@@ -10,21 +10,6 @@ NODE_ENV=production
# Generate one with: openssl rand -base64 64
#JWT_SECRET=your_very_long_random_jwt_secret_here
# How long a gallery guest stays recognised (#1210). Default 30d. It was 24h,
# which meant a client reviewing a gallery across two weekends registered again
# in between — and each re-registration is a separate guest whose likes and
# favourites no longer join up with the first visit's. Takes any jsonwebtoken
# duration ('7d', '12h'); shorten it if your galleries hold sensitive work.
#GUEST_TOKEN_TTL=30d
# OIDC SSO for admins (#798) — configured in the admin UI; only these two
# values live in the environment:
# Key encrypting the OIDC client secret at rest (defaults to JWT_SECRET).
#OIDC_ENCRYPTION_KEY=
# Break-glass: 'true' re-enables local password login even while the SSO
# settings disable it (recovery when the IdP is down or misconfigured).
#OIDC_BREAK_GLASS=false
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
@@ -71,85 +56,29 @@ DB_NAME=picpeak_prod
# Admin Account (initial setup) — OPTIONAL
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
# open /admin and PicPeak shows a setup screen. The one-time setup token is
# written to data/SETUP_TOKEN with mode 0600 — read it with
# `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged
# unless that write fails, so it never sits in `docker logs`.
# The all-in-one image keeps it at /data/db/SETUP_TOKEN — inside the volume,
# in the db/ subdirectory (#1218). On a NAS with no shell, set ADMIN_PASSWORD
# below instead: it needs no file at all.
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
# and saved to data/SETUP_TOKEN.
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
# credentials written to data/ADMIN_CREDENTIALS.txt).
#ADMIN_USERNAME=admin
#ADMIN_EMAIL=admin@yourdomain.com
#ADMIN_PASSWORD=your_secure_admin_password_here
# Email Configuration — OPTIONAL, and normally left alone.
# SMTP is configured in the setup wizard / Settings -> Email and stored in the
# database (email_configs); that is what the mail queue actually sends with.
# These variables are a legacy path kept for config-as-code deployments: when
# SMTP_HOST is set, the initial migration seeds the database row from it.
# Developers running the `dev` compose profile want SMTP_HOST=mailhog here so
# that seed points at the mailhog container.
# Email Configuration
# For Gmail: use app-specific password
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
#SMTP_HOST=smtp.gmail.com
#SMTP_PORT=587
#SMTP_SECURE=false
#SMTP_USER=your-email@gmail.com
#SMTP_PASS=your-app-specific-password
#EMAIL_FROM=noreply@yourdomain.com
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-specific-password
EMAIL_FROM=noreply@yourdomain.com
# Webhook email transport (#1225) — OPTIONAL, an alternative to SMTP entirely.
# When EMAIL_WEBHOOK_URL is set, PicPeak stops sending mail itself and POSTs
# each composed message as JSON to that URL instead; something downstream
# (n8n, Make, a self-hosted relay) delivers it. Useful when SMTP is the part
# you cannot get working — app passwords, blocked ports, a NAS with no
# outbound 25.
#
# Deliberately environment-only, not an admin setting: it redirects every
# outbound message including password resets, so it should not be changeable
# from a compromised admin session.
#
# EMAIL_WEBHOOK_SECRET is REQUIRED. The body is signed with it and sent as
# X-PicPeak-Signature (HMAC-SHA256, hex) — the same scheme as gallery
# webhooks, so a receiver verifies both the same way. Set the URL without a
# secret and the transport stays OFF and says so in the log, rather than
# posting unauthenticated mail to the internet.
#
# Payload: { from, to[], cc[], subject, html, text, attachments[] }, where each
# attachment is { filename, content_type, content_base64 }. Attachments are
# included rather than dropped; a message whose attachments exceed 10 MB fails
# and stays in the queue instead of arriving without its invoice.
#
# The receiver must be a public https:// address unless you opt in — a container or LAN
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
# normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that.
#
# Webhook and email-webhook deliveries connect to the DNS answer they just
# validated and ignore HTTP_PROXY / HTTPS_PROXY. Behind a mandatory egress
# proxy set the *_ALLOW_PRIVATE_URLS flag, which sends through the proxy
# without pinning.
#
# A mail account with its own SMTP host (Settings -> Mail accounts) keeps
# sending through it; this replaces the global transport only.
#
# Set EMAIL_FROM above as well. A webhook-only install never gets an
# email_configs row (that is seeded only when SMTP_HOST is set), so EMAIL_FROM
# is where the sender address comes from.
#EMAIL_WEBHOOK_URL=https://n8n.example.com/webhook/picpeak-mail
#EMAIL_WEBHOOK_SECRET=generate-a-long-random-string
#EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=false
# Application URLs — OPTIONAL. Leave unset for the normal install.
# The public origin is captured by the setup wizard (it proposes the address
# you opened the browser at) and stored as the `general_site_url` setting, so
# you can change it later in Settings -> General without touching this file.
# Setting FRONTEND_URL here OVERRIDES that setting and makes the field
# read-only in the admin UI - use it only for config-as-code deployments.
# Application URLs
# Use full origin with scheme, no trailing slash.
# Admin UI is served by the frontend at /admin.
#FRONTEND_URL=https://yourdomain.com
#ADMIN_URL=https://yourdomain.com
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
# Static HTML title + description used for social link previews when the
# fetcher doesn't trigger the per-event OG endpoint — most notably the
@@ -162,10 +91,9 @@ BRAND_TITLE=PicPeak
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
# API URL for email assets (logos, images in notification emails)
# OPTIONAL: when unset this is derived from the resolved public origin + /api,
# so the wizard's answer covers it. Set it only for split-origin deployments
# where the API lives on a different host than the gallery.
#API_URL=https://yourdomain.com/api
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
API_URL=https://yourdomain.com/api
# Frontend API base
# For pre-built images and production behind a reverse proxy, keep '/api'.
@@ -178,22 +106,6 @@ VITE_API_URL=/api
# DB_PORT=5432
# REDIS_PORT=6379
# File watcher (watch-folder auto-import, local storage only)
# Max photos processed in parallel — raise on hosts with memory headroom,
# lower to 1 on very small hosts. Default: 2
# FILE_WATCHER_CONCURRENCY=2
# External-media folder watcher (issue 1187). Reference-mode events can opt in
# per event (Event → Source Mode → "Watch folder for new files"); new images in
# the folder are then imported without pressing Import. Deleted files are
# never removed from the gallery.
# EXTERNAL_MEDIA_WATCH=true # global kill switch
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
# Release Channel
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
# 'stable' uses the :stable tag (same as :latest on main)
@@ -297,70 +209,8 @@ LOGS=./logs
# attempts is exponential: 1m, 5m, 30m, 2h, 12h.
# WEBHOOK_MAX_ATTEMPTS=5
# -----------------------------------------------------------------------------
# Face recognition — "People in this gallery" (#1074, optional)
# -----------------------------------------------------------------------------
# Requires the optional picpeak-ml sidecar container:
# docker compose --profile faces up -d
#
# NONE of these variables do anything until the `faces` feature flag is
# enabled in Admin → Settings, AND the per-event "Detect people in this
# gallery" toggle is switched on. Both default to OFF. With the flag off the
# backend never contacts the sidecar, so leaving these at their defaults on an
# install without the container is completely inert.
#
# Face embeddings are biometric data (GDPR Art. 9 special category in the EU).
# The photographer is the controller and needs a lawful basis for the people
# in their photos — read https://docs.picpeak.app/features/face-recognition
# before enabling.
#
# NOT AVAILABLE ON THE ALL-IN-ONE IMAGE. The single-container build sets
# PICPEAK_SINGLE_CONTAINER=true and the backend refuses to enable face
# recognition there regardless of these variables or the feature flag: that
# image runs the backend, frontend, database and every worker in one
# container, with no ML sidecar to talk to, and face detection would compete
# with image processing for the same CPU and memory. Use the standard
# multi-container deployment if you want this feature.
#
# FACE_ML_TOKEN (no default — REQUIRED to run the sidecar)
# Shared secret between the backend and the sidecar. The sidecar refuses to
# start without it rather than serving anonymously, so an accidentally
# published port is never a free face-detection API. Generate with:
# openssl rand -hex 32
# FACE_ML_TOKEN=
#
# FACE_ML_URL (default: http://picpeak-ml:8000)
# Defaults to the sidecar's compose service name, so the standard
# deployment needs no configuration here. Only change it if you run the
# sidecar outside the default compose network.
# FACE_ML_URL=http://picpeak-ml:8000
#
# FACE_PROCESSOR_CONCURRENCY (default: 1)
# Face-detection workers in the backend. Defaults to 1 deliberately: face
# scanning shares a host with Sharp image processing, which is the real
# memory pressure (see UPLOAD_PROCESSOR_CONCURRENCY). Raise only on hosts
# with headroom to spare.
# FACE_PROCESSOR_CONCURRENCY=1
#
# FACE_ORT_THREADS (default: 1)
# ONNX Runtime threads inside the sidecar. More threads mean faster
# per-photo inference and higher RSS.
# FACE_ORT_THREADS=1
# Note on FRONTEND_API_URL (documentation only):
# When using pre-built frontend images, runtime env vars cannot override the built JS.
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
# should you change VITE_API_URL at build time.
# Optional product usage (#1110): disabled until explicit in-app consent.
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
# Keep this value stable until participation has been deleted.
# USAGE_ENCRYPTION_KEY=
# Graceful shutdown budget in milliseconds. On SIGTERM the server stops
# accepting requests, drains workers and closes the pool; whatever is still
# running after this long is abandoned so the process exits before Docker's
# 10 s stop grace period (raise stop_grace_period together with this value).
#SHUTDOWN_TIMEOUT_MS=8000
+8 -11
View File
@@ -24,20 +24,17 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS and version:
- Browser and version:
- PicPeak version and Docker image tag (if applicable):
- Deployment method: [Docker Compose, all-in-one container, manual]
- Database and version: [PostgreSQL, SQLite]
- OS: [e.g. Ubuntu 22.04]
- Browser: [e.g. Chrome 120, Safari 17]
- PicPeak Version: [e.g. 1.0.22]
- Deployment Method: [e.g. Docker Compose, Manual]
- Database: [e.g. PostgreSQL 15, SQLite]
**Logs**
Please include relevant logs:
```
# Backend logs (Docker Compose)
docker compose logs --tail=50 backend
# Or all-in-one container logs (replace picpeak if your container has another name)
docker logs --tail=50 picpeak
# Backend logs
docker-compose logs backend | tail -50
# Frontend console errors
[paste any browser console errors]
@@ -47,4 +44,4 @@ docker logs --tail=50 picpeak
Add any other context about the problem here.
**Possible Solution**
If you have an idea how to fix the issue, please describe it here.
If you have an idea how to fix the issue, please describe it here.
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📚 Documentation
url: https://docs.picpeak.app
about: Installation, configuration and feature guides
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
about: Please read the documentation before opening an issue
- name: 💬 Discussions
url: https://github.com/PicPeak/picpeak/discussions
about: Ask questions and discuss with the community
- name: 🔒 Security Issues
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
about: Please review our security policy for reporting vulnerabilities
about: Please review our security policy for reporting vulnerabilities
+1 -4
View File
@@ -9,7 +9,6 @@ assignees: ''
**What documentation needs improvement?**
Please specify which document or section needs attention:
- [ ] Documentation website (https://docs.picpeak.app)
- [ ] README.md
- [ ] DEPLOYMENT.md
- [ ] CONTRIBUTING.md
@@ -17,8 +16,6 @@ Please specify which document or section needs attention:
- [ ] Code Comments
- [ ] Other: ___________
Link to the affected page or file:
**Describe the issue**
What's wrong or missing in the documentation?
@@ -33,4 +30,4 @@ Who is this documentation for?
- [ ] End users (photographers/clients)
**Additional context**
Add any other context, examples, or references here.
Add any other context, examples, or references here.
-47
View File
@@ -1,47 +0,0 @@
# picpeak — All-in-one
**picpeak** is an open-source, self-hosted **photo-sharing platform for photographers**, with an optional CRM / accounting suite. This image is the **all-in-one** build: the backend, the built web UI and SQLite in **one container, one process** — no compose file, no separate database, no reverse proxy to wire up.
- 📦 **Source, docs & issues:** https://github.com/PicPeak/picpeak
- 🧩 **Multi-container images:** [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) + [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend)
## Supported tags
- `latest` / `stable` — latest stable release
- `x.y.z` — a pinned release (**recommended for production**)
- `beta` / `main` — latest build from `main` (may be unstable)
- **Architectures:** `linux/amd64`, `linux/arm64` (x86 and ARM NAS)
## Quick start
docker run -d --name picpeak -p 3000:3000 \
-v picpeak:/data \
-e JWT_SECRET="$(openssl rand -base64 48)" \
picpeak/aio:stable
Then open **http://localhost:3000/admin** and complete the setup wizard. Read the one-time setup token with:
docker exec picpeak cat /data/db/SETUP_TOKEN
> 🔗 Share links need to know your address. The image defaults `FRONTEND_URL` to `http://localhost:3000`; pass `-e FRONTEND_URL=https://photos.example.com` (or set the site URL in Settings) before you send a gallery to a client.
## Ports & volumes
- Container port **3000** (HTTP; put your own TLS terminator in front for public use).
- **One volume: `/data`** — back it up and you have backed up the install.
- `/data/db``picpeak.db` and `SETUP_TOKEN`
- `/data/storage` — originals, thumbnails, archives
- `/data/logs`, `/data/backup`
## External Postgres
SQLite is this image's default, not its only option. Point it at an existing database exactly like the backend image:
-e DATABASE_CLIENT=pg -e DB_HOST=… -e DB_USER=… -e DB_PASSWORD=…
## How it differs from the compose stack
- **SQLite takes one writer at a time** — right for a home server, a NAS or a single studio; the compose stack with PostgreSQL is what scales.
- **No Redis** — background jobs run in-process.
- **Face recognition is unavailable** here. It needs the separate [`picpeak/ml`](https://hub.docker.com/r/picpeak/ml) sidecar, and a second image-processing pipeline competing with thumbnailing for one container's CPU would just make the install slow. Run the multi-container deployment for that feature.
You can move to the full stack later without reinstalling: take a `.picpeak` backup and restore it there.
## Docs
Volume layout, the external-Postgres variant, TLS, updates and the limits: **https://docs.picpeak.app/deployment/single-container**
-56
View File
@@ -1,56 +0,0 @@
# picpeak — ML sidecar (face detection)
**picpeak** is an open-source, self-hosted **photo-sharing platform for photographers**. This image is the **optional face-detection sidecar**: it detects faces in one image and returns a bounding box, five landmarks, quality signals and a 512-d embedding per face.
**Nothing else.** No database, no volumes, no state, no egress, no model download at runtime. Clustering, person identity, thresholds and every privacy decision live in the picpeak backend, where the data already is — this service forgets each image the moment it answers.
If you don't run this container, the feature does not exist.
- 📦 **Source, docs & issues:** https://github.com/PicPeak/picpeak
- 🧩 **Runs with:** [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) + [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend)
## Supported tags
- `latest` / `stable` — latest stable release
- `x.y.z` — a pinned release (**recommended for production** — keep it on the **same** tag as the backend)
- `beta` / `main` — latest build from `main` (may be unstable)
- **Architectures:** `linux/amd64`, `linux/arm64`
> The sidecar's API contract is versioned with the backend that calls it, so `PICPEAK_CHANNEL` resolves the same string across all picpeak images.
## Turning it on
The maintained compose file already contains this service behind a profile — you do not write it by hand:
docker compose --profile faces up -d
Then two deliberate actions in the app, neither of which is installing this container:
1. Enable the **`faces`** feature flag in admin settings.
2. Enable **"Detect people in this gallery"** per event.
**Nothing in the backend touches this service while the flag is off**, so an install without this container never attempts a connection.
## Configuration
| | |
|---|---|
| `FACE_ML_TOKEN` | **Required.** The container **refuses to start** without it, so an accidentally published port is never a free face-detection API. Must match the backend's `FACE_ML_TOKEN`. |
| `FACE_ORT_THREADS` | ONNX Runtime threads (default `1`). |
Port **8000**, no volumes, no published ports needed — the backend reaches it on the compose network. `FACE_ML_URL` defaults to `http://picpeak-ml:8000` (the compose service name), so the standard deployment needs no URL configuration.
## API
All endpoints except `/health` require the `X-Face-ML-Token` header.
| | |
|---|---|
| `GET /health` | `{"status": "ok"}` — unauthenticated, used by the healthcheck |
| `GET /info` | `{detector, embedder, model_version, dim}` |
| `POST /faces` | multipart `image``{model_version, faces: [...]}` |
## Models
YuNet (detection) + FaceNet-512 (embedding), **both MIT**, baked into the image and verified by SHA-256 at build time — never downloaded at runtime, so airgapped installs work and a model cannot change under a running deployment. See [`ml/LICENSES.md`](https://github.com/PicPeak/picpeak/blob/main/ml/LICENSES.md) for why these and not InsightFace's non-commercial weights.
## Not available on the all-in-one image
[`picpeak/aio`](https://hub.docker.com/r/picpeak/aio) sets `PICPEAK_SINGLE_CONTAINER=true` and the backend refuses to enable face recognition there — a second image-processing pipeline competing with thumbnailing for one small container's CPU would not fail loudly, it would just make the install slow. Run the multi-container deployment for this feature.
## Docs
**https://docs.picpeak.app** · sidecar internals, model conversion and the alignment/threshold contract: [`ml/README.md`](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)
+1 -9
View File
@@ -1,8 +1,6 @@
# Docker Build and Push Workflow
This GitHub Actions workflow automatically builds and pushes Docker images for the backend, the frontend, the all-in-one image and the optional ML sidecar to GitHub Container Registry (ghcr.io). On the canonical org repo every one of them is mirrored to Docker Hub as `docker.io/picpeak/{backend,frontend,aio,ml}`; forks build the same images GHCR-only.
The **all-in-one image** (`<repo>/aio`, built from `Dockerfile.aio` at the repo root, #1042) bundles the backend and the built frontend into a single container with SQLite as the default engine — one `docker run`, no compose. It follows the same per-arch build → digest-merge → per-version tag scheme as the other two images, is mirrored to Docker Hub (`docker.io/picpeak/aio`) alongside GHCR on the canonical org repo, and every PR additionally runs a `smoke-aio` job that boots the image and asserts the SPA shell, brand-title rendering, immutable asset caching, and the SQLite engine resolution.
This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io).
## Features
@@ -12,7 +10,6 @@ The **all-in-one image** (`<repo>/aio`, built from `Dockerfile.aio` at the repo
- 🔒 **Security scanning** with Trivy vulnerability scanner
- 💾 **Build caching** for faster subsequent builds
- 📊 **Build summaries** in GitHub Actions UI
- 📝 **Docker Hub pages** for `aio` and `ml` synced from `.github/dockerhub/*.md` on every `main` merge (`dockerhub-descriptions` job). `backend` and `frontend` pages are still hand-maintained in the Hub UI — add `.github/dockerhub/{backend,frontend}.md` with their current text before putting them under the same job.
## Authentication
@@ -55,11 +52,6 @@ docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0
# Pull for specific architecture
docker pull --platform linux/arm64 ghcr.io/picpeak/picpeak/backend:latest
# The same images on Docker Hub (identical tags, identical digests)
docker pull picpeak/backend:latest
docker pull picpeak/aio:stable
docker pull picpeak/ml:stable
```
### Using in Docker Compose
File diff suppressed because it is too large Load Diff
+26 -6
View File
@@ -27,12 +27,32 @@ jobs:
manifest-file: .release-please-manifest.json
target-branch: stable
# NOTE: stable release PRs are intentionally NOT auto-merged here
# anymore. Fixes accumulate in the rolling release PR and are cut as
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
# or on demand via workflow_dispatch / a manual merge of the release
# PR). Beta keeps instant releases — see release-please-beta.yml —
# because same-day reporter verification depends on it.
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
# whenever no PAT is configured.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout in this job — set the repo explicitly so gh works
# without a git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
# is a valid review; enable auto-merge as the PAT so the merge commit is
# attributed to a real identity and triggers the tag-cutting run (#719).
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -1,86 +0,0 @@
name: Cut Stable Release (daily batch)
# Stable fixes accumulate in release-please's rolling release PR instead of
# each cutting its own patch version (the old per-merge auto-merge produced
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
# stable release PR once a day, so a day of N bugfixes ships as ONE version
# with all N changelog entries — and one Docker build instead of N.
#
# - schedule only fires from the default branch (main); the stable copy of
# this file is inert and exists to keep the branches in sync.
# - Need a release NOW? Run this via workflow_dispatch, or merge the
# release PR by hand — the schedule is a default, not a gate.
# - Approval/merge mechanics mirror the old inline step (#719): approve as
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
# auto-merge as the PAT so the merge attributes to a real identity and
# triggers the tag-cutting run. --auto waits for green checks.
on:
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
merge-stable-release-pr:
runs-on: ubuntu-latest
steps:
- name: Approve and enable auto-merge on the open stable release PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout — set the repo explicitly so gh works without a
# git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
exit 0
fi
# Strict selection (review P1): this job runs daily even without a
# stable push, and `gh pr list --head` matches the branch NAME only
# — a fork PR can spoof `release-please--branches--stable`. Pin the
# base to stable AND require a same-repo head (isCrossRepository
# == false); a fork PR is cross-repository, so it can never be
# picked and auto-merged with the privileged PAT.
pr=$(gh pr list \
--base stable \
--head release-please--branches--stable \
--state open \
--json number,isCrossRepository \
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
if [ -z "$pr" ]; then
echo "No open same-repo stable release PR — nothing to cut today."
exit 0
fi
# Approve is tolerant — a pre-existing approval already satisfies
# branch protection and re-approving can return non-zero.
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
# But the auto-merge enable is the load-bearing step: this scheduled
# job is the ONLY automatic stable cut, so DON'T swallow its failure
# (review P2) — an expired/under-scoped PAT would otherwise stop
# releases while the workflow stays green.
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
# are already green — the normal case at 18:00, since the fixes
# merged hours earlier and CI passed. So success is EITHER the PR is
# already merged OR an auto-merge request is now pending; only a PR
# that is still open with no auto-merge request is a real failure
# (expired/under-scoped PAT) worth failing the job on (review round 2).
# One snapshot of both fields (review round 3): querying state and
# autoMergeRequest separately races — auto-merge can complete
# between the two calls, so the first sees OPEN and the second sees
# the request already cleared on the now-merged PR → false failure.
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
if [ "$state" = "MERGED" ]; then
echo "Stable release PR #$pr merged immediately (checks were already green)."
elif [ "$automerge" = "true" ]; then
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
else
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
exit 1
fi
+18 -89
View File
@@ -6,6 +6,11 @@ name: Tests
# calendar) plus the photo / settings / OG / auth surface — wiring them
# into CI makes regressions visible at PR time instead of post-merge.
#
# Six backend suites are excluded via --testPathIgnorePatterns. They
# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM
# regressions). Excluding them here keeps CI green from day 1; revisit
# each individually as its own fix.
#
# Triggers on any change that could affect either suite. The backend
# job intentionally omits frontend paths and vice versa so unrelated
# PRs don't pay both build costs.
@@ -23,41 +28,7 @@ permissions:
jobs:
backend:
runs-on: ubuntu-latest
# 20, not 10. This job normally finishes in ~3 minutes, but it is the only
# one that boots Postgres and runs the full integration suite, so it is the
# only one exposed to runner contention — observed spread has reached 9.2
# minutes, and a release PR (#1088) was cancelled at 10.3 with every test
# passing and jest still running. A cancelled job reads as a red X on a
# green branch, which costs a re-run and a diagnosis every time it happens.
#
# The cap is a runaway guard, not a performance budget; 20 leaves real
# headroom over the worst observed run while still killing a hung suite
# well inside the hour GitHub would otherwise allow. frontend and ml keep
# 10 — they take seconds and have never come close.
timeout-minutes: 20
# The .picpeak restore suites gate their real-Postgres cases behind
# PICPEAK_PG_TEST_URL and `describe.skip` themselves out when it is
# unset — so until now they never ran here. That hid the half that
# matters: sequence resync, operator/role preservation across a
# cross-instance restore, and (with #1041) whether a SQLite-shaped
# row actually lands in Postgres with the right STORED VALUES rather
# than merely not throwing. Everything else in the suite still runs
# on SQLite; this service only un-gates those cases.
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: picpeak
POSTGRES_PASSWORD: testpass
POSTGRES_DB: picpeak_test
options: >-
--health-cmd "pg_isready -U picpeak -d picpeak_test"
--health-interval 2s
--health-timeout 2s
--health-retries 30
ports:
- 5432:5432
timeout-minutes: 10
steps:
- name: Checkout code
@@ -81,10 +52,18 @@ jobs:
# The S3 path itself is covered separately by the integration
# suite when MinIO is provisioned.
SKIP_S3_TESTS: 'true'
# Un-gates the real-Postgres cases in the .picpeak restore suites
# (see the `services:` note above). Absent it they silently skip.
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
run: npx jest --ci
run: |
# Excluded suites — fail on upstream/beta too, tracked
# separately as test-infra debt:
# adminSettings.logo — supertest fixture
# integration/adminPhotos.reference — supertest fixture
# integration/webhookDelivery — supertest fixture
# services/backupService.enhanced — knex mock chain
# routes/__tests__/adminAuth — supertest fixture
# (adminNotifications was excluded; #597 fix re-enables it.)
npx jest \
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \
--ci
frontend:
runs-on: ubuntu-latest
@@ -105,56 +84,6 @@ jobs:
working-directory: ./frontend
run: npm ci
- name: Lint frontend (including Rules of Hooks)
working-directory: ./frontend
run: npm run lint
- name: Run Vitest suite
working-directory: ./frontend
run: npm test -- --run
nginx:
runs-on: ubuntu-latest
timeout-minutes: 5
strategy:
matrix:
# Match the two shipped frontend Dockerfiles.
image: ['nginx:1.28-alpine', 'nginx:1.30-alpine']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify token-safe nginx logging
env:
NGINX_TEST_IMAGE: ${{ matrix.image }}
run: python3 tests/nginx/test_request_logging.py
# Optional face-detection sidecar (#1074). Runs on every PR regardless of
# whether the feature is enabled anywhere — these tests need no model
# weights (they stub the pipeline out) and cover the auth boundary, the
# request guards and the alignment geometry, which is where a mistake is a
# security problem or a silent accuracy problem rather than a visible bug.
ml:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
# Matches ml/Dockerfile's base image, so a wheel that resolves here
# resolves in the image too.
python-version: '3.12'
cache: 'pip'
cache-dependency-path: ml/requirements.txt
- name: Install ml deps
working-directory: ./ml
run: pip install -r requirements.txt pytest httpx
- name: Run pytest suite
working-directory: ./ml
run: python -m pytest tests/ -q
+2 -28
View File
@@ -130,31 +130,5 @@ docker-compose.dev.yml
# New layout development files
new-layouts/
# Backend runtime storage (generated media, previews, thumbnails,
# CRM/accounting documents) — never commit
backend/storage/
# Python artifacts — the picpeak-ml sidecar (#1074) is the only Python in
# this tree, but bytecode and virtualenvs must never be committed.
__pycache__/
*.py[cod]
.pytest_cache/
ml/.venv/
ml/venv/
# Locally produced model weights. The image fetches these by pinned URL and
# SHA-256 at build time; a 90MB blob must not end up in git history.
ml/*.onnx
ml/*.h5
# Issue / PR screenshots belong on a `screenshots/*` branch, never on main or
# stable — that is what those branches exist for. Two landed at the repo root
# in #1241 and shipped as part of the source tree.
#
# Anchored with a leading slash so docs/ keeps its own images.
/issue-*.png
/issue-*.jpg
/screenshot-*.png
/screenshot-*.jpg
/*-screenshot.png
/*-screenshot.jpg
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.131.7-beta.0"
".": "3.83.0-beta.0"
}
+1 -1
View File
@@ -1 +1 @@
{".":"3.44.0"}
{".":"3.45.7"}
+835 -2287
View File
File diff suppressed because it is too large Load Diff
+16 -26
View File
@@ -59,7 +59,7 @@ Unsure where to begin? You can start by looking through these issues:
### Prerequisites
- Node.js 22.12.0 or later (matches `backend/package.json`)
- Node.js 18+
- Docker & Docker Compose
- Git
@@ -73,32 +73,23 @@ cd picpeak
# Install dependencies
cd backend && npm install
cd ../frontend && npm install
cd ..
# Start Postgres and Redis (the app itself runs on the host, see below)
docker compose up -d postgres redis
# Set up environment
cp .env.example .env
# Edit .env with your settings
# Backend config — note this is backend/.env, not the root one
cp backend/.env.example backend/.env
# JWT_SECRET must be set: the host process validates it and exits without one.
# (The containers generate it themselves; `npm run dev` does not.)
# Backend, with nodemon hot reload — http://localhost:3001
cd backend && npm run dev
# Frontend, with Vite hot reload, in a second shell — http://localhost:5173
cd frontend && npm run dev
# Start development servers
docker-compose -f docker-compose.dev.yml up
```
Open **http://localhost:5173**. Vite proxies `/api` to the backend on `3001`, so
you do not need the root `.env` for this loop at all — that one configures the
compose stack.
**After pulling changes that touch `backend/package.json` / `backend/package-lock.json` (or the frontend equivalents)**, rebuild the affected image so the live-mounted source can `require()` the new deps:
Running the two Node processes on the host is the fastest loop: both reload on save, and you get a real debugger and stack traces without rebuilding an image.
```bash
docker compose -f docker-compose.dev.yml up -d --build backend
# (or `frontend`, or both)
```
**Prefer everything in containers?** `docker compose up -d` builds `backend`, `frontend` and `ml` from source using the production Dockerfiles. That works, but there is no hot reload — you rebuild on every change (`docker compose up -d --build backend`).
> `docker-compose.dev.yml` is listed in `.gitignore` and is not part of the repo. If you keep a local one for live-mounting `./backend/src` and `./frontend/src` against `backend/Dockerfile.dev` / `frontend/Dockerfile.dev`, remember it bakes `node_modules` into the image: after pulling a change to `backend/package.json`, rebuild that image or you will get a `MODULE_NOT_FOUND` restart loop.
The dev compose bakes `node_modules` into the image while live-mounting `./backend/src` and `./frontend/src` from disk. A dep added on disk won't be picked up until the image is rebuilt — typical symptom is a `MODULE_NOT_FOUND` restart loop on the affected container.
### Running Tests
@@ -172,14 +163,13 @@ PicPeak runs on two long-lived branches:
| Branch | Role | What targets it |
|---|---|---|
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
| **`stable`** | Curated release channel. Production-recommended. | Security fixes and regular bugfix backports, kept small and free of unrelated features. |
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
### Which branch should my PR target?
- **New feature** → target `main`.
- **Bugfix that ONLY affects active dev** → target `main`.
- **Bugfix that current stable users need** → target `main`; regular bug fixes are generally backported automatically to `stable`. Maintainers handle conflicts or create a separate focused backport PR when needed.
- **Security vulnerability** → report privately using [SECURITY.md](SECURITY.md). Security fixes are always released on both `stable` and `main`; coordinate any fix with the maintainers before opening a public PR.
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
@@ -197,6 +187,6 @@ See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteri
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
- Security vulnerabilities: Follow the [security policy](SECURITY.md) and use [private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
Thank you for contributing! 🎉
Thank you for contributing! 🎉
-178
View File
@@ -1,178 +0,0 @@
# All-in-one image (#1042): one container, one Node process.
#
# The backend serves the built frontend itself via server.js's SERVE_FRONTEND
# block (SPA fallback, OG crawler intercept, brand-title render, immutable
# asset caching) — no nginx, no supervisor, no bundled Postgres/Redis. SQLite
# is the explicit default engine; pointing DB_HOST/DB_USER/DB_PASSWORD (+
# DATABASE_CLIENT=pg) at an external Postgres works exactly like the backend
# image. Build context is the REPO ROOT (both backend/ and frontend/ are
# needed): docker build -f Dockerfile.aio .
#
# KEEP IN SYNC: the runtime stage below mirrors backend/Dockerfile's
# production stage (base image, apk set, npm removal, nodejs user, fontconfig
# registration, directory layout, healthcheck, entrypoint). When
# backend/Dockerfile changes, change this file too — the aio smoke job in
# docker-build.yml catches boot-level drift, not package-level drift.
# ---------------------------------------------------------------------------
# Frontend build — mirrors frontend/Dockerfile's builder stage
# ---------------------------------------------------------------------------
FROM node:22-alpine AS frontend-builder
ARG CACHEBUST=1
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci --legacy-peer-deps
COPY frontend/ .
RUN npm run build
# ---------------------------------------------------------------------------
# Backend deps — mirrors backend/Dockerfile's builder stage
# ---------------------------------------------------------------------------
FROM node:22-alpine AS backend-builder
ARG CACHEBUST=1
WORKDIR /app
COPY backend/package*.json ./
RUN npm ci --omit=dev
# ---------------------------------------------------------------------------
# Runtime — mirrors backend/Dockerfile's production stage + the frontend dist
# ---------------------------------------------------------------------------
FROM node:22-alpine
ARG CACHEBUST=1
ARG BUILD_DATE
ARG VCS_REF
ARG VERSION
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
LABEL org.opencontainers.image.description="PicPeak all-in-one (backend + frontend, single container)"
LABEL org.opencontainers.image.licenses="MIT"
WORKDIR /app
# Explicit engine selection (#1038/#1042): SQLite is this image's DEFAULT
# engine — set explicitly, never inferred, and wait-for-db.sh skips its
# Postgres readiness wait for it. Point the container at an external Postgres
# by overriding DATABASE_CLIENT=pg and setting DB_HOST/DB_USER/DB_PASSWORD,
# exactly like the backend image. The boot resolver still logs the engine and
# refuses the populated-both conflict.
# STORAGE_PATH: getStoragePath() falls back to path.join(__dirname,
# '../../../storage') — which resolves to the container-root `/storage` here,
# writable by root but EACCES for the nodejs user after the su-exec drop.
# Compose masks this by setting STORAGE_PATH=/app/storage; this image must
# pin the same path (it is the directory the Dockerfile creates and chowns).
ENV NODE_ENV=production \
DATABASE_CLIENT=sqlite3
# See backend/Dockerfile for the rationale of each of the following blocks.
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# sqlite — DatabaseBackupService.createSQLiteBackup() SPAWNS the `sqlite3`
# CLI for `.backup` and PRAGMA integrity_check; the npm module does not
# ship that binary. backend/Dockerfile omits it because compose always runs
# Postgres — this image defaults to SQLite, so without it every database
# backup fails with ENOENT.
RUN apk add --no-cache dumb-init postgresql-client sqlite ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
fc-cache -f
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
COPY --from=backend-builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs backend/ .
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
fc-cache -f /app/assets/fonts
# ---------------------------------------------------------------------------
# One volume, one layout (#1042 scope: "single data layout on one volume")
# ---------------------------------------------------------------------------
# /data/db picpeak.db (+ -wal/-shm) and SETUP_TOKEN
# /data/storage originals, thumbnails, archives
# /data/logs application logs
# /data/backup built-in backup output; /backup symlinks here
#
# `-v picpeak:/data` and nothing else to remember — back up /data and you have
# backed up the install. /backup is where migrations 029 + 030 seed the backup
# destinations, so it is symlinked in rather than left dangling.
ENV DATA_ROOT=/data \
DATA_DIR=/data/db \
DATABASE_PATH=/data/db/picpeak.db \
STORAGE_PATH=/data/storage \
LOG_DIR=/data/logs \
BACKUP_DIR=/data/backup
# FRONTEND_URL is deliberately NOT set here (#705). It used to default to
# http://localhost:3000 so share links would not come out relative, but a
# baked-in value OVERRIDES the general_site_url setting the setup wizard
# writes — so a single-container install could never configure its own public
# address, and the Settings field would show as env-pinned for everyone.
# getFrontendBaseUrl() now resolves the setting, then the origin the request
# arrived on, and getAbsoluteFrontendUrl() still ends at http://localhost:3000,
# so links stay absolute without pinning anything. Override with
# -e FRONTEND_URL=https://photos.example.com for config-as-code deployments.
# /app/storage is a second entrance to the same volume. The business-document
# writers (quoteService, invoice sending/reminders, contract signatures) build
# their paths from `path.join(process.cwd(), 'storage', ...)` and never consult
# STORAGE_PATH. Compose hides that because it sets STORAGE_PATH=/app/storage
# with WORKDIR /app, so the two happen to be the same directory; here they are
# not, and /app is root-owned, so a quote or invoice PDF would fail to write as
# UID 1001 — and be lost with the container even if it succeeded. Teaching
# those services STORAGE_PATH is the real fix and belongs in its own change;
# the symlink restores the coincidence compose already relies on.
RUN mkdir -p /data/db /data/storage/events/active /data/storage/events/archived \
/data/storage/thumbnails /data/logs \
/data/backup/picpeak /data/backup/database && \
ln -s /data/backup /backup && \
ln -s /data/storage /app/storage && \
chown -R nodejs:nodejs /data
VOLUME ["/data"]
# The frontend bundle, served by server.js's SERVE_FRONTEND block. Explicit
# opt-in rather than the dist-exists autodetect, so the behavior is pinned
# even if the autodetect heuristic ever changes.
COPY --from=frontend-builder --chown=nodejs:nodejs /app/dist /app/frontend/dist
ENV SERVE_FRONTEND=true \
FRONTEND_DIR=/app/frontend/dist
# Marks this as the single-container build. The backend refuses to enable face
# recognition (#1074) when it sees this, on performance grounds: that feature
# needs a separate ML container this image does not contain, and it would add
# a second image-processing pipeline competing with Sharp for the CPU and
# memory of a container sized for one photographer plus guests browsing. The
# failure would not be loud — just a slow install that looks broken.
#
# An explicit marker rather than inferring it from SERVE_FRONTEND or the
# SQLite path: legitimate multi-container deployments do both of those, and
# none of them should lose the feature by accident.
ENV PICPEAK_SINGLE_CONTAINER=true
# No USER directive — same as backend/Dockerfile: the container starts as root
# so wait-for-db.sh can chown bind-mounted volumes to UID 1001, then drops
# privileges via su-exec (#484).
EXPOSE 3000
# Shell form so it resolves $PORT: a hard-coded 3000 marks an otherwise healthy
# container unhealthy forever the moment anyone overrides the port.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider "http://localhost:${PORT:-3000}/health" || exit 1
ENTRYPOINT ["dumb-init", "--"]
# --max-http-header-size matches nginx's `large_client_header_buffers 4 32k`.
# Requests reach Node directly here, and its 16 KiB default would reject a guest
# carrying several per-gallery JWT cookies before Express ever saw them.
CMD ["./wait-for-db.sh", "node", "--max-http-header-size=32768", "server.js"]
+460 -133
View File
@@ -1,48 +1,90 @@
# 📸 PicPeak - Open Source Photo Sharing for Events
> [!IMPORTANT]
> **PicPeak has moved to its own GitHub organization.**
>
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
>
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
# 📸 PicPeak
**Open-source, self-hosted photo sharing for events.**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-theluap-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/theluap)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support ☕](https://buymeacoffee.com/theluap)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support the project ](https://buymeacoffee.com/theluap)
</div>
---
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
> [!IMPORTANT]
> **PicPeak has moved to its own GitHub organization.** Docker images are now at `ghcr.io/picpeak/picpeak/{backend,frontend,aio,ml}` (and on Docker Hub as `picpeak/{backend,frontend,aio,ml}`) and active development is on `main`. The old `ghcr.io/the-luap/...` path still responds but its tags are **frozen** at 2026-05-27 — if updates never arrive, check your image path first. See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit.
## Contents
- [Live Demo](#-live-demo)
- [Quick Start](#-quick-start)
- [Why PicPeak?](#-why-picpeak)
- [Features](#-features)
- [Documentation](#-documentation)
- [Comparison](#-comparison-with-alternatives)
- [Tech Stack](#-tech-stack)
- [Contributing & Support](#-contributing)
- [License](#-license)
## 🎮 Live Demo
Try PicPeak without installing anything — [demo.picpeak.app](https://demo.picpeak.app) · [admin panel](https://demo.picpeak.app/admin)
Try PicPeak without installing anything:
| Email | Password |
| | |
|---|---|
| `demo@picpeak.app` | `Demo2026!` |
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
| **Email** | `demo@picpeak.app` |
| **Password** | `Demo2026!` |
> The demo resets periodically. Uploaded content may be removed without notice.
## 🌟 Why Choose PicPeak?
Unlike expensive SaaS solutions, PicPeak gives you:
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
- **🔒 Complete Data Control** - Your photos stay on your server
- **🎨 White-Label Ready** - Full branding customization
- **📱 Mobile-First Design** - Beautiful on all devices
- **🚀 Lightning Fast** - Optimized performance and caching
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
## ✨ Key Features
### For Photographers
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
- 🔗 **External Media (Reference Mode)** - Browse and import from a readonly external folder library without copying originals
-**Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
- 🔐 **Password Protection** - Secure client galleries
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 📽️ **Live Slideshow** - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options ([guide](docs/live-slideshow.md))
- 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
### For Clients
- 🖼️ **Beautiful Galleries** - Clean, modern interface
- 📱 **Mobile Optimized** - Swipe through photos on any device
- ⬇️ **Bulk Downloads** - Download all photos with one click
- 🔍 **Smart Search** - Find photos quickly
- 📤 **Guest Uploads** - Optional client photo uploads
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
### Technical Excellence
- 🐳 **Docker Ready** - Deploy in minutes
- 🔄 **Auto-Processing** - Automatic thumbnail generation
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
- 💾 **Smart Storage** - Automatic archiving of expired galleries
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies
### For Studios — CRM & Accounting (Beta · off by default)
- 📝 **Quotes → Contracts → Invoices** - One deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only
- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below)
## 🚀 Quick Start
Get PicPeak running in under 5 minutes:
@@ -54,8 +96,8 @@ cd picpeak
# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser. Edit .env only to customise
# (domain, SMTP, storage paths, …) — nothing is required.
# admin account is created in the browser (see below). Edit .env only to
# customise (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env
# Start with Docker Compose
@@ -64,92 +106,288 @@ docker compose up -d
# Access at http://localhost:3000
```
On first start, open **http://localhost:3000/admin** and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in **[First-run setup](https://docs.picpeak.app/getting-started/first-login)**.
### First run — create your admin account
> **Updating / release channels:** set `PICPEAK_CHANNEL` (`stable` default, or `beta`) in `.env`, then `docker compose pull && docker compose up -d`. See [RELEASING.md](RELEASING.md) for the promotion cadence.
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
### Or: one container, no compose file
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
For a home server, a NAS, or a single small studio, the all-in-one image runs the whole app as one process with SQLite — no compose file, no separate database, no reverse proxy to wire up:
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
Note on Docker file permissions
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
**ARM64 (aarch64) systems:** Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](docker-compose.amd64.override.yml) for a transitional fallback.
## 🔄 Release Channels
PicPeak offers two release channels for different needs. Stable promotions are cut from a known-good beta point every 46 weeks — see [RELEASING.md](RELEASING.md) for the maintainer's promotion criteria and cadence policy.
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Switching Channels
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
docker run -d --name picpeak -p 3000:3000 \
-v picpeak:/data \
ghcr.io/picpeak/picpeak/aio:main
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
No environment variables to set — the JWT secret is generated on first start and kept on the volume.
Then update your containers:
Then open **http://localhost:3000/admin** and read the setup token with `docker exec picpeak cat /data/db/SETUP_TOKEN`, or open `db/SETUP_TOKEN` on the volume with any file manager if the host has no shell.
```bash
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
`:main` is the active-development tag, and today it is the only one the all-in-one image has — `Dockerfile.aio` landed after the current stable release, so `:stable` and `:latest` first appear for this image once the aio build reaches the `stable` branch. Switch to `:stable` then, or pin a published version tag if you would rather not track `main`.
### Update Notifications
The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a `.picpeak` backup and restore it into the full stack. See **[Single-container install](https://docs.picpeak.app/deployment/single-container)** for the volume layout, the external-Postgres variant, TLS, and the limits.
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
### Docker images
| | GHCR | Docker Hub |
|---|---|---|
| Backend | `ghcr.io/picpeak/picpeak/backend` | [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) |
| Frontend | `ghcr.io/picpeak/picpeak/frontend` | [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend) |
| All-in-one | `ghcr.io/picpeak/picpeak/aio` | [`picpeak/aio`](https://hub.docker.com/r/picpeak/aio) |
| ML sidecar (optional) | `ghcr.io/picpeak/picpeak/ml` | [`picpeak/ml`](https://hub.docker.com/r/picpeak/ml) |
Both registries get the same digests and the same tags — `stable`/`latest`, a pinned `x.y.z`, and `beta`/`main` for the active development channel — for `linux/amd64` and `linux/arm64`. Keep every image in one install on the **same** tag.
## 🌟 Why PicPeak?
Unlike expensive SaaS solutions, PicPeak gives you:
- **💰 No Monthly Fees** — one-time setup, unlimited galleries
- **🔒 Complete Data Control** — your photos stay on your server
- **🎨 White-Label Ready** — full branding customization
- **📱 Mobile-First Design** — beautiful on all devices
- **🌍 Multi-Language** — built-in i18n (EN, DE)
## ✨ Features
**For photographers** — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a [Live Slideshow](https://docs.picpeak.app/features/live-slideshow) projector view that auto-picks-up new uploads during live events.
**For clients** — clean mobile-optimized galleries, one-click bulk downloads, smart search, **[People in this gallery](https://docs.picpeak.app/features/face-recognition)** face grouping (opt-in per gallery, needs the optional [ML sidecar](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)), optional guest uploads, and download protection (watermarking + right-click prevention).
**Technical** — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible [storage backends](https://docs.picpeak.app/features/storage-backends), [webhooks](https://docs.picpeak.app/features/webhooks), and security-first defaults (JWT, rate limiting, CORS).
<details>
<summary><strong>🧾 For studios — CRM &amp; Accounting (Beta, off by default)</strong></summary>
- 📝 **Quotes → Contracts → Invoices** — one deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
- ⏱️ **Hours Logging & Calendar** — per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
- 🧾 **Inbound Supplier Invoices & Expenses** — capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
- 📊 **Tax Report & Accountant Export** — period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export
- 🌍 **VAT & Multi-currency** — single VAT-code registry snapshotted onto each document
</details>
> [!WARNING]
> **CRM & Accounting — examples only, verify locally.** Feature-flagged off by default. Seeded contract blocks are written by the maintainer, **not a lawyer**; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are **jurisdiction-specific guidance only**. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read **[the CRM disclaimers](https://docs.picpeak.app/features/crm/disclaimers)** first.
```bash
UPDATE_CHECK_ENABLED=false
```
## 📖 Documentation
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings, API, branding, and more.
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:
| Topic | Link |
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
- 📽️ [**Live Slideshow**](https://docs.picpeak.app/features/live-slideshow) - Fullscreen projector view that auto-updates during live events
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Backup configuration, restore wizard, full disaster recovery
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
Project meta:
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
- 📜 [**License**](LICENSE) - MIT License
- 🔒 [**Security**](SECURITY.md) - Security policies
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
## 🌐 Public Landing Page
Spotlight your studio with a customizable marketing page at `/`:
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
- Use **Reset to default** anytime to restore the bundled template.
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
## 🎯 Use Cases
Perfect for:
- 💒 **Wedding Photographers** - Share ceremony photos securely
- 🎂 **Event Photography** - Birthday parties, corporate events
- 📸 **Portrait Studios** - Client galleries with download limits
- 🏢 **Corporate Events** - Internal photo sharing with branding
- 🎓 **School Photography** - Secure parent access with expiration
- 📽️ **Live Events** - Put a [Live Slideshow](docs/live-slideshow.md) on the venue projector that updates as you shoot
## 🏗️ Tech Stack
- **Backend**: Node.js, Express, SQLite/PostgreSQL
- **Frontend**: React, Tailwind CSS, Framer Motion
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](#storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
## 💾 Storage Backends
PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
| Capability | `STORAGE_BACKEND=local` (default) | `STORAGE_BACKEND=s3` |
|---|---|---|
| Photo / thumbnail / hero storage | Local filesystem under `STORAGE_PATH` | Bucket on any S3-compatible service |
| Admin UI upload | ✅ | ✅ |
| Filesystem auto-import (chokidar watcher) | ✅ | ❌ — disabled (use the upload API) |
| Watermarks, fingerprinting, fragmentation | ✅ | ✅ (materialized to a tmp file just-in-time) |
| Bulk download zips (cached + on-the-fly) | ✅ | ✅ |
| Backups | ✅ | ✅ |
| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
### Switching to an S3-compatible backend
1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
## 🔔 Webhooks
PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
### Event types
| Event | Fires when |
|---|---|
| 🚀 Deployment (Docker, env, reverse proxy, SSL) | [docs.picpeak.app/deployment](https://docs.picpeak.app/deployment) |
| 📦 Single-container install (one `docker run`, SQLite) | [docs.picpeak.app/deployment/single-container](https://docs.picpeak.app/deployment/single-container) |
| ⚙️ Admin settings reference | [docs.picpeak.app/guides/admin-settings](https://docs.picpeak.app/guides/admin-settings) |
| 🎯 Creating events | [docs.picpeak.app/guides/creating-events](https://docs.picpeak.app/guides/creating-events) |
| 📽️ Live Slideshow | [docs.picpeak.app/features/live-slideshow](https://docs.picpeak.app/features/live-slideshow) |
| 🙂 People in galleries (face grouping) | [docs.picpeak.app/features/face-recognition](https://docs.picpeak.app/features/face-recognition) |
| 💾 Backup & Restore | [docs.picpeak.app/guides/backup-restore](https://docs.picpeak.app/guides/backup-restore) |
| 🔌 API reference | [docs.picpeak.app/api](https://docs.picpeak.app/api) |
| 🪝 Webhooks | [docs.picpeak.app/features/webhooks](https://docs.picpeak.app/features/webhooks) |
| 💾 Storage backends (local / S3) | [docs.picpeak.app/features/storage-backends](https://docs.picpeak.app/features/storage-backends) |
| 💻 System requirements & tuning | [docs.picpeak.app/deployment/system-requirements](https://docs.picpeak.app/deployment/system-requirements) |
| 🧾 CRM & Accounting | [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm) · [disclaimers](https://docs.picpeak.app/features/crm/disclaimers) |
| 🗺️ Roadmap | [GitHub Issues](https://github.com/PicPeak/picpeak/issues) |
| `event.created` | Gallery created (admin or API) |
| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
**Project meta:** [Support](SUPPORT.md) · [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
### Payload shape
```json
{
"id": "delivery-uuid",
"type": "event.published",
"created_at": "2026-04-28T05:25:00.000Z",
"data": {
"event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
}
}
```
Also sent on every request:
- `X-PicPeak-Signature` — `HMAC-SHA256(secret, raw_body)` as hex
- `X-PicPeak-Event` — the event type (handy for routing without parsing the body)
- `X-PicPeak-Delivery` — UUID for idempotency on the receiver side
- `User-Agent: PicPeak-Webhooks/1.0`
### Verifying signatures
**Node.js**
```js
const crypto = require('crypto');
function verify(secret, rawBody, signature) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
```
**Python**
```python
import hmac, hashlib
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
```
**curl + openssl** (one-liner for a quick replay)
```sh
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH
```
### Retries + observability
- `2xx` → success, recorded with latency
- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
### SSRF protection
Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
## 💻 System Requirements
### Minimum Requirements
- **CPU**: 2 CPU cores
- **RAM**: **4 GB minimum** for a normal photo-upload workload — sharp/libvips
decodes the full uncompressed frame before resize, and the default two
worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a
batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the
backend mid-batch (surfaces as 503s on thumbnails — see [Low-memory
hosts](#low-memory-hosts) below for the recipe to run on 2 GB).
- **Storage**: 20GB minimum (plus photo storage needs)
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
- **Node.js**: v18.0.0 or higher
- **Database**: SQLite (included) or PostgreSQL 12+
### Docker Requirements (Recommended)
- **Docker**: v20.10.0+
- **Docker Compose**: v2.0.0+
### Low-memory hosts
Running on 2 GB RAM (e.g. an entry-level VPS) is workable but requires
tuning the upload-processor concurrency down. The backend auto-detects
total RAM at startup via `os.totalmem()` — on a host that reports < 3 GB,
it defaults `UPLOAD_PROCESSOR_CONCURRENCY` to **1** instead of 2 and logs
a one-shot warning. You can pin the value explicitly in `.env`:
```env
# Single worker loop — slower batch processing, lower peak RSS
UPLOAD_PROCESSOR_CONCURRENCY=1
```
The trade-off is throughput: a single worker processes one photo at a
time, so a 100-photo batch takes ~2× as long but won't OOM. **Health-check
note**: if the backend dies under memory pressure, the gallery serves
`503 Service Unavailable` on thumbnails until Docker's
`restart: unless-stopped` brings the container back. Persistent 503s
during/after an upload batch on a low-memory host are almost always this.
### Video Support Requirements
When enabling video uploads, consider these additional resources:
| Resource | Recommendation | Notes |
|----------|----------------|-------|
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
**Technical Notes:**
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
- Maximum upload size: **10GB per video file**
- Chunked upload support for files >100MB (resumable uploads)
- Supported formats: MP4, WebM, MOV, AVI
- Video thumbnails are automatically generated from the first few seconds
**For Nginx/Reverse Proxy:**
If using Nginx, increase the client max body size:
```nginx
client_max_body_size 10G;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
```
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
See our [Contributing Guide](CONTRIBUTING.md) for details.
## 📊 Comparison with Alternatives
@@ -166,79 +404,168 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
| Quotes / Contracts / Invoices | 🧪 Beta | ❌ | ❌ | ✅ |
| Incoming Invoices & Accounting | 🧪 Beta | ❌ | ❌ | ❌ |
<sub>*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.</sub>
*You still bring your own server (own hardware or a VPS) and, if you want one, a domain.
**Limited only by your server storage.
***Pixieset's "unlimited" is photos only; video is capped by plan (roughly 010 h depending on tier).
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
## 🏗 Tech Stack
## 🛡 Security
- **Backend**: Node.js, Express, SQLite/PostgreSQL
- **Frontend**: React, Tailwind CSS, Framer Motion
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](https://docs.picpeak.app/features/storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
- **External media**: point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals read-only, index quickly, and generate thumbnails on demand
PicPeak takes security seriously:
- 🔐 Password hashing with bcrypt
- 🎫 JWT-based authentication
- 🚦 Rate limiting on all endpoints
- 🛡️ CORS protection
- 📝 Activity logging
- 🔒 Secure file access
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
## 📸 Screenshots
<details>
<summary>Click to see the admin dashboard, analytics, and event management</summary>
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
### 🎛️ Admin Dashboard
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
### 📊 Analytics & Insights
### 📊 **Analytics & Insights**
Track gallery performance, view statistics, and monitor user engagement.
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
### 📁 Event Management
### 📁 **Event Management**
Organize and manage your photo galleries with intuitive event management tools.
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
### ✨ **Key Interface Highlights**
<details>
<summary>👆 Click to see more interface details</summary>
#### What makes PicPeak's interface special:
- **🎨 Clean Design**: Modern, photographer-friendly interface
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details>
## 🤝 Contributing
## 🗺️ Roadmap
We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the [Contributing Guide](CONTRIBUTING.md) to get started.
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security). See [SECURITY.md](SECURITY.md) for the policy.
### 🚧 Beta Features (Use at your own risk)
These features are currently in beta testing and may have limited functionality or stability:
| Feature | Description | Status |
|---------|-------------|--------|
| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements
| Feature | Description | Priority | Status |
|---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **External Media Library (Reference Mode)** | Use an external folder library as a readonly source with import and ondemand thumbnail generation | High | ✅ Implemented |
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
## ☕ Support the Project
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider [buying me a coffee](https://buymeacoffee.com/theluap) — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also ⭐ star the repo, share it, file good bug reports, or open a PR.
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
<p align="left">
<a href="https://buymeacoffee.com/theluap" target="_blank">
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
</a>
</p>
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
## 🙏 Acknowledgments
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
### 👥 Contributors
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
**[@the-luap](https://github.com/the-luap)** — creator and lead maintainer
- Gallery foundation (events, uploads, sharing, download protection, templates)
- Backup & restore, analytics, branding/theming
- The architecture every later feature builds on
**[@Luca-Timo](https://github.com/Luca-Timo)**
- Native Apple Silicon multi-arch images
- CRM & accounting suite (quotes/contracts/invoices)
- Hours logging & Treuhänder/Banana tax export
- Gallery header/banner decoupling
**[@Rekoo-PS](https://github.com/Rekoo-PS)** — bug reports & product feedback
- Login-loop fix, mobile-lightbox overhaul, bulk-delete workflow
- Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
### 🤖 AI-Assisted Development
This project was generated with the assistance of AI technology, but has been:
- ✅ **Fully tested end-to-end** by human developers
- 🔒 **Security audited** with comprehensive security checks
- 👨‍💻 **Human-reviewed** for code quality and best practices
- 🧪 **Production-tested** in real-world scenarios
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
## ⚠️ CRM & Accounting disclaimers — examples only, verify locally
The CRM & accounting modules (contracts, invoices, QR-bills, the tax
report and the accountant exports) ship seeded content and computed
figures that are intended as a **starting point only**:
- **Contract blocks** (image rights, NDA, model release, cancellation,
jurisdiction, …) are written by the maintainer, **not by a lawyer**.
Every operator must have their lawyer review and adapt them before
sending any contract to a customer.
- **QR-bills and SEPA EPC payloads** are rendered from the data you
typed. Picpeak is open source — please scan a test invoice with your
bank's app to check the QR actually works. We are not responsible for
any mistakes that come from sending an invoice with bad data on it.
- **Tax, VAT & accounting figures** (the tax report, VAT-payable, the
per-rate breakdown, the Treuhänder / Banana export, etc.) are computed
from the data you enter and the defaults you configure. They are
**guidance only and jurisdiction-specific** — tax rules, VAT rates,
deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat
rate) and filing duties differ by country and change over time. **Every
operator must check their own tax / VAT regulations and verify the
numbers with their accountant / Treuhänder / tax authority before
relying on any figure or export.** Picpeak makes no warranty that the
output is correct for your jurisdiction or situation.
Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before
enabling the Contracts, Invoices or Accounting features.
## 📄 License
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
## 🚀 Ready to Get Started?
1.**Star this repository** to show your support
2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app)
3. 🐛 Report issues or request features
4. 🤝 Join our community and contribute!
---
<p align="center">
Made with ❤️ by photographers, for photographers
<br>
<a href="https://www.picpeak.app">Homepage</a> ·
<a href="https://demo.picpeak.app">Live Demo</a> ·
<a href="https://docs.picpeak.app">Documentation</a> ·
<a href="https://www.picpeak.app">Homepage</a>
<a href="https://demo.picpeak.app">Live Demo</a>
<a href="https://github.com/PicPeak/picpeak">GitHub</a>
<a href="https://docs.picpeak.app">Documentation</a> •
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
</p>
+5 -24
View File
@@ -52,34 +52,23 @@ The actual mechanics, in order:
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
5. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
```bash
git commit --allow-empty -m "chore: release X.Y.Z" -m "Release-As: X.Y.Z"
```
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
6. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
7. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
8. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
9. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
## Hotfix path (backport to current stable)
Regular bug fixes are generally backported automatically from `main` to `stable`. Keep backports focused on the fix, without unrelated features, and resolve conflicts manually when needed.
**Security fixes are always released on both `stable` and `main`.** Do not wait for a full promotion to deliver a security update. A fix first applied to `stable` must also be forward-ported to `main`; a fix first applied to `main` must also reach `stable`. See [SECURITY.md](SECURITY.md) for the support policy.
When a backport needs manual handling:
If a critical bug or security issue affects the current stable and `main` has moved too far for a full promotion to be appropriate, backport just the fix:
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
2. Cherry-pick or hand-write the minimal fix.
3. Open a PR to `stable` with the smallest possible diff.
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
6. For security fixes, verify that the fix has been published through **both** release channels; merging the code is only part of delivery.
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
@@ -94,14 +83,6 @@ PicPeak follows [Semantic Versioning](https://semver.org/) with one project-spec
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
### Stable ↔ pre-release number alignment
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
## Things that don't go through this process
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
+68 -61
View File
@@ -1,81 +1,88 @@
# Security Policy
## Scope
This policy covers the PicPeak backend, frontend, all-in-one (AIO) image, optional
ML component, and the Docker images published by the PicPeak project. Other
PicPeak repositories define their own supported versions and release channels.
## Supported Versions
Security support follows the current release channels:
We release patches for security vulnerabilities. Currently supported versions:
| Version or channel | Security support |
| --- | --- |
| Latest stable release from `stable` | Supported; security fixes are published through this channel |
| Latest beta release from `main` | Supported; security fixes are published through this channel |
| Superseded stable or beta releases | Upgrade to the latest release in the same channel; older releases are not maintained separately |
| 2.x and earlier | No longer supported |
See the [latest stable release](https://github.com/PicPeak/picpeak/releases/latest)
and [all releases, including betas](https://github.com/PicPeak/picpeak/releases).
Version numbers differ between channels; each channel receives its own updates.
### Security fixes and bug backports
**Security fixes are always released on both `stable` and `main`.** A fix that
lands on one branch must also reach the other branch and be published through
both release channels. Security updates do not wait for the next full
`main`-to-`stable` promotion.
Regular bug fixes are also generally backported automatically to `stable`.
Backports remain focused on the fix, without pulling in unrelated features.
Maintainers resolve conflicts or handle a backport manually when necessary.
The [release process](RELEASING.md) describes backports, forward-ports and
publication. Operators must apply the published updates to their installations.
| Version | Supported |
| ------- | ------------------ |
| 2.x.x | :white_check_mark: |
| < 2.0 | :x: |
## Reporting a Vulnerability
**Do not report vulnerabilities in public issues, discussions or pull requests.**
We take the security of PicPeak seriously. If you have discovered a security vulnerability, please follow these steps:
Report privately through:
### 1. **Do NOT create a public GitHub issue**
- [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) (preferred).
- Email **info@picpeak.app** if you cannot use GitHub's private reporting form.
### 2. Report the vulnerability privately by:
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- **Alternative:** Email us at **info@picpeak.app** with the details
- Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
Include the affected component, version or image tag, deployment method,
reproduction steps, expected impact and any suggested fix. Share only the
information needed to reproduce the problem; remove credentials and personal
data from logs or examples.
### 3. You can expect:
- Acknowledgment within 48 hours
- Regular updates on our progress
- Credit in the fix announcement (unless you prefer to remain anonymous)
We aim to acknowledge reports within 48 hours. This is a response target, not a
guaranteed service level or a promised resolution time. We will provide progress
updates and coordinate disclosure with the reporter. Reporter credit is optional;
tell us if you prefer to remain anonymous.
## Security Measures
## Deployment Security
PicPeak implements several security measures:
Security depends on both the software and its configuration. Operators should:
### Authentication & Authorization
- JWT-based authentication with secure token storage
- bcrypt password hashing with configurable rounds
- Role-based access control for admin functions
- Session timeout management
- Use HTTPS and configure the reverse proxy and trusted proxy settings correctly.
- Use strong credentials and keep deployment secrets private.
- Apply updates for the chosen release channel and restrict unnecessary network access.
- Keep backups and verify that they can be restored.
### Input Validation
- All user inputs are validated and sanitized
- SQL injection prevention through parameterized queries
- XSS protection via Content Security Policy
- File upload restrictions and validation
See the deployment guides for [HTTPS](https://docs.picpeak.app/deployment/ssl-certificates),
[reverse proxies](https://docs.picpeak.app/deployment/reverse-proxy),
[security settings](https://docs.picpeak.app/guides/admin-settings/security)
and [backup and restore](https://docs.picpeak.app/guides/backup-restore).
### Rate Limiting
- API rate limiting to prevent abuse
- Brute force protection on authentication endpoints
- Configurable limits per endpoint
### Data Protection
- HTTPS enforcement in production
- Secure cookie settings
- CORS configuration
- Sensitive data encryption
### Infrastructure
- Regular dependency updates
- Security headers (HSTS, X-Frame-Options, etc.)
- Activity logging for audit trails
- Automated backups
## Best Practices for Deployment
1. **Always use HTTPS** in production
2. **Change default passwords** immediately
3. **Keep dependencies updated** regularly
4. **Configure firewall rules** appropriately
5. **Monitor logs** for suspicious activity
6. **Backup regularly** and test restoration
## Vulnerability Disclosure
We coordinate disclosure with the reporter while preparing fixes. Security fixes
are published through both supported channels. Advisories and release notes
identify affected versions, the fixed version in each channel, the impact and
any required mitigation or upgrade steps. Reporter credit is included with
permission.
We believe in responsible disclosure. Once a vulnerability is fixed:
For ordinary bugs and support requests, use
[GitHub Issues](https://github.com/PicPeak/picpeak/issues) or
[GitHub Discussions](https://github.com/PicPeak/picpeak/discussions).
1. We'll publish a security advisory
2. Credit researchers (with permission)
3. Detail the impact and mitigation steps
4. Release patches for all supported versions
## Contact
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
Thank you for helping keep PicPeak and its users safe!
+2 -4
View File
@@ -170,12 +170,10 @@ If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your ad
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is not logged — that would leave a live credential in `docker logs`):
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
```bash
docker compose exec backend cat /app/data/SETUP_TOKEN
docker compose logs backend | grep -i "setup token"
```
Only if that write fails does the backend log the token instead.
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods
-33
View File
@@ -1,33 +0,0 @@
# Getting help with PicPeak
## Documentation
Start with [docs.picpeak.app](https://docs.picpeak.app) for installation,
configuration, gallery features and administration guides.
- [Getting started](https://docs.picpeak.app/getting-started)
- [Deployment](https://docs.picpeak.app/deployment)
- [Admin settings](https://docs.picpeak.app/guides/admin-settings)
- [Release channels](https://docs.picpeak.app/deployment/release-channels)
## Questions and troubleshooting
Use [GitHub Discussions](https://github.com/PicPeak/picpeak/discussions) for
setup questions, troubleshooting and advice from the community. Include your
PicPeak version, deployment method and what you have already tried.
## Bugs and feature requests
Search [existing issues](https://github.com/PicPeak/picpeak/issues) first, then
[choose an issue template](https://github.com/PicPeak/picpeak/issues/new/choose)
to report a bug, suggest a feature or identify a documentation problem.
For bugs, include the exact version, reproduction steps and relevant logs.
See [Contributing](CONTRIBUTING.md) for development and pull request guidance.
## Security vulnerabilities
Follow the [security policy](SECURITY.md) and use
[private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
or email **info@picpeak.app**. Do not report vulnerabilities in public issues
or discussions.
+3 -1
View File
@@ -1,7 +1,9 @@
node_modules
npm-debug.log
.env
storage
storage/events/active/*
storage/events/archived/*
storage/thumbnails/*
data/*.db
logs/*
coverage
+1 -24
View File
@@ -106,23 +106,6 @@ ARCHIVE_PATH=/app/storage/events/archived
# EVENTS_PATH=./storage/events
# ARCHIVE_PATH=./storage/events/archived
# File watcher (auto-import from the events/active folder, local storage only)
# Max photos processed in parallel by the watcher. The boot scan and bulk
# folder drops fire one handler per file — this bound keeps thumbnail
# generation from exhausting memory on small hosts. Default: 2
# FILE_WATCHER_CONCURRENCY=2
# External-media folder watcher (issue 1187). Reference-mode events can opt in
# per event (Event → Source Mode → "Watch folder for new files"); new images in
# the folder are then imported without pressing Import. Deleted files are
# never removed from the gallery.
# EXTERNAL_MEDIA_WATCH=true # global kill switch
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
# Analytics Backend Configuration (OPTIONAL)
# Used for server-side tracking only
# Primary configuration should be done through Admin UI > Settings > Analytics
@@ -130,10 +113,4 @@ ARCHIVE_PATH=/app/storage/events/archived
# UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
# Logging
LOG_LEVEL=info
# Optional product usage (#1110): disabled until explicit in-app consent.
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
# Encryption material for the backend-only signing key (32+ characters).
# Defaults to JWT_SECRET; keep it stable until participation has been deleted.
# USAGE_ENCRYPTION_KEY=
LOG_LEVEL=info
+1 -9
View File
@@ -17,15 +17,7 @@ module.exports = {
'linebreak-style': ['error', 'unix'],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
// varsIgnorePattern + ignoreRestSiblings cover the "omit fields via rest
// spread" idiom (e.g. adminEvents/helpers.js pulling password hashes out
// of ...rest), which is intentional and would otherwise need a disable
// comment at every occurrence.
'no-unused-vars': ['error', {
'argsIgnorePattern': '^_',
'varsIgnorePattern': '^_',
'ignoreRestSiblings': true
}],
'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
'no-console': ['warn', { allow: ['warn', 'error'] }]
}
};
+2 -14
View File
@@ -27,15 +27,6 @@ FROM node:22-alpine
WORKDIR /app
# knexfile.js picks its config block by NODE_ENV, and the `development` block
# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that
# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while
# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in
# the same log. The compose files still override this, so nothing changes for
# compose users. See #1038.
ENV NODE_ENV=production
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
# stage's declaration never reached this stage. Consuming it in the RUN below
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
@@ -77,12 +68,9 @@ RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
# documents (see https://docs.picpeak.app/features/accounting/incoming-invoices).
# exiftool extracts the embedded full-res JPEG preview from RAW/DNG uploads
# (Apple ProRAW etc.) — sharp's libvips has no raw loader, so the pipeline
# thumbnails/displays that preview while keeping the original for download.
# documents (see docs/accounting-inbound-invoices.md).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
fc-cache -f
# Create non-root user
+1 -4
View File
@@ -8,10 +8,7 @@ RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
# exiftool: extract embedded JPEG previews from RAW/DNG uploads (#821) — kept in
# sync with the production Dockerfile so dev/native runtimes don't accept a DNG
# and then fail it with ENOENT.
RUN apk add --no-cache dumb-init ffmpeg exiftool
RUN apk add --no-cache dumb-init ffmpeg
# Copy package files
COPY package*.json ./
@@ -110,11 +110,6 @@ describe('Admin settings logo upload flow', () => {
}
}));
jest.doMock('../src/middleware/permissions', () => ({
requirePermission: () => (req, res, next) => next(),
userHasAnyPermission: jest.fn().mockResolvedValue(true)
}));
jest.doMock('../src/services/publicSiteService', () => ({
clearPublicSiteCache: jest.fn(),
getDefaultPublicSitePayload: jest.fn(),
@@ -1,477 +0,0 @@
/**
* Restoring an archive must put the photos back into their categories.
*
* The archive writer already persists `category_name` per photo in
* `photos_manifest.json` — that is why the manifest exists, and the comment
* above it says so: "(and category linkage) can't be derived from the
* extracted files alone". The restore route then read only
* `original_filename` from it and kept deriving the category from the ZIP's
* first path segment.
*
* Archives store photos exactly as they sit on disk, so an event whose photos
* live in the gallery root produces a FLAT zip. `path.dirname()` is '.' for
* every entry, no category is resolved, and every restored photo lands with
* `category_id = null` — silently, with a 200 response.
*
* These pin the manifest as the source of truth, with the directory as the
* fallback that keeps foldered and legacy archives working.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('archive restore restores categories (flat archives included)', () => {
let tmpDir; let db; let cleanup; let app; let storagePath;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-restore-cat-'));
storagePath = path.join(tmpDir, 'storage');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = storagePath;
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
// bootCrmDb points STORAGE_PATH at its own tmp dir; follow it rather than
// fighting it, so the archives the tests write are where the route looks.
storagePath = process.env.STORAGE_PATH;
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
app = express();
app.use(express.json());
app.use('/admin/archives', require('../../src/routes/adminArchives'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await db('photos').del();
await db('photo_categories').del();
await db('events').del();
});
/** A one-pixel JPEG is enough; the route only stats the extracted file. */
const PIXEL = Buffer.from(
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a'
+ 'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA'
+ 'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
'base64',
);
async function writeArchive(name, entries) {
// Required lazily: the suite calls jest.resetModules() in beforeAll, and
// archiver's readable-stream copy does not survive being split across the
// two module registries.
const archiver = require('archiver');
const archivePath = path.join(storagePath, 'archives', name);
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(archivePath);
const zip = archiver('zip', { zlib: { level: 0 } });
output.on('close', resolve);
zip.on('error', reject);
zip.pipe(output);
for (const [entryName, buffer] of Object.entries(entries)) {
zip.append(buffer, { name: entryName });
}
zip.finalize();
});
return path.join('archives', name);
}
async function seedArchivedEvent(archiveRelPath, slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-06-27',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
is_archived: 1, // sqlite stores booleans as 0/1, see utils/dbCompat
archive_path: archiveRelPath,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
const categoryOf = async (filename) => {
const photo = await db('photos').where('filename', filename).first();
if (!photo || !photo.category_id) return null;
const category = await db('photo_categories').where('id', photo.category_id).first();
return category ? category.name : null;
};
it('takes the category from the manifest when the archive is flat', async () => {
// Exactly the shape a gallery-root event archives to: no directories.
const manifest = JSON.stringify([
{ filename: 'a.jpg', original_filename: 'DSC_0001.jpg', category_name: 'Polterabend' },
{ filename: 'b.jpg', original_filename: 'DSC_0002.jpg', category_name: 'Ceremony' },
]);
const archiveRelPath = await writeArchive('flat.zip', {
'a.jpg': PIXEL,
'b.jpg': PIXEL,
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'flat-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// The whole bug: both of these used to be null.
expect(await categoryOf('a.jpg')).toBe('Polterabend');
expect(await categoryOf('b.jpg')).toBe('Ceremony');
});
it('reuses an existing category row instead of creating a duplicate', async () => {
const archiveRelPath = await writeArchive('reuse.zip', {
'c.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'c.jpg', original_filename: 'DSC_0003.jpg', category_name: 'Party' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'reuse-event');
await db('photo_categories').insert({
event_id: eventId, name: 'Party', slug: 'party', created_at: new Date(),
});
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('c.jpg')).toBe('Party');
const rows = await db('photo_categories').where({ event_id: eventId, name: 'Party' });
expect(rows).toHaveLength(1);
});
it('still falls back to the directory for legacy archives with no manifest', async () => {
// No manifest at all — the shape every archive had before the manifest
// landed. The directory is the only signal left, and it must keep working.
//
// `individual/` is what a REAL archive contains: entry names are the
// storage key minus `events/active/{slug}`, and that layout is
// `individual/` / `collages/`. Categories have never been directories, so
// the fallback invents a category with that name — not useful, but better
// than losing every category, and this pins what actually happens rather
// than a category-shaped folder no archive produces.
const archiveRelPath = await writeArchive('foldered.zip', {
'individual/d.jpg': PIXEL,
});
const eventId = await seedArchivedEvent(archiveRelPath, 'foldered-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('d.jpg')).toBe('individual');
});
it('reuses a GLOBAL category instead of cloning it into the event', async () => {
// Seeded categories (Ceremony, Reception, ...) have event_id NULL. An
// event-only lookup misses them, so the restore used to create a second
// "Ceremony" — and because is_global defaults to TRUE, that duplicate then
// appeared in every other event's category list.
const [g] = await db('photo_categories').insert({
event_id: null, name: 'Ceremony', slug: 'ceremony', is_global: true, created_at: new Date(),
}).returning('id');
const globalId = typeof g === 'object' ? g.id : g;
const archiveRelPath = await writeArchive('global.zip', {
'individual/gl.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'gl.jpg', original_filename: 'DSC_1.jpg', category_name: 'Ceremony' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'global-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const photo = await db('photos').where('filename', 'gl.jpg').first();
expect(photo.category_id).toBe(globalId);
// No clone, global or otherwise.
const all = await db('photo_categories').where('name', 'Ceremony');
expect(all).toHaveLength(1);
});
it('does not create a GLOBAL category when it has to invent one', async () => {
// is_global defaults to true on this column, so an unqualified insert would
// leak a restore's category name into every gallery on the instance.
const archiveRelPath = await writeArchive('newcat.zip', {
'individual/nc.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'nc.jpg', original_filename: 'DSC_2.jpg', category_name: 'Polterabend' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'newcat-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const created = await db('photo_categories').where('name', 'Polterabend').first();
expect(created.event_id).toBe(eventId);
expect(created.is_global === false || created.is_global === 0).toBe(true);
});
it('matches the manifest when the ZIP was written with original filenames', async () => {
// With general_use_original_filenames_for_downloads on at archive time,
// archiveService names entries after the ORIGINAL filename while the
// manifest stays keyed by photos.filename. Looking up the extracted
// basename missed every entry, so categories were lost on exactly those
// archives.
const archiveRelPath = await writeArchive('original-names.zip', {
'individual/DSC_4242.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'stored_9f8e7d.jpg', original_filename: 'DSC_4242.jpg', category_name: 'Drohne' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'original-names-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('DSC_4242.jpg')).toBe('Drohne');
});
it('prefers the event-scoped category when a global shares its name', async () => {
// The category API permits both. A single OR-lookup with .first() returned
// whichever the engine chose, so a photo could be reassigned to the global
// row and lose event-local settings such as allow_downloads.
const archiveRelPath = await writeArchive('collide.zip', {
'individual/co.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'co.jpg', original_filename: 'DSC_3.jpg', category_name: 'Reception' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'collide-event');
await db('photo_categories').insert({
event_id: null, name: 'Reception', slug: 'reception-global', is_global: true, created_at: new Date(),
});
const [own] = await db('photo_categories').insert({
event_id: eventId, name: 'Reception', slug: 'reception-own', is_global: false, created_at: new Date(),
}).returning('id');
const ownId = typeof own === 'object' ? own.id : own;
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const photo = await db('photos').where('filename', 'co.jpg').first();
expect(photo.category_id).toBe(ownId);
});
it('matches a sanitized original filename, as the ZIP would have written it', async () => {
// archiveService runs original names through sanitizeForZipEntry() before
// writing the entry, so the emitted name differs from the manifest column.
const archiveRelPath = await writeArchive('sanitized.zip', {
'individual/od_dr_DSC_5.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'stored_abc.jpg', original_filename: 'od/dr/DSC_5.jpg', category_name: 'Strand' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'sanitized-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('od_dr_DSC_5.jpg')).toBe('Strand');
});
it('ignores a legacy event-owned row when falling back to globals', async () => {
// The bug fixed here left rows behind on upgraded instances: event-owned
// AND is_global true, because the column defaults true. Matching on the
// flag alone would let one event's leftover be adopted by another event's
// restore, tying photos to a category that vanishes with someone else's
// gallery.
const otherEventId = await seedArchivedEvent('archives/none.zip', 'legacy-owner-event');
await db('photo_categories').insert({
event_id: otherEventId, name: 'Sunset', slug: 'sunset-legacy',
is_global: true, created_at: new Date(),
});
const archiveRelPath = await writeArchive('legacy-global.zip', {
'individual/lg.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'lg.jpg', original_filename: 'DSC_6.jpg', category_name: 'Sunset' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'legacy-global-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const photo = await db('photos').where('filename', 'lg.jpg').first();
const cat = await db('photo_categories').where('id', photo.category_id).first();
// Its own row, not the other event's leftover.
expect(cat.event_id).toBe(eventId);
});
it('drops an ambiguous original-name alias rather than guessing', async () => {
// Two photos in different ZIP folders can share an original basename;
// archiveService treats the paths as distinct and suffixes neither. Both
// would collapse onto one alias, and whichever won would hand the other
// photo someone else's category.
const archiveRelPath = await writeArchive('ambiguous.zip', {
'individual/SHARED.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'a_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Alpha' },
{ filename: 'b_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Beta' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'ambiguous-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// Falls back to the directory rather than picking Alpha or Beta at random.
expect(await categoryOf('SHARED.jpg')).toBe('individual');
for (const name of ['Alpha', 'Beta']) {
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
}
});
it('honours a manifest that says UNCATEGORIZED, instead of inventing one from the directory', async () => {
// The case the manifest-first change was for. A real archive puts every
// photo under `individual/`, so a photo the manifest records as having no
// category used to come back filed under a category called "individual" —
// the manifest being authoritative for "category X" but not for "none".
const manifest = JSON.stringify([
{ filename: 'u.jpg', original_filename: 'DSC_7000.jpg', category_name: null },
]);
const archiveRelPath = await writeArchive('uncategorized.zip', {
'individual/u.jpg': PIXEL,
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'uncategorized-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('u.jpg')).toBeNull();
// And no junk category row was created as a side effect.
const rows = await db('photo_categories').where({ event_id: eventId });
expect(rows).toHaveLength(0);
});
it('drops a canonical filename that two photos claim, rather than guessing', async () => {
// photos.filename is not unique within an event: s3AutoImporter takes
// path.basename(entry.key) and dedupes by path, so two imported files in
// different subfolders both land as IMG_1234.jpg. Both ZIP entries reduce
// to the same basename at restore, so keeping the last row seen would give
// one photo the other's category.
const archiveRelPath = await writeArchive('dup-canonical.zip', {
'individual/IMG_1234.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'IMG_1234.jpg', original_filename: 'a.jpg', category_name: 'Alpha' },
{ filename: 'IMG_1234.jpg', original_filename: 'b.jpg', category_name: 'Beta' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'dup-canonical-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('IMG_1234.jpg')).toBe('individual');
for (const name of ['Alpha', 'Beta']) {
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
}
});
it("drops a name that one row owns canonically and another claims as an alias", async () => {
// Undecidable: with original-filename archiving ON the ZIP entry under
// this name is the ALIAS owner's file, with it OFF it is the canonical
// owner's, and the manifest does not record which mode was used. The
// point of the two-pass split is that this now resolves the same way
// every run — the archive query has no ORDER BY, so it used to be a coin
// flip between dropping the name and overwriting it.
const archiveRelPath = await writeArchive('alias-vs-canonical.zip', {
'individual/CANON.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'CANON.jpg', original_filename: 'unrelated.jpg', category_name: 'Canonical' },
{ filename: 'other_stored.jpg', original_filename: 'CANON.jpg', category_name: 'Aliased' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'alias-vs-canonical-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// Falls back to the directory rather than guessing either row.
expect(await categoryOf('CANON.jpg')).toBe('individual');
for (const name of ['Canonical', 'Aliased']) {
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
}
});
it('picks the lowest id and warns when two categories share a name', async () => {
// Allowed: two event-scoped categories with the same display name and
// different slugs. .first() used to pick either, so a re-run could move
// photos between them and inherit the wrong allow_downloads.
const archiveRelPath = await writeArchive('dupe-category.zip', {
'individual/DUPE.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'DUPE.jpg', original_filename: 'DUPE.jpg', category_name: 'Ceremony' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'dupe-category-event');
const [first] = await db('photo_categories').insert({
name: 'Ceremony', slug: 'ceremony-a', is_global: 0, event_id: eventId,
}).returning('id');
await db('photo_categories').insert({
name: 'Ceremony', slug: 'ceremony-b', is_global: 0, event_id: eventId,
});
const firstId = typeof first === 'object' ? first.id : first;
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// Stable, not arbitrary: the same run twice lands on the same row.
const photo = await db('photos').where({ event_id: eventId, filename: 'DUPE.jpg' }).first();
expect(photo.category_id).toBe(firstId);
// And no third "Ceremony" was invented.
expect((await db('photo_categories').where({ event_id: eventId, name: 'Ceremony' })).length)
.toBe(2);
});
it('does not invent a category for a photo row that already exists', async () => {
// archiveEvent retains photo rows, so a restore can skip every insert.
// Resolving categories before that check created one from the stale
// manifest name that nothing then used — renaming a category while its
// event was archived left the old name behind as an empty duplicate.
const archiveRelPath = await writeArchive('existing-rows.zip', {
'individual/KEPT.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'KEPT.jpg', original_filename: 'KEPT.jpg', category_name: 'OldName' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'existing-rows-event');
await db('photos').insert({
event_id: eventId, filename: 'KEPT.jpg', path: 'whatever/KEPT.jpg', type: 'jpg',
uploaded_at: new Date().toISOString(),
});
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await db('photo_categories').where({ event_id: eventId, name: 'OldName' }).first())
.toBeFalsy();
});
});
@@ -40,7 +40,7 @@ jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('GET /api/admin/system-health/backup-coverage', () => {
let db;
@@ -29,7 +29,7 @@ jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('GET /api/admin/system-health/backup-integrity', () => {
let cleanup;
@@ -40,15 +40,6 @@ describe('Admin photos in reference mode', () => {
}
}));
// The routes gained requirePermission() after this fixture was written.
// It resolves the caller's role through admin_users/roles, which this
// minimal schema does not create, so every request died in the RBAC
// lookup before reaching the handler. RBAC is not what this suite is
// about — stub it out the same way adminAuth already is.
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next()
}));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
ensureThumbnail: jest.fn()
@@ -107,21 +98,8 @@ describe('Admin photos in reference mode', () => {
table.string('type').notNullable();
table.integer('size_bytes');
table.integer('category_id');
// Mirrors migration 041: the upload route never writes this column, it
// relies on the NOT NULL DEFAULT 'managed' to mark managed originals.
table.string('source_origin').notNullable().defaultTo('managed');
table.string('source_origin');
table.string('external_relpath');
// Columns the upload insert writes (migrations 048, 062, 071, 085, 193)
// and the PATCH handler writes (migration 178). Without them the insert
// and the update both fail on "no such column".
table.string('original_filename', 512);
table.string('source_filename', 255);
table.datetime('captured_at').nullable();
table.string('media_type').defaultTo('image');
table.string('mime_type');
table.string('processing_status', 16).notNullable().defaultTo('complete');
table.string('upload_id', 64).nullable();
table.boolean('auto_categorized');
table.datetime('uploaded_at').defaultTo(db.fn.now());
table.float('average_rating').defaultTo(0);
table.integer('like_count').defaultTo(0);
@@ -175,10 +153,7 @@ describe('Admin photos in reference mode', () => {
.field('category_id', String(categoryId))
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
// 202 Accepted since the upload route went async (851744c3): the files are
// stored and a pending row is inserted, thumbnails/EXIF follow in the
// background worker. This assertion still said 200 from before that.
expect(uploadResponse.status).toBe(202);
expect(uploadResponse.status).toBe(200);
expect(uploadResponse.body).toHaveProperty('photos');
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
@@ -228,24 +203,5 @@ describe('Admin photos in reference mode', () => {
const updated = await db('photos').where({ id: photo.id }).first();
expect(updated.category_id).toBeNull();
// A real id still round-trips — the '0' guard must not swallow it.
await request(app)
.patch(`/api/admin/events/1/photos/${photo.id}`)
.send({ category_id: String(categoryId) })
.expect(200);
expect((await db('photos').where({ id: photo.id }).first()).category_id).toBe(categoryId);
// Numeric 0 and unparseable input clear the category too, rather than
// writing a category id that can never exist.
for (const value of [0, 'not-a-category']) {
await request(app)
.patch(`/api/admin/events/1/photos/${photo.id}`)
.send({ category_id: value })
.expect(200);
expect((await db('photos').where({ id: photo.id }).first()).category_id).toBeNull();
await db('photos').where({ id: photo.id }).update({ category_id: categoryId });
}
});
});
@@ -1,269 +0,0 @@
/**
* POST /admin/thumbnails/regenerate for external/reference photos (#1129).
*
* The route used to resolve every source as `storage/events/active/<path>` and
* `fs.access` it. External and reference rows do not live there — their
* originals sit under `events.external_path` — so every one of them failed the
* check and was counted as an error.
*
* That alone would be inert. What made it destructive is that the tier
* deletion runs FIRST (deliberately, so S3 and external rows are not skipped):
* on a reference install the button dropped every ?w= tier and rebuilt
* nothing, while the UI reported success — the response is sent before the
* background loop starts.
*
* The background work is fired with setImmediate, so every assertion here has
* to wait for it to drain rather than trusting the response.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('admin thumbnail regeneration (#1129)', () => {
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage; let logInfo;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
// One instance, not a fresh object per call — the route and the
// assertions have to be looking at the same mock.
jest.doMock('../../src/services/storage', () => {
const instance = { delete: jest.fn().mockResolvedValue(undefined) };
return { getStorage: () => instance };
});
jest.doMock('../../src/services/imageProcessor', () => ({
ensureThumbnail: jest.fn().mockResolvedValue('thumbnails/thumb_ext1_shot.jpg'),
ensurePreviewImage: jest.fn().mockResolvedValue('previews/p.jpg'),
deleteThumbnailTiers: jest.fn().mockResolvedValue(undefined),
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
}));
// Same module registry as the route, so the spy sees its calls. The
// completion line is what drain() below waits for.
logInfo = jest.spyOn(require('../../src/utils/logger'), 'info');
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
// success, which ends the jest worker mid-suite.
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
imageProcessor = require('../../src/services/imageProcessor');
storage = require('../../src/services/storage').getStorage();
app = express();
app.use(express.json());
app.use('/admin/thumbnails', require('../../src/routes/adminThumbnails'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
jest.clearAllMocks();
await db('photos').del();
await db('events').del();
});
async function seedEvent() {
const [row] = await db('events').insert({
slug: 'nas-wedding', event_type: 'wedding', event_name: 'nas',
event_date: '2026-01-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: 'nas-share', expires_at: new Date().toISOString(),
source_mode: 'reference', external_path: 'weddings/2026-08',
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function seedPhoto(eventId, overrides = {}) {
const [row] = await db('photos').insert({
event_id: eventId, filename: 'shot.jpg', path: 'nas-wedding/shot.jpg',
type: 'individual', ...overrides,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/**
* The work runs in setImmediate, after the response. Wait for the loop's
* "regeneration complete" log line rather than a fixed 150 ms: under a
* loaded machine (fifteen suites in parallel, each booting a migrated
* SQLite) the loop occasionally took longer than that, and the assertions
* then ran against a half-finished mock call list.
*/
const drain = async () => {
const deadline = Date.now() + 10000;
const done = () => logInfo.mock.calls.some((c) => /regeneration complete/.test(String(c[0])));
while (!done() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
};
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'external',
external_relpath: 'shot.jpg',
thumbnail_path: 'thumbnails/stale.jpg',
});
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
expect(res.status).toBe(200);
await drain();
// The whole bug: this used to be zero calls and one logged
// "Original file not found" per photo.
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
it('nulls thumbnail_path so the valid-thumbnail short-circuit cannot skip the rebuild', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'external',
external_relpath: 'shot.jpg',
thumbnail_path: 'thumbnails/still-on-disk.jpg',
});
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// Without this the endpoint is a no-op whenever the OLD thumbnail is still
// readable — which is the normal case after a settings change, and exactly
// when the admin pressed the button.
const [photoArg] = imageProcessor.ensureThumbnail.mock.calls[0];
expect(photoArg.thumbnail_path).toBeNull();
expect(photoArg.source_origin).toBe('external');
// Carried through so ensureThumbnail can resolve off the mount rather than
// under events/active.
expect(photoArg.external_relpath).toBe('shot.jpg');
});
it('still drops the responsive tiers first', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'external', external_relpath: 'shot.jpg' });
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// They are keyed by width outside thumbnail_path and carry no settings
// version, so leaving them serves the old fit to phones indefinitely.
expect(imageProcessor.deleteThumbnailTiers).toHaveBeenCalledTimes(1);
});
it('leaves videos alone rather than handing a container file to Sharp', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'managed', media_type: 'video', filename: 'clip.mp4' });
await seedPhoto(eventId, { source_origin: 'managed', filename: 'still.jpg' });
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(res.body.count).toBe(1);
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
expect(imageProcessor.ensureThumbnail.mock.calls[0][0].filename).toBe('still.jpg');
});
/**
* On S3, ensureThumbnail downloads the source to a randomly-named temp file,
* and for non-RAW input withProcessableImage passes no outputBasename — so
* generateThumbnail derives the key from that random name and it differs on
* every run. Nulling thumbnail_path hides the old key from everything that
* would otherwise clean it up, so each regeneration would strand a full
* thumbnail in the bucket, once per photo per run.
*/
describe('superseded canonical renditions', () => {
it('removes the old thumbnail when the key moved', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_OLDRANDOM_shot.jpg',
});
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEWRANDOM_shot.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).toHaveBeenCalledWith('thumbnails/thumb_OLDRANDOM_shot.jpg');
});
it('does NOT delete when the key is unchanged — that is the new file', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_stable.jpg',
});
// Local storage resolves to a stable path, so the key is identical.
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_stable.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).not.toHaveBeenCalled();
});
it.each([
['a Windows-style legacy path', 'thumbnails\\thumb_ext1_shot.jpg'],
['a leading ./', './thumbnails/thumb_ext1_shot.jpg'],
['a doubled separator', 'thumbnails//thumb_ext1_shot.jpg'],
])('does not delete the file it just wrote when the old path is %s', async (_name, stored) => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'managed', thumbnail_path: stored });
// Both storage backends fold these to the same key, so this is the SAME
// object — deleting it would remove the freshly generated thumbnail and
// leave the row pointing at nothing.
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_ext1_shot.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).not.toHaveBeenCalled();
});
it('counts the photo as regenerated even if the old object cannot be removed', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_OLD.jpg',
});
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEW.jpg');
storage.delete.mockRejectedValueOnce(new Error('bucket said no'));
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// Losing the old object is untidy; the regeneration itself succeeded.
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
});
it('scopes to one event when asked', async () => {
const a = await seedEvent();
await seedPhoto(a, { source_origin: 'external', external_relpath: 'a.jpg' });
const [b] = await db('events').insert({
slug: 'other', event_type: 'wedding', event_name: 'other', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: 'other-share', expires_at: new Date().toISOString(),
}).returning('id');
await seedPhoto(typeof b === 'object' ? b.id : b, { source_origin: 'managed' });
const res = await request(app).post('/admin/thumbnails/regenerate').send({ eventId: a });
await drain();
expect(res.body.count).toBe(1);
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
});
@@ -1,109 +0,0 @@
/**
* Backup credential exposure regression tests.
*
* The generic settings reads (GET /admin/settings, GET /admin/settings/:type)
* masked the recaptcha/umami/rybbit keys but returned backup_s3_secret_key
* and backup_rsync_ssh_key (an SSH PRIVATE KEY) in plaintext to any
* settings.view holder; GET /admin/backup/config returned them too. Both now
* mask, and PUT /admin/backup/config skips the mask sentinel so the edit
* form round-trips without clobbering stored credentials.
*/
const request = require('supertest');
const express = require('express');
const { bootCrmDb } = require('./helpers/crmDb');
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => {
req.admin = { id: 1, username: 'test-admin' };
next();
},
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
requireSuperAdmin: () => (_req, _res, next) => next(),
}));
describe('backup credential masking', () => {
let db;
let cleanup;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Upsert: several backup_* keys are pre-seeded by the backup migrations.
const seed = [
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('s3'), setting_type: 'backup' },
{ setting_key: 'backup_s3_endpoint', setting_value: JSON.stringify('https://s3.example.com'), setting_type: 'backup' },
{ setting_key: 'backup_s3_bucket', setting_value: JSON.stringify('backups'), setting_type: 'backup' },
{ setting_key: 'backup_s3_access_key', setting_value: JSON.stringify('AKIAEXAMPLE'), setting_type: 'backup' },
{ setting_key: 'backup_s3_secret_key', setting_value: JSON.stringify('super-secret-s3-key'), setting_type: 'backup' },
{ setting_key: 'backup_rsync_ssh_key', setting_value: JSON.stringify('-----BEGIN OPENSSH PRIVATE KEY-----abc'), setting_type: 'backup' },
];
for (const row of seed) {
await db('app_settings').insert(row).onConflict('setting_key').merge();
}
app = express();
app.use(express.json());
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('masks the credentials in GET /admin/backup/config', async () => {
const res = await request(app).get('/api/admin/backup/config').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
// Non-secret fields stay readable for the form.
expect(res.body.backup_s3_bucket).toBe('backups');
});
it('masks the credentials in the generic GET /admin/settings/:type read', async () => {
const res = await request(app).get('/api/admin/settings/backup').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
});
it('masks the credentials in the generic GET /admin/settings read', async () => {
const res = await request(app).get('/api/admin/settings').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
});
it('PUT /admin/backup/config keeps the stored secret when the sentinel round-trips', async () => {
await request(app)
.put('/api/admin/backup/config')
.send({
backup_destination_type: 's3',
backup_s3_endpoint: 'https://s3.example.com',
backup_s3_bucket: 'renamed-bucket',
backup_s3_access_key: 'AKIAEXAMPLE',
backup_s3_secret_key: '••••••••',
backup_rsync_ssh_key: '••••••••',
})
.expect(200);
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
expect(JSON.parse(secret.setting_value)).toBe('super-secret-s3-key');
const sshKey = await db('app_settings').where({ setting_key: 'backup_rsync_ssh_key' }).first();
expect(JSON.parse(sshKey.setting_value)).toBe('-----BEGIN OPENSSH PRIVATE KEY-----abc');
const bucket = await db('app_settings').where({ setting_key: 'backup_s3_bucket' }).first();
expect(JSON.parse(bucket.setting_value)).toBe('renamed-bucket');
});
it('PUT /admin/backup/config stores a genuinely new secret', async () => {
await request(app)
.put('/api/admin/backup/config')
.send({ backup_s3_secret_key: 'rotated-s3-key' })
.expect(200);
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
expect(JSON.parse(secret.setting_value)).toBe('rotated-s3-key');
});
});
@@ -23,7 +23,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — configurable walker (backup_paths)', () => {
let db;
@@ -34,7 +34,7 @@ jest.mock('../../src/services/databaseBackup', () => ({
DatabaseBackupService: class {},
}));
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — inline DB dump + fail-loud guard', () => {
let db;
@@ -23,7 +23,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — per-Stage-B-path statistics', () => {
let db;
@@ -14,7 +14,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — config + file collection + manifest (smoke)', () => {
let db;
@@ -7,7 +7,7 @@
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('booking cutover — draft invoices on hold', () => {
let db; let cleanup; let adminId; let customerId; let quoteService;
@@ -1,195 +0,0 @@
/**
* POST /api/admin/business-profile/logo and PUT /api/admin/business-profile
* — GHSA-6wrv-9pr4-hhmw regression coverage.
*
* The upload route used to take the stored file extension straight from
* the client-supplied filename and only checked `file.mimetype` against an
* allowlist — a file could declare an image MIME type while carrying a
* `.html`/`.js` extension and arbitrary content, land in the same-origin
* `/uploads/logos` static mount, and execute as script. The mass-assignable
* `logoPath` field on PUT compounded it: an attacker could point the
* "logo" at any other uploaded file.
*
* These tests pin:
* (a) a MIME/extension mismatch is rejected at upload,
* (b) the extension actually written to disk always matches the
* validated MIME type, never the client-supplied filename,
* (c) legitimate PNG/JPEG/SVG uploads still succeed,
* (d) `logoPath` on PUT cannot be set to an arbitrary string pointing at
* another file, only to a path the upload route itself produced.
*
* Defense-in-depth (not a re-opening of the above): fileFilter only pairs
* the claimed MIME type against the extension — it can't see the bytes,
* since it runs before multer finishes writing the stream to disk. A file
* whose declared MIME/extension pair is valid but whose actual content
* doesn't match (e.g. a PNG-declared upload that isn't really a PNG) is
* now caught by validateFileContent() (magic-number check) after multer
* writes it, closing the gap where declared-vs-actual content diverges.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bplogo-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bplogo-route-test-secret';
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
// Real magic-number-prefixed payloads, for content-sniffing to accept.
const REAL_PNG_BYTES = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
Buffer.from('not a real png body, but the header is real'),
]);
const REAL_JPEG_BYTES = Buffer.concat([
Buffer.from([0xFF, 0xD8, 0xFF]),
Buffer.from('not a real jpeg body, but the header is real'),
]);
describe('business profile — logo upload content/extension validation', () => {
let db;
let cleanup;
let app;
let token;
const uploadLogo = (buffer, filename, mimetype) => request(app)
.post('/api/admin/business-profile/logo')
.set('Authorization', `Bearer ${token}`)
.attach('logo', buffer, { filename, contentType: mimetype });
const put = (payload) => request(app)
.put('/api/admin/business-profile')
.set('Authorization', `Bearer ${token}`)
.send(payload);
const get = () => request(app)
.get('/api/admin/business-profile')
.set('Authorization', `Bearer ${token}`);
const profileOf = (res) => (res.body.data || res.body).profile;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = buildRouteApp('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
// fileFilter rejections surface via Express's generic error handler
// (the pre-existing behaviour of every sibling logo/favicon upload
// route in this codebase — none of them special-case multer's
// fileFilter `Error` into a 400 either), so the status code itself
// can be 400 or 500 depending on environment. What actually matters
// for GHSA-6wrv-9pr4-hhmw is that the request never succeeds and
// nothing with the dangerous extension is ever written to disk.
const logosDirFiles = () => {
const logosDir = path.join(process.env.STORAGE_PATH, 'uploads', 'logos');
return fs.existsSync(logosDir) ? fs.readdirSync(logosDir) : [];
};
it('rejects an HTML/script payload disguised as an image via mismatched extension', async () => {
const evil = Buffer.from('<script>alert(document.domain)</script>');
const res = await uploadLogo(evil, 'evil.html', 'image/svg+xml');
expect(res.status).not.toBe(200);
expect(logosDirFiles().some((f) => f.endsWith('.html'))).toBe(false);
});
it('rejects a .js file disguised with an image MIME type', async () => {
const evil = Buffer.from('alert(1)');
const res = await uploadLogo(evil, 'evil.js', 'image/png');
expect(res.status).not.toBe(200);
expect(logosDirFiles().some((f) => f.endsWith('.js'))).toBe(false);
});
it('rejects a disallowed MIME type outright', async () => {
const res = await uploadLogo(Buffer.from('whatever'), 'file.pdf', 'application/pdf');
expect(res.status).not.toBe(200);
expect(logosDirFiles().some((f) => f.endsWith('.pdf'))).toBe(false);
});
it('accepts a legitimate PNG upload and stores it with a .png extension', async () => {
const res = await uploadLogo(REAL_PNG_BYTES, 'logo.png', 'image/png');
expect(res.status).toBe(200);
const logoPath = (res.body.data || res.body).logoPath;
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.png$/);
const onDisk = path.join(process.env.STORAGE_PATH, logoPath.replace(/^\//, ''));
expect(fs.existsSync(onDisk)).toBe(true);
expect(profileOf(await get()).logoPath).toBe(logoPath);
});
it('accepts a legitimate JPEG upload and stores it with a .jpg extension', async () => {
const res = await uploadLogo(REAL_JPEG_BYTES, 'logo.jpg', 'image/jpeg');
expect(res.status).toBe(200);
const logoPath = (res.body.data || res.body).logoPath;
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.jpg$/);
});
it('rejects a PNG-declared upload whose bytes are not actually a PNG, and leaves nothing on disk', async () => {
const before = logosDirFiles();
const res = await uploadLogo(Buffer.from('totally not a png'), 'logo.png', 'image/png');
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/content does not match/i);
// No new file left behind: the rejected upload's own file was cleaned
// up, and every other file on disk (if any) is unchanged.
expect(logosDirFiles()).toEqual(before);
});
it('accepts a legitimate SVG upload and always stores it with a .svg extension, even under a spoofed filename', async () => {
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>');
// Client-declared filename ext is .svg here to pass validateFileType
// (mismatched ext is covered by the rejection tests above); the point
// of this test is that the ON-DISK extension comes from the MIME type
// lookup table, not path.extname(originalname).
const res = await uploadLogo(svg, 'vector-logo.svg', 'image/svg+xml');
expect(res.status).toBe(200);
const logoPath = (res.body.data || res.body).logoPath;
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.svg$/);
});
it('rejects logoPath on PUT set to an arbitrary string pointing at another file', async () => {
const before = profileOf(await get()).logoPath;
const res = await put({ logoPath: '/uploads/logos/cms-somepage-1234.png' });
expect(res.status).toBe(400);
expect(profileOf(await get()).logoPath).toBe(before);
});
it('rejects logoPath on PUT with a path-traversal payload', async () => {
const res = await put({ logoPath: '/uploads/logos/../../../../etc/passwd' });
expect(res.status).toBe(400);
});
it('accepts logoPath on PUT when it matches the pattern this route itself writes', async () => {
const upload = await uploadLogo(REAL_PNG_BYTES, 'logo2.png', 'image/png');
const uploadedPath = (upload.body.data || upload.body).logoPath;
// Round-trip: PUT-ing back the exact value the upload endpoint
// returned (what the frontend's generic profile save does) must
// keep working.
const res = await put({ logoPath: uploadedPath });
expect(res.status).toBe(200);
expect(profileOf(await get()).logoPath).toBe(uploadedPath);
});
it('still allows clearing logoPath with an empty string', async () => {
const res = await put({ logoPath: '' });
expect(res.status).toBe(200);
expect(profileOf(await get()).logoPath).toBe('');
});
});
@@ -1,144 +0,0 @@
/**
* PUT /api/admin/business-profile — email signature fields (migration 198).
*
* The two new columns are boolean + free text, which is exactly the shape
* that goes wrong quietly: `optional({ values: 'falsy' })` on the boolean
* would silently drop `false`, leaving the admin unable to switch the
* signature back off. The round-trip below is what pins that.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bpsig-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bpsig-route-test-secret';
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
describe('business profile — email signature round-trip', () => {
let db;
let cleanup;
let app;
let token;
const put = (payload) => request(app)
.put('/api/admin/business-profile')
.set('Authorization', `Bearer ${token}`)
.send(payload);
const get = () => request(app)
.get('/api/admin/business-profile')
.set('Authorization', `Bearer ${token}`);
// GET returns the snapshot at the top level; PUT wraps it in
// successResponse's `data` envelope. Read either.
const profileOf = (res) => (res.body.data || res.body).profile;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = buildRouteApp('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('defaults to off with an empty legal line', async () => {
const res = await get();
expect(res.status).toBe(200);
const profile = profileOf(res);
expect(profile.emailSignatureEnabled).toBe(false);
expect(profile.emailSignatureExtra).toBe('');
});
it('persists the toggle and the legal line', async () => {
const res = await put({
emailSignatureEnabled: true,
emailSignatureExtra: 'Handelsregister Vaduz FL-0002.123.456-7',
});
expect(res.status).toBe(200);
const profile = profileOf(await get());
expect(profile.emailSignatureEnabled).toBe(true);
expect(profile.emailSignatureExtra).toBe('Handelsregister Vaduz FL-0002.123.456-7');
});
it('switches the toggle back off — `false` is not dropped as falsy', async () => {
await put({ emailSignatureEnabled: true });
const res = await put({ emailSignatureEnabled: false });
expect(res.status).toBe(200);
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
});
it('clears the legal line with an empty string', async () => {
await put({ emailSignatureExtra: 'something' });
const res = await put({ emailSignatureExtra: '' });
expect(res.status).toBe(200);
expect(profileOf(await get()).emailSignatureExtra).toBe('');
});
it('trims surrounding whitespace off the legal line', async () => {
await put({ emailSignatureExtra: ' Registered in Vaduz ' });
expect(profileOf(await get()).emailSignatureExtra).toBe('Registered in Vaduz');
});
// Codex review: express-validator's isBoolean() accepts the STRINGS
// 'false' and '0', and Boolean('false') is true — so a form-encoded client
// trying to switch the signature OFF switched it on instead.
it.each([['false'], ['0']])('treats the string %s as off, not on', async (value) => {
await put({ emailSignatureEnabled: true });
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
const res = await put({ emailSignatureEnabled: value });
expect(res.status).toBe(200);
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
});
it.each([['true'], ['1']])('treats the string %s as on', async (value) => {
await put({ emailSignatureEnabled: false });
const res = await put({ emailSignatureEnabled: value });
expect(res.status).toBe(200);
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
});
it('rejects a non-boolean toggle and a legal line over 500 chars', async () => {
expect((await put({ emailSignatureEnabled: 'yes please' })).status).toBe(400);
expect((await put({ emailSignatureExtra: 'x'.repeat(501) })).status).toBe(400);
});
it('does not let an unmapped column ride in on the payload', async () => {
// ALLOWED_PROFILE_FIELDS is the whitelist; the route's camel→snake map
// is the second gate. Neither should pass a raw snake_case key through.
const before = await db('business_profile').where({ id: 1 }).first();
await put({ email_signature_enabled: true, id: 999 });
const after = await db('business_profile').where({ id: 1 }).first();
expect(after.id).toBe(before.id);
expect(after.email_signature_enabled).toBe(before.email_signature_enabled);
});
it('invalidates the wrapper signature cache on write', async () => {
const { wrapEmailHtml } = require('../../src/services/emailProcessor');
await put({ emailSignatureEnabled: false });
expect(await wrapEmailHtml('<p>x</p>', 'S')).not.toContain('Bahnhofstrasse 9');
// Same request cycle, well inside the 60 s memo window: the PUT must
// clear the cache or the operator sees a stale footer for a minute.
await put({ emailSignatureEnabled: true, addressLine1: 'Bahnhofstrasse 9' });
expect(await wrapEmailHtml('<p>x</p>', 'S')).toContain('Bahnhofstrasse 9');
});
});
@@ -1,227 +0,0 @@
/**
* Backfilling captured_at on a library imported before #1172.
*
* The point of the endpoint, rather than a migration: it resolves originals
* through resolvePhotoFilePath, which is the only path that reaches an
* external row. The thumbnail regenerator resolves under
* storage/events/active/<photo.path>, which never exists for those (#1129) —
* so it cannot be the model.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const sharp = require('sharp');
describe('capture date backfill (#1172)', () => {
let tmpDir; let db; let app; let mediaRoot;
const writeJpegWithExif = async (abs, iso) => {
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 9, g: 9, b: 9 } } })
.withExif({ IFD2: { DateTimeOriginal: exifDate } }).jpeg().toFile(abs);
};
const settle = async () => { for (let i = 0; i < 60; i++) { await new Promise((r) => setTimeout(r, 50)); const s = await status(); if (!s.body.isRunning) return s; } throw new Error('backfill did not settle'); };
const status = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capfill-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capfill-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seed({ relpath, exifIso, writeFile = true, archived = false }) {
await db('photos').del();
await db('events').del();
const [e] = await db('events').insert({
slug: 'capfill', event_type: 'wedding', event_name: 'capfill', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `capfill-${Math.random()}`, expires_at: new Date().toISOString(),
source_mode: 'reference', external_path: 'trip', is_archived: archived,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
if (writeFile) await writeJpegWithExif(path.join(mediaRoot, 'trip', relpath), exifIso);
const [p] = await db('photos').insert({
event_id: eventId, filename: path.basename(relpath), path: `capfill/${path.basename(relpath)}`,
// Root-relative, as this branch stores it (#1163) — the file lives at
// <mediaRoot>/trip/<relpath>.
type: 'individual', source_origin: 'external', external_relpath: `trip/${relpath}`,
uploaded_at: new Date().toISOString(), captured_at: null,
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
it('fills captured_at for an external photo the thumbnail regenerator cannot reach', async () => {
const { photoId } = await seed({ relpath: 'a.jpg', exifIso: '2026-06-01T09:45:03Z' });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
const done = await settle();
expect(done.body.lastResult.success).toBe(1);
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeTruthy();
});
it('counts a photo with no EXIF separately from a failure', async () => {
// "The mount is broken" and "these files carry no date" need different
// answers from an operator, so they are not the same number.
await db('photos').del(); await db('events').del();
const { photoId } = await seed({ relpath: 'plain.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await sharp({ create: { width: 40, height: 30, channels: 3, background: { r: 1, g: 1, b: 1 } } })
.jpeg().toFile(path.join(mediaRoot, 'trip', 'plain.jpg'));
await request(app).post('/api/admin/photos/repair-capture-dates');
const done = await settle();
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 1, failed: 0 });
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
});
it('counts an unreachable original as a failure, not as missing EXIF', async () => {
await seed({ relpath: 'gone.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await request(app).post('/api/admin/photos/repair-capture-dates');
const done = await settle();
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 0, failed: 1 });
});
it('reports nothing to do once every photo has a date', async () => {
const { photoId } = await seed({ relpath: 'b.jpg', exifIso: '2026-06-02T09:00:00Z' });
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
expect((await status()).body.withoutCaptureDate).toBe(0);
});
it('skips a watcher-imported video, which carries media_type "image"', async () => {
// fileWatcher.processNewPhoto sets type='video' and a video/* mime but
// never media_type (fileWatcher.js:128-130), so the row keeps the 'image'
// default from migration 048. Filtering on media_type alone queued it every
// run: extractCaptureDate returns null for a video, captured_at stays null,
// and the backlog never cleared.
const { eventId } = await seed({ relpath: 'clip.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await db('photos').del();
await db('photos').insert({
event_id: eventId, filename: 'clip.mp4', path: 'capfill/clip.mp4',
type: 'video', media_type: 'image', mime_type: 'video/mp4',
source_origin: 'external', external_relpath: 'trip/clip.mp4',
uploaded_at: new Date().toISOString(), captured_at: null,
});
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const s = await status();
// And it is not counted as a permanent backlog either.
expect(s.body.total).toBe(0);
expect(s.body.withoutCaptureDate).toBe(0);
});
it('never reports more dated photos than it has photos', async () => {
// Both counts come from one aggregate; as two queries an import committing
// between them produced withCaptureDate > total and a negative backlog.
const { photoId } = await seed({ relpath: 'counted.jpg', exifIso: '2026-06-05T08:00:00Z' });
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
const s = await status();
expect(s.body.total).toBe(1);
expect(s.body.withCaptureDate).toBe(1);
expect(s.body.withoutCaptureDate).toBe(0);
expect(s.body.withoutCaptureDate).toBeGreaterThanOrEqual(0);
});
it('skips archived events instead of failing them on every run', async () => {
// Archiving deletes the originals and keeps the rows, so an archived photo
// can never get a date. Counting it would fail it every pass and leave the
// status endpoint permanently reporting a backlog.
await seed({ relpath: 'archived.jpg', exifIso: '2026-06-04T09:00:00Z', archived: true });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const s = await status();
expect(s.body.total).toBe(0);
expect(s.body.withoutCaptureDate).toBe(0);
expect(s.body.isRunning).toBe(false);
});
it('does not overwrite a date written while it was running', async () => {
// whereNull on the update: an import or a replacement finishing mid-run has
// already written a better value than this pass would.
const { photoId } = await seed({ relpath: 'c.jpg', exifIso: '2026-06-03T09:00:00Z' });
const claimed = '2020-01-01T00:00:00.000Z';
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(1);
await db('photos').where({ id: photoId }).update({ captured_at: claimed });
const done = await settle();
expect(new Date((await db('photos').where({ id: photoId }).first()).captured_at).toISOString()).toBe(claimed);
expect(done.body.lastResult.success).toBe(0);
// Read but not written, so it is accounted for rather than dropped.
expect(done.body.lastResult.skipped).toBe(1);
});
it('does not date a row whose file was replaced while it was reading (#1201)', async () => {
// replacePhoto swaps a NEW file under an existing row and rewrites
// path/filename (reachable from replace_by_name). The replacement carries
// no date of its own, so captured_at is still NULL and the whereNull guard
// alone would let the previous file's EXIF date land on it. The write is
// fenced on the identity that was read, so the row is skipped instead —
// and not counted as updated either.
const { photoId } = await seed({ relpath: 'orig.jpg', exifIso: '2026-06-03T09:00:00Z' });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(1);
// Simulate the replacement landing before the loop writes.
await db('photos').where({ id: photoId })
.update({ path: 'capfill/replaced.jpg', filename: 'replaced.jpg' });
const done = await settle();
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
expect(done.body.lastResult.success).toBe(0);
// Not an error and not "no EXIF" — the date was found, another writer just
// got there first. It stays in the backlog for the next run.
expect(done.body.lastResult).toMatchObject({ noExif: 0, failed: 0, skipped: 1 });
});
});
@@ -1,211 +0,0 @@
/**
* Layered per-event category ordering (#782).
*
* Two ordering layers, resolved per event:
* - GLOBAL default — photo_categories.display_order (migration 159),
* set via POST /reorder-global; applies everywhere.
* - PER-EVENT override — event_category_order (migration 160), set via
* POST /reorder; overrides the default for one gallery.
* - DELETE /reorder/:eventId clears an event's override.
*
* Verified against a real SQLite DB with the full core-migration set applied.
*/
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
jest.setTimeout(120000);
describe('category ordering (#782)', () => {
let db;
let cleanup;
let token;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const auth = (r) => r.set('Authorization', `Bearer ${token}`);
async function insertEvent(slug) {
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug, share_link: slug,
event_name: slug, event_date: '2026-01-01',
});
return (await db('events').where({ slug }).first()).id;
}
async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) {
const res = await db('photo_categories').insert({
name,
slug: name.toLowerCase().replace(/\s+/g, '-'),
is_global: is_global ? 1 : 0,
event_id,
display_order,
}).returning('id');
return res[0]?.id ?? res[0];
}
const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200);
describe('migration 159 backfill', () => {
it('seeds display_order from alphabetical order, scoped per event', async () => {
const eventId = await insertEvent('backfill-ev');
await insertCat('Reception', { event_id: eventId });
await insertCat('Ceremony', { event_id: eventId });
await insertCat('Pre-Ceremony', { event_id: eventId });
// Re-run the migration: addColumn is guarded (no-op); the backfill loop
// re-runs and assigns per-scope alphabetical order — what an upgrade does.
await require('../../migrations/core/159_add_category_display_order').up(db);
const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc');
expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']);
expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]);
});
});
describe('global default order (POST /reorder-global)', () => {
it('reverses the global order and every non-customised event follows it', async () => {
const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body;
expect(before.length).toBeGreaterThan(1);
const reversedIds = before.map((c) => c.id).reverse();
const res = await auth(request(app).post('/api/admin/categories/reorder-global'))
.send({ orderedIds: reversedIds })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(reversedIds);
// A fresh event (no override) shows globals in the new global order.
const eventId = await insertEvent('follows-global');
const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id);
expect(globalsInEvent).toEqual(reversedIds);
});
});
describe('per-event override (POST /reorder)', () => {
it('pins a custom order for one event without affecting another', async () => {
const eventA = await insertEvent('override-a');
const eventB = await insertEvent('override-b');
const a1 = await insertCat('A-Ceremony', { event_id: eventA });
const a2 = await insertCat('A-Reception', { event_id: eventA });
// Current resolved list for A (globals + A's two categories).
const listA = (await getEvent(eventA)).body;
// Put A-Reception first, then A-Ceremony, then the globals in their order.
const globalsA = listA.filter((c) => c.is_global).map((c) => c.id);
const desired = [a2, a1, ...globalsA];
const res = await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventA, orderedIds: desired })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(desired);
// override_position is set on every row for a customised event.
expect(res.body.every((c) => c.override_position != null)).toBe(true);
// Event B is untouched — no override, follows the global default.
const listB = (await getEvent(eventB)).body;
expect(listB.every((c) => c.override_position == null)).toBe(true);
});
it('accepts global ids but rejects another events category', async () => {
const eventId = await insertEvent('scope-ev');
const own = await insertCat('Own', { event_id: eventId });
const global = (await db('photo_categories').where('is_global', 1).first()).id;
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
// A global id is allowed (globals can be arranged per event).
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, global] })
.expect(200);
// A foreign event's category is out of scope.
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, foreign] })
.expect(400);
});
});
describe('reset (DELETE /reorder/:eventId)', () => {
it('clears the override and reverts to the global default', async () => {
const eventId = await insertEvent('reset-ev');
const c1 = await insertCat('R-One', { event_id: eventId });
const list = (await getEvent(eventId)).body;
const globals = list.filter((c) => c.is_global).map((c) => c.id);
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
.expect(200);
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
expect(res.body.every((c) => c.override_position == null)).toBe(true);
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
});
});
describe('event ownership (PR #790 review)', () => {
let limitedToken;
let foreignEventId;
beforeAll(async () => {
const bcrypt = require('bcrypt');
// A non-super_admin role that DOES hold settings.view + settings.edit —
// the exact case the review flagged (settings.edit is grantable).
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
const roleId = roleRes[0]?.id ?? roleRes[0];
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
const a2 = await db('admin_users').insert({
username: 'limited', email: 'limited@example.com',
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
must_change_password: false, created_at: new Date(),
}).returning('id');
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
// An event owned by a DIFFERENT admin (the seeded super_admin).
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
});
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
});
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
});
});
describe('POST / (create) appends to the end of its scope', () => {
it('assigns display_order = max + 1 within the event', async () => {
const eventId = await insertEvent('append-ev');
await insertCat('First', { event_id: eventId, display_order: 1 });
await insertCat('Second', { event_id: eventId, display_order: 2 });
const res = await auth(request(app).post('/api/admin/categories'))
.send({ name: 'Third', is_global: false, event_id: eventId })
.expect(200);
expect(res.body.display_order).toBe(3);
});
});
});
@@ -1,418 +0,0 @@
/**
* CRM mint-and-send paths — integration tests (#587).
*
* Pins the three document "mint" flows end-to-end through the real
* HTTP → route → service → DB → email-queue → file pipeline:
*
* 1. POST /api/admin/quotes/:id/send (draft → sent + PDF + token + email)
* 2. POST /api/admin/invoices/:id/cancel (issued → cancelled + Storno row)
* — the issue spec named this /:id/storno; the real route is
* /:id/cancel (invoiceService.cancelInvoice → createStorno).
* 3. POST /api/admin/contracts/:id/countersign
* (signed_by_customer → fully_signed + stamped PDF + sha256 + email)
*
* Real SQLite with the full core-migration run (helpers/crmDb), real
* pdfkit/pdf-lib rendering — no mock-fs, no network.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
// Full migration run + cold-requiring pdfService/emailProcessor is slow
// under CI load; match the other CRM integration suites.
jest.setTimeout(120000);
const CUSTOMER_EMAIL = 'customer@example.com';
// 1x1 transparent PNG — smallest valid signature pad output.
const SIGNATURE_DATA_URL = 'data:image/png;base64,'
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
// SQLite round-trips dates inconsistently (epoch ms number, numeric
// string, or ISO string) — parse robustly before comparing.
const toMillis = (v) => {
if (typeof v === 'number') return v;
if (typeof v === 'string' && /^\d+$/.test(v)) return Number(v);
return Date.parse(v);
};
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
// Count embedded image XObjects per page via pdf-lib — used to prove BOTH
// signature stamps (customer + admin) made it into the final PDF instead of
// only asserting file existence/hash (codex review of #850 round 2).
async function countImagesPerPage(pdfPath) {
const { PDFDocument, PDFName, PDFDict } = require('pdf-lib');
const doc = await PDFDocument.load(fs.readFileSync(pdfPath));
return doc.getPages().map((page) => {
const resources = page.node.Resources();
const xobjects = resources && resources.lookupMaybe(PDFName.of('XObject'), PDFDict);
if (!xobjects) return 0;
let images = 0;
for (const [, ref] of xobjects.entries()) {
const stream = page.doc.context.lookup(ref);
const subtype = stream && stream.dict && stream.dict.get(PDFName.of('Subtype'));
if (subtype && subtype.toString() === '/Image') images += 1;
}
return images;
});
}
let db;
let cleanup;
let tmpDir;
// Real (symlink-resolved) storage root — on macOS os.tmpdir() returns
// /var/... while the services persist under process.cwd() which
// resolves to /private/var/....
let storageRoot;
let adminId;
let customerId;
let token;
let quoteApp;
let invoiceApp;
let contractApp;
let quoteService;
let invoiceService;
let contractService;
const prevCwd = process.cwd();
const auth = { get Authorization() { return `Bearer ${token}`; } };
async function enableFlag(key) {
const updated = await db('feature_flags').where({ key }).update({ value: true });
if (!updated) await db('feature_flags').insert({ key, value: true });
}
// ----- per-path seed helpers -----------------------------------------
async function seedQuote() {
const id = await quoteService.createQuote({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
eventName: 'Testshooting',
lineItems: [
{ position: 1, quantity: 1, description: 'Photo package', unit_price_minor: 150000, discount_percent: 0 },
],
}, adminId);
return id;
}
async function seedIssuedInvoice(status = 'sent') {
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 7.7,
lineItems: [
{ position: 1, quantity: 1, description: 'Wedding coverage', unit_price_minor: 200000, discount_percent: 0 },
],
}, adminId);
const id = invoiceIds[0];
// Fast-forward past the send step — Storno only applies to issued
// documents (sent/paid/overdue), and rendering+sending the original
// is covered by the quote path already.
await db('invoices').where({ id }).update({
status, sent_at: new Date(), updated_at: new Date(),
});
return db('invoices').where({ id }).first();
}
async function seedCustomerSignedContract() {
const id = await contractService.createContract({
customerAccountId: customerId,
title: 'Fotografie-Vertrag',
}, adminId);
// Real send + customer-sign flow (codex review of #850): a direct
// status UPDATE skipped the customer's signature asset and stamped
// PDF, so countersign exercised its unsigned-PDF fallback and a
// regression dropping the customer's signature would stay green.
const { token } = await contractService.sendContract(id, adminId);
await contractService.recordCustomerSignature({
token,
name: 'Custo Mer',
ip: '127.0.0.1',
signatureDataUrl: SIGNATURE_DATA_URL,
accepted: true,
});
return db('contracts').where({ id }).first();
}
// ----- suite ----------------------------------------------------------
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
// Business-doc PDFs (quotes/invoices/contracts) persist under
// `getStoragePath()/business-docs/...`, and safePath also allows a
// `process.cwd()/storage/business-docs/...` root — chdir into the temp
// dir so every test artifact lands isolated and gets cleaned up.
process.chdir(tmpDir);
// Mirror what the services store: the raw STORAGE_PATH bootCrmDb
// exported, NOT a symlink-resolved variant. On macOS os.tmpdir() is
// /var/... while realpath is /private/var/..., so canonicalizing here
// would make every stored path fail the prefix check.
storageRoot = path.join(process.env.STORAGE_PATH, 'business-docs');
// Fail-fast on the pre-existing logActivity-inside-transaction
// deadlock: createContract and createStorno call logActivity() from
// inside a knex transaction WITHOUT passing the trx as executor, so
// the audit insert tries to grab a second connection from the
// single-connection SQLite pool while the trx holds it. In
// production that stalls each call for the full 60 s acquire
// timeout (the error is then swallowed by logActivity's catch);
// here we shrink the timeout so the same swallowed failure costs
// 2 s instead of blowing the per-test budget. Behaviour under test
// is unchanged — the mint paths themselves never wait on this.
db.client.pool.acquireTimeoutMillis = 2000;
// node-sqlite3 detects Date bind params via `InstanceOf(global.Date)`
// against the NATIVE realm's Date — under jest's vm sandbox the
// service code's `new Date()` is a different constructor, the check
// fails, and the value stringifies to the literal "[object Object]"
// (the exact pathology helpers/crmDb.js documents for
// createPublicToken). Normalize Date bindings to ISO strings before
// they reach the driver so the real service inserts round-trip the
// same way they do outside jest.
// Patch on the prototype — knex mints transaction clients via
// Object.create(prototype), so an instance-level patch would miss
// every query issued inside a db.transaction().
const clientProto = Object.getPrototypeOf(db.client);
const origQuery = clientProto._query;
clientProto._query = function patchedQuery(connection, obj) {
if (obj && Array.isArray(obj.bindings)) {
obj.bindings = obj.bindings.map(
(b) => (b && typeof b === 'object' && typeof b.toISOString === 'function' ? b.toISOString() : b),
);
}
return origQuery.call(this, connection, obj);
};
({ adminId, customerId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
// CRM surfaces are feature-flagged; migration 107 seeds them OFF.
await enableFlag('quotes');
await enableFlag('bills');
await enableFlag('contracts');
quoteService = require('../../src/services/quoteService');
invoiceService = require('../../src/services/invoiceService');
contractService = require('../../src/services/contractService');
quoteApp = buildRouteApp('/api/admin/quotes', require('../../src/routes/adminQuotes'));
invoiceApp = buildRouteApp('/api/admin/invoices', require('../../src/routes/adminInvoices'));
contractApp = buildRouteApp('/api/admin/contracts', require('../../src/routes/adminContracts'));
}, 120000);
afterAll(async () => {
process.chdir(prevCwd);
if (cleanup) await cleanup();
});
describe('POST /api/admin/quotes/:id/send', () => {
test('draft quote: 200 → sent + sent_at + PDF on disk + action token + quote_sent email', async () => {
const quoteId = await seedQuote();
await db('email_queue').del();
const res = await request(quoteApp)
.post(`/api/admin/quotes/${quoteId}/send`)
.set(auth);
expect(res.status).toBe(200);
expect(res.body.sent).toBe(true);
expect(res.body.token).toMatch(/^[0-9a-f]{64}$/);
// DB state
const quote = await db('quotes').where({ id: quoteId }).first();
expect(quote.status).toBe('sent');
expect(quote.sent_at).toBeTruthy();
// PDF persisted inside the isolated storage root
expect(quote.pdf_path).toBeTruthy();
expect(quote.pdf_path.startsWith(path.join(storageRoot, 'quote'))).toBe(true);
expect(fs.existsSync(quote.pdf_path)).toBe(true);
expect(fs.statSync(quote.pdf_path).size).toBeGreaterThan(0);
// Action token row: right quote, future expiry
const tokenRow = await db('quote_action_tokens').where({ token: res.body.token }).first();
expect(tokenRow).toBeTruthy();
expect(tokenRow.quote_id).toBe(quoteId);
expect(toMillis(tokenRow.expires_at)).toBeGreaterThan(Date.now());
// Email queued to the customer's primary address
const emails = await db('email_queue').where({ email_type: 'quote_sent' });
expect(emails).toHaveLength(1);
expect(emails[0].recipient_email).toBe(CUSTOMER_EMAIL);
const emailData = JSON.parse(emails[0].email_data);
expect(emailData.quote_number).toBe(quote.quote_number);
});
test('already-sent quote: 409 (spec said 400; service throws 409)', async () => {
const quoteId = await seedQuote();
await request(quoteApp).post(`/api/admin/quotes/${quoteId}/send`).set(auth).expect(200);
const res = await request(quoteApp)
.post(`/api/admin/quotes/${quoteId}/send`)
.set(auth);
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/cannot send a quote with status 'sent'/i);
});
});
describe('POST /api/admin/invoices/:id/cancel (Storno mint)', () => {
test('sent invoice: original cancelled, Storno row minted with negated totals + lineage', async () => {
const original = await seedIssuedInvoice('sent');
await db('email_queue').del();
const res = await request(invoiceApp)
.post(`/api/admin/invoices/${original.id}/cancel`)
.set(auth);
// Route responds via successResponse default — 200, not the 201
// the issue spec assumed.
expect(res.status).toBe(200);
expect(res.body.cancelled).toBe(true);
expect(res.body.stornoId).toBeGreaterThan(0);
const storno = await db('invoices').where({ id: res.body.stornoId }).first();
expect(storno.kind).toBe('storno');
expect(storno.cancels_invoice_id).toBe(original.id);
expect(storno.deal_uuid).toBe(original.deal_uuid);
// Negated amounts
expect(storno.net_amount_minor).toBe(-original.net_amount_minor);
expect(storno.vat_amount_minor).toBe(-original.vat_amount_minor);
expect(storno.total_amount_minor).toBe(-original.total_amount_minor);
// Freshly sequenced number from the same series
expect(typeof storno.invoice_number).toBe('string');
expect(storno.invoice_number.length).toBeGreaterThan(0);
expect(storno.invoice_number).not.toBe(original.invoice_number);
// Line items snapshotted onto the Storno
const originalItems = await db('invoice_line_items').where({ invoice_id: original.id });
const stornoItems = await db('invoice_line_items').where({ invoice_id: storno.id });
expect(stornoItems).toHaveLength(originalItems.length);
// Original flipped + back-linked
const refreshed = await db('invoices').where({ id: original.id }).first();
expect(refreshed.status).toBe('cancelled');
expect(refreshed.cancellation_storno_id).toBe(storno.id);
// sendStorno side effects (codex review of #850): cancelInvoice
// swallows a sendStorno failure by design, so without these
// assertions a broken render/persist/queue leg would stay green.
const sentStorno = await db('invoices').where({ id: storno.id }).first();
expect(sentStorno.status).toBe('sent');
expect(sentStorno.pdf_path).toBeTruthy();
expect(fs.existsSync(sentStorno.pdf_path)).toBe(true);
const stornoEmails = await db('email_queue').where({ email_type: 'storno_issued' });
expect(stornoEmails.length).toBeGreaterThanOrEqual(1);
expect(stornoEmails[0].recipient_email).toBe(CUSTOMER_EMAIL);
});
test('paid invoice can be cancelled via Storno too (refund document leg)', async () => {
const original = await seedIssuedInvoice('paid');
const res = await request(invoiceApp)
.post(`/api/admin/invoices/${original.id}/cancel`)
.set(auth);
expect(res.status).toBe(200);
expect(res.body.stornoId).toBeGreaterThan(0);
const refreshed = await db('invoices').where({ id: original.id }).first();
expect(refreshed.status).toBe('cancelled');
});
test('already-cancelled invoice: 409 ALREADY_CANCELLED', async () => {
const original = await seedIssuedInvoice('sent');
await request(invoiceApp).post(`/api/admin/invoices/${original.id}/cancel`).set(auth).expect(200);
const res = await request(invoiceApp)
.post(`/api/admin/invoices/${original.id}/cancel`)
.set(auth);
expect(res.status).toBe(409);
expect(res.body.code).toBe('ALREADY_CANCELLED');
});
});
describe('POST /api/admin/contracts/:id/countersign', () => {
test('customer-signed contract: 200 → fully_signed + stamped PDF + sha256 + signature asset + email with attachment', async () => {
const contract = await seedCustomerSignedContract();
await db('email_queue').del();
const res = await request(contractApp)
.post(`/api/admin/contracts/${contract.id}/countersign`)
.set(auth)
.send({ name: 'Admin Tester', signatureDataUrl: SIGNATURE_DATA_URL });
expect(res.status).toBe(200);
expect(res.body.status).toBe('fully_signed');
const row = await db('contracts').where({ id: contract.id }).first();
expect(row.status).toBe('fully_signed');
expect(row.signed_admin_name).toBe('Admin Tester');
expect(row.signed_by_admin_at).toBeTruthy();
// The customer's own signature (from the real sign flow in the seed)
// must survive countersigning — layered, not replaced.
expect(row.signed_customer_signature_path).toBeTruthy();
expect(fs.existsSync(row.signed_customer_signature_path)).toBe(true);
expect(row.signed_customer_name).toBe('Custo Mer');
// Admin signature image persisted under the storage root
expect(row.signed_admin_signature_path).toBeTruthy();
expect(row.signed_admin_signature_path.startsWith(
path.join(storageRoot, 'contract', 'signatures'),
)).toBe(true);
expect(fs.existsSync(row.signed_admin_signature_path)).toBe(true);
// Stamped, fully-signed PDF written and hashed. The issue spec
// called this `integrity_hash`; the real column is
// `signed_pdf_sha256` (plus `pdf_sha256` for the unsigned base).
expect(row.signed_pdf_render_failed_at).toBeFalsy();
expect(row.signed_pdf_path).toBeTruthy();
expect(fs.existsSync(row.signed_pdf_path)).toBe(true);
expect(row.signed_pdf_sha256).toMatch(/^[0-9a-f]{64}$/);
expect(sha256(fs.readFileSync(row.signed_pdf_path))).toBe(row.signed_pdf_sha256);
// BOTH stamps must be embedded in the final document — a regression
// stamping the admin onto the unsigned base PDF would keep every
// path/hash assertion above green (codex review of #850 round 2).
const imagesPerPage = await countImagesPerPage(row.signed_pdf_path);
const maxImagesOnAPage = Math.max(...imagesPerPage);
expect(maxImagesOnAPage).toBeGreaterThanOrEqual(2);
// contract_fully_signed email to the customer's primary address,
// carrying the signed PDF as attachment (plus the audit cert).
const emails = await db('email_queue').where({ email_type: 'contract_fully_signed' });
const customerCopy = emails.find((e) => e.recipient_email === CUSTOMER_EMAIL);
expect(customerCopy).toBeTruthy();
const emailData = JSON.parse(customerCopy.email_data);
expect(emailData.contract_number).toBe(contract.contract_number);
expect(Array.isArray(emailData.attachments)).toBe(true);
const pdfAttachment = emailData.attachments.find(
(a) => a.filename === `${contract.contract_number}-signed.pdf`,
);
expect(pdfAttachment).toBeTruthy();
expect(pdfAttachment.contentType).toBe('application/pdf');
expect(fs.existsSync(pdfAttachment.contentPath)).toBe(true);
});
test('draft contract: 409 — countersign requires sent/signed_by_customer', async () => {
const draftId = await contractService.createContract({
customerAccountId: customerId,
title: 'Noch nicht versendet',
}, adminId);
const res = await request(contractApp)
.post(`/api/admin/contracts/${draftId}/countersign`)
.set(auth)
.send({ name: 'Admin Tester' });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/cannot counter-sign a contract with status 'draft'/i);
});
});
@@ -14,7 +14,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService,
// nodemailer, etc.) on first use; the global 5 s per-test budget is
// too tight for that. Bump it for this file only.
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('discount line items (negative unit_price_minor)', () => {
let db;
@@ -1,256 +0,0 @@
/**
* Download resolutions (#858).
*
* Pins the contracts that are easy to break later:
*
* - the global → per-event cascade, including NULL = inherit
* - the picker never offers a size ABOVE the standard (a photographer who
* lowers the standard is not silently handing out full-res), and 'Original'
* only reappears when the admin explicitly allows it
* - `fit: 'inside'` + no-upscaling resize semantics, which is exactly what
* the requester asked for on the issue
* - a guest-supplied resolution is validated against the policy rather than
* trusted
*/
const sharp = require('sharp');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Both modules under test pull in src/database/db.js transitively. bootCrmDb
// only works when it runs BEFORE the first require of db.js (it sets
// TEST_DATABASE_PATH, which knexfile reads at module-init time), so these are
// required lazily in beforeAll rather than at module scope — otherwise knex
// binds to the shared default SQLite file and every run after the first one
// fails with "table `migrations` already exists".
let resolveEventDownloadPolicy;
let pickRequestedResolution;
let parseResolution;
let invalidateDownloadGlobals;
let resizeToBox;
describe('Download resolutions (#858)', () => {
let db;
let cleanup;
const setGlobal = async (key, value) => {
await db('app_settings').where({ setting_key: key }).del();
await db('app_settings').insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'download',
updated_at: new Date().toISOString(),
});
invalidateDownloadGlobals();
};
const PRESETS = [
{ label: 'Large', width: 3000, height: 2000 },
{ label: 'Medium', width: 1500, height: 1000 },
{ label: 'Small', width: 800, height: 600 },
];
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
({
resolveEventDownloadPolicy,
pickRequestedResolution,
parseResolution,
invalidateDownloadGlobals,
} = require('../../src/utils/downloadResolutions'));
({ resizeToBox } = require('../../src/services/imageProcessor'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await setGlobal('download_resolutions', PRESETS);
await setGlobal('download_standard_resolution', 'original');
await setGlobal('download_resolution_picker_enabled', false);
await setGlobal('download_allow_original', false);
});
describe('cascade', () => {
it('inherits the global standard when the event has no override', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
const policy = await resolveEventDownloadPolicy({ download_standard_resolution: null });
expect(policy.standard).toBe('1500x1000');
expect(policy.standardBox).toEqual({ width: 1500, height: 1000 });
});
it('lets an event override the global standard', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
const policy = await resolveEventDownloadPolicy({ download_standard_resolution: '800x600' });
expect(policy.standard).toBe('800x600');
});
it('treats a NULL picker flag as inherit and an explicit false as override', async () => {
await setGlobal('download_resolution_picker_enabled', true);
expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: null })).pickerEnabled).toBe(true);
expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: false })).pickerEnabled).toBe(false);
});
});
describe('choice list', () => {
it('never offers a size larger than the standard', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
const { choices } = await resolveEventDownloadPolicy({});
expect(choices.map((c) => c.id)).toEqual(['1500x1000', '800x600']);
// The regression that matters: 3000x2000 must not be reachable.
expect(choices.some((c) => c.id === '3000x2000')).toBe(false);
});
it('bounds EACH dimension, not the pixel area (codex review round 2)', async () => {
// 2000x700 is 1.4MP — under 1500x1000's 1.5MP — so an area comparison
// would offer it and hand back a 2000px-wide file despite a 1500px cap.
await setGlobal('download_resolutions', [
...PRESETS,
{ label: 'Wide', width: 2000, height: 700 },
]);
await setGlobal('download_standard_resolution', '1500x1000');
const { choices } = await resolveEventDownloadPolicy({});
expect(choices.some((c) => c.id === '2000x700')).toBe(false);
});
it('omits Original when the standard is capped and the admin has not allowed it', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
const { choices } = await resolveEventDownloadPolicy({});
expect(choices.some((c) => c.id === 'original')).toBe(false);
});
it('re-adds Original when the admin explicitly allows it', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
await setGlobal('download_allow_original', true);
const { choices } = await resolveEventDownloadPolicy({});
expect(choices[0].id).toBe('original');
});
it('offers Original when the standard already is original', async () => {
const { choices } = await resolveEventDownloadPolicy({});
expect(choices[0].id).toBe('original');
expect(choices.map((c) => c.id)).toContain('3000x2000');
});
});
describe('request validation', () => {
it('falls back to the standard when nothing is requested', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
const policy = await resolveEventDownloadPolicy({});
expect(pickRequestedResolution(policy, undefined)).toBe('1500x1000');
});
it('refuses any explicit request while the picker is off', async () => {
const policy = await resolveEventDownloadPolicy({});
expect(policy.pickerEnabled).toBe(false);
expect(pickRequestedResolution(policy, '800x600')).toBeNull();
});
it('refuses a size that is not on the offered list', async () => {
await setGlobal('download_resolution_picker_enabled', true);
await setGlobal('download_standard_resolution', '1500x1000');
const policy = await resolveEventDownloadPolicy({});
// Above the standard → not offered → rejected rather than silently served.
expect(pickRequestedResolution(policy, '3000x2000')).toBeNull();
expect(pickRequestedResolution(policy, '9999x9999')).toBeNull();
expect(pickRequestedResolution(policy, '800x600')).toBe('800x600');
});
it('parses only well-formed resolution ids', () => {
expect(parseResolution('original')).toBeNull();
expect(parseResolution(null)).toBeNull();
expect(parseResolution('abc')).toBeNull();
expect(parseResolution('0x0')).toBeNull();
expect(parseResolution('1500x1000')).toEqual({ width: 1500, height: 1000 });
});
});
describe('job dedup identity (codex review round 1)', () => {
// The leak this pins: a PIN client's archive contains hidden photos. If the
// dedup key ignored the visibility scope, a guest asking for the same size
// would be handed the client's job token — and the delivery route only
// checked the event id.
let jobService;
beforeAll(() => {
jobService = require('../../src/services/downloadJobService');
});
it('separates client and guest archives of the same size and photo set', () => {
const guest = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public');
const client = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'hidden');
expect(guest).not.toBe(client);
});
it('keys on the RESOLVED photo set, so a stale archive is not reused', () => {
const before = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public');
const afterUpload = jobService.dedupKey(1, '1500x1000', [1, 2, 3, 4], false, 'public');
const afterHide = jobService.dedupKey(1, '1500x1000', [1, 2], false, 'public');
expect(new Set([before, afterUpload, afterHide]).size).toBe(3);
});
it('is order-independent for the same set', () => {
expect(jobService.dedupKey(1, 'original', [3, 1, 2], true, 'public'))
.toBe(jobService.dedupKey(1, 'original', [1, 2, 3], true, 'public'));
});
it('maps access levels onto the two visibility scopes', () => {
expect(jobService.visibilityScopeFor('client')).toBe('hidden');
expect(jobService.visibilityScopeFor('guest')).toBe('public');
expect(jobService.visibilityScopeFor(undefined)).toBe('public');
});
});
describe('resize semantics', () => {
const make = (w, h) => sharp({
create: { width: w, height: h, channels: 3, background: { r: 10, g: 100, b: 200 } },
}).jpeg().toBuffer();
const box = { width: 1500, height: 1000 };
it('fits a 3:2 photo exactly into a 3:2 box', async () => {
const out = await sharp(await resizeToBox(await make(6000, 4000), box)).metadata();
expect([out.width, out.height]).toEqual([1500, 1000]);
});
it('treats the box as an "up to" bound for other aspect ratios', async () => {
// Portrait: height is the binding edge, width comes out smaller.
const portrait = await sharp(await resizeToBox(await make(4000, 6000), box)).metadata();
expect(portrait.height).toBe(1000);
expect(portrait.width).toBeLessThan(1500);
const fourThree = await sharp(await resizeToBox(await make(4000, 3000), box)).metadata();
expect(fourThree.height).toBe(1000);
expect(fourThree.width).toBeLessThan(1500);
});
it('never upscales an image already smaller than the box', async () => {
const out = await sharp(await resizeToBox(await make(800, 600), box)).metadata();
expect([out.width, out.height]).toEqual([800, 600]);
});
it('passes the buffer through untouched for the original size', async () => {
const src = await make(4000, 3000);
expect(await resizeToBox(src, null)).toBe(src);
});
it('keeps the source format so the filename and mime type stay honest', async () => {
// A .gif re-encoded as JPEG would ship mislabelled bytes, since the
// download routes keep the original filename and mime type.
const gif = await sharp({
create: { width: 4000, height: 3000, channels: 3, background: { r: 1, g: 2, b: 3 } },
}).gif().toBuffer();
const out = await sharp(await resizeToBox(gif, box)).metadata();
expect(out.format).toBe('gif');
expect(out.width).toBe(1333);
});
it('returns the input rather than throwing on an undecodable source', async () => {
const junk = Buffer.from('not an image');
expect(await resizeToBox(junk, box)).toBe(junk);
});
});
});
@@ -1,276 +0,0 @@
/**
* Global email footer signature (migration 198, issue #1264).
*
* The signature is rendered by `wrapEmailHtml` and nowhere else, which is
* the whole point of the design: every template, preview, test mail and
* manual send passes through that one wrapper, so none of them needed a
* per-template change. These tests pin that contract at the wrapper.
*
* The load-bearing case is the DISABLED one — an upgraded install must keep
* a byte-identical footer until an admin opts in.
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('wrapEmailHtml — business-profile signature footer', () => {
let db;
let cleanup;
let wrapEmailHtml;
let renderEmailSignatureText;
let businessProfileService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ wrapEmailHtml, renderEmailSignatureText } = require('../../src/services/emailProcessor'));
businessProfileService = require('../../src/services/businessProfileService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('business_profile').where({ id: 1 }).update({
company_name: null,
address_line1: null,
address_line2: null,
postal_code: null,
city: null,
country_code: null,
country_name: null,
phone: null,
mobile: null,
email: null,
website: null,
vat_id: null,
email_signature_enabled: false,
email_signature_extra: null,
});
businessProfileService.invalidateEmailSignatureCache();
});
const fullProfile = {
company_name: 'Müller Fotografie GmbH',
address_line1: 'Bahnhofstrasse 1',
address_line2: 'Postfach 42',
postal_code: '9494',
city: 'Schaan',
country_code: 'li',
country_name: 'Liechtenstein',
phone: '+41 79 123 45 67',
mobile: '+41 78 000 11 22',
email: 'hello@example.com',
website: 'example.com',
vat_id: 'CHE-123.456.789',
email_signature_enabled: true,
email_signature_extra: 'Handelsregister Vaduz\nFL-0002.123.456-7',
};
async function enable(overrides = {}) {
await db('business_profile').where({ id: 1 }).update({ ...fullProfile, ...overrides });
businessProfileService.invalidateEmailSignatureCache();
}
it('renders nothing extra when the toggle is off', async () => {
// Profile fully populated, signature switched OFF: the operator's
// address must not leak into mail just because they filled in the
// invoice issuer block.
await enable({ email_signature_enabled: false });
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
expect(html).not.toContain('Bahnhofstrasse 1');
expect(html).not.toContain('hello@example.com');
expect(html).not.toContain('CHE-123.456.789');
});
it('produces a byte-identical footer to a no-profile install when disabled', async () => {
const withEmptyProfile = await wrapEmailHtml('<p>Body</p>', 'Subject');
await enable({ email_signature_enabled: false });
const withDisabledSignature = await wrapEmailHtml('<p>Body</p>', 'Subject');
expect(withDisabledSignature).toBe(withEmptyProfile);
});
it('renders address, contacts, VAT id and the legal line when enabled', async () => {
await enable();
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
expect(html).toContain('Müller Fotografie GmbH');
expect(html).toContain('Bahnhofstrasse 1');
expect(html).toContain('Postfach 42');
// "LI-9494 Schaan / Liechtenstein" — same shape as the PDF issuer block.
expect(html).toContain('LI-9494 Schaan / Liechtenstein');
expect(html).toContain('VAT ID: CHE-123.456.789');
expect(html).toContain('Handelsregister Vaduz<br />FL-0002.123.456-7');
});
it('links phone, mobile, email and website with safe schemes', async () => {
await enable();
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
// Separators stripped from the tel: href, kept in the visible text.
expect(html).toContain('href="tel:+41791234567"');
expect(html).toContain('href="tel:+41780001122"');
expect(html).toContain('href="mailto:hello@example.com"');
// A bare hostname is promoted to https:// rather than left relative.
expect(html).toContain('href="https://example.com"');
});
it('keeps an already-absolute website URL as typed', async () => {
await enable({ website: 'http://legacy.example.org/studio' });
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
expect(html).toContain('href="http://legacy.example.org/studio"');
});
it('neutralises a javascript: website into an inert https URL', async () => {
await enable({ website: 'javascript:alert(1)' });
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
expect(html).not.toContain('href="javascript:');
expect(html).toContain('href="https://javascript:alert(1)"');
});
it('HTML-escapes every signature field', async () => {
await enable({
company_name: '<script>alert(1)</script>',
address_line1: 'Rue "des" Fleurs & Co',
vat_id: '<img src=x onerror=alert(1)>',
email_signature_extra: '</p><script>alert(2)</script>',
});
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
// Escaped, so the markup is inert text — the tags never open.
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('<script>alert(2)</script>');
expect(html).not.toContain('<img src=x');
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
expect(html).toContain('Rue &quot;des&quot; Fleurs &amp; Co');
});
it('does not repeat the branding company name', async () => {
// Footer already prints the branding name; the profile name is only
// added when the operator gave a different legal name.
await db('app_settings')
.insert({ setting_key: 'branding_company_name', setting_value: JSON.stringify('Müller Fotografie GmbH'), setting_type: 'branding' })
.onConflict('setting_key')
.merge();
await enable();
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
expect(html.match(/Müller Fotografie GmbH/g).length).toBe(
// header alt, footer alt, footer name line, copyright line — the
// signature must not add a fifth.
(await wrapEmailHtml('<p>Body</p>', 'Subject', 'en')).match(/Müller Fotografie GmbH/g).length
);
expect(html.match(/Müller Fotografie GmbH/g).length).toBe(4);
await db('app_settings').where({ setting_key: 'branding_company_name' }).del();
});
it('uses the German VAT label for a German mail', async () => {
await enable();
const de = await wrapEmailHtml('<p>Body</p>', 'Subject', 'de');
const en = await wrapEmailHtml('<p>Body</p>', 'Subject', 'en');
expect(de).toContain('USt-IdNr.: CHE-123.456.789');
expect(en).toContain('VAT ID: CHE-123.456.789');
});
it('omits empty fields instead of rendering blank rows', async () => {
await enable({
address_line2: null, mobile: null, website: null, vat_id: null, email_signature_extra: null,
});
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
expect(html).toContain('hello@example.com');
expect(html).not.toContain('VAT ID:');
expect(html).not.toMatch(/&middot;\s*&middot;/);
});
it('renders no signature block when enabled but the profile is blank', async () => {
await db('business_profile').where({ id: 1 }).update({ email_signature_enabled: true });
businessProfileService.invalidateEmailSignatureCache();
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
// The signature <div> is the only element carrying this margin.
expect(html).not.toContain('<div style="margin:15px 0 5px;');
});
// Codex review: sendTemplateEmail uses a template's own body_text when it
// has one — every seeded template does — so the text/plain alternative is
// NOT derived from the wrapped HTML and carried no signature at all.
describe('plain-text alternative', () => {
it('renders the signature as plain text', async () => {
await enable();
const signature = await businessProfileService.getEmailSignature();
const text = renderEmailSignatureText(signature, { brandingCompanyName: 'PicPeak', language: 'en' });
expect(text).toContain('Bahnhofstrasse 1');
expect(text).toContain('hello@example.com');
expect(text).toContain('VAT ID: CHE-123.456.789');
expect(text).toContain('Handelsregister Vaduz');
// A separator, the text equivalent of the footer's top border.
expect(text).toMatch(/^\n\n--\n/);
});
it('carries no HTML markup or entities', async () => {
await enable();
const signature = await businessProfileService.getEmailSignature();
const text = renderEmailSignatureText(signature, { brandingCompanyName: 'PicPeak', language: 'en' });
expect(text).not.toContain('<');
expect(text).not.toContain('&middot;');
expect(text).not.toContain('&amp;');
});
it('is empty when the signature is disabled', () => {
expect(renderEmailSignatureText(null, {})).toBe('');
});
it('uses the German VAT label for a German mail', async () => {
await enable();
const signature = await businessProfileService.getEmailSignature();
expect(renderEmailSignatureText(signature, { language: 'de' })).toContain('USt-IdNr.');
expect(renderEmailSignatureText(signature, { language: 'en' })).toContain('VAT ID');
});
it('does not repeat the branding company name', async () => {
await enable();
const signature = await businessProfileService.getEmailSignature();
const text = renderEmailSignatureText(signature, {
brandingCompanyName: 'Müller Fotografie GmbH', language: 'en',
});
expect(text).not.toContain('Müller Fotografie GmbH');
});
});
it('leaves the plain-text part free of signature markup', async () => {
const { htmlToText } = require('../../src/services/emailProcessor');
await enable();
const text = htmlToText(await wrapEmailHtml('<p>Body</p>', 'Subject'));
expect(text).toContain('Bahnhofstrasse 1');
expect(text).not.toContain('<p');
expect(text).not.toContain('style=');
expect(text).not.toContain('&middot;');
// The separator survives as a real character, not an entity.
expect(text).toContain('Bahnhofstrasse 1 \u00b7 Postfach 42');
});
});
@@ -1,50 +0,0 @@
/**
* Catalog-driven event-type defaults (#800 follow-up).
*
* The contract→event conversion used to hardcode `event_type: 'wedding'` and
* the v1 API validated against a fixed whitelist. Both now follow the live
* event_types catalog; these tests pin the shared resolver.
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('resolveDefaultEventType follows the catalog', () => {
let db;
let cleanup;
let eventTypeService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Require AFTER bootCrmDb so the service shares this db instance
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
eventTypeService = require('../../src/services/eventTypeService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it("prefers the 'other' catch-all while it is active", async () => {
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
});
it('falls over to the first active type when other is deactivated', async () => {
const other = await db('event_types').where({ slug_prefix: 'other' }).first();
await db('event_types').where({ id: other.id }).update({ is_active: 0 });
const resolved = await eventTypeService.resolveDefaultEventType();
expect(resolved).not.toBe('other');
expect(await db('event_types').where({ slug_prefix: resolved }).first()).toBeTruthy();
await db('event_types').where({ id: other.id }).update({ is_active: 1 });
});
it("returns the literal 'other' only for an empty catalog", async () => {
const rows = await db('event_types').select('*');
await db('event_types').del();
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
await db('event_types').insert(rows);
});
});
@@ -6,7 +6,7 @@
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll.
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('event type slug rename cascade', () => {
let db;
@@ -1,133 +0,0 @@
/**
* Setup-window event type deletion (#800).
*
* The first-run setup wizard may delete the seeded SYSTEM event types —
* but ONLY while the `setup_wizard_completed` flag is unset (migration 161
* seeds it false on a fresh install, true when an admin already exists).
* These tests pin the whole contract:
*
* - fresh install → flag false → system types deletable (in-use checks
* still apply), and the per-type reminder template goes with the type
* - reminder-template self-heal does NOT resurrect templates for slugs
* that no longer exist in the catalog
* - after markSetupWizardCompleted() → system deletion is refused again
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('event type deletion during the setup window (#800)', () => {
let db;
let cleanup;
let eventTypeService;
let setupService;
let ensureEventReminderTemplatesSeeded;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Require AFTER bootCrmDb so every service shares this db instance
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
eventTypeService = require('../../src/services/eventTypeService');
setupService = require('../../src/services/setupService');
({ ensureEventReminderTemplatesSeeded } = require('../../src/services/eventReminderTemplates'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('migration 161 seeds the flag false on a fresh (admin-less) install', async () => {
const row = await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).first();
expect(row).toBeTruthy();
expect(JSON.parse(row.setting_value)).toBe(false);
expect(await setupService.isSetupWizardCompleted()).toBe(false);
});
it('refuses to delete a system type that events already use, even in the window', async () => {
const corporate = await db('event_types').where({ slug_prefix: 'corporate' }).first();
await db('events').insert({
slug: 'corporate-test-2026-01-01',
event_name: 'Test',
event_type: 'corporate',
event_date: '2026-01-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: 'share-corporate-test',
expires_at: new Date(Date.now() + 86400000),
});
await expect(eventTypeService.deleteEventType(corporate.id))
.rejects.toMatchObject({ code: 'IN_USE' });
});
it('deletes an unused system type in the window, taking its reminder template along', async () => {
// Seed the per-type reminder templates first so there is something to clean up.
await ensureEventReminderTemplatesSeeded(db);
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeTruthy();
const wedding = await db('event_types').where({ slug_prefix: 'wedding' }).first();
expect(wedding.is_system).toBeTruthy();
const result = await eventTypeService.deleteEventType(wedding.id);
expect(result.success).toBe(true);
expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy();
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
// The deleted slug must NOT stay creatable through the legacy fallback —
// the live catalog is authoritative while it has rows.
expect(await eventTypeService.isValidEventType('wedding')).toBe(false);
expect(await eventTypeService.isValidEventType('birthday')).toBe(true);
});
it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => {
// The seeder caches success per process — reset the module to force a
// genuine second pass, exactly what a backend restart would run.
jest.resetModules();
const fresh = require('../../src/services/eventReminderTemplates');
await fresh.ensureEventReminderTemplatesSeeded(db);
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
// Types still in the catalog keep their templates.
expect(await db('email_templates').where({ template_key: 'event_reminder_birthday' }).first()).toBeTruthy();
expect(await db('email_templates').where({ template_key: 'event_reminder_default' }).first()).toBeTruthy();
});
it('re-locks system types once the wizard is marked complete', async () => {
await setupService.markSetupWizardCompleted();
expect(await setupService.isSetupWizardCompleted()).toBe(true);
const birthday = await db('event_types').where({ slug_prefix: 'birthday' }).first();
await expect(eventTypeService.deleteEventType(birthday.id))
.rejects.toMatchObject({ code: 'SYSTEM_TYPE' });
// Custom (non-system) types remain deletable as before.
const custom = await eventTypeService.createEventType({ name: 'Family', slug_prefix: 'family' });
const result = await eventTypeService.deleteEventType(custom.id);
expect(result.success).toBe(true);
});
it('fails closed when the completion marker row is missing', async () => {
// A portable-backup restore can replace app_settings with a set that
// predates migration 161 (which will not rerun) — absence must mean
// "configured instance", never an open deletion window.
await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
expect(await setupService.isSetupWizardCompleted()).toBe(true);
await setupService.markSetupWizardCompleted();
});
it('refuses to delete the last remaining event type', async () => {
// Reduce the catalog to a single custom type via direct db writes (the
// service paths are already covered above), then hit the guard.
const solo = await eventTypeService.createEventType({ name: 'Solo', slug_prefix: 'solo' });
await db('events').del();
await db('event_types').whereNot('id', solo.id).del();
await expect(eventTypeService.deleteEventType(solo.id))
.rejects.toMatchObject({ code: 'LAST_TYPE' });
// Deactivating it would empty the ACTIVE catalog just the same.
await expect(eventTypeService.updateEventType(solo.id, { is_active: false }))
.rejects.toMatchObject({ code: 'LAST_ACTIVE' });
});
});
@@ -1,161 +0,0 @@
/**
* External imports must record captured_at (#1172).
*
* Managed uploads get it from photoProcessor, which external media never goes
* through — so every externally imported photo carried captured_at NULL, and
* the gallery's "Date Taken" sort fell back to uploaded_at through its
* COALESCE. On a library imported in two batches that ordered a 12-day trip by
* which folder was imported first: the reporter's first two days landed at
* positions 4204-5296 of 5555.
*
* Driven through the real route against real files carrying real EXIF, because
* the whole question is whether the import reads the file it already has open.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const sharp = require('sharp');
describe('external import capture dates (#1172)', () => {
let tmpDir; let db; let app; let mediaRoot;
/**
* A real JPEG carrying DateTimeOriginal.
*
* IFD2, not IFD0 — DateTimeOriginal lives in the Exif IFD, and exifr does not
* see it anywhere else (IFD0 takes plain DateTime, which surfaces as
* ModifyDate instead).
*/
const writeJpegWithExif = async (rel, iso) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 10, g: 20, b: 30 } } })
.withExif({ IFD2: { DateTimeOriginal: exifDate } })
.jpeg()
.toFile(full);
return full;
};
const writeJpegNoExif = async (rel) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 200, g: 10, b: 10 } } })
.jpeg().toFile(full);
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capdate-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capdate-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('../../src/services/imageProcessor', () => {
const actual = jest.requireActual('../../src/services/imageProcessor');
return { ...actual, generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'), ensureThumbnail: jest.fn() };
});
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
const [e] = await db('events').insert({
slug: `capdate-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding', event_name: 'capdate', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `capdate-${Math.random()}`, expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId, external_path) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path, recursive: true });
it('records the EXIF capture date on import', async () => {
const eventId = await seedEvent();
await writeJpegWithExif('trip/a.jpg', '2026-06-01T09:45:03Z');
await runImport(eventId, 'trip');
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.captured_at).toBeTruthy();
// NOT asserted as an absolute instant. EXIF carries a naive wall-clock
// time and exifr resolves it against the HOST timezone, so the stored UTC
// value differs between a CEST developer machine and a UTC runner. What
// this fix is about is that the field is populated and orders correctly;
// that captured_at is not a true instant is a separate, pre-existing
// problem shared with managed uploads (#1172's own footnote).
expect(new Date(photo.captured_at).getUTCFullYear()).toBe(2026);
expect(new Date(photo.captured_at).getUTCMonth()).toBe(5); // June
});
it('imports a photo with no EXIF date rather than failing it', async () => {
// Plenty of sources carry none; that must stay an import, not an error.
const eventId = await seedEvent();
await writeJpegNoExif('trip/plain.jpg');
const res = await runImport(eventId, 'trip');
expect(res.body.imported).toBe(1);
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.captured_at).toBeNull();
});
it('orders a two-batch import by capture time, not by batch', async () => {
// The reported shape: the FIRST days of the trip imported second. Sorting
// on COALESCE(captured_at, uploaded_at) put them after the last days,
// because uploaded_at is the import timestamp.
const eventId = await seedEvent();
await writeJpegWithExif('late/day12.jpg', '2026-06-12T10:00:00Z');
await runImport(eventId, 'late');
await writeJpegWithExif('early/day01.jpg', '2026-06-01T10:00:00Z');
await runImport(eventId, 'early');
const rows = await db('photos')
.where({ event_id: eventId })
.orderByRaw('COALESCE(captured_at, uploaded_at) asc')
.select('filename');
expect(rows.map((r) => r.filename)).toEqual(['day01.jpg', 'day12.jpg']);
});
});
@@ -1,209 +0,0 @@
/**
* Two overlapping external imports insert every file twice (#1162).
*
* The route checked for an existing external_relpath and then inserted, with
* an fs.stat and a `sharp().metadata()` read sitting in between. A reporter
* double-clicked a slow import of a 6012-file tree and got 8004 rows.
*
* Both halves of the fix are driven here through the real route:
*
* - the in-flight guard, which turns the second click into a 409 instead of
* a second full walk of the tree;
* - convergence when the guard cannot help (another replica, another
* process), which is the unique index from migration 186 firing and the
* loop counting a skip rather than dying or duplicating.
*
* The second is exercised by inserting a competing row from inside the mocked
* `sharp().metadata()` call — literally inside the window the bug lived in.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('concurrent external imports (#1162)', () => {
let tmpDir; let db; let app; let mediaRoot;
// When set, the mocked sharp metadata read inserts this row first — the
// other run winning the race between our SELECT and our INSERT.
let stealDuringMetadata = null;
let thumbnailDelayMs = 0;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extdup-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
}
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extdup-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
// The window. In production this is a real decode of a NAS-hosted file —
// hundreds of milliseconds during which the row we just proved absent can
// appear. Standing in for the other run here makes that deterministic.
jest.doMock('sharp', () => () => ({
metadata: async () => {
if (stealDuringMetadata) {
const { db: liveDb } = require('../../src/database/db');
await liveDb('photos').insert(stealDuringMetadata);
stealDuringMetadata = null;
}
return { width: 100, height: 200 };
},
}));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(async () => {
if (thumbnailDelayMs) await new Promise((r) => setTimeout(r, thumbnailDelayMs));
return 'thumbnails/mock.jpg';
}),
ensureThumbnail: jest.fn(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
stealDuringMetadata = null;
thumbnailDelayMs = 0;
const [e] = await db('events').insert({
slug: `extdup-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'extdup',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `extdup-${Math.random()}`,
expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path: 'nas', recursive: true });
async function relpathCounts(eventId) {
const rows = await db('photos').where({ event_id: eventId }).select('external_relpath');
const counts = new Map();
for (const r of rows) counts.set(r.external_relpath, (counts.get(r.external_relpath) || 0) + 1);
return counts;
}
it('rejects a second import while the first is still running', async () => {
const eventId = await seedEvent();
// Enough to keep the first request inside its loop while the second
// arrives — the "slow import looks hung, so I clicked again" case.
thumbnailDelayMs = 20;
const [first, second] = await Promise.all([runImport(eventId), runImport(eventId)]);
const statuses = [first.status, second.status].sort();
expect(statuses).toEqual([200, 409]);
const rejected = first.status === 409 ? first : second;
expect(rejected.body.error).toMatch(/already running/i);
});
it('leaves exactly one row per file after both runs', async () => {
const eventId = await seedEvent();
thumbnailDelayMs = 20;
await Promise.all([runImport(eventId), runImport(eventId)]);
const counts = await relpathCounts(eventId);
expect(counts.size).toBe(3);
expect([...counts.values()]).toEqual([1, 1, 1]);
});
it('releases the event once the import finishes, so a re-import still works', async () => {
const eventId = await seedEvent();
expect((await runImport(eventId)).status).toBe(200);
// Not 409 — the guard is per run, not a permanent lock on the event.
const second = await runImport(eventId);
expect(second.status).toBe(200);
expect(second.body.imported).toBe(0);
expect(second.body.skipped).toBe(3);
});
it('converges when another writer wins the race mid-file', async () => {
// The guard is in-process, so it cannot see a second replica. This is what
// the unique index is for: the insert bounces, and the file is counted as
// skipped rather than duplicated or lost to a 500.
const eventId = await seedEvent();
stealDuringMetadata = {
event_id: eventId,
filename: 'a.jpg',
path: 'x/a.jpg',
type: 'individual',
source_origin: 'external',
// Root-relative, as the route now writes it (#1163) — the competing
// writer has to target the same value for the race to be real.
external_relpath: path.join('nas', 'individual', 'a.jpg'),
};
const res = await runImport(eventId);
expect(res.status).toBe(200);
const counts = await relpathCounts(eventId);
expect(counts.get(path.join('nas', 'individual', 'a.jpg'))).toBe(1);
// Two imported by us, one lost to the other writer and reported honestly.
expect(res.body.imported).toBe(2);
expect(res.body.skipped).toBe(1);
});
it('does not let one contended file abort the rest of the import', async () => {
const eventId = await seedEvent();
stealDuringMetadata = {
event_id: eventId,
filename: 'a.jpg',
path: 'x/a.jpg',
type: 'individual',
source_origin: 'external',
// Root-relative, as the route now writes it (#1163) — the competing
// writer has to target the same value for the race to be real.
external_relpath: path.join('nas', 'individual', 'a.jpg'),
};
await runImport(eventId);
// All three files present — the contended one via the other writer's row.
expect((await relpathCounts(eventId)).size).toBe(3);
});
});
@@ -1,175 +0,0 @@
/**
* Importing a second folder must not move the photos already in the event (#1163).
*
* events.external_path is overwritten by every import, and external_relpath
* used to be stored relative to it — so a second import silently rebased every
* existing row onto the new folder. The reporter had 7547 of 8004 originals
* pointing at files that do not exist, and nothing said so: thumbnails are
* written to local storage during the import while the base path is still
* correct, so the grid carries on rendering.
*
* Driven through the real route and the real resolver, against a real
* directory tree — the failure is entirely about whether a file is where the
* app looks for it.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('a second external import (#1163)', () => {
let tmpDir; let db; let app; let mediaRoot; let resolvePhotoFilePath;
const touch = async (rel) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await fs.promises.writeFile(full, 'not-a-real-jpeg');
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-ext2nd-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'ext2nd-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('sharp', () => () => ({ metadata: async () => ({ width: 100, height: 200 }) }));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'),
ensureThumbnail: jest.fn(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
({ resolvePhotoFilePath } = require('../../src/services/photoResolver'));
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
const [e] = await db('events').insert({
slug: `ext2nd-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'ext2nd',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `ext2nd-${Math.random()}`,
expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId, external_path) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path, recursive: true });
/** Where the app would go looking for this photo's original, right now. */
async function resolved(eventId, filename) {
const event = await db('events').where({ id: eventId }).first();
const photo = await db('photos').where({ event_id: eventId, filename }).first();
return resolvePhotoFilePath(event, photo);
}
it('stores paths relative to the media root, not to the imported folder', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/old.jpg');
await runImport(eventId, 'Trip');
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.external_relpath).toBe(path.join('Trip', 'Leknes', 'old.jpg'));
});
it('leaves the first folders originals reachable after a second import', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/old.jpg');
await touch('Trip/Sub/new.jpg');
await runImport(eventId, 'Trip');
const before = await resolved(eventId, 'old.jpg');
await runImport(eventId, 'Trip/Sub');
const after = await resolved(eventId, 'old.jpg');
// The regression: `after` used to be <root>/Trip/Sub/Leknes/old.jpg.
expect(after).toBe(before);
expect(fs.existsSync(after)).toBe(true);
});
it('every original in the event is still on disk afterwards', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/a.jpg');
await touch('Trip/Leknes/b.jpg');
await touch('Trip/Sub/c.jpg');
await runImport(eventId, 'Trip');
await runImport(eventId, 'Trip/Sub');
const event = await db('events').where({ id: eventId }).first();
const photos = await db('photos').where({ event_id: eventId });
expect(photos).toHaveLength(3);
for (const photo of photos) {
expect(fs.existsSync(resolvePhotoFilePath(event, photo))).toBe(true);
}
});
it('does not re-insert a file the first import already took', async () => {
// The dedupe check compares stored paths, so it has to be comparing the
// same shape the insert writes.
const eventId = await seedEvent();
await touch('Trip/Sub/c.jpg');
await runImport(eventId, 'Trip');
const second = await runImport(eventId, 'Trip/Sub');
expect(second.body.imported).toBe(0);
expect(second.body.skipped).toBe(1);
expect(await db('photos').where({ event_id: eventId }).count('* as c').first()).toEqual({ c: 1 });
});
it('resolves a subfolder that repeats its parents name', async () => {
// The old resolver stripped the relpath's first segment when it matched the
// base path's last one, which broke exactly this layout.
const eventId = await seedEvent();
await touch('Trip/Trip/x.jpg');
await runImport(eventId, 'Trip');
const event = await db('events').where({ id: eventId }).first();
const photo = await db('photos').where({ event_id: eventId }).first();
expect(resolvePhotoFilePath(event, photo)).toBe(path.join(mediaRoot, 'Trip', 'Trip', 'x.jpg'));
});
});
@@ -1,121 +0,0 @@
/**
* PostgreSQL integration test for the external-path fold (#1163).
*
* Gated the same way as picpeakRestorePg: runs only when PICPEAK_PG_TEST_URL
* points at a throwaway Postgres DB, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_fold_test" \
* npx jest __tests__/integration/externalRelpathFoldPg.test.js
*
* This exists because of a defect SQLite could not have caught. The two-pass
* rewrite parks each row on a temporary value, and that value was first written
* with a leading NUL. SQLite stores NUL in TEXT without complaint; Postgres
* rejects it outright ("invalid byte sequence for encoding UTF8"), so migration
* 187 would have rolled back on exactly the installs needing the repair — and
* only on the engine most of them run.
*
* The staging value is therefore an engine-level contract, not an
* implementation detail, and it is pinned here on the engine that constrains it.
*/
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('external relpath fold on Postgres', () => {
let pgDb; let mediaRoot; let fold;
const touch = async (rel, bytes) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await fs.promises.writeFile(full, Buffer.alloc(bytes));
return bytes;
};
beforeAll(async () => {
mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-foldpg-'));
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
jest.resetModules();
({ foldExternalRelpaths: fold } = require('../../src/services/externalRelpathFold'));
pgDb = knex({ client: 'pg', connection: PG_URL });
}, 60000);
afterAll(async () => {
if (pgDb) await pgDb.destroy();
await fs.promises.rm(mediaRoot, { recursive: true, force: true }).catch(() => {});
delete process.env.EXTERNAL_MEDIA_ROOT;
});
beforeEach(async () => {
await pgDb.raw('DROP TABLE IF EXISTS photos, events, app_settings CASCADE');
await pgDb.schema.createTable('events', (t) => {
t.increments('id');
t.text('external_path');
});
await pgDb.schema.createTable('photos', (t) => {
t.increments('id');
t.integer('event_id');
t.text('external_relpath');
t.bigInteger('size_bytes');
t.string('source_origin').defaultTo('managed');
});
await pgDb.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key');
t.text('setting_value');
t.string('setting_type');
t.string('updated_at');
});
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
});
const relpaths = async () =>
(await pgDb('photos').orderBy('id').select('external_relpath')).map((r) => r.external_relpath);
it('completes the two-pass repair that a NUL staging value would abort', async () => {
// The exact shape that forces staging: `photo.jpg` repairs up to
// `Trip/photo.jpg`, while the row already holding `Trip/photo.jpg` folds
// deeper. Every final value is distinct, but a final value equals another
// row's current one, so the rewrite has to park first.
const a = await touch('Trip/photo.jpg', 11);
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
await pgDb('events').insert({ id: 1, external_path: 'Trip/Sub' });
await pgDb('photos').insert([
{ event_id: 1, external_relpath: 'photo.jpg', size_bytes: a, source_origin: 'external' },
{ event_id: 1, external_relpath: 'Trip/photo.jpg', size_bytes: b, source_origin: 'external' },
]);
await fold(pgDb);
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
});
it('leaves no staging value behind', async () => {
await touch('Trip/a.jpg', 8);
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
await fold(pgDb);
const rows = await relpaths();
expect(rows).toEqual(['Trip/a.jpg']);
expect(rows.some((r) => r.includes('staging'))).toBe(false);
});
it('folds and marks in one transaction', async () => {
await touch('Trip/a.jpg', 8);
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
await fold(pgDb);
// Second run is a no-op: the marker committed with the rewrites.
await fold(pgDb);
expect(await relpaths()).toEqual(['Trip/a.jpg']);
});
});
@@ -1,217 +0,0 @@
/**
* Auto-category rule engine (#1074 phase 3).
*
* The rules themselves are simple enough to read. What needs testing is the
* promise around them: this engine may only ever fill an EMPTY category, and
* everything it touches must be reversible. A photographer's own assignment
* is a decision; this is a heuristic, and the heuristic never wins.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-autocat-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'autocat-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let engine;
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** A scanned photo with `faceCount` faces, each `faceSide` px square. */
async function addScannedPhoto(eventId, faceCount, { faceSide = 400, categoryId = null } = {}) {
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
width: 1000,
height: 1000,
processing_status: 'complete',
face_status: 'done',
face_count: faceCount,
category_id: categoryId,
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
for (let i = 0; i < faceCount; i++) {
await db('photo_faces').insert({
photo_id: photoId,
event_id: eventId,
bbox_x: 10, bbox_y: 10, bbox_w: faceSide, bbox_h: faceSide,
det_score: 0.95,
model_version: 'test-v1',
created_at: new Date().toISOString(),
});
}
return photoId;
}
async function enable(on) {
const existing = await db('app_settings')
.where('setting_key', 'face_auto_categorize_enabled').first();
if (existing) {
await db('app_settings')
.where('setting_key', 'face_auto_categorize_enabled')
.update({ setting_value: JSON.stringify(on) });
}
}
async function categoryOf(photoId) {
const photo = await db('photos').where({ id: photoId }).first();
if (!photo.category_id) return null;
const cat = await db('photo_categories').where({ id: photo.category_id }).first();
return cat?.slug ?? null;
}
describe('faceAutoCategories (#1074 phase 3)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
engine = require('../../src/services/faceAutoCategories');
await enable(true);
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('rules', () => {
it('sorts by face count, and by face size for portraits', async () => {
const eventId = await seedEvent('rules');
// 400px face in a 1000x1000 frame = 16% of the frame, over the 8% floor.
const portrait = await addScannedPhoto(eventId, 1, { faceSide: 400 });
const details = await addScannedPhoto(eventId, 0);
const small = await addScannedPhoto(eventId, 3);
const group = await addScannedPhoto(eventId, 9);
await engine.categorizeEvent(eventId);
expect(await categoryOf(details)).toBe('details');
expect(await categoryOf(portrait)).toBe('portraits');
expect(await categoryOf(small)).toBe('small-groups');
expect(await categoryOf(group)).toBe('groups');
});
it('does not call a distant single face a portrait', async () => {
// One person in a wide landscape is not a portrait of them. 60px in a
// 1000x1000 frame is 0.36% — far below the 8% floor.
const eventId = await seedEvent('small-face');
const distant = await addScannedPhoto(eventId, 1, { faceSide: 60 });
await engine.categorizeEvent(eventId);
expect(await categoryOf(distant)).toBeNull();
});
it('ignores photos that have not been scanned', async () => {
const eventId = await seedEvent('unscanned');
const [p] = await db('photos').insert({
event_id: eventId, filename: 'u.jpg', path: '/tmp/u.jpg', type: 'individual',
processing_status: 'complete', face_status: 'pending',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
await engine.categorizeEvent(eventId);
expect(await categoryOf(photoId)).toBeNull();
});
});
describe('the promise', () => {
it('NEVER overwrites a category a person chose', async () => {
// The single most important behaviour in this file.
const eventId = await seedEvent('no-overwrite');
const [c] = await db('photo_categories').insert({
name: 'Ceremony', slug: 'ceremony', is_global: false, event_id: eventId,
created_at: new Date().toISOString(),
}).returning('id');
const ceremonyId = typeof c === 'object' ? c.id : c;
// 9 faces — the rules would call this "groups" if they were allowed to.
const claimed = await addScannedPhoto(eventId, 9, { categoryId: ceremonyId });
await engine.categorizeEvent(eventId);
expect(await categoryOf(claimed)).toBe('ceremony');
const row = await db('photos').where({ id: claimed }).first();
expect(row.auto_categorized).toBeFalsy();
});
it('marks only what it assigned, so undo is exact', async () => {
const eventId = await seedEvent('undo');
const [c] = await db('photo_categories').insert({
name: 'Ceremony', slug: 'ceremony-2', is_global: false, event_id: eventId,
created_at: new Date().toISOString(),
}).returning('id');
const ceremonyId = typeof c === 'object' ? c.id : c;
const manual = await addScannedPhoto(eventId, 4, { categoryId: ceremonyId });
const auto = await addScannedPhoto(eventId, 4);
await engine.categorizeEvent(eventId);
expect(await categoryOf(auto)).toBe('small-groups');
const result = await engine.undoEvent(eventId);
expect(result.cleared).toBe(1);
// The automatic one is cleared...
expect(await categoryOf(auto)).toBeNull();
// ...and the photographer's own choice survives untouched.
expect(await categoryOf(manual)).toBe('ceremony-2');
});
it('is a no-op while the setting is off', async () => {
const eventId = await seedEvent('disabled');
const photoId = await addScannedPhoto(eventId, 0);
await enable(false);
const result = await engine.categorizeEvent(eventId);
await enable(true);
expect(result.skipped).toBe(true);
expect(await categoryOf(photoId)).toBeNull();
});
it('is idempotent — a second run assigns nothing new', async () => {
const eventId = await seedEvent('idempotent');
await addScannedPhoto(eventId, 0);
await addScannedPhoto(eventId, 7);
const first = await engine.categorizeEvent(eventId);
const second = await engine.categorizeEvent(eventId);
expect(first.assigned).toBe(2);
expect(second.assigned).toBe(0);
});
it('reuses one category per slug rather than creating duplicates', async () => {
const eventId = await seedEvent('reuse');
await addScannedPhoto(eventId, 0);
await addScannedPhoto(eventId, 0);
await addScannedPhoto(eventId, 0);
await engine.categorizeEvent(eventId);
const details = await db('photo_categories')
.where({ slug: 'details' })
.where(function () { this.where('event_id', eventId).orWhere('is_global', true); });
expect(details).toHaveLength(1);
});
});
});
@@ -1,325 +0,0 @@
/**
* Clustering engine (#1074).
*
* Uses synthetic embeddings with known identities rather than real faces: the
* question here is whether the ALGORITHM groups vectors correctly, which is
* separable from whether the model produces good vectors. Model quality is
* the spike's job.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceclust-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceclust-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let clustering;
/** Deterministic unit vector for identity `id`, jittered by `variant`. */
function makeEmbedding(id, variant = 0, dim = 64) {
const vec = new Float32Array(dim);
for (let i = 0; i < dim; i++) {
vec[i] = Math.sin((i + 1) * (id + 1) * 0.7) + variant * 0.02 * Math.cos(i * 3.1);
}
let norm = 0;
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
norm = Math.sqrt(norm);
for (let i = 0; i < dim; i++) vec[i] /= norm;
return vec;
}
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function insertFace(eventId, embedding, overrides = {}) {
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const row = {
photo_id: photoId,
event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
det_score: 0.99,
embedding: clustering.packEmbedding(embedding),
model_version: 'test-v1',
created_at: new Date().toISOString(),
...overrides,
};
const [f] = await db('photo_faces').insert(row).returning('id');
return { ...row, id: typeof f === 'object' ? f.id : f };
}
describe('faceClustering (#1074)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
clustering = require('../../src/services/faceClustering');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('embedding round-trip', () => {
it('survives pack/unpack through the BLOB column exactly', async () => {
const original = makeEmbedding(1);
const eventId = await seedEvent('roundtrip');
const face = await insertFace(eventId, original);
const stored = await db('photo_faces').where({ id: face.id }).first();
const restored = clustering.unpackEmbedding(stored.embedding);
expect(restored).toHaveLength(original.length);
for (let i = 0; i < original.length; i++) {
expect(restored[i]).toBeCloseTo(original[i], 6);
}
});
it('returns null for a corrupt blob rather than throwing', () => {
expect(clustering.unpackEmbedding(Buffer.from([1, 2, 3]))).toBeNull();
expect(clustering.unpackEmbedding(null)).toBeNull();
});
});
describe('assignment', () => {
it('groups the same identity and separates different ones', async () => {
const eventId = await seedEvent('grouping');
const faces = [];
// Three identities, four shots each, interleaved so assignment order
// is not conveniently grouped.
for (let variant = 0; variant < 4; variant++) {
for (const identity of [1, 2, 3]) {
faces.push(await insertFace(eventId, makeEmbedding(identity, variant)));
}
}
await clustering.assignFaces(eventId, faces);
const people = await db('event_people').where({ event_id: eventId });
expect(people).toHaveLength(3);
// Every face of one identity must share a person id.
const rows = await db('photo_faces').where({ event_id: eventId }).select('id', 'person_id');
const byPerson = new Map();
for (const r of rows) {
byPerson.set(r.person_id, (byPerson.get(r.person_id) || 0) + 1);
}
expect([...byPerson.values()].sort()).toEqual([4, 4, 4]);
});
it('leaves low-quality faces unassigned instead of spawning junk people', async () => {
const eventId = await seedEvent('quality-floor');
const good = await insertFace(eventId, makeEmbedding(5));
// Tiny bbox — below the 40px floor.
const tiny = await insertFace(eventId, makeEmbedding(6), { bbox_w: 12, bbox_h: 12 });
// Weak detection score.
const weak = await insertFace(eventId, makeEmbedding(7), { det_score: 0.2 });
await clustering.assignFaces(eventId, [good, tiny, weak]);
const rows = await db('photo_faces')
.whereIn('id', [good.id, tiny.id, weak.id])
.select('id', 'person_id');
const map = Object.fromEntries(rows.map((r) => [r.id, r.person_id]));
expect(map[good.id]).not.toBeNull();
// Still stored — they show in "this photo contains" — just unassigned.
expect(map[tiny.id]).toBeNull();
expect(map[weak.id]).toBeNull();
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
});
it('never mixes embedding spaces from different model versions', async () => {
const eventId = await seedEvent('model-version');
const a = await insertFace(eventId, makeEmbedding(9), { model_version: 'v1' });
await clustering.assignFaces(eventId, [a]);
// Same vector, different model. Comparable numerically, meaningless
// semantically — it must NOT join the v1 cluster.
const b = await insertFace(eventId, makeEmbedding(9), { model_version: 'v2' });
await clustering.assignFaces(eventId, [b]);
const people = await db('event_people').where({ event_id: eventId });
expect(people).toHaveLength(2);
});
});
describe('merge and split', () => {
it('merge moves every face and removes the source person', async () => {
const eventId = await seedEvent('merge');
const f1 = await insertFace(eventId, makeEmbedding(11));
const f2 = await insertFace(eventId, makeEmbedding(21));
await clustering.assignFaces(eventId, [f1, f2]);
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
expect(people).toHaveLength(2);
await clustering.mergePeople(eventId, [people[1].id], people[0].id);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
const remaining = await db('event_people').where({ event_id: eventId }).first();
expect(remaining.face_count_total).toBe(2);
const orphaned = await db('photo_faces')
.where({ event_id: eventId }).whereNull('person_id');
expect(orphaned).toHaveLength(0);
});
it('split pulls the named faces into a new person', async () => {
const eventId = await seedEvent('split');
const faces = [];
for (let v = 0; v < 4; v++) faces.push(await insertFace(eventId, makeEmbedding(13, v)));
await clustering.assignFaces(eventId, faces);
const person = await db('event_people').where({ event_id: eventId }).first();
expect(person.face_count_total).toBe(4);
const newId = await clustering.splitPerson(eventId, person.id, [faces[0].id, faces[1].id]);
expect(newId).toBeTruthy();
const original = await db('event_people').where({ id: person.id }).first();
const created = await db('event_people').where({ id: newId }).first();
expect(original.face_count_total).toBe(2);
expect(created.face_count_total).toBe(2);
});
it('deletes a person left with no faces rather than keeping a ghost', async () => {
const eventId = await seedEvent('empty-person');
const f = await insertFace(eventId, makeEmbedding(15));
await clustering.assignFaces(eventId, [f]);
const person = await db('event_people').where({ event_id: eventId }).first();
await db('photo_faces').where({ id: f.id }).update({ person_id: null });
await clustering.recomputeCentroid(person.id);
expect(await db('event_people').where({ id: person.id }).first()).toBeUndefined();
});
});
describe('regressions from external review', () => {
it('merge carries a name and suppression onto the survivor', async () => {
// A merge used to move the faces and delete the source outright, so a
// photographer-entered name vanished and a person they had hidden came
// back guest-visible.
const eventId = await seedEvent('merge-metadata');
const a = await insertFace(eventId, makeEmbedding(61));
const b = await insertFace(eventId, makeEmbedding(62));
await clustering.assignFaces(eventId, [a, b]);
const [p1, p2] = await db('event_people').where({ event_id: eventId }).orderBy('id');
// Target is unnamed and visible; the SOURCE carries the human state.
await db('event_people').where({ id: p2.id }).update({ label: 'Anna', is_hidden: true });
await clustering.mergePeople(eventId, [p2.id], p1.id);
const survivor = await db('event_people').where({ id: p1.id }).first();
expect(survivor.label).toBe('Anna');
expect(!!survivor.is_hidden).toBe(true);
});
it('recluster keeps hidden/ignored on people that were never named', async () => {
// The old query remembered only rows with a label, so a suppressed
// bystander came back visible after one "Re-group people".
const eventId = await seedEvent('recluster-suppression');
const faces = [];
for (let v = 0; v < 3; v++) faces.push(await insertFace(eventId, makeEmbedding(71, v)));
await clustering.assignFaces(eventId, faces);
const person = await db('event_people').where({ event_id: eventId }).first();
expect(person.label).toBeNull();
await db('event_people').where({ id: person.id }).update({ is_ignored: true });
await clustering.recluster(eventId);
const after = await db('event_people').where({ event_id: eventId });
expect(after.length).toBeGreaterThan(0);
expect(after.every((p) => !!p.is_ignored)).toBe(true);
});
});
describe('recluster', () => {
it('re-derives clusters and preserves photographer-assigned names', async () => {
// This is the property that makes re-clustering safe to offer as a
// button: without it, one click silently discards every typed name.
const eventId = await seedEvent('recluster');
const faces = [];
for (let v = 0; v < 3; v++) {
faces.push(await insertFace(eventId, makeEmbedding(31, v)));
faces.push(await insertFace(eventId, makeEmbedding(32, v)));
}
await clustering.assignFaces(eventId, faces);
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
expect(people).toHaveLength(2);
await db('event_people').where({ id: people[0].id }).update({ label: 'Anna' });
await db('event_people').where({ id: people[1].id }).update({ label: 'Ben' });
const count = await clustering.recluster(eventId);
expect(count).toBe(2);
const after = await db('event_people').where({ event_id: eventId });
const labels = after.map((p) => p.label).filter(Boolean).sort();
expect(labels).toEqual(['Anna', 'Ben']);
});
it('is stable across repeated runs', async () => {
const eventId = await seedEvent('recluster-stable');
const faces = [];
for (let v = 0; v < 3; v++) {
for (const id of [41, 42]) faces.push(await insertFace(eventId, makeEmbedding(id, v)));
}
await clustering.assignFaces(eventId, faces);
const first = await clustering.recluster(eventId);
const second = await clustering.recluster(eventId);
expect(second).toBe(first);
});
});
describe('consolidate', () => {
it('refuses to merge two people the photographer named differently', async () => {
// A human assertion this heuristic does not get to overrule.
const eventId = await seedEvent('consolidate-labels');
const a = await insertFace(eventId, makeEmbedding(51));
await clustering.assignFaces(eventId, [a]);
const first = await db('event_people').where({ event_id: eventId }).first();
// A near-identical centroid that would otherwise merge.
const [inserted] = await db('event_people').insert({
event_id: eventId,
centroid: clustering.packEmbedding(makeEmbedding(51, 0.01)),
face_count_total: 1,
model_version: 'test-v1',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id');
const secondId = typeof inserted === 'object' ? inserted.id : inserted;
await db('event_people').where({ id: first.id }).update({ label: 'Anna' });
await db('event_people').where({ id: secondId }).update({ label: 'Ben' });
await clustering.consolidate(eventId);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
});
});
});
@@ -1,239 +0,0 @@
/**
* External imports are queued for face scanning, in the right order (#1090).
*
* Managed uploads are enqueued by photoProcessor, which writes face_status
* 'pending' once a photo is processed (photoProcessor.js:573 — "the only
* correct place to enqueue"). External media never goes through photoProcessor:
* adminExternalMedia inserts rows directly, so they stayed NULL and were only
* ever picked up by a manual Re-scan.
*
* The ordering matters as much as the enqueue. events.external_path is written
* only AFTER the whole import loop, so marking rows 'pending' as they are
* inserted publishes claimable work while the event still points at the old
* directory — or none at all, on a first import. The face worker polls
* continuously, would resolve those photos against the wrong path, and mark
* them permanently 'failed', a state only an explicit Re-scan clears.
*
* This drives the real route rather than re-implementing it, so removing the
* enqueue fails the first test and moving it back onto the insert fails the
* second.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('external import queues faces (#1090)', () => {
let tmpDir; let db; let app; let mediaRoot;
// Recorded from inside the per-photo thumbnail call, i.e. mid-loop.
let pendingSeenDuringLoop = 0;
let externalPathDuringLoop;
// When set to an event id, the mocked thumbnail call turns detection on
// mid-loop, standing in for an admin flipping the toggle during an import.
let flipFacesOnDuringLoop = null;
// Stands in for a concurrent Re-scan completing a row mid-import.
let markDoneDuringLoop = false;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extenq-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
}
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extenq-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
// Runs once per photo, inside the import loop — the only hook that can
// observe the intermediate state the ordering bug would expose.
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(async () => {
const { db: liveDb } = require('../../src/database/db');
const rows = await liveDb('photos').where({ face_status: 'pending' });
pendingSeenDuringLoop += rows.length;
const ev = await liveDb('events').first();
externalPathDuringLoop = ev ? ev.external_path : undefined;
if (markDoneDuringLoop) {
const rows = await liveDb('photos').orderBy('id', 'asc').limit(1);
if (rows.length) {
await liveDb('photos').where({ id: rows[0].id }).update({ face_status: 'done' });
}
}
if (flipFacesOnDuringLoop) {
await liveDb('events').where({ id: flipFacesOnDuringLoop })
.update({ face_recognition_enabled: true });
}
return 'thumbnails/mock.jpg';
}),
ensureThumbnail: jest.fn(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
// bootCrmDb runs every migrations/core/*.up() directly — knex's Migrator
// deadlocks on 001_init's nested initializeDatabase() call.
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent({ facesEnabled, flagOn }) {
await db('feature_flags').insert({ key: 'faces', value: flagOn })
.onConflict('key').merge()
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: flagOn }); });
// The flag read is TTL-cached (requireFeatureFlag.js:26-34); production
// invalidates after every write, and so must this.
require('../../src/middleware/requireFeatureFlag').invalidateFeatureFlagCache();
await db('photos').del();
await db('events').del();
const [e] = await db('events').insert({
slug: `extenq-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'extenq',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `extenq-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: facesEnabled,
source_mode: 'reference',
}).returning('id');
pendingSeenDuringLoop = 0;
externalPathDuringLoop = undefined;
markDoneDuringLoop = false;
return typeof e === 'object' ? e.id : e;
}
async function runImport(eventId) {
return request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path: 'nas', recursive: true });
}
it('queues imported photos when detection is on', async () => {
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
const res = await runImport(eventId);
expect(res.status).toBe(200);
const photos = await db('photos').where({ event_id: eventId });
expect(photos.length).toBeGreaterThan(0);
// The regression: these stayed NULL and waited for a manual Re-scan.
expect(photos.every((p) => p.face_status === 'pending')).toBe(true);
});
it('does not publish claimable rows before events.external_path is written', async () => {
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
await runImport(eventId);
// Observed from inside the loop: nothing is claimable yet. The event path
// is already committed (see the test above), so this is no longer load
// bearing for correctness — but keeping the enqueue at the end is what lets
// the feature setting be read after the loop, so the invariant stays.
expect(pendingSeenDuringLoop).toBe(0);
// ...and afterwards both are in place.
const ev = await db('events').where({ id: eventId }).first();
expect(ev.external_path).toBe('nas');
expect((await db('photos').where({ event_id: eventId, face_status: 'pending' })).length)
.toBe((await db('photos').where({ event_id: eventId })).length);
});
it('honours a toggle flipped DURING the import', async () => {
// The setting is read after the loop, not before: on a large library the
// loop runs for minutes, and the toggle endpoint only queues rows that
// already existed when it fired. Reading it up front would strand every
// photo imported after that moment at NULL forever.
const eventId = await seedEvent({ facesEnabled: false, flagOn: true });
flipFacesOnDuringLoop = eventId;
await runImport(eventId);
flipFacesOnDuringLoop = null;
const photos = await db('photos').where({ event_id: eventId });
expect(photos.length).toBeGreaterThan(0);
expect(photos.every((p) => p.face_status === 'pending')).toBe(true);
});
it('commits events.external_path before the first row is inserted', async () => {
// enqueueEvent accepts processing_status NULL (faceProcessor.js:243-246),
// which these inserts leave unset — so a toggle or Re-scan firing mid-import
// can queue partial rows. If the event still pointed at the old directory
// they would resolve against it and burn to 'failed'. Setting the path
// first also means a half-finished import leaves rows that still resolve,
// instead of rows stranded against the previous path.
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
await runImport(eventId);
// Sampled from inside the per-photo thumbnail call, i.e. while rows are
// still being inserted.
expect(externalPathDuringLoop).toBe('nas');
});
it('does not re-queue rows a concurrent scan already handled', async () => {
// Committing the event path before the loop means a toggle or Re-scan
// firing mid-import can now genuinely queue and even finish some of these
// rows. A blanket update at the end would drag 'done' rows back to
// 'pending' for a duplicate sidecar scan and knock 'processing' rows out
// from under the worker.
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
markDoneDuringLoop = true;
await runImport(eventId);
markDoneDuringLoop = false;
const done = await db('photos').where({ event_id: eventId, face_status: 'done' });
expect(done.length).toBeGreaterThan(0); // the concurrent scan's work survived
});
it('leaves face_status untouched when the per-event toggle is off', async () => {
const eventId = await seedEvent({ facesEnabled: false, flagOn: true });
await runImport(eventId);
const photos = await db('photos').where({ event_id: eventId });
expect(photos.length).toBeGreaterThan(0);
expect(photos.every((p) => p.face_status === null)).toBe(true);
});
it('leaves face_status untouched when the global flag is off', async () => {
// Installs without the feature must never accumulate face_status rows —
// the same invariant photoProcessor's guard protects.
const eventId = await seedEvent({ facesEnabled: true, flagOn: false });
await runImport(eventId);
const photos = await db('photos').where({ event_id: eventId });
expect(photos.length).toBeGreaterThan(0);
expect(photos.every((p) => p.face_status === null)).toBe(true);
});
});
@@ -1,163 +0,0 @@
/**
* External / reference photos are scannable (#1090).
*
* faceProcessor used to short-circuit every photo with source_origin
* 'external' or 'reference' to 'skipped', because resolvePhotoStorageKey
* returns null for anything outside managed storage and ensurePreviewImage
* could not build a preview for it. #1078 removed that limitation —
* ensurePreviewImage now reads externals straight off the mount and writes
* the preview into managed storage — but the guard stayed, so the whole
* feature was a no-op on external-media installs. The reporter's gallery sat
* at 0/3230 with every row 'skipped' and no error.
*
* These pin both halves: the guard is gone, and a photo whose source is
* genuinely missing still fails rather than being quietly skipped — the blanket
* skip used to absorb that case too, so a real breakage looked like an
* unsupported one.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceext-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceext-test-secret';
// A real, existing media root. getExternalMediaRoot only honours the env var
// if the directory exists and caches it on first call, so this has to be set
// up before anything requires externalMediaService.
process.env.EXTERNAL_MEDIA_ROOT = path.join(path.dirname(process.env.TEST_DATABASE_PATH), 'media');
fs.mkdirSync(path.join(process.env.EXTERNAL_MEDIA_ROOT, 'share', 'individual'), { recursive: true });
// Non-empty on purpose: an empty directory is read as an unmounted share
// (faceTransientSource.test.js), so a "healthy storage, dead photo" fixture
// needs a sibling present or it defers instead of failing.
fs.writeFileSync(path.join(process.env.EXTERNAL_MEDIA_ROOT, 'share', 'individual', 'sibling.jpg'), 'x');
const sharp = require('sharp');
let mockPreviewBuffer;
// Set per-test: what ensurePreviewImage returns for the photo under test.
let previewKeyResult; // eslint-disable-line prefer-const
const mockEnsurePreviewImage = jest.fn(async () => previewKeyResult);
const mockDetectFaces = jest.fn();
jest.mock('../../src/services/imageProcessor', () => ({
...jest.requireActual('../../src/services/imageProcessor'),
ensurePreviewImage: (...args) => mockEnsurePreviewImage(...args),
}));
jest.mock('../../src/services/storage', () => ({
getStorage: () => ({ get: async () => mockPreviewBuffer }),
}));
jest.mock('../../src/services/faceClient', () => ({
detectFaces: (...args) => mockDetectFaces(...args),
SidecarUnavailableError: class extends Error {},
}));
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let faceProcessor;
async function seedPhoto({ sourceOrigin = 'managed', sourceMode = 'managed' } = {}) {
const [e] = await db('events').insert({
slug: `ext-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'ext',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `ext-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
source_mode: sourceMode,
external_path: 'share',
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const [p] = await db('photos').insert({
event_id: eventId,
filename: 'ext.jpg',
path: '/tmp/ext.jpg',
type: 'individual',
width: 1920,
height: 1440,
processing_status: 'complete',
face_status: 'processing',
source_origin: sourceOrigin,
external_relpath: sourceOrigin === 'managed' ? null : 'individual/ext.jpg',
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
describe('face scanning of external/reference photos (#1090)', () => {
beforeAll(async () => {
mockPreviewBuffer = await sharp({
create: { width: 1920, height: 1440, channels: 3, background: { r: 20, g: 40, b: 80 } },
}).jpeg().toBuffer();
({ db, cleanup } = await bootCrmDb());
await db('feature_flags').insert({ key: 'faces', value: true })
.onConflict('key').merge()
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
faceProcessor = require('../../src/services/faceProcessor');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(() => {
mockEnsurePreviewImage.mockClear();
mockDetectFaces.mockClear();
previewKeyResult = 'previews/preview_ext.jpg';
mockDetectFaces.mockResolvedValue({
model_version: 'test-v1',
faces: [{
bbox: [100, 100, 50, 50],
score: 0.99,
landmarks: [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
yaw: 0, pitch: 0, blur: 500,
embedding: Array.from({ length: 64 }, (_, i) => (i === 0 ? 1 : 0)),
}],
});
});
it.each(['external', 'reference'])('scans a %s photo instead of skipping it', async (origin) => {
const { photoId } = await seedPhoto({ sourceOrigin: origin, sourceMode: 'reference' });
const result = await faceProcessor.processPhotoFaces(photoId);
// The regression: this used to return 'skipped' without ever building a
// preview or contacting the sidecar.
expect(result.status).not.toBe('skipped');
expect(mockEnsurePreviewImage).toHaveBeenCalled();
expect(mockDetectFaces).toHaveBeenCalled();
const photo = await db('photos').where({ id: photoId }).first();
expect(photo.face_status).toBe('done');
expect(await db('photo_faces').where({ photo_id: photoId }).first()).toBeTruthy();
});
it('fails, not skips, when the external source is genuinely gone', async () => {
// A missing file is a property of that photo, so it should be visible as a
// failure the admin can act on — not silently absorbed the way the old
// blanket skip did.
//
// The containing directory exists here on purpose. An absent directory is
// a dropped mount, which defers rather than fails
// (faceTransientSource.test.js); this is the other case — healthy storage,
// dead photo.
previewKeyResult = null;
const { photoId } = await seedPhoto({ sourceOrigin: 'external', sourceMode: 'reference' });
const result = await faceProcessor.processPhotoFaces(photoId);
expect(result.status).toBe('failed');
expect(mockDetectFaces).not.toHaveBeenCalled();
const photo = await db('photos').where({ id: photoId }).first();
expect(photo.face_status).toBe('failed');
expect(photo.face_error).toMatch(/preview/i);
});
});
@@ -1,452 +0,0 @@
/**
* Automatic consolidation reporting and the suggestion band (#1107).
*
* Centroids are built to an EXACT cosine similarity rather than jittered
* towards one, because every assertion here is about which side of a threshold
* a pair falls on. `pairAtSimilarity` returns two unit vectors whose dot
* product is the requested number to floating-point precision, and each pair
* is built on its own orthogonal basis so two different pairs are never
* accidentally similar to each other.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facesuggest-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facesuggest-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let clustering;
// Mirrors the service: merge at match + 0.08, so with a 0.60 floor the
// suggestion band is [0.60, 0.68).
const THRESHOLDS = {
face_match_threshold: 0.6,
face_quality_min_score: 0.7,
face_quality_min_px: 40,
};
const DIM = 64;
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
function pairAtSimilarity(target, basis) {
const a = new Float32Array(DIM);
const b = new Float32Array(DIM);
const orth = Math.sqrt(1 - target * target);
a[basis] = 1;
b[basis] = target;
b[basis + 1] = orth;
return [a, b];
}
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function insertPerson(eventId, centroid, overrides = {}) {
const [row] = await db('event_people').insert({
event_id: eventId,
centroid: clustering.packEmbedding(centroid),
face_count_total: 5,
model_version: 'test-v1',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** One person with one real face, so merge/split have something to move. */
async function insertPersonWithFace(eventId, centroid, overrides = {}) {
const personId = await insertPerson(eventId, centroid, overrides);
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
await db('photo_faces').insert({
photo_id: photoId,
event_id: eventId,
person_id: personId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
det_score: 0.99,
embedding: clustering.packEmbedding(centroid),
model_version: 'test-v1',
created_at: new Date().toISOString(),
});
return personId;
}
/** An additional face on an existing person, so a split has something to move. */
async function addFaceTo(eventId, personId, centroid) {
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const [f] = await db('photo_faces').insert({
photo_id: photoId,
event_id: eventId,
person_id: personId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
det_score: 0.99,
embedding: clustering.packEmbedding(centroid),
model_version: 'test-v1',
created_at: new Date().toISOString(),
}).returning('id');
return typeof f === 'object' ? f.id : f;
}
const suggest = (eventId) => clustering.suggestMerges(eventId, { thresholds: THRESHOLDS });
describe('face merge suggestions (#1107)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
clustering = require('../../src/services/faceClustering');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('the band', () => {
it('suggests a pair between the match and auto-merge thresholds', async () => {
const eventId = await seedEvent('band-inside');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
const out = await suggest(eventId);
expect(out).toHaveLength(1);
expect([out[0].person_a_id, out[0].person_b_id].sort()).toEqual([idA, idB].sort());
expect(out[0].score).toBeCloseTo(0.64, 4);
});
it('stays silent above the auto-merge threshold — consolidate() owns that pair', async () => {
const eventId = await seedEvent('band-above');
const [a, b] = pairAtSimilarity(0.75, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b);
expect(await suggest(eventId)).toEqual([]);
});
it('stays silent below the match threshold — further apart than one face would join', async () => {
const eventId = await seedEvent('band-below');
const [a, b] = pairAtSimilarity(0.5, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b);
expect(await suggest(eventId)).toEqual([]);
});
});
describe('what it refuses to ask about', () => {
it('never questions two people the photographer named differently', async () => {
const eventId = await seedEvent('named-apart');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a, { label: 'Anna' });
await insertPerson(eventId, b, { label: 'Beatrix' });
expect(await suggest(eventId)).toEqual([]);
});
it('still asks when only one of the two is named', async () => {
const eventId = await seedEvent('one-named');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a, { label: 'Anna' });
await insertPerson(eventId, b);
expect(await suggest(eventId)).toHaveLength(1);
});
it('skips a person marked "not a real person" — that answer was already given', async () => {
const eventId = await seedEvent('ignored');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b, { is_ignored: true });
expect(await suggest(eventId)).toEqual([]);
});
it('never crosses embedding spaces', async () => {
const eventId = await seedEvent('model-skew');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b, { model_version: 'test-v2' });
expect(await suggest(eventId)).toEqual([]);
});
});
describe('dismissal', () => {
it('stops suggesting a pair the photographer rejected, and survives a repeat', async () => {
const eventId = await seedEvent('dismissal');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
expect(await suggest(eventId)).toHaveLength(1);
await clustering.dismissMergeSuggestion(eventId, idB, idA); // reversed on purpose
expect(await suggest(eventId)).toEqual([]);
// A second dismissal hits the UNIQUE constraint. Dismissing twice is a
// double-click, not an error.
await expect(clustering.dismissMergeSuggestion(eventId, idA, idB)).resolves.toEqual({
dismissed: true,
});
expect(await suggest(eventId)).toEqual([]);
});
/**
* The swallow-the-duplicate branch has to discriminate, because the failure
* it must NOT swallow looks identical to the caller: returning
* "kept separate" for a decision that was never written means the pair
* silently comes back after the next scan.
*
* Tested on the predicate directly — provoking a read-only database or a
* dropped table mid-suite would corrupt the shared fixture for every other
* case in this file.
*/
it.each([
['postgres unique violation', { code: '23505', message: 'duplicate key value violates unique constraint' }, true],
['sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT', message: 'UNIQUE constraint failed: event_people_merge_dismissals.event_id' }, true],
['better-sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT_UNIQUE', message: 'UNIQUE constraint failed' }, true],
['sqlite foreign-key violation', { code: 'SQLITE_CONSTRAINT', message: 'FOREIGN KEY constraint failed' }, false],
['sqlite busy', { code: 'SQLITE_BUSY', message: 'database is locked' }, false],
['missing table', { code: 'SQLITE_ERROR', message: 'no such table: event_people_merge_dismissals' }, false],
['postgres read-only transaction', { code: '25006', message: 'cannot execute INSERT in a read-only transaction' }, false],
['no error at all', null, false],
])('%s → swallowed: %s', (_name, err, expected) => {
expect(clustering.isUniqueViolation(err)).toBe(expected);
});
/**
* The dismissal read is the only thing standing between the automatic pass
* and a pair the photographer explicitly separated. If it fails open, a
* timeout silently restores the merge that "Not the same" was supposed to
* prevent — so anything other than a missing table must stop the pass.
*/
it('refuses to consolidate when the dismissal list cannot be read', async () => {
const eventId = await seedEvent('dismissals-unreadable');
// Well above the auto-merge threshold, so only a refusal keeps them apart.
const [a, b] = pairAtSimilarity(0.97, 0);
await insertPersonWithFace(eventId, a);
await insertPersonWithFace(eventId, b);
// Break the read for real rather than mocking knex: dropping a selected
// column makes the query fail with something that is NOT "missing
// table", which is exactly the class that must not fail open.
await db.schema.alterTable('event_people_merge_dismissals', (t) => t.dropColumn('person_b_id'));
try {
await expect(clustering.consolidate(eventId, { thresholds: THRESHOLDS }))
.rejects.toThrow();
// Nothing merged: the pass gave up rather than overriding a decision
// it could not read.
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
} finally {
await db.schema.alterTable('event_people_merge_dismissals', (t) => {
t.integer('person_b_id').notNullable().defaultTo(0);
});
}
});
it.each([
['postgres undefined_table', { code: '42P01', message: 'relation "x" does not exist' }, true],
['sqlite missing table', { code: 'SQLITE_ERROR', message: 'no such table: x' }, true],
// The one that matters: a missing COLUMN is a broken query, not a
// pre-migration install, and must NOT be allowed to fail open.
['postgres undefined_column', { code: '42703', message: 'column "x" does not exist' }, false],
['sqlite missing column', { code: 'SQLITE_ERROR', message: 'no such column: x' }, false],
['statement timeout', { code: '57014', message: 'canceling statement due to statement timeout' }, false],
])('missing-table check — %s → %s', (_name, err, expected) => {
expect(clustering.isMissingTable(err)).toBe(expected);
});
it('normalizes the pair so one row covers both orderings', async () => {
const eventId = await seedEvent('dismissal-normalized');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idB, idA);
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
expect(rows).toHaveLength(1);
expect(rows[0].person_a_id).toBe(Math.min(idA, idB));
expect(rows[0].person_b_id).toBe(Math.max(idA, idB));
});
});
describe('one suggestion per person per round', () => {
it('does not offer A-B, A-C and B-C for a three-way fragment', async () => {
const eventId = await seedEvent('three-way');
// Three mutually similar centroids, all inside the band.
const base = new Float32Array(DIM); base[0] = 1;
const people = [];
for (let k = 0; k < 3; k++) {
const v = new Float32Array(DIM);
v[0] = 0.9;
v[1 + k] = Math.sqrt(1 - 0.81);
people.push(await insertPerson(eventId, v));
}
await insertPerson(eventId, base);
const out = await suggest(eventId);
// Every returned pair must name people not already spoken for: accepting
// the first suggestion must never leave a second one pointing at a person
// that the merge just deleted.
const seen = new Set();
for (const s of out) {
expect(seen.has(s.person_a_id)).toBe(false);
expect(seen.has(s.person_b_id)).toBe(false);
seen.add(s.person_a_id);
seen.add(s.person_b_id);
}
});
it('offers the most similar pair first', async () => {
const eventId = await seedEvent('ordering');
const [a1, b1] = pairAtSimilarity(0.62, 0);
const [a2, b2] = pairAtSimilarity(0.67, 10);
await insertPerson(eventId, a1);
await insertPerson(eventId, b1);
await insertPerson(eventId, a2);
await insertPerson(eventId, b2);
const out = await suggest(eventId);
expect(out).toHaveLength(2);
expect(out[0].score).toBeGreaterThan(out[1].score);
});
});
describe('manual splits survive the automatic pass', () => {
/**
* The regression that matters most once consolidation runs on every scan:
* a photographer splitting a wrongly-merged cluster produces two people
* who are look-alikes BY CONSTRUCTION, so their centroids sit above the
* merge threshold and the very next scan would put them straight back.
*/
it('records a split as a separation, so consolidation leaves it alone', async () => {
const eventId = await seedEvent('split-protected');
const base = new Float32Array(DIM); base[0] = 1;
// One cluster holding two near-identical faces.
const personId = await insertPersonWithFace(eventId, base);
const extraFaceId = await addFaceTo(eventId, personId, base);
const newPersonId = await clustering.splitPerson(eventId, personId, [extraFaceId]);
expect(newPersonId).toBeTruthy();
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
expect(rows).toHaveLength(1);
expect([rows[0].person_a_id, rows[0].person_b_id].sort())
.toEqual([personId, newPersonId].sort());
// Identical centroids — nothing but the recorded separation can stop
// this merge.
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toEqual([]);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
});
});
describe('consolidation reporting', () => {
it('records what an automatic pass merged, so it is not silent', async () => {
const eventId = await seedEvent('report-merged');
// 0.97 is above the 0.68 auto-merge threshold — consolidate() acts.
const [a, b] = pairAtSimilarity(0.97, 0);
await insertPersonWithFace(eventId, a);
await insertPersonWithFace(eventId, b);
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toHaveLength(1);
const event = await db('events').where({ id: eventId }).first();
expect(Number(event.faces_last_consolidated_count)).toBe(1);
expect(event.faces_last_consolidated_at).toBeTruthy();
});
it('never absorbs an ignored cluster — that would mark a real person ignored', async () => {
const eventId = await seedEvent('consolidate-ignored');
// Well above the auto-merge threshold: only the is_ignored flag can
// stop this pair.
const [a, b] = pairAtSimilarity(0.97, 0);
const real = await insertPersonWithFace(eventId, a);
const junk = await insertPersonWithFace(eventId, b, { is_ignored: true });
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toEqual([]);
// Both still standing, and the real person is still guest-visible —
// mergePeople ORs is_ignored onto the survivor, so absorbing the junk
// cluster would have hidden a real person from the gallery.
const survivors = await db('event_people').where({ event_id: eventId }).select('id', 'is_ignored');
expect(survivors.map((p) => p.id).sort()).toEqual([real, junk].sort());
const realRow = survivors.find((p) => p.id === real);
expect(realRow.is_ignored === true || realRow.is_ignored === 1).toBe(false);
});
it('never merges a pair the photographer said was not the same person', async () => {
const eventId = await seedEvent('consolidate-dismissed');
// Also above the auto-merge threshold: the dismissal is the only thing
// standing between these two, which is the point — a human "no" has to
// outrank the automatic pass, not just the suggestion list.
const [a, b] = pairAtSimilarity(0.97, 0);
const idA = await insertPersonWithFace(eventId, a);
const idB = await insertPersonWithFace(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toEqual([]);
expect(await db('event_people').where({ event_id: eventId }).count({ c: '*' }).first())
.toEqual(expect.objectContaining({ c: 2 }));
});
// NOT covered by a test: reporting what a pass merged before it died
// partway. `consolidate` calls `mergePeople` through the module-local
// binding, so a spy on the export cannot intercept it, and no realistic
// database failure lands on the second merge only. The recording therefore
// sits in a `finally` — each mergePeople is its own transaction, so a pass
// that throws has still committed what it did, and the alternative is a
// real merge going unreported. Verified by reading, not by assertion.
it('clears a previous count when a later pass merges nothing', async () => {
const eventId = await seedEvent('report-cleared');
await db('events').where({ id: eventId }).update({ faces_last_consolidated_count: 7 });
const [a, b] = pairAtSimilarity(0.5, 0);
await insertPersonWithFace(eventId, a);
await insertPersonWithFace(eventId, b);
await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
const event = await db('events').where({ id: eventId }).first();
expect(Number(event.faces_last_consolidated_count)).toBe(0);
});
});
});
@@ -1,415 +0,0 @@
/**
* Privacy and visibility guarantees for face recognition (#1074).
*
* These are the tests that matter most in this feature. Two of them cover
* defects that would be invisible in normal use:
*
* - The people strip is computed from face rows, which have no concept of
* photo visibility. Handing a guest a raw count leaks how many hidden
* photos someone appears in, and a cover face picked without scoping
* renders a crop of a photo the guest may not open.
*
* - Face embeddings are biometric data. They must not ride along in a
* .picpeak export, which gets handed to clients and moved between
* operators.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceprivacy-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceprivacy-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let clustering; let peopleService; let faceProcessor;
function makeEmbedding(id, variant = 0, dim = 64) {
const vec = new Float32Array(dim);
for (let i = 0; i < dim; i++) {
vec[i] = Math.sin((i + 1) * (id + 1) * 0.7) + variant * 0.02 * Math.cos(i * 3.1);
}
let norm = 0;
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
norm = Math.sqrt(norm);
for (let i = 0; i < dim; i++) vec[i] /= norm;
return vec;
}
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function addPhotoWithFace(eventId, embedding, { visibility = 'visible', score = 0.99 } = {}) {
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
visibility,
processing_status: 'complete',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const row = {
photo_id: photoId,
event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
det_score: score,
embedding: clustering.packEmbedding(embedding),
model_version: 'test-v1',
created_at: new Date().toISOString(),
};
const [f] = await db('photo_faces').insert(row).returning('id');
return { photoId, face: { ...row, id: typeof f === 'object' ? f.id : f } };
}
describe('face privacy and visibility (#1074)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
clustering = require('../../src/services/faceClustering');
peopleService = require('../../src/services/facePeopleService');
faceProcessor = require('../../src/services/faceProcessor');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('visibility scoping', () => {
it('counts only photos the audience can actually see', async () => {
const eventId = await seedEvent('visibility-count');
const faces = [];
// Same person: 3 visible photos, 4 hidden ones.
for (let v = 0; v < 3; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(1, v))).face);
}
for (let v = 3; v < 7; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(1, v), { visibility: 'hidden' })).face);
}
await clustering.assignFaces(eventId, faces);
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
const clientView = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
expect(guestView).toHaveLength(1);
// The leak this test exists to prevent: 3, never 7.
expect(guestView[0].face_count).toBe(3);
expect(clientView[0].face_count).toBe(7);
});
it('never returns face_count_total to a guest', async () => {
const eventId = await seedEvent('no-total-leak');
const { face } = await addPhotoWithFace(eventId, makeEmbedding(2));
await clustering.assignFaces(eventId, [face]);
const [person] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
expect(person).not.toHaveProperty('total_face_count');
expect(person).not.toHaveProperty('is_hidden');
});
it('picks a cover face from a photo the guest may open', async () => {
const eventId = await seedEvent('cover-scoping');
// The BEST face (highest score) is in a hidden photo — a naive
// implementation would hand its crop to the guest.
const hidden = await addPhotoWithFace(eventId, makeEmbedding(3, 0), {
visibility: 'hidden', score: 0.99,
});
const visible = await addPhotoWithFace(eventId, makeEmbedding(3, 1), {
visibility: 'visible', score: 0.80,
});
await clustering.assignFaces(eventId, [hidden.face, visible.face]);
const [guestPerson] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
expect(guestPerson.cover.photo_id).toBe(visible.photoId);
expect(guestPerson.cover.photo_id).not.toBe(hidden.photoId);
});
it('prefers the cover the photographer chose (#1096)', async () => {
const eventId = await seedEvent('chosen-cover');
// The auto-pick would take the 0.99 face. The photographer picked the
// other one — without this the PATCH saved, the toast said so, and the
// avatar reverted on the very next read.
const best = await addPhotoWithFace(eventId, makeEmbedding(9, 0), { score: 0.99 });
const chosen = await addPhotoWithFace(eventId, makeEmbedding(9, 1), { score: 0.70 });
await clustering.assignFaces(eventId, [best.face, chosen.face]);
const [before] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
expect(before.cover.photo_id).toBe(best.photoId);
// Clustering must not have written one: an automatic seed here would be
// indistinguishable from a real choice the moment listPeople honours it.
const seeded = await db('event_people').where({ id: before.id }).first();
expect(seeded.cover_face_id).toBeFalsy();
await db('event_people').where({ id: before.id }).update({ cover_face_id: chosen.face.id });
const [after] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
expect(after.cover.photo_id).toBe(chosen.photoId);
});
it('carries a chosen cover through a merge', async () => {
const eventId = await seedEvent('cover-merge');
const a = await addPhotoWithFace(eventId, makeEmbedding(20, 0), { score: 0.90 });
const b = await addPhotoWithFace(eventId, makeEmbedding(60, 0), { score: 0.95 });
await clustering.assignFaces(eventId, [a.face]);
await clustering.assignFaces(eventId, [b.face]);
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
expect(people.length).toBeGreaterThan(1);
// The SOURCE carries the choice; the target has none.
await db('event_people').where({ id: people[1].id }).update({ cover_face_id: b.face.id });
await clustering.mergePeople(eventId, [people[1].id], people[0].id);
const target = await db('event_people').where({ id: people[0].id }).first();
expect(target.cover_face_id).toBe(b.face.id);
});
it('carries a chosen cover through a recluster', async () => {
const eventId = await seedEvent('cover-recluster');
const faces = [];
for (let v = 0; v < 3; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(21, v), { score: 0.9 - v * 0.1 })).face);
}
await clustering.assignFaces(eventId, faces);
const [person] = await db('event_people').where({ event_id: eventId });
// Pick the WORST-scoring face, so an automatic re-pick would differ.
const chosen = faces[2].id;
await db('event_people').where({ id: person.id }).update({ cover_face_id: chosen });
await clustering.recluster(eventId);
const after = await db('event_people').where({ event_id: eventId }).whereNotNull('cover_face_id');
expect(after).toHaveLength(1);
expect(after[0].cover_face_id).toBe(chosen);
});
it('falls back to a visible face when the chosen cover is hidden from this audience', async () => {
const eventId = await seedEvent('chosen-cover-hidden');
// Choosing a cover must never override the visibility scoping — that
// would hand a guest a crop of a photo they cannot open.
const hidden = await addPhotoWithFace(eventId, makeEmbedding(10, 0), {
visibility: 'hidden', score: 0.99,
});
const visible = await addPhotoWithFace(eventId, makeEmbedding(10, 1), { score: 0.70 });
await clustering.assignFaces(eventId, [hidden.face, visible.face]);
const [person] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
await db('event_people').where({ id: person.id }).update({ cover_face_id: hidden.face.id });
const [guestView] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
expect(guestView.cover.photo_id).toBe(visible.photoId);
expect(guestView.cover.photo_id).not.toBe(hidden.photoId);
});
it('drops a person entirely when all their photos are hidden', async () => {
const eventId = await seedEvent('all-hidden');
const faces = [];
for (let v = 0; v < 3; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(4, v), { visibility: 'hidden' })).face);
}
await clustering.assignFaces(eventId, faces);
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
expect(guestView).toHaveLength(0);
const clientView = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
expect(clientView).toHaveLength(1);
});
it('omits hidden and ignored people from the guest response', async () => {
const eventId = await seedEvent('hidden-people');
const a = (await addPhotoWithFace(eventId, makeEmbedding(5))).face;
const b = (await addPhotoWithFace(eventId, makeEmbedding(6))).face;
await clustering.assignFaces(eventId, [a, b]);
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
await db('event_people').where({ id: people[0].id }).update({ is_hidden: true });
await db('event_people').where({ id: people[1].id }).update({ is_ignored: true });
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
expect(guestView).toHaveLength(0);
const adminView = await peopleService.listPeople(eventId, { isClient: true, forAdmin: true });
expect(adminView).toHaveLength(2);
});
it('does not attach a hidden person to a photo a guest can see', async () => {
const eventId = await seedEvent('person-ids-hidden');
const { photoId, face } = await addPhotoWithFace(eventId, makeEmbedding(7));
await clustering.assignFaces(eventId, [face]);
const person = await db('event_people').where({ event_id: eventId }).first();
await db('event_people').where({ id: person.id }).update({ is_hidden: true });
const guestMap = await peopleService.getPersonIdsByPhoto(eventId, [photoId], { forAdmin: false });
expect(guestMap.get(photoId)).toBeUndefined();
const adminMap = await peopleService.getPersonIdsByPhoto(eventId, [photoId], { forAdmin: true });
expect(adminMap.get(photoId)).toEqual([person.id]);
});
it('respects the minimum cluster size so one-off bystanders stay out', async () => {
const eventId = await seedEvent('min-cluster');
const solo = (await addPhotoWithFace(eventId, makeEmbedding(8))).face;
const crowd = [];
for (let v = 0; v < 4; v++) {
crowd.push((await addPhotoWithFace(eventId, makeEmbedding(9, v))).face);
}
await clustering.assignFaces(eventId, [solo, ...crowd]);
const people = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 3 });
expect(people).toHaveLength(1);
expect(people[0].face_count).toBe(4);
});
});
describe('erasure', () => {
it('purgeEvent removes every face row and resets the photos', async () => {
const eventId = await seedEvent('purge');
const faces = [];
for (let v = 0; v < 3; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(10, v))).face);
}
await clustering.assignFaces(eventId, faces);
await db('photos').where({ event_id: eventId }).update({ face_status: 'done', face_count: 1 });
expect(await db('photo_faces').where({ event_id: eventId })).not.toHaveLength(0);
expect(await db('event_people').where({ event_id: eventId })).not.toHaveLength(0);
await faceProcessor.purgeEvent(eventId);
expect(await db('photo_faces').where({ event_id: eventId })).toHaveLength(0);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
const photos = await db('photos').where({ event_id: eventId });
expect(photos.every((p) => p.face_status === null && p.face_count === null)).toBe(true);
});
it('purgePhotoFaces removes face rows WITHOUT relying on the FK cascade', async () => {
// The regression this guards: PicPeak does not enable
// `PRAGMA foreign_keys` on SQLite, so ON DELETE CASCADE never fires
// there and biometric embeddings outlived the photo. The pragma is
// explicitly OFF here so the assertion can only pass if the deletion
// path purges the rows itself.
await db.raw('PRAGMA foreign_keys = OFF');
const eventId = await seedEvent('purge-no-cascade');
const faces = [];
for (let v = 0; v < 3; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(20, v))).face);
}
await clustering.assignFaces(eventId, faces);
const person = await db('event_people').where({ event_id: eventId }).first();
expect(person.face_count_total).toBe(3);
const victim = faces[0];
await faceProcessor.purgePhotoFaces(victim.photo_id);
expect(await db('photo_faces').where({ photo_id: victim.photo_id })).toHaveLength(0);
// …and the person it belonged to was rebuilt, not left with a stale count.
const after = await db('event_people').where({ id: person.id }).first();
expect(after.face_count_total).toBe(2);
});
it('purging the last face of a person removes the person too', async () => {
await db.raw('PRAGMA foreign_keys = OFF');
const eventId = await seedEvent('purge-last-face');
const { face, photoId } = await addPhotoWithFace(eventId, makeEmbedding(21));
await clustering.assignFaces(eventId, [face]);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
await faceProcessor.purgePhotoFaces(photoId);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
});
it('deleting an event removes its people and faces', async () => {
await db.raw('PRAGMA foreign_keys = ON');
const eventId = await seedEvent('event-delete');
const { face } = await addPhotoWithFace(eventId, makeEmbedding(11));
await clustering.assignFaces(eventId, [face]);
await db('photos').where({ event_id: eventId }).del();
await db('events').where({ id: eventId }).del();
expect(await db('photo_faces').where({ event_id: eventId })).toHaveLength(0);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
});
});
describe('all-in-one image block (#1042 / PR #1068)', () => {
// Blocked for performance: the AIO image runs backend, frontend, SQLite
// and every worker in one container, with no ML sidecar to talk to. The
// failure there would not be loud — just a slow install that looks
// broken — so the gate is asserted rather than assumed.
const faceSettings = require('../../src/services/faceSettings');
afterEach(() => { delete process.env.PICPEAK_SINGLE_CONTAINER; });
it('reports the feature off regardless of the flag row', async () => {
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
expect(faceSettings.isSingleContainerImage()).toBe(true);
// Even with the flag ON in the database.
await db('feature_flags').insert({ key: 'faces', value: true })
.onConflict('key').merge()
.catch(async () => {
await db('feature_flags').where({ key: 'faces' }).update({ value: true });
});
expect(await faceSettings.isFeatureEnabled()).toBe(false);
});
it('refuses per-event detection too', async () => {
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
const eventId = await seedEvent('aio-block');
const event = await db('events').where({ id: eventId }).first();
expect(event.face_recognition_enabled).toBeTruthy();
expect(await faceSettings.isEnabledForEvent(event)).toBe(false);
});
it('accepts only explicit truthy markers', () => {
for (const v of ['true', '1', 'yes', 'TRUE']) {
process.env.PICPEAK_SINGLE_CONTAINER = v;
expect(faceSettings.isSingleContainerImage()).toBe(true);
}
for (const v of ['false', '0', '', 'no']) {
process.env.PICPEAK_SINGLE_CONTAINER = v;
expect(faceSettings.isSingleContainerImage()).toBe(false);
}
delete process.env.PICPEAK_SINGLE_CONTAINER;
expect(faceSettings.isSingleContainerImage()).toBe(false);
});
});
describe('export and backup exclusion', () => {
it('excludes both face tables from .picpeak exports', () => {
const { EXCLUDED_TABLES } = require('../../src/services/picpeakExportService');
expect(EXCLUDED_TABLES.has('photo_faces')).toBe(true);
expect(EXCLUDED_TABLES.has('event_people')).toBe(true);
});
it('excludes both face tables from the database backup table list', async () => {
const databaseBackup = require('../../src/services/databaseBackup');
const service = databaseBackup.DatabaseBackupService
? new databaseBackup.DatabaseBackupService()
: databaseBackup;
if (typeof service.getTables !== 'function') return; // shape differs; covered by the export test
const tables = await service.getTables();
expect(tables).not.toContain('photo_faces');
expect(tables).not.toContain('event_people');
// Sanity: the filter didn't eat everything.
expect(tables).toContain('events');
});
});
});
@@ -1,146 +0,0 @@
/**
* Bounding-box coordinate space (#1074).
*
* The sidecar reports boxes in the pixel space of the image it was HANDED —
* the ≤1920px preview — while every consumer (the strip's avatar crop, the
* admin manager, the auto-category portrait rule) compares them against
* photos.width/height, the ORIGINAL dimensions. faceProcessor scales once so
* everything downstream can assume original-image coordinates.
*
* This is the defect that survived longest in review, and it is invisible on
* any photo already under 1920px — the entire demo gallery was 750px, so the
* scale factor was always exactly 1.0 and the correction never ran. Verified
* by hand afterwards on a real 4000x3000 upload (stored box moved from
* 1493,204 to 3110,426 — a factor of 2.083, exactly 4000/1920). This test
* exists so that verification does not have to be repeated by hand.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facescale-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facescale-test-secret';
// A 1920x1440 JPEG standing in for the preview rendition. faceProcessor reads
// its dimensions with sharp to derive the scale, so it must be a real image.
const sharp = require('sharp');
let mockPreviewBuffer;
const mockSidecarBox = [1493, 204, 131, 161]; // what the sidecar sees on the preview
jest.mock('../../src/services/imageProcessor', () => ({
...jest.requireActual('../../src/services/imageProcessor'),
ensurePreviewImage: jest.fn(async () => 'previews/preview_test.jpg'),
}));
jest.mock('../../src/services/storage', () => ({
getStorage: () => ({ get: async () => mockPreviewBuffer }),
}));
jest.mock('../../src/services/faceClient', () => ({
detectFaces: jest.fn(async () => ({
model_version: 'test-v1',
faces: [{
bbox: mockSidecarBox,
score: 0.99,
landmarks: [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
yaw: 0, pitch: 0, blur: 500,
embedding: Array.from({ length: 64 }, (_, i) => (i === 0 ? 1 : 0)),
}],
})),
SidecarUnavailableError: class extends Error {},
}));
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let faceProcessor;
async function seedPhoto(width, height) {
const [e] = await db('events').insert({
slug: `scale-${width}-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'scale',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `scale-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const [p] = await db('photos').insert({
event_id: eventId,
filename: 'big.jpg',
path: '/tmp/big.jpg',
type: 'individual',
width,
height,
processing_status: 'complete',
face_status: 'processing',
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
describe('face bbox coordinate space (#1074)', () => {
beforeAll(async () => {
mockPreviewBuffer = await sharp({
create: { width: 1920, height: 1440, channels: 3, background: { r: 20, g: 40, b: 80 } },
}).jpeg().toBuffer();
({ db, cleanup } = await bootCrmDb());
// The faces flag gates everything; turn it on for this suite.
await db('feature_flags').insert({ key: 'faces', value: true })
.onConflict('key').merge()
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
faceProcessor = require('../../src/services/faceProcessor');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('scales preview-space boxes into ORIGINAL image coordinates', async () => {
// 4000px original, 1920px preview -> every coordinate must grow by 4000/1920.
const { photoId } = await seedPhoto(4000, 3000);
await faceProcessor.processPhotoFaces(photoId);
const face = await db('photo_faces').where({ photo_id: photoId }).first();
const scale = 4000 / 1920;
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0] * scale, 1);
expect(face.bbox_y).toBeCloseTo(mockSidecarBox[1] * scale, 1);
expect(face.bbox_w).toBeCloseTo(mockSidecarBox[2] * scale, 1);
expect(face.bbox_h).toBeCloseTo(mockSidecarBox[3] * scale, 1);
// The regression this guards: the raw preview-space value being stored.
expect(face.bbox_x).not.toBeCloseTo(mockSidecarBox[0], 1);
// And a sanity check that it lands inside the original frame.
expect(face.bbox_x + face.bbox_w).toBeLessThanOrEqual(4000);
});
it('leaves boxes untouched when the photo is already preview-sized', async () => {
// The case that hid the bug: no downscale, so scale is exactly 1 and the
// stored box equals what the sidecar reported.
const { photoId } = await seedPhoto(1920, 1440);
await faceProcessor.processPhotoFaces(photoId);
const face = await db('photo_faces').where({ photo_id: photoId }).first();
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0], 1);
expect(face.bbox_w).toBeCloseTo(mockSidecarBox[2], 1);
});
it('falls back to unscaled rather than corrupting when width is unknown', async () => {
// Pre-dimension-migration rows have no width. Storing a box scaled by
// NaN/0 would be worse than storing an unscaled one.
const { photoId } = await seedPhoto(null, null);
await faceProcessor.processPhotoFaces(photoId);
const face = await db('photo_faces').where({ photo_id: photoId }).first();
expect(Number.isFinite(face.bbox_x)).toBe(true);
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0], 1);
});
});
@@ -1,226 +0,0 @@
/**
* A deferred photo must not stall the queue.
*
* claimNextPhoto orders by id ascending, and the queue defaults to a single
* worker. So returning an unreachable photo to 'pending' — the obvious way to
* say "try again later" — makes that same row the oldest pending one forever:
* the worker reclaims it after every backoff and never reaches a higher id.
* One dead mount would stall face scanning for the entire install, including
* unrelated events and fresh uploads.
*
* The row is instead left parked in 'processing' with its face_started_at
* intact. It is not claimable, so the worker advances; the existing janitor
* returns it to 'pending' after STUCK_TIMEOUT_MS, which is the retry.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-defer-'));
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'defer-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let faceQueue; let faceProcessor;
describe('deferred photos do not block the queue', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
faceQueue = require('../../src/services/faceQueue');
faceProcessor = require('../../src/services/faceProcessor');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
});
it('exports TransientSourceError for the queue to branch on', () => {
// The queue imports this from faceProcessor; if the export is dropped the
// instanceof check silently becomes false and every deferral turns back
// into a permanent failure.
expect(typeof faceProcessor.TransientSourceError).toBe('function');
expect(new faceProcessor.TransientSourceError(1, 'x'))
.toBeInstanceOf(Error);
});
it('does NOT return a deferred row to pending', () => {
// Source inspection, deliberately. workerLoop is an unexported infinite
// loop, so the branch cannot be driven directly, and asserting on database
// state alone does not distinguish the fix from the bug — a version that
// re-queues the row passes every state assertion in this file. What
// actually matters is that this one branch does not call releaseToPending,
// so that is what is pinned. Same approach as the contract tests added for
// #596.
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'services', 'faceQueue.js'), 'utf8'
);
const marker = 'if (err instanceof TransientSourceError) {';
const start = src.indexOf(marker);
expect(start).toBeGreaterThan(-1);
// The branch body, up to its closing brace.
const body = src.slice(start, src.indexOf('\n }', start));
expect(body).not.toMatch(/releaseToPending/);
expect(body).toMatch(/continue/);
// And the sidecar branch, which SHOULD still release, so this test fails
// if the two branches are ever collapsed back together.
const sideStart = src.indexOf('if (err instanceof SidecarUnavailableError) {');
expect(sideStart).toBeGreaterThan(-1);
const sideBody = src.slice(sideStart, src.indexOf('\n }', sideStart));
expect(sideBody).toMatch(/releaseToPending/);
});
it('leaves a deferred row claimable-later, not claimable-now', async () => {
// A row parked in 'processing' is invisible to claimNextPhoto, which only
// ever selects face_status='pending' — that is what lets the worker move
// past it instead of spinning on it.
const [e] = await db('events').insert({
slug: `defer-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'defer',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `defer-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const [stuck] = await db('photos').insert({
event_id: eventId,
filename: 'stuck.jpg',
path: 'd/stuck.jpg',
type: 'individual',
processing_status: 'complete',
face_status: 'processing',
face_started_at: new Date().toISOString(),
source_origin: 'external',
}).returning('id');
const stuckId = typeof stuck === 'object' ? stuck.id : stuck;
const parked = await db('photos')
.where({ id: stuckId, face_status: 'pending' })
.first();
expect(parked).toBeUndefined(); // not claimable while parked
// The janitor's contract is what turns the park into a retry: it resets
// 'processing' rows whose face_started_at is older than the stuck timeout.
// Backdate past it and the row becomes claimable again.
const longAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
await db('photos').where({ id: stuckId }).update({ face_started_at: longAgo });
const cutoff = new Date(Date.now() - 600000).toISOString();
const reset = await db('photos')
.where('face_status', 'processing')
.where('face_started_at', '<', cutoff)
.update({ face_status: 'pending', face_started_at: null });
expect(reset).toBeGreaterThan(0);
const after = await db('photos').where({ id: stuckId }).first();
expect(after.face_status).toBe('pending');
});
it('claimNextPhoto skips events inside their backoff window', async () => {
// The per-event cooldown is what stops the janitor handing a whole dead
// gallery back every sweep. Without the exclusion the worker walks all of
// it again — one slow stat per photo against a possibly hard-mounted
// share — before reaching any healthy event.
const mk = async (name) => {
const [e] = await db('events').insert({
slug: `cd-${name}-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: name,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `cd-${name}-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const [p2] = await db('photos').insert({
event_id: eventId,
filename: `${name}.jpg`,
path: `cd/${name}.jpg`,
type: 'individual',
processing_status: 'complete',
face_status: 'pending',
source_origin: 'external',
}).returning('id');
return { eventId, photoId: typeof p2 === 'object' ? p2.id : p2 };
};
await db('photos').del();
const dead = await mk('dead'); // lower id -> would win the FIFO
const healthy = await mk('healthy');
// Without exclusion the dead event's row is claimed first...
const first = await faceQueue.claimNextPhoto([]);
expect(first.id).toBe(dead.photoId);
await db('photos').where({ id: dead.photoId }).update({ face_status: 'pending' });
// ...and with it, the worker reaches the healthy event instead.
const second = await faceQueue.claimNextPhoto([dead.eventId]);
expect(second.id).toBe(healthy.photoId);
});
it('backoff spares managed rows in a mixed-source event', async () => {
// A reference event can hold managed uploads alongside imported external
// ones. Excluding the whole event id would leave those unscanned for as
// long as external rows keep renewing the cooldown — indefinitely, during
// a real outage — even though their local source is fine.
await db('photos').del();
const [e] = await db('events').insert({
slug: `mix-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'mix',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `mix-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
source_mode: 'reference',
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const add = async (origin, name) => {
const [p2] = await db('photos').insert({
event_id: eventId,
filename: name,
path: `mix/${name}`,
type: 'individual',
processing_status: 'complete',
face_status: 'pending',
source_origin: origin,
}).returning('id');
return typeof p2 === 'object' ? p2.id : p2;
};
await add('external', 'ext.jpg'); // lower id, would win the FIFO
const managedId = await add('managed', 'man.jpg');
// Event is in backoff: the external row is skipped, the managed one is not.
const claimed = await faceQueue.claimNextPhoto([eventId]);
expect(claimed).toBeTruthy();
expect(claimed.id).toBe(managedId);
});
it('startQueue is exported and does not throw on import', () => {
// faceQueue requires faceProcessor for TransientSourceError while
// faceProcessor is itself required by the routes — a circular require here
// would surface as an undefined export rather than a crash, so assert the
// module actually loaded something usable.
expect(faceQueue).toBeTruthy();
expect(Object.keys(faceQueue).length).toBeGreaterThan(0);
});
});
@@ -1,219 +0,0 @@
/**
* "The scan finished" is not a thing this queue is told (#1107).
*
* It claims photos one at a time, so a backfill is just a lot of independent
* claims and the only available signal is a worker finding nothing left. That
* signal is NOT sufficient on its own — with concurrency above one the other
* workers may still be busy, and a photo released back to `pending` by a down
* sidecar is still owed — so the drain is tested against the queue directly.
*
* These are the cases that decide whether consolidation runs too early (a
* wasted pass over half-formed clusters) or never (the feature silently does
* nothing, which is the state #1107 was filed about).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facedrain-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facedrain-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let faceQueue; let clustering;
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
// The drain rechecks this before consolidating, so the fixture has to be
// a gallery that actually has detection on.
face_recognition_enabled: true,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** Both halves of the "two deliberate actions" rule have to be on. */
async function enableFacesGlobally() {
const existing = await db('feature_flags').where({ key: 'faces' }).first();
if (existing) await db('feature_flags').where({ key: 'faces' }).update({ value: true });
else await db('feature_flags').insert({ key: 'faces', value: true });
}
async function insertPhoto(eventId, faceStatus) {
const [row] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
face_status: faceStatus,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
describe('faceQueue drain consolidation (#1107)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
faceQueue = require('../../src/services/faceQueue');
clustering = require('../../src/services/faceClustering');
await enableFacesGlobally();
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(() => {
faceQueue.touchedEvents.clear();
faceQueue.consolidationRetryAt.clear();
faceQueue.inFlightByEvent.clear();
jest.restoreAllMocks();
});
it('does nothing at all when no photo has been scanned', async () => {
const spy = jest.spyOn(clustering, 'consolidate');
await faceQueue.drainConsolidation();
expect(spy).not.toHaveBeenCalled();
});
it('waits while the event still has photos queued', async () => {
const eventId = await seedEvent('drain-pending');
await insertPhoto(eventId, 'done');
await insertPhoto(eventId, 'pending');
faceQueue.touchedEvents.add(eventId);
const spy = jest.spyOn(clustering, 'consolidate');
await faceQueue.drainConsolidation();
expect(spy).not.toHaveBeenCalled();
// Still owed, so it must keep its place for the next idle tick — dropping
// it here would mean the gallery never consolidates at all.
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
});
it('waits while a photo is still being processed by another worker', async () => {
const eventId = await seedEvent('drain-processing');
await insertPhoto(eventId, 'done');
await insertPhoto(eventId, 'processing');
faceQueue.touchedEvents.add(eventId);
const spy = jest.spyOn(clustering, 'consolidate');
await faceQueue.drainConsolidation();
expect(spy).not.toHaveBeenCalled();
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
});
it('consolidates once the queue is empty, and does not repeat itself', async () => {
const eventId = await seedEvent('drain-empty');
await insertPhoto(eventId, 'done');
await insertPhoto(eventId, 'failed');
await insertPhoto(eventId, 'skipped');
faceQueue.touchedEvents.add(eventId);
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
await faceQueue.drainConsolidation();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(eventId);
// Drained and handled, so a second idle tick must not pay for it again.
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
await faceQueue.drainConsolidation();
expect(spy).toHaveBeenCalledTimes(1);
});
it('a failing consolidation never propagates into the worker loop, and is retried', async () => {
const eventId = await seedEvent('drain-throws');
await insertPhoto(eventId, 'done');
faceQueue.touchedEvents.add(eventId);
const spy = jest.spyOn(clustering, 'consolidate').mockRejectedValue(new Error('boom'));
await expect(faceQueue.drainConsolidation()).resolves.toBeUndefined();
// A transient database error must not cost the gallery its consolidation
// outright — the event keeps its place so a later tick retries.
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
// ...but not on the very next tick. The worker idles every couple of
// seconds, so an immediate retry would hot-loop a permanently broken event
// and warn every time.
expect(faceQueue.consolidationRetryAt.get(eventId)).toBeGreaterThan(Date.now());
const callsBefore = spy.mock.calls.length;
await faceQueue.drainConsolidation();
expect(spy).toHaveBeenCalledTimes(callsBefore);
// Once the backoff elapses it really does try again, and succeeds.
faceQueue.consolidationRetryAt.set(eventId, Date.now() - 1);
spy.mockResolvedValue([]);
await faceQueue.drainConsolidation();
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
expect(faceQueue.consolidationRetryAt.has(eventId)).toBe(false);
});
it('waits while another worker is still inside processPhotoFaces', async () => {
const eventId = await seedEvent('drain-inflight');
// Every row already reads as drained: the last photo is committed 'done'
// inside the transaction, and auto-categorisation runs afterwards. Only
// the in-flight count knows a worker is still there.
await insertPhoto(eventId, 'done');
faceQueue.touchedEvents.add(eventId);
faceQueue.inFlightByEvent.set(eventId, 1);
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
await faceQueue.drainConsolidation();
// Consolidating here would record its count, and the busy worker would
// then re-mark the event — the next pass merges nothing and overwrites the
// real number with zero.
expect(spy).not.toHaveBeenCalled();
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
faceQueue.inFlightByEvent.delete(eventId);
await faceQueue.drainConsolidation();
expect(spy).toHaveBeenCalledTimes(1);
});
it('does not consolidate an event whose detection was switched off mid-drain', async () => {
const eventId = await seedEvent('drain-disabled');
await insertPhoto(eventId, 'done');
await db('events').where({ id: eventId }).update({ face_recognition_enabled: false });
faceQueue.touchedEvents.add(eventId);
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
await faceQueue.drainConsolidation();
// An earlier photo legitimately marked the event before the toggle went
// off. Merging someone's clusters just after they disabled the feature is
// not a thing to do quietly.
expect(spy).not.toHaveBeenCalled();
// Dropped rather than retried — it is not coming back on its own.
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
});
it('treats events independently — a busy gallery does not hold up a finished one', async () => {
const busy = await seedEvent('drain-busy');
const done = await seedEvent('drain-done');
await insertPhoto(busy, 'pending');
await insertPhoto(done, 'done');
faceQueue.touchedEvents.add(busy);
faceQueue.touchedEvents.add(done);
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
await faceQueue.drainConsolidation();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(done);
expect(faceQueue.touchedEvents.has(busy)).toBe(true);
});
});
@@ -1,567 +0,0 @@
/**
* "Not the same person" has to outlive re-derivation (#1132).
*
* The decision used to be stored as a pair of event_people.id, and neither
* person ids nor face ids survive:
*
* - recluster() deletes every person and re-assigns, so person ids die but
* photo_faces.id survives
* - a full re-scan replaces a photo's faces outright, so FACE ids die too
*
* The embedding is the only stable handle, so that is what the separation is
* keyed on. These tests simulate both kinds of re-derivation by destroying the
* ids and rebuilding from the same vectors — which is exactly what the real
* paths do — and assert the constraint still binds.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sep-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sep-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let clustering;
const THRESHOLDS = { face_match_threshold: 0.6, face_quality_min_score: 0.7, face_quality_min_px: 40 };
const DIM = 64;
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
function pairAtSimilarity(target, basis) {
const a = new Float32Array(DIM);
const b = new Float32Array(DIM);
a[basis] = 1;
b[basis] = target;
b[basis + 1] = Math.sqrt(1 - target * target);
return [a, b];
}
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `${slug}-share`, expires_at: new Date().toISOString(),
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function insertPerson(eventId, centroid, overrides = {}) {
const [row] = await db('event_people').insert({
event_id: eventId,
centroid: clustering.packEmbedding(centroid),
face_count_total: 1,
model_version: 'test-v1',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** The mirror of pairAtSimilarity's second vector: same similarity, other side. */
function mirrorAtSimilarity(target, basis) {
const b = new Float32Array(DIM);
b[basis] = target;
b[basis + 1] = -Math.sqrt(1 - target * target);
return b;
}
async function insertFaceWithPhoto(eventId, personId, centroid) {
const [p] = await db('photos').insert({
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/x.jpg', type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const [f] = await db('photo_faces').insert({
photo_id: photoId, event_id: eventId, person_id: personId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
embedding: clustering.packEmbedding(centroid),
model_version: 'test-v1', created_at: new Date().toISOString(),
}).returning('id');
return { faceId: typeof f === 'object' ? f.id : f, photoId };
}
async function insertFace(eventId, personId, centroid) {
const { faceId } = await insertFaceWithPhoto(eventId, personId, centroid);
return faceId;
}
/**
* What a re-scan does to identity: the people are gone and the faces come back
* with brand-new ids. Same vectors, nothing else preserved.
*/
async function simulateRescan(eventId, vectors) {
await db('photo_faces').where({ event_id: eventId }).del();
await db('event_people').where({ event_id: eventId }).del();
const ids = [];
for (const vec of vectors) {
const personId = await insertPerson(eventId, vec);
await insertFace(eventId, personId, vec);
ids.push(personId);
}
return ids;
}
describe('separations survive re-derivation (#1132)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
clustering = require('../../src/services/faceClustering');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('the matcher', () => {
it('binds a pair that still looks like the one that was separated', () => {
const [a, b] = pairAtSimilarity(0.64, 0);
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
});
it('binds regardless of which way round the candidates arrive', () => {
const [a, b] = pairAtSimilarity(0.64, 0);
// Neither the stored pair nor the candidate pair has a meaningful order.
expect(clustering.separationForbids(b, a, [{ a, b }])).toBe(true);
});
it('lapses once a side has drifted past recognition', () => {
const [a, b] = pairAtSimilarity(0.64, 0);
// A cluster reshaped far enough is no longer the cluster the
// photographer pointed at, so the constraint should stop applying rather
// than bind something they never saw.
const drifted = new Float32Array(DIM);
drifted[10] = 1;
expect(clustering.separationForbids(drifted, b, [{ a, b }])).toBe(false);
});
it('does not bind two clusters that are both the SAME side', () => {
// A split leaves two halves of one cluster, so the pair it records is
// often similar to itself — here 0.95. Two candidates that are plainly
// both side A (0.97 to each other) each clear the bar against BOTH
// stored sides, so a test that only asks "does each side match
// something" says yes and refuses to let that person cluster with
// itself. It fragments into singletons — the person the split was not
// even about.
const [a, b] = pairAtSimilarity(0.95, 0);
const x = new Float32Array(DIM); x[0] = 1;
const y = mirrorAtSimilarity(0.97, 0);
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
// The pair it was actually about still binds.
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
});
it('ignores a separation recorded under a different embedding model', () => {
const [a, b] = pairAtSimilarity(0.64, 0);
// Vectors from another model are meaningless here, not merely stale —
// the same rule assignment and consolidation apply to person centroids.
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v2' }],
{ modelVersion: 'test-v1' })).toBe(false);
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v1' }],
{ modelVersion: 'test-v1' })).toBe(true);
});
it('ignores an unrelated pair entirely', () => {
const [a, b] = pairAtSimilarity(0.64, 0);
const [x, y] = pairAtSimilarity(0.64, 20);
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
});
});
describe('across a re-scan', () => {
it('still refuses to merge the pair after every id has changed', async () => {
const eventId = await seedEvent('sep-rescan');
// Well above the auto-merge threshold: only the separation keeps them apart.
const [a, b] = pairAtSimilarity(0.97, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await insertFace(eventId, idA, a);
await insertFace(eventId, idB, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
const newIds = await simulateRescan(eventId, [a, b]);
// The premise: nothing the old row named still exists.
expect(newIds).not.toContain(idA);
expect(newIds).not.toContain(idB);
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toEqual([]);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
});
it('keeps the pair out of the suggestion list too', async () => {
const eventId = await seedEvent('sep-rescan-suggest');
const [a, b] = pairAtSimilarity(0.64, 0); // inside the suggestion band
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
await simulateRescan(eventId, [a, b]);
expect(await clustering.suggestMerges(eventId, { thresholds: THRESHOLDS })).toEqual([]);
});
it('a split still binds after the ids it recorded are gone', async () => {
const eventId = await seedEvent('sep-split-rescan');
// Two faces that look alike enough to have been clustered together, but
// are not the same vector — which is what a split is FOR, and the only
// case it can survive re-derivation in. Two byte-identical embeddings
// carry no information about which side is which, so a separation
// between them has nothing to key on once the ids are gone.
const [base, other] = pairAtSimilarity(0.96, 0);
const personId = await insertPerson(eventId, base);
await insertFace(eventId, personId, base);
const extra = await insertFace(eventId, personId, other);
const newPersonId = await clustering.splitPerson(eventId, personId, [extra]);
expect(newPersonId).toBeTruthy();
// The snapshot must have been taken AFTER recomputeCentroid — before it,
// the new person has no centroid at all.
const row = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
expect(row.centroid_a).toBeTruthy();
expect(row.centroid_b).toBeTruthy();
await simulateRescan(eventId, [base, other]);
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toEqual([]);
});
});
describe('when a photo is hard-deleted', () => {
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
it('drops the separation when one side has no photos left', async () => {
const eventId = await seedEvent('sep-purge-gone');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await insertFace(eventId, idA, a);
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
await purgePhotoFaces(photoId);
// Person B is gone with its only photo. The row held a COPY of its
// centroid, so leaving it standing would keep a vector derived from a
// deleted photo alive in a table nothing else touches.
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
});
it('keeps the constraint when a side still has another cluster on it', async () => {
const eventId = await seedEvent('sep-purge-descendant');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await insertFace(eventId, idA, a);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
// Re-derivation can leave one stored side represented by more than one
// current person. Deleting the photo behind ONE of them must not throw
// the whole decision away — the other still stands for that side, and the
// pair would be free to merge again.
const twin = new Float32Array(DIM);
for (let i = 0; i < DIM; i++) twin[i] = 0.98 * b[i];
twin[6] = Math.sqrt(1 - 0.98 ** 2);
const survivor = await insertPerson(eventId, twin);
await insertFace(eventId, survivor, twin);
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
await purgePhotoFaces(photoId);
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
expect(rows).toHaveLength(1);
// Re-anchored onto the survivor, so it still binds.
expect(clustering.separationForbids(a, twin, [{
a: clustering.unpackEmbedding(rows[0].centroid_a),
b: clustering.unpackEmbedding(rows[0].centroid_b),
}])).toBe(true);
});
it('re-takes the snapshot from what is left when the person survives', async () => {
const eventId = await seedEvent('sep-purge-survives');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await insertFace(eventId, idA, a);
await insertFace(eventId, idB, b);
// A second face on B, close enough that B stays recognisably B — so
// purging it moves B's centroid rather than deleting the person, and the
// side still resolves to B afterwards.
const other = new Float32Array(DIM);
for (let i = 0; i < DIM; i++) other[i] = 0.95 * b[i];
other[5] = Math.sqrt(1 - 0.95 ** 2);
const { photoId } = await insertFaceWithPhoto(eventId, idB, other);
await clustering.recomputeCentroid(idB);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
const before = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
await purgePhotoFaces(photoId);
const after = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
expect(after).toBeTruthy();
expect(Buffer.from(after.centroid_b).equals(Buffer.from(before.centroid_b))).toBe(false);
// It now equals the recomputed centroid — nothing of the deleted face left.
const person = await db('event_people').where({ id: idB }).first();
expect(Buffer.from(after.centroid_b).equals(Buffer.from(person.centroid))).toBe(true);
});
});
describe('when the photographer changes their mind', () => {
it('a manual merge clears the separation between the merged people', async () => {
const eventId = await seedEvent('sep-merge-overrules');
const [a, b] = pairAtSimilarity(0.97, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await insertFace(eventId, idA, a);
await insertFace(eventId, idB, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
// ...and then decides they ARE the same person after all.
await clustering.mergePeople(eventId, [idB], idA);
// The row is keyed on the centroids as well as the ids, so leaving it
// would survive the ids it names: the next recluster would recognise
// those two sides and pull the merge apart again.
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
await simulateRescan(eventId, [a, b]);
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
});
});
describe('cleanup after the ids have already died', () => {
// The rows these paths must find are exactly the ones whose person ids no
// longer resolve — that is the state this whole feature creates. Matching
// on ids alone walks past them, which is worse than not cleaning up at
// all: the surviving row still enforces its vectors.
it('a merge clears a separation that had already outlived its ids', async () => {
const eventId = await seedEvent('sep-merge-stale');
const [a, b] = pairAtSimilarity(0.97, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
// A recluster: same vectors, brand-new people. The row now names nobody.
const [newA, newB] = await simulateRescan(eventId, [a, b]);
expect([newA, newB]).not.toContain(idA);
await clustering.mergePeople(eventId, [newB], newA);
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
// And it stays merged through the next re-derivation.
await simulateRescan(eventId, [a, b]);
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
});
it('a purge clears a separation that had already outlived its ids', async () => {
const eventId = await seedEvent('sep-purge-stale');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
// Same recluster, then hard-delete the photo behind the B side.
await db('photo_faces').where({ event_id: eventId }).del();
await db('event_people').where({ event_id: eventId }).del();
const newA = await insertPerson(eventId, a);
await insertFace(eventId, newA, a);
const newB = await insertPerson(eventId, b);
const { photoId } = await insertFaceWithPhoto(eventId, newB, b);
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
await purgePhotoFaces(photoId);
expect(await db('event_people').where({ id: newB }).first()).toBeUndefined();
// The row named idA/idB, neither of which exists — but its centroid_b is
// a copy of a vector derived from the photo that was just destroyed.
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
});
});
describe('when the whole gallery is deleted', () => {
it('deleteEventCascade clears the separations too', () => {
// Source inspection, deliberately. deleteEventCascade takes an admin
// context and does filesystem cleanup, so driving it here would test the
// scaffolding rather than the contract. The contract is narrow and
// absolute: this table now holds centroid BLOBs, it has no event FK by
// design, and nothing else in the codebase would ever reach it — so the
// one delete has to be in the cascade or the embeddings outlive the
// gallery. Same approach as the contract tests added for #596.
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'routes', 'adminEvents', 'helpers.js'), 'utf8'
);
const body = src.slice(src.indexOf('async function deleteEventCascade'));
expect(body).toContain('event_people_merge_dismissals\').where(\'event_id\', eventId).del()');
// Guarded, not caught: a failed statement aborts the transaction on PG.
expect(body).toContain('hasTable(\'event_people_merge_dismissals\')');
});
it('permanent archive deletion clears the face data too', () => {
// Same contract, second door. This route deletes the event row directly
// and leans on the FK cascade, which is inert on SQLite — and no FK
// reaches the dismissals table on either engine. archiveEvent's purge is
// nonfatal, so an event really can arrive here still holding embeddings.
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'routes', 'adminArchives.js'), 'utf8'
);
expect(src).toContain('event_people_merge_dismissals');
expect(src).toContain('db(\'photo_faces\').where(\'event_id\', req.params.id).del()');
expect(src).toContain('db(\'event_people\').where(\'event_id\', req.params.id).del()');
});
});
describe('during assignment', () => {
it('will not put a new face into a cluster it was separated from', async () => {
const eventId = await seedEvent('sep-assign');
const [a, b] = pairAtSimilarity(0.97, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
// A face that looks like side B arrives. Its nearest centroid is A (0.97,
// far above the 0.6 match threshold), and before #1132 it would simply
// have joined — reforming the pair the photographer pulled apart, because
// assignment consulted no separations at all.
await db('event_people').where({ id: idB }).del();
const [p] = await db('photos').insert({
event_id: eventId, filename: 'new.jpg', path: '/tmp/n.jpg', type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const [f] = await db('photo_faces').insert({
photo_id: photoId, event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
embedding: clustering.packEmbedding(b), model_version: 'test-v1',
created_at: new Date().toISOString(),
}).returning('id');
const faceId = typeof f === 'object' ? f.id : f;
const assignments = await clustering.assignFaces(
eventId, [{ id: faceId, embedding: clustering.packEmbedding(b), model_version: 'test-v1',
det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
{ thresholds: THRESHOLDS },
);
expect(assignments).toHaveLength(1);
expect(assignments[0].personId).not.toBe(idA);
// It opened its own person rather than being forced into the wrong one.
expect(assignments[0].personId).toBeTruthy();
});
it('holds back a face that is only loosely like the side it belongs to', async () => {
const eventId = await seedEvent('sep-assign-loose');
// The separated sides are CENTROIDS; an individual face sits well below
// its own centroid — that is why faces join at 0.6 and not at 0.92. A
// face 0.85-like its own side would clear no strict bar against it, and
// before this it walked straight into the other person during a
// recluster, which is the exact merge the photographer undid.
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
const idA = await insertPerson(eventId, sideA);
const idB = await insertPerson(eventId, sideB);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
await db('event_people').where({ id: idB }).del();
// 0.65 to side A — above the 0.6 match threshold, so it would join A —
// and 0.85 to side B, which is where it actually belongs.
const face = new Float32Array(DIM);
face[0] = 0.65; face[1] = 0.553; face[2] = Math.sqrt(1 - 0.65 ** 2 - 0.553 ** 2);
const [p] = await db('photos').insert({
event_id: eventId, filename: 'loose.jpg', path: '/tmp/l.jpg', type: 'individual',
}).returning('id');
const [f] = await db('photo_faces').insert({
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
embedding: clustering.packEmbedding(face), model_version: 'test-v1',
created_at: new Date().toISOString(),
}).returning('id');
const assignments = await clustering.assignFaces(
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(face),
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
{ thresholds: THRESHOLDS },
);
expect(assignments[0].personId).not.toBe(idA);
expect(assignments[0].personId).toBeTruthy();
});
it('binds while the clusters are still being rebuilt one face at a time', async () => {
const eventId = await seedEvent('sep-assign-rebuild');
// recluster() empties event_people and re-assigns from scratch, so for
// the first faces of a batch the "person" on the other side of the
// comparison is a cluster of ONE. A settled centroid it is not, and
// holding it to the strict threshold meant the pair was already merged
// by the time the constraint could bind — with nothing left to split it.
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
const idA = await insertPerson(eventId, sideA);
const idB = await insertPerson(eventId, sideB);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
await db('event_people').where({ event_id: eventId }).del();
// Two faces, one per side, each a little off its own side's centroid —
// 0.91, just under the strict bar — and 0.66 to each other, over the
// match threshold. Exactly the pair that must not re-form.
const off = Math.sqrt(1 - 0.91 ** 2);
const faceA = new Float32Array(DIM);
faceA[0] = 0.91; faceA[3] = off;
const faceB = new Float32Array(DIM);
faceB[0] = 0.91 * 0.7; faceB[1] = 0.91 * Math.sqrt(1 - 0.7 ** 2); faceB[3] = off;
const rows = [];
for (const vec of [faceA, faceB]) {
const [p] = await db('photos').insert({
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/r.jpg', type: 'individual',
}).returning('id');
const [f] = await db('photo_faces').insert({
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
embedding: clustering.packEmbedding(vec), model_version: 'test-v1',
created_at: new Date().toISOString(),
}).returning('id');
rows.push({ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(vec),
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 });
}
// The premise: they are close enough to each other to cluster together.
expect(clustering.dot(faceA, faceB)).toBeGreaterThan(THRESHOLDS.face_match_threshold);
const assignments = await clustering.assignFaces(eventId, rows, { thresholds: THRESHOLDS });
expect(assignments[0].personId).not.toBe(assignments[1].personId);
});
it('leaves ordinary assignment alone when no separation applies', async () => {
const eventId = await seedEvent('sep-assign-clean');
const base = new Float32Array(DIM); base[0] = 1;
const personId = await insertPerson(eventId, base);
const [p] = await db('photos').insert({
event_id: eventId, filename: 'x.jpg', path: '/tmp/x.jpg', type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const [f] = await db('photo_faces').insert({
photo_id: photoId, event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
embedding: clustering.packEmbedding(base), model_version: 'test-v1',
created_at: new Date().toISOString(),
}).returning('id');
const assignments = await clustering.assignFaces(
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(base),
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
{ thresholds: THRESHOLDS },
);
// The whole point of the strict threshold: a constraint that fires when
// it should not would quietly wreck ordinary clustering.
expect(assignments[0].personId).toBe(personId);
});
});
});
@@ -1,212 +0,0 @@
/**
* A dropped mount defers a scan; a dead photo fails it.
*
* ensurePreviewImage returns null for both "this JPEG is corrupt" and "the
* NFS share is gone", and #1090 made that distinction matter: external
* libraries now reach this path, and network mounts drop far more often than
* local disks. Failing on an outage strands the photo — faceQueue only ever
* claims 'pending', and nothing re-queues a failure automatically, so a mount
* that blinked mid-scan would cost an entire gallery a manual Re-scan.
*
* The probe checks the containing DIRECTORY rather than the file, because that
* is what separates the two cases: a missing file inside a healthy directory
* is a broken photo, an unreachable directory is broken storage.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-transient-'));
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'transient-test-secret';
// Created BEFORE anything requires externalMediaService: getExternalMediaRoot
// only honours the env var if the directory already exists, and caches the
// result on first call — set it later and every path silently resolves
// against a fallback root instead.
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpRoot, 'media');
fs.mkdirSync(process.env.EXTERNAL_MEDIA_ROOT, { recursive: true });
let previewKeyResult = null;
const mockEnsurePreviewImage = jest.fn(async () => previewKeyResult);
jest.mock('../../src/services/imageProcessor', () => ({
...jest.requireActual('../../src/services/imageProcessor'),
ensurePreviewImage: (...args) => mockEnsurePreviewImage(...args),
}));
jest.mock('../../src/services/faceClient', () => ({
detectFaces: jest.fn(async () => ({ model_version: 'test-v1', faces: [] })),
SidecarUnavailableError: class extends Error {},
}));
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let faceProcessor;
async function seedExternalPhoto({ externalPath, relpath = 'individual/a.jpg' }) {
const [e] = await db('events').insert({
slug: `tr-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'tr',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `tr-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
source_mode: 'reference',
external_path: externalPath,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const [p] = await db('photos').insert({
event_id: eventId,
filename: 'a.jpg',
path: 'tr/a.jpg',
type: 'individual',
width: 4000,
height: 3000,
processing_status: 'complete',
face_status: 'processing',
source_origin: 'external',
// Stored relative to EXTERNAL_MEDIA_ROOT (#1163), which is what the import
// route writes — `relpath` above is expressed relative to the event's
// folder only because that reads better at the call sites.
external_relpath: path.join(externalPath, relpath),
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
describe('transient source vs dead photo', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await db('feature_flags').insert({ key: 'faces', value: true })
.onConflict('key').merge()
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
faceProcessor = require('../../src/services/faceProcessor');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
});
beforeEach(() => {
previewKeyResult = null; // i.e. ensurePreviewImage could not build one
mockEnsurePreviewImage.mockClear();
});
it('defers, not fails, when the source directory is unreachable', async () => {
// Nothing was ever created under EXTERNAL_MEDIA_ROOT for this path, so the
// directory does not resolve — the shape a dropped mount presents.
const { photoId } = await seedExternalPhoto({ externalPath: 'vanished-share' });
await expect(faceProcessor.processPhotoFaces(photoId))
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
// Critically: still claimable. A 'failed' here is what stranded the photo.
const photo = await db('photos').where({ id: photoId }).first();
expect(photo.face_status).not.toBe('failed');
});
it('defers when the event root survives an unmount but is empty', async () => {
// The common NFS/SMB shape: unmounting leaves the mountpoint behind as an
// ordinary empty directory, so fs.access succeeds on storage that is
// entirely gone. The EVENT ROOT is the thing that goes empty — the photo's
// own subdirectory vanishes with it.
const emptyRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'unmounted');
await fs.promises.mkdir(emptyRoot, { recursive: true });
const { photoId } = await seedExternalPhoto({ externalPath: 'unmounted' });
await expect(faceProcessor.processPhotoFaces(photoId))
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
const photo = await db('photos').where({ id: photoId }).first();
expect(photo.face_status).not.toBe('failed');
});
it('fails when the directory is healthy but the file is gone', async () => {
// Directory exists, file does not — a genuinely broken photo, which should
// surface as a failure the admin can see rather than retry forever.
const live = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'live-share', 'individual');
await fs.promises.mkdir(live, { recursive: true });
// Non-empty: an empty directory is now read as an unmounted share, so the
// "healthy storage, dead photo" case needs a sibling file present.
await fs.promises.writeFile(path.join(live, 'sibling.jpg'), 'x');
const { photoId } = await seedExternalPhoto({ externalPath: 'live-share' });
const result = await faceProcessor.processPhotoFaces(photoId);
expect(result.status).toBe('failed');
const photo = await db('photos').where({ id: photoId }).first();
expect(photo.face_status).toBe('failed');
expect(photo.face_error).toMatch(/preview/i);
});
it('fails a missing subdirectory rather than deferring the whole event', async () => {
// individual/ deleted while collages/ is fine. Probing only the photo's own
// directory reports ENOENT and would read as a mount-wide outage, deferring
// the event and starving every healthy sibling folder. The root is
// populated, so the mount is up and this is a broken path.
const root = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'partial');
await fs.promises.mkdir(path.join(root, 'collages'), { recursive: true });
await fs.promises.writeFile(path.join(root, 'collages', 'kept.jpg'), 'x');
const { photoId } = await seedExternalPhoto({ externalPath: 'partial' });
const result = await faceProcessor.processPhotoFaces(photoId);
expect(result.status).toBe('failed');
});
it('defers a file that exists but cannot be read', async () => {
// EACCES / EIO / ESTALE on the file itself, with the mount up: a transient
// condition wearing a per-file disguise. Only ENOENT means genuinely gone.
const root = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'locked');
const dir = path.join(root, 'individual');
await fs.promises.mkdir(dir, { recursive: true });
const file = path.join(dir, 'a.jpg');
await fs.promises.writeFile(file, 'x');
await fs.promises.chmod(file, 0o000);
const { photoId } = await seedExternalPhoto({ externalPath: 'locked' });
try {
await expect(faceProcessor.processPhotoFaces(photoId))
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
} finally {
await fs.promises.chmod(file, 0o644).catch(() => {});
}
});
it('still fails managed photos without probing the mount', async () => {
// The probe is scoped to external/reference rows: a managed photo with no
// preview is broken, and there is no mount to blame.
const [e] = await db('events').insert({
slug: `tr-m-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'trm',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `tr-m-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
const [p] = await db('photos').insert({
event_id: typeof e === 'object' ? e.id : e,
filename: 'm.jpg',
path: 'trm/m.jpg',
type: 'individual',
width: 100,
height: 100,
processing_status: 'complete',
face_status: 'processing',
source_origin: 'managed',
}).returning('id');
const result = await faceProcessor.processPhotoFaces(typeof p === 'object' ? p.id : p);
expect(result.status).toBe('failed');
});
});
@@ -1,300 +0,0 @@
/**
* Guest filters must respect show_feedback_to_guests (#1044 follow-up).
*
* Every filter token on /photos is an OR of two halves: what THIS viewer
* marked, and what ANYONE marked. The response fields built from the second
* half — like_count, comment_count, color_label_count — are all gated on
* show_feedback_to_guests. The FILTER was not.
*
* So with the setting off, the numbers were hidden but `?filter=liked` still
* returned exactly the photos other people had liked: the same information as
* a set instead of a count, one token at a time. These tests pin the gate on
* every token, and pin that the viewer's own half is never gated — filtering
* by what you yourself marked is yours to do regardless.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'filter-visibility-secret';
const SLUG = 'filter-visibility-event';
const ME = 'guest-me-identifier';
const SOMEONE_ELSE = 'guest-other-identifier';
describe('guest filters and show_feedback_to_guests (#1044)', () => {
let db;
let cleanup;
let app;
let eventId;
let mine;
let theirs;
let myGuestRowId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const setVisibility = (visible) => db('event_feedback_settings')
.where({ event_id: eventId })
.update({ show_feedback_to_guests: visible });
// A real verified guest, which is how the viewer's own feedback is actually
// identified — NOT the `guest_id` query parameter the frontend invents.
const guestToken = () => jwt.sign(
{ type: 'guest', guestId: myGuestRowId, eventId },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
// The photo payload itself, not just the filtered id list — `is_liked` and
// the aggregate counts live here (#1286).
const payload = async ({ as = 'me' } = {}) => {
const req = request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
if (as === 'me') req.set('x-guest-token', guestToken());
const res = await req;
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return Object.fromEntries((photos || []).map((p) => [p.id, p]));
};
const filter = async (token, { as = 'me', claimGuestId } = {}) => {
const req = request(app)
.get(`/api/gallery/${SLUG}/photos`)
.query({ filter: token, ...(claimGuestId ? { guest_id: claimGuestId } : {}) })
.set('Authorization', `Bearer ${galleryToken()}`);
if (as === 'me') req.set('x-guest-token', guestToken());
const res = await req;
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Filter Visibility',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'filter-visibility-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const addPhoto = async (name) => {
const p = await db('photos').insert({
event_id: eventId,
filename: name,
path: `events/filter/${name}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
return p[0]?.id ?? p[0];
};
mine = await addPhoto('mine.jpg');
theirs = await addPhoto('theirs.jpg');
await db('event_feedback_settings').insert({
event_id: eventId,
feedback_enabled: true,
allow_likes: true,
allow_comments: true,
allow_ratings: true,
allow_favorites: true,
allow_color_labels: true,
moderate_comments: false,
show_feedback_to_guests: true,
});
const guestRow = await db('gallery_guests').insert({
event_id: eventId,
name: 'Me',
identifier: ME,
created_at: new Date().toISOString(),
last_seen_at: new Date().toISOString(),
is_deleted: false,
}).returning('id');
myGuestRowId = guestRow[0]?.id ?? guestRow[0];
const feedback = (photoId, who, type, extra = {}) => db('photo_feedback').insert({
photo_id: photoId,
event_id: eventId,
guest_identifier: who,
// Submission links to the per-person guest row when one is present, and
// that is the column the viewer's own half resolves through.
guest_id: who === ME ? myGuestRowId : null,
feedback_type: type,
is_approved: true,
is_hidden: false,
created_at: new Date().toISOString(),
...extra,
});
// Everything on `theirs` belongs to somebody else; `mine` is this viewer's.
await feedback(mine, ME, 'like');
await feedback(theirs, SOMEONE_ELSE, 'like');
await feedback(theirs, SOMEONE_ELSE, 'favorite');
await feedback(theirs, SOMEONE_ELSE, 'comment', { comment_text: 'lovely' });
await feedback(theirs, SOMEONE_ELSE, 'rating', { rating: 5 });
await feedback(theirs, SOMEONE_ELSE, 'color_label', { color_label: 'green' });
// The denormalized counters the aggregate half of the filter reads.
await db('photos').where('id', theirs).update({
like_count: 1, favorite_count: 1, comment_count: 1, average_rating: 5, color_label_count: 1,
});
await db('photos').where('id', mine).update({ like_count: 1 });
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('with feedback visible to guests', () => {
beforeAll(() => setVisibility(true));
it('shows other people\'s marks through every token, as before', async () => {
expect(await filter('liked')).toEqual([mine, theirs].sort((a, b) => a - b));
expect(await filter('favorited')).toEqual([theirs]);
expect(await filter('rated')).toEqual([theirs]);
expect(await filter('commented')).toEqual([theirs]);
expect(await filter('color:green')).toEqual([theirs]);
});
});
describe('with feedback hidden from guests', () => {
beforeAll(() => setVisibility(false));
it('stops every token from selecting on other people\'s marks', async () => {
// `theirs` is the photo only other guests marked. It must not come back
// through any token — a filter that selects on hidden feedback reports
// that feedback just as surely as a count would.
expect(await filter('favorited')).toEqual([]);
expect(await filter('rated')).toEqual([]);
expect(await filter('commented')).toEqual([]);
expect(await filter('color:green')).toEqual([]);
});
it('still filters by what the viewer marked themselves', async () => {
// The viewer's own half is never gated: this is their own action, and
// hiding it would break "show me the ones I liked" for no privacy gain.
expect(await filter('liked')).toEqual([mine]);
});
it('drops the viewer\'s own feedback once an admin hides it', async () => {
// Moderation has to reach the filter too. getPhotoFeedback excludes
// hidden rows for the guest's OWN feedback, so a photo matching here
// would come back with nothing visible on it to explain why.
await db('photo_feedback')
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
.update({ is_hidden: true });
expect(await filter('liked')).toEqual([]);
await db('photo_feedback')
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
.update({ is_hidden: false });
expect(await filter('liked')).toEqual([mine]);
});
it('ignores a guest_id supplied by the caller', async () => {
// The own-half is resolved from the request identity. If it honoured the
// query string instead, anyone holding another guest's identifier could
// read that guest's hidden memberships one token at a time — straight
// back through the gate this file exists to pin.
expect(await filter('favorited', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
expect(await filter('color:green', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
// And an anonymous caller claiming to be me gets nothing of mine.
expect(await filter('liked', { as: 'anon', claimGuestId: ME })).toEqual([]);
});
});
// #1286 — the viewer's OWN like is not other people's feedback.
describe("a guest's own likes with feedback hidden (#1286)", () => {
beforeAll(() => setVisibility(false));
it('still reports is_liked on the photo the viewer liked', async () => {
// The regression: every heart came back empty on a gallery with
// sharing off, so the grid looked like it had discarded the guest's
// choices on every reload.
const photos = await payload();
expect(photos[mine].is_liked).toBe(true);
});
it("does not report is_liked for someone else's like", async () => {
const photos = await payload();
expect(photos[theirs].is_liked).toBe(false);
});
it('keeps the aggregate like_count hidden', async () => {
// The count IS other people's feedback and must stay gated — the fix
// must not leak it back through the same payload.
const photos = await payload();
expect(photos[mine].like_count).toBe(0);
expect(photos[theirs].like_count).toBe(0);
expect(photos[theirs].has_feedback).toBe(false);
});
it('reports nothing as liked for a viewer who liked nothing', async () => {
const photos = await payload({ as: 'anon' });
expect(photos[mine].is_liked).toBe(false);
expect(photos[theirs].is_liked).toBe(false);
});
it("still respects an admin hiding the viewer's own like (#1150)", async () => {
await db('photo_feedback')
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
.update({ is_hidden: true });
const photos = await payload();
expect(photos[mine].is_liked).toBe(false);
await db('photo_feedback')
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
.update({ is_hidden: false });
});
it('matches what the liked filter already returned', async () => {
// The filter half was never gated; the payload flag was. After the fix
// the two agree, which is what makes the grid and the Likes chip show
// the same set.
expect(await filter('liked')).toEqual([mine]);
const photos = await payload();
const flagged = Object.values(photos).filter((p) => p.is_liked).map((p) => p.id);
expect(flagged).toEqual([mine]);
});
});
describe('with feedback visible again (#1286 regression guard)', () => {
beforeAll(() => setVisibility(true));
it('is_liked and the counts both come back', async () => {
const photos = await payload();
expect(photos[mine].is_liked).toBe(true);
expect(photos[theirs].is_liked).toBe(false);
expect(photos[theirs].like_count).toBe(1);
});
});
});
@@ -1,121 +0,0 @@
/**
* Gallery password invisible-Unicode fallback (#654).
*
* Passwords relayed through chat apps (Instagram DMs especially) pick up
* invisible characters on copy-paste — zero-width space/joiners, word
* joiner, BOM, soft hyphen — which fail the byte-exact bcrypt compare and
* surface as "incorrect password" for a correct password. The verify route
* retries the compare with those characters stripped, in the SAME request,
* so the fallback costs no reCAPTCHA token and no failed-attempt quota.
*
* Pins the contract:
* - exact submitted bytes always win first, so stored passwords that
* legitimately contain these characters (e.g. ZWJ emoji sequences)
* keep working
* - paste artifacts (mid-string ZWSP, leading BOM, trailing space) are
* rescued by the sanitized fallback compare
* - the fallback never invents a match (missing ZWJ still 401s), and a
* rescued login records no failed attempt
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sanitize-test-secret';
const PLAIN_SLUG = 'sanitize-plain-event';
const ZWJ_SLUG = 'sanitize-zwj-event';
const PLAIN_PASSWORD = 'wedding2026';
// Stored password legitimately containing a ZWJ emoji sequence.
const ZWJ_PASSWORD = 'Family\u{1F468}\u200D\u{1F469}Aa1';
describe('gallery/verify invisible-Unicode fallback (#654)', () => {
let db;
let cleanup;
let app;
const makeEvent = async (slug, password) => {
const inserted = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `Sanitize ${slug}`,
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: await bcrypt.hash(password, 4),
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-share`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return inserted[0]?.id ?? inserted[0];
};
let plainEventId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
plainEventId = await makeEvent(PLAIN_SLUG, PLAIN_PASSWORD);
await makeEvent(ZWJ_SLUG, ZWJ_PASSWORD);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', require('../../src/routes/auth'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const verify = (slug, password) =>
request(app).post('/api/auth/gallery/verify').send({ slug, password });
it('accepts the exact password', async () => {
const res = await verify(PLAIN_SLUG, PLAIN_PASSWORD);
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('rescues a mid-string zero-width space from chat-app copy-paste', async () => {
const res = await verify(PLAIN_SLUG, 'wedding\u200B2026');
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('rescues leading BOM + trailing space paste artifacts', async () => {
const res = await verify(PLAIN_SLUG, `\uFEFF${PLAIN_PASSWORD} `);
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('records no login_fail for a rescued login (single-request fallback)', async () => {
await verify(PLAIN_SLUG, 'wedding\u200B2026').expect(200);
const failed = await db('access_logs')
.where({ event_id: plainEventId, action: 'login_fail' });
expect(failed).toHaveLength(0);
});
it('still accepts a stored password that legitimately contains a ZWJ', async () => {
const res = await verify(ZWJ_SLUG, ZWJ_PASSWORD);
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('does not invent a match when the ZWJ is missing from the input', async () => {
const res = await verify(ZWJ_SLUG, 'Family\u{1F468}\u{1F469}Aa1');
expect(res.status).toBe(401);
});
it('rejects a plain wrong password', async () => {
const res = await verify(PLAIN_SLUG, 'not-the-password');
expect(res.status).toBe(401);
});
});
@@ -17,7 +17,7 @@ const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(60000);
let db; let cleanup; let service; let app;
@@ -19,7 +19,7 @@
*/
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(60000);
let db; let cleanup; let service; let adminId;
@@ -1,229 +0,0 @@
/**
* Spotting one person registered twice (#1210).
*
* Guest registration always inserts. A client whose token expired, or who
* opens the gallery on a second device, becomes a new gallery_guests row and
* their likes and favourites split across the copies — so the photographer's
* "final selection" is only trustworthy after someone notices two Tinas with
* half the picks each and merges them.
*
* Merging already worked. This is the half that was missing: saying which rows
* are the same person, so the admin does not have to find them by eye.
*
* Detection only — the registration path is deliberately untouched. Reusing a
* row because someone typed a matching email would let anyone who knows that
* email inherit the identity and its selections, and answering differently for
* a known email would leak which addresses are in the gallery, which is
* exactly what /guest/recover goes out of its way to avoid.
*/
const request = require('supertest');
const express = require('express');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
describe('duplicate guest detection (#1210)', () => {
let db; let cleanup; let app; let eventId;
const listGuests = async () => {
const res = await request(app).get(`/api/admin/events/${eventId}/guests`);
expect(res.status).toBe(200);
return res.body;
};
const addGuest = async (name, email, extra = {}) => {
const [g] = await db('gallery_guests').insert({
event_id: eventId, name, email,
identifier: `id-${name}-${Math.random()}`,
created_at: new Date().toISOString(),
last_seen_at: new Date().toISOString(),
is_deleted: false,
...extra,
}).returning('id');
return typeof g === 'object' ? g.id : g;
};
const byId = (body, id) => body.guests.find((g) => g.id === id);
beforeAll(async () => {
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const [ev] = await db('events').insert({
slug: 'dupe-guests', event_type: 'wedding', event_name: 'Dupe Guests',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/dupe-guests/share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
app = express();
app.use(express.json());
app.use('/api/admin', require('../../src/routes/adminGuests'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('gallery_guests').where({ event_id: eventId }).del();
});
it('groups duplicates under a shared key', async () => {
const first = await addGuest('Tina', 'tina@example.com');
const second = await addGuest('Tina', 'tina@example.com');
const other = await addGuest('Marc', 'marc@example.com');
const body = await listGuests();
// A shared group key rather than a list of sibling ids: the payload stays
// linear in the number of guests, and the case/whitespace folding lives in
// one place instead of being reimplemented on the client.
expect(byId(body, first).duplicate_group).toBe('tina@example.com');
expect(byId(body, second).duplicate_group).toBe('tina@example.com');
expect(byId(body, other).duplicate_group).toBeNull();
});
it('counts the groups and the rows in them', async () => {
await addGuest('Tina', 'tina@example.com');
await addGuest('Tina', 'tina@example.com');
await addGuest('Ben', 'ben@example.com');
await addGuest('Ben', 'ben@example.com');
await addGuest('Ben', 'ben@example.com');
await addGuest('Marc', 'marc@example.com');
expect((await listGuests()).duplicates).toEqual({ groups: 2, guests: 5 });
});
it('matches the same address typed with different capitals or a stray space', async () => {
// The same person on a different day. Both read as distinct rows in the
// admin list, which is precisely why they need catching here.
const a = await addGuest('Tina', 'tina@example.com');
const b = await addGuest('Tina', 'Tina@Example.com ');
const body = await listGuests();
expect(byId(body, a).duplicate_group).toBe('tina@example.com');
expect(byId(body, b).duplicate_group).toBe('tina@example.com');
expect(body.duplicates).toEqual({ groups: 1, guests: 2 });
});
it('does not treat two guests without an email as the same person', async () => {
// require_name_email is off by default, so a shared gallery link produces
// plenty of these. Grouping them would merge strangers.
const a = await addGuest('Anon', null);
const b = await addGuest('Anon', null);
const body = await listGuests();
expect(byId(body, a).duplicate_group).toBeNull();
expect(byId(body, b).duplicate_group).toBeNull();
expect(body.duplicates).toEqual({ groups: 0, guests: 0 });
});
it('does not treat a shared name as evidence of anything', async () => {
const a = await addGuest('Anna', 'anna.k@example.com');
const b = await addGuest('Anna', 'anna.m@example.com');
const body = await listGuests();
expect(byId(body, a).duplicate_group).toBeNull();
expect(byId(body, b).duplicate_group).toBeNull();
});
it('moves a pending invite to the survivor instead of stranding it', async () => {
// Creating an invite inserts a real gallery_guests row, so an admin who
// pre-mints one and then sees the guest self-register has two rows — and
// this feature now points that pair out and offers the merge. Redemption
// resolves guest_invites.guest_id with `is_deleted: false`, so merging
// without moving the invite leaves the emailed link returning 404
// guest_missing while the invite dialog still shows it as Pending.
const placeholder = await addGuest('Tina', 'tina@example.com');
const selfRegistered = await addGuest('Tina', 'tina@example.com');
const [inv] = await db('guest_invites').insert({
event_id: eventId, guest_id: placeholder, token: 'invite-token-1',
created_by_admin_id: 1, created_at: new Date().toISOString(),
}).returning('id');
const inviteId = typeof inv === 'object' ? inv.id : inv;
const res = await request(app)
.post(`/api/admin/events/${eventId}/guests/${selfRegistered}/merge`)
.send({ mergeIds: [placeholder] });
expect(res.status).toBe(200);
const invite = await db('guest_invites').where({ id: inviteId }).first();
expect(invite.guest_id).toBe(selfRegistered);
// And it still resolves: the survivor is not soft-deleted.
const target = await db('gallery_guests').where({ id: invite.guest_id }).first();
expect(Boolean(target.is_deleted)).toBe(false);
});
it('leaves a spent invite pointing at what it actually redeemed', async () => {
// A redeemed invite is a record of who redeemed what. Retargeting it would
// rewrite that history to name a guest who was never on the other end.
const old = await addGuest('Tina', 'tina@example.com');
const kept = await addGuest('Tina', 'tina@example.com');
const [inv] = await db('guest_invites').insert({
event_id: eventId, guest_id: old, token: 'invite-token-2',
created_by_admin_id: 1, redeemed_at: new Date().toISOString(),
created_at: new Date().toISOString(),
}).returning('id');
const inviteId = typeof inv === 'object' ? inv.id : inv;
await request(app)
.post(`/api/admin/events/${eventId}/guests/${kept}/merge`)
.send({ mergeIds: [old] });
expect((await db('guest_invites').where({ id: inviteId }).first()).guest_id).toBe(old);
});
it('canonicalises the survivor\'s address so recovery can still find them', async () => {
// Grouping folds case and whitespace, so a merge can be proposed between a
// clean address and a legacy one that is not. /guest/recover lowercases
// what the guest types and matches on equality, so a survivor left holding
// the raw value becomes permanently unrecoverable by email.
const legacy = await addGuest('Tina', 'Tina@Example.com ');
const other = await addGuest('Tina', 'tina@example.com');
const res = await request(app)
.post(`/api/admin/events/${eventId}/guests/${legacy}/merge`)
.send({ mergeIds: [other] });
expect(res.status).toBe(200);
expect((await db('gallery_guests').where({ id: legacy }).first()).email).toBe('tina@example.com');
});
it('leaves an already-canonical survivor untouched', async () => {
const kept = await addGuest('Tina', 'tina@example.com');
const dupe = await addGuest('Tina', 'Tina@Example.com');
await request(app)
.post(`/api/admin/events/${eventId}/guests/${kept}/merge`)
.send({ mergeIds: [dupe] });
expect((await db('gallery_guests').where({ id: kept }).first()).email).toBe('tina@example.com');
});
it('ignores a removed guest', async () => {
const kept = await addGuest('Tina', 'tina@example.com');
await addGuest('Tina', 'tina@example.com', { is_deleted: true });
const body = await listGuests();
// The deleted row is not listed at all, so the survivor is not a duplicate
// of something the admin cannot see or merge.
expect(body.guests.map((g) => g.id)).toEqual([kept]);
expect(byId(body, kept).duplicate_group).toBeNull();
expect(body.duplicates).toEqual({ groups: 0, guests: 0 });
});
});
@@ -1,167 +0,0 @@
/**
* Minimal in-process OIDC provider for integration tests (#798).
*
* Serves just enough of the spec for openid-client's full validation to
* pass: discovery, JWKS (RS256), authorization endpoint (immediate redirect,
* no login UI), and token endpoint (authorization_code + PKCE). Claims for
* the next login are scripted per test via `setNextUser()`.
*
* Runs on an ephemeral localhost port over plain http — the service allows
* that in NODE_ENV=test only.
*/
const http = require('http');
const crypto = require('crypto');
const { URL } = require('url');
function b64url(input) {
return Buffer.from(input).toString('base64url');
}
class MockOidcProvider {
constructor() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
this.privateKey = privateKey;
this.publicJwk = publicKey.export({ format: 'jwk' });
this.publicJwk.kid = 'test-key-1';
this.publicJwk.alg = 'RS256';
this.publicJwk.use = 'sig';
this.clientId = 'picpeak-test';
this.clientSecret = 'test-client-secret';
this.codes = new Map(); // code -> { nonce, redirectUri, codeChallenge, user }
this.nextUser = { sub: 'user-1', email: 'sso@example.com', email_verified: true };
// Test hooks:
this.tamperNonce = false; // sign the ID token with a WRONG nonce
this.emailViaUserinfoOnly = false; // omit email from the ID token; serve it on /userinfo
this.advertiseEndSession = true; // include end_session_endpoint in discovery (#798 phase 3)
this.accessTokens = new Map(); // access_token -> user (for /userinfo)
this.server = null;
this.issuer = null;
}
setNextUser(user) {
this.nextUser = user;
}
signIdToken({ sub, nonce, extraClaims = {} }) {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', kid: this.publicJwk.kid, typ: 'JWT' };
const payload = {
iss: this.issuer,
aud: this.clientId,
sub,
iat: now,
exp: now + 300,
nonce,
...extraClaims,
};
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), this.privateKey);
return `${signingInput}.${signature.toString('base64url')}`;
}
async start() {
this.server = http.createServer((req, res) => this.handle(req, res));
await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve));
this.issuer = `http://127.0.0.1:${this.server.address().port}`;
return this.issuer;
}
async stop() {
if (this.server) await new Promise((resolve) => this.server.close(resolve));
}
handle(req, res) {
const url = new URL(req.url, this.issuer);
const json = (status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (url.pathname === '/.well-known/openid-configuration') {
return json(200, {
issuer: this.issuer,
authorization_endpoint: `${this.issuer}/authorize`,
token_endpoint: `${this.issuer}/token`,
userinfo_endpoint: `${this.issuer}/userinfo`,
jwks_uri: `${this.issuer}/jwks`,
...(this.advertiseEndSession ? { end_session_endpoint: `${this.issuer}/logout` } : {}),
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
});
}
if (url.pathname === '/jwks') {
return json(200, { keys: [this.publicJwk] });
}
if (url.pathname === '/authorize') {
// "Log in" instantly: mint a code bound to this request's params and
// bounce back to the redirect_uri like a real IdP would.
const code = crypto.randomBytes(16).toString('base64url');
this.codes.set(code, {
nonce: url.searchParams.get('nonce'),
redirectUri: url.searchParams.get('redirect_uri'),
codeChallenge: url.searchParams.get('code_challenge'),
user: this.nextUser,
});
const back = new URL(url.searchParams.get('redirect_uri'));
back.searchParams.set('code', code);
back.searchParams.set('state', url.searchParams.get('state'));
res.writeHead(302, { location: back.href });
return res.end();
}
if (url.pathname === '/token' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const params = new URLSearchParams(body);
const stored = this.codes.get(params.get('code'));
if (!stored) return json(400, { error: 'invalid_grant' });
this.codes.delete(params.get('code'));
// PKCE check — S256(code_verifier) must match the challenge.
const verifier = params.get('code_verifier') || '';
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
if (challenge !== stored.codeChallenge) {
return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
}
const { sub, ...extraClaims } = stored.user;
// Spec-compliant providers may keep profile/email claims OFF the ID
// token and serve them from /userinfo only — this hook simulates that.
const idTokenClaims = this.emailViaUserinfoOnly ? {} : extraClaims;
const idToken = this.signIdToken({
sub,
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
extraClaims: idTokenClaims,
});
const accessToken = crypto.randomBytes(16).toString('base64url');
this.accessTokens.set(accessToken, stored.user);
return json(200, {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 300,
id_token: idToken,
});
});
return undefined;
}
if (url.pathname === '/userinfo') {
const auth = req.headers.authorization || '';
const user = this.accessTokens.get(auth.replace(/^Bearer\s+/i, ''));
if (!user) return json(401, { error: 'invalid_token' });
return json(200, { ...user });
}
return json(404, { error: 'not_found' });
}
}
module.exports = { MockOidcProvider };
@@ -1,283 +0,0 @@
/**
* Hidden feedback, seen from the guest who left it (#1150).
*
* Everything in the system treats a hidden row as absent: getPhotoFeedback
* drops it even for the guest's own feedback, the /photos filters drop it, and
* updatePhotoFeedbackStats does not count it. Two places disagreed — the
* per-viewer `is_liked` heart and the `my_color_label` badge — so a like the
* photographer had hidden still showed as liked on a photo whose like_count
* was zero.
*
* Making those two agree exposes the second half: the duplicate check that
* powers like/favorite toggling did NOT skip hidden rows, so the now-empty
* heart, when clicked, found the hidden row and toggled it OFF. The click
* appeared to do nothing and it took two more to get back to a filled heart.
*
* Hiding a non-comment is deliberate, not an accident of the raw route: #839
* and #1044 both ship it, with tests asserting that a hidden reaction or
* colour label stops counting. So the fix is to make hidden mean absent
* consistently — not to stop admins hiding these.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-feedback-secret';
const SLUG = 'hidden-own-feedback';
const ME = 'guest-me-identifier';
describe('a guest\'s own hidden feedback (#1150)', () => {
let db; let cleanup; let app; let feedbackService;
let eventId; let photoId; let myGuestRowId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const guestToken = () => jwt.sign(
{ type: 'guest', guestId: myGuestRowId, eventId },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const getPhoto = async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`)
.set('x-guest-token', guestToken());
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return (photos || []).find((p) => p.id === photoId);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
feedbackService = require('../../src/services/feedbackService');
const [ev] = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Hidden Own Feedback',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'hidden-own-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [p] = await db('photos').insert({
event_id: eventId, filename: 'shot.jpg', path: 'events/hidden/shot.jpg',
type: 'individual', uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = typeof p === 'object' ? p.id : p;
const [g] = await db('gallery_guests').insert({
event_id: eventId, name: 'Me', identifier: ME,
created_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
is_deleted: false,
}).returning('id');
myGuestRowId = typeof g === 'object' ? g.id : g;
await db('event_feedback_settings').insert({
event_id: eventId, feedback_enabled: true, allow_likes: true,
allow_color_labels: true, moderate_comments: false,
show_feedback_to_guests: true,
});
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
const like = () => db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, guest_identifier: ME,
guest_id: myGuestRowId, feedback_type: 'like',
is_approved: true, is_hidden: false, created_at: new Date().toISOString(),
});
beforeEach(async () => {
await db('photo_feedback').where({ photo_id: photoId }).del();
await db('photos').where('id', photoId).update({ like_count: 0, color_label_count: 0 });
});
describe('the read surfaces agree with each other', () => {
it('un-fills the heart once the like is hidden', async () => {
await like();
await feedbackService.updatePhotoFeedbackStats(photoId);
expect((await getPhoto()).is_liked).toBe(true);
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
await feedbackService.updatePhotoFeedbackStats(photoId);
const photo = await getPhoto();
// like_count already ignored hidden rows, so the heart was the only
// thing still claiming this photo was liked.
expect(photo.like_count).toBe(0);
expect(photo.is_liked).toBe(false);
});
it('drops a hidden colour label from the badge', async () => {
await db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, guest_identifier: ME,
guest_id: myGuestRowId, feedback_type: 'color_label', color_label: 'green',
is_approved: true, is_hidden: true, created_at: new Date().toISOString(),
});
expect((await getPhoto()).my_color_label).toBeFalsy();
});
});
describe('and every other surface agrees', () => {
it('keeps a hidden like out of /my-feedback', async () => {
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
const res = await request(app)
.get(`/api/gallery/${SLUG}/my-feedback`)
.set('Authorization', `Bearer ${galleryToken()}`)
.set('x-guest-token', guestToken());
expect(res.status).toBe(200);
// In guest identity mode the Liked/Favorited/Rated chips and their
// filters are built from THIS array, not from is_liked — so a hidden
// like left an empty heart while the chip still counted it.
expect(res.body.filter((f) => f.feedback_type === 'like')).toHaveLength(0);
});
it('does not count a hidden row against the guest cap', async () => {
await db('event_feedback_settings')
.where({ event_id: eventId }).update({ max_likes_per_guest: 1 });
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
// The hidden row is room, not an occupant: the guest sees an empty
// heart, and meeting that click with limit_reached leaves the control
// dead until they un-like something they can still see.
const result = await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
});
expect(result.limit_reached).toBeUndefined();
await db('event_feedback_settings')
.where({ event_id: eventId }).update({ max_likes_per_guest: null });
});
it('keeps the hidden record when the guest changes their replacement', async () => {
// A hidden colour label and a visible replacement now coexist. The
// toggle/switch and rating-clear paths DELETE over the guest-scoped set,
// so an unfiltered scope took the admin's record with it — leaving
// nothing to review or unhide.
const [orig] = await db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, guest_identifier: ME,
guest_id: myGuestRowId, feedback_type: 'color_label', color_label: 'red',
is_approved: true, is_hidden: true, created_at: new Date().toISOString(),
}).returning('id');
const hiddenId = typeof orig === 'object' ? orig.id : orig;
// The guest, seeing no label, picks green, then switches to blue, then
// toggles blue off — every mutation the single-value path offers.
const opts = { feedback_type: 'color_label', guest_identifier: ME, guest_id: myGuestRowId };
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'green' });
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'blue' });
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'blue' });
const survivor = await db('photo_feedback').where('id', hiddenId).first();
expect(survivor).toBeTruthy();
expect(survivor.is_hidden).toBeTruthy();
expect(survivor.color_label).toBe('red');
});
it('leaves other anonymous rows alone when there is no identity to scope by', async () => {
// With neither guest_id nor guest_identifier the collapse scope degrades
// to `guest_identifier IS NULL` — every identifier-less row on the
// photo, i.e. other people's. Verified: knex renders that as `is null`.
const anon = (extra) => ({
photo_id: photoId, event_id: eventId, feedback_type: 'like',
is_approved: true, created_at: new Date().toISOString(), ...extra,
});
const [h] = await db('photo_feedback').insert(anon({ is_hidden: true })).returning('id');
const hiddenId = typeof h === 'object' ? h.id : h;
await db('photo_feedback').insert(anon({ is_hidden: false }));
await db('photo_feedback').insert(anon({ is_hidden: false }));
await feedbackService.moderateFeedback(hiddenId, 'approve', 1);
// All three survive: two unrelated visitors plus the unhidden one.
expect(await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false }))
.toHaveLength(3);
});
it('collapses the replacement when an admin unhides the original', async () => {
await like();
const original = await db('photo_feedback').where({ photo_id: photoId }).first();
await db('photo_feedback').where('id', original.id).update({ is_hidden: true });
// The guest, seeing an empty heart, likes again — a second row.
await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
});
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(2);
await feedbackService.moderateFeedback(original.id, 'approve', 1);
// Two visible rows for one guest would double-count in the tallies and
// need two toggles to clear, since each deletes a single row.
const visible = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
expect(visible).toHaveLength(1);
expect(visible[0].id).toBe(original.id);
await feedbackService.updatePhotoFeedbackStats(photoId);
expect((await db('photos').where('id', photoId).first()).like_count).toBe(1);
});
});
describe('and clicking still works afterwards', () => {
it('re-liking creates a fresh row instead of toggling the hidden one off', async () => {
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
// What the guest sees is an empty heart, so this is an ADD.
const result = await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like',
guest_identifier: ME,
guest_id: myGuestRowId,
});
// Before this, the duplicate check found the hidden row and deleted it —
// `removed: true` — so the click did nothing visible and the moderation
// was silently undone.
expect(result.removed).toBeUndefined();
const visible = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
expect(visible).toHaveLength(1);
expect((await getPhoto()).is_liked).toBe(true);
});
});
});
@@ -1,220 +0,0 @@
/**
* Image-security settings applied as creation defaults (#1296).
*
* Four controls in Settings → Image security were written, reloaded and
* rendered as toggles, and read by nothing:
*
* default_protection_level, default_image_quality,
* enable_canvas_rendering
*
* Each maps onto an `events` column migration 038 already created, and each
* is labelled "… by default". `enable_devtools_protection` was the only one
* of the five ever wired.
*
* The load-bearing constraint is that this is CREATION-time only. Applying
* these to existing events would silently change live galleries on upgrade —
* an install with enable_canvas_rendering already on would flip every grid to
* canvas rendering, which is the memory profile under investigation in #1287.
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('image-security creation defaults', () => {
let db;
let cleanup;
let getImageSecurityDefaults;
let resolveImageSecurityColumns;
let readBooleanSetting;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ getImageSecurityDefaults, resolveImageSecurityColumns, readBooleanSetting } =
require('../../src/routes/adminEvents/helpers'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const setSetting = async (key, value) => {
await db('app_settings')
.insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'security' })
.onConflict('setting_key')
.merge();
};
beforeEach(async () => {
await db('app_settings').whereIn('setting_key', [
'default_protection_level', 'default_image_quality',
'enable_canvas_rendering',
]).del();
});
it('returns nothing when no settings are configured', async () => {
// Every key absent must fall through to the column defaults, which is
// exactly the behaviour before this existed.
expect(await getImageSecurityDefaults()).toEqual({});
});
it('maps each setting onto its events column', async () => {
await setSetting('default_protection_level', 'enhanced');
await setSetting('default_image_quality', 72);
await setSetting('enable_canvas_rendering', true);
expect(await getImageSecurityDefaults()).toEqual({
protection_level: 'enhanced',
image_quality: 72,
use_canvas_rendering: true,
});
});
it('carries a false canvas setting through, rather than dropping it', async () => {
// `false` is a real choice — dropping it as falsy would leave the column
// default in place and make "off" unreachable.
await setSetting('enable_canvas_rendering', false);
expect(await getImageSecurityDefaults()).toEqual({ use_canvas_rendering: false });
});
it.each([
['an unknown protection level', 'default_protection_level', 'paranoid'],
['a non-enum protection level', 'default_protection_level', 42],
['image quality above 100', 'default_image_quality', 250],
['image quality of zero', 'default_image_quality', 0],
['a non-numeric quality', 'default_image_quality', 'high'],
['a non-boolean canvas value', 'enable_canvas_rendering', 'yes'],
// parseInt would have rescued each of these into a valid-looking
// integer. The settings PUT stores values without validating them, so
// they can genuinely be in the table.
['a numeric prefix with trailing junk', 'default_image_quality', '72oops'],
['a fractional quality', 'default_image_quality', 72.5],
['a single-element array', 'default_image_quality', [72]],
])('ignores %s and falls through to the column default', async (_label, key, value) => {
await setSetting(key, value);
expect(await getImageSecurityDefaults()).toEqual({});
});
it('applies only the keys that are configured', async () => {
await setSetting('default_protection_level', 'maximum');
expect(await getImageSecurityDefaults()).toEqual({ protection_level: 'maximum' });
});
it('never throws, so a settings failure cannot block event creation', async () => {
await setSetting('default_image_quality', { nonsense: true });
await expect(getImageSecurityDefaults()).resolves.toEqual({});
});
describe('double-encoded settings (the settings tab round trip)', () => {
// GET returns setting_value undecoded and the tab PUTs the whole object
// back through JSON.stringify, so on SQLite one visit to the tab turns
// every value it read into a doubly-encoded string. A single parse left
// a string behind, the type checks rejected it, and the defaults went
// silently dead again.
const setRaw = async (key, raw) => {
await db('app_settings')
.insert({ setting_key: key, setting_value: raw, setting_type: 'security' })
.onConflict('setting_key').merge();
};
it('reads a double-encoded boolean', async () => {
await setRaw('enable_canvas_rendering', JSON.stringify(JSON.stringify(true)));
expect(await getImageSecurityDefaults()).toEqual({ use_canvas_rendering: true });
});
it('reads a double-encoded protection level', async () => {
await setRaw('default_protection_level', JSON.stringify(JSON.stringify('enhanced')));
expect(await getImageSecurityDefaults()).toEqual({ protection_level: 'enhanced' });
});
it('reads a double-encoded integer', async () => {
await setRaw('default_image_quality', JSON.stringify(JSON.stringify(72)));
expect(await getImageSecurityDefaults()).toEqual({ image_quality: 72 });
});
it('reads a value buried under many saves, not just one', async () => {
// Each visit to the settings tab used to add a layer, so the depth is
// however many times someone opened it — not a number to cap.
let raw = JSON.stringify('maximum');
for (let i = 0; i < 8; i += 1) raw = JSON.stringify(raw);
await setRaw('default_protection_level', raw);
expect(await getImageSecurityDefaults()).toEqual({ protection_level: 'maximum' });
});
it('still rejects a malformed value however many times it was encoded', async () => {
await setRaw('default_image_quality', JSON.stringify(JSON.stringify('72oops')));
expect(await getImageSecurityDefaults()).toEqual({});
});
});
describe('readBooleanSetting shares the same decoder', () => {
// Every reader of app_settings has to agree, or the settings tab shows
// protection disabled while newly created galleries turn it on.
const setRaw2 = async (key, raw) => {
await db('app_settings')
.insert({ setting_key: key, setting_value: raw, setting_type: 'security' })
.onConflict('setting_key').merge();
};
it('reads a double-encoded false as false, not as absent', async () => {
await setRaw2('enable_devtools_protection', JSON.stringify(JSON.stringify(false)));
expect(await readBooleanSetting('enable_devtools_protection')).toBe(false);
});
it('still reads a singly-encoded value', async () => {
await setRaw2('enable_devtools_protection', JSON.stringify(true));
expect(await readBooleanSetting('enable_devtools_protection')).toBe(true);
});
it('returns undefined for a non-boolean, so the caller keeps its default', async () => {
await setRaw2('enable_devtools_protection', JSON.stringify('sometimes'));
expect(await readBooleanSetting('enable_devtools_protection')).toBeUndefined();
});
});
describe('resolveImageSecurityColumns', () => {
it('omits every column when neither the request nor the settings supply one', () => {
expect(resolveImageSecurityColumns({}, {})).toEqual({});
});
it('uses the global default when the request says nothing', () => {
expect(resolveImageSecurityColumns({}, { protection_level: 'maximum' }))
.toEqual({ protection_level: 'maximum' });
});
it('lets an explicit request value win over the global default', () => {
expect(resolveImageSecurityColumns(
{ protection_level: 'basic' },
{ protection_level: 'maximum' },
)).toEqual({ protection_level: 'basic' });
});
it('keeps an explicit false canvas value instead of reading it as absent', () => {
const columns = resolveImageSecurityColumns(
{ use_canvas_rendering: false },
{ use_canvas_rendering: true },
);
expect(columns.use_canvas_rendering).toBeFalsy();
});
it('keeps a zero-ish explicit value rather than falling through', () => {
// 0 is out of range for the column, but the guard is `!== undefined`,
// not truthiness — the validator is what rejects out-of-range input.
expect(resolveImageSecurityColumns({ image_quality: 0 }, { image_quality: 85 }))
.toEqual({ image_quality: 0 });
});
it('resolves each column independently', () => {
expect(resolveImageSecurityColumns(
{ image_quality: 60 },
{ protection_level: 'enhanced' },
)).toEqual({
protection_level: 'enhanced',
image_quality: 60,
});
});
it('tolerates a missing body, which is what an empty API request looks like', () => {
expect(resolveImageSecurityColumns(undefined, { image_quality: 90 }))
.toEqual({ image_quality: 90 });
});
});
});
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
// on first use; bump the budget for this file.
jest.setTimeout(120000);
jest.setTimeout(60000);
describe('incoming-invoice categorise / re-bill chain', () => {
let db;
@@ -32,7 +32,7 @@ jest.mock('../../src/services/restoreService', () => ({
},
}));
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('installFromBackupBoot', () => {
let db;
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(120000);
jest.setTimeout(30000);
let db;
let cleanup;
@@ -1,236 +0,0 @@
/**
* Shared run state for the maintenance sweeps (#1181).
*
* The behaviour that matters here cannot be observed from one process holding
* a module-level flag, which is exactly why the flag moved into the database.
* A second replica is simulated the only way that is honest in a single-process
* test: by asserting on the shared row itself, and by driving claim() twice —
* a second caller getting null is precisely what a second replica gets.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('maintenance job state (#1181)', () => {
let tmpDir; let db; let app; let jobs;
const dimStatus = () => request(app).get('/api/admin/photos/repair-dimensions/status');
const capStatus = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mjs-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mjs-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
jobs = require('../../src/services/maintenanceJobState');
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await db('maintenance_jobs').update({
is_running: false, started_at: null, heartbeat_at: null, finished_at: null, last_result: null, owner: null, claim_token: null,
});
});
test('the lease table is kept out of .picpeak archives', () => {
// It is live state, not data. An archive taken mid-sweep would otherwise
// carry is_running = true and a claim token owned by a process on the
// SOURCE install; restored inside the staleness window, the target reports
// the job as running and refuses new POSTs with no runner to release it.
// The importer filters on this same set, so archives written before the
// exclusion are skipped on restore too.
const { EXCLUDED_TABLES } = require('../../src/services/picpeakExportService');
expect(EXCLUDED_TABLES.has('maintenance_jobs')).toBe(true);
});
test('the migration seeds a row for each job', async () => {
const names = await db('maintenance_jobs').pluck('job_name');
// 190 seeds the orientation backfill alongside 189's two (#1198).
expect(names.sort()).toEqual([
'photo_capture_date_backfill', 'photo_dimension_repair', 'photo_orientation_backfill',
]);
});
test('a second claim is refused while the first is alive', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
// What a second replica's POST does. Nothing about the first claim lives in
// this process, so this is the same question the other replica asks.
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
});
test('the two jobs claim independently', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
expect(await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL)).toEqual(expect.any(String));
});
test('each claim gets a distinct token', async () => {
const first = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
await jobs.release(jobs.JOB_DIMENSION_REPAIR, first);
const second = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
// Same process, same pid — so an owner string would have collided here and
// the fencing below would be worthless.
expect(second).not.toBe(first);
});
test('a claim whose heartbeat has gone quiet can be taken over', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
// The replica holding it was killed: no release, no further heartbeats.
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
});
test('a superseded runner cannot renew its lease', async () => {
const oldToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
const newToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
expect(newToken).toEqual(expect.any(String));
// The old runner is still alive and mid-loop. Its renewal must tell it so,
// which is what makes the route loop stop instead of running alongside the
// new owner.
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, oldToken)).toBe(false);
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, newToken)).toBe(true);
});
test('a superseded runner cannot release the new owner\'s claim', async () => {
const oldToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).update({ heartbeat_at: longAgo });
const newToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
// The old runner finishes late and tries to write its result. Unfenced,
// this cleared is_running under the new owner and let a THIRD sweep start.
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, oldToken, { success: 999, noExif: 0, failed: 0 })).toBe(false);
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
expect(state.isRunning).toBe(true);
expect(state.lastResult).toBeNull();
// And the row is still the new owner's to release.
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, newToken, { success: 1, noExif: 0, failed: 0 })).toBe(true);
});
test('a stale run reads as not running, so the button comes back', async () => {
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
// is_running is still true in the row — nothing released it — but a status
// poll must not leave the operator staring at a job that cannot finish.
expect((await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).first()).is_running).toBeTruthy();
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(false);
});
test('a heartbeat keeps a long run claimed', async () => {
const token = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, token)).toBe(true);
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
});
test('release stores the result and read gives it back parsed', async () => {
const token = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, token, { success: 3, noExif: 2, failed: 1 });
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
expect(state.isRunning).toBe(false);
expect(state.lastResult).toEqual({ success: 3, noExif: 2, failed: 1 });
});
test('releasing without a result keeps the previous run visible', async () => {
const first = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, first, { success: 7, noExif: 0, failed: 0 });
// The "nothing to do" path: claimed, found no candidates, released. It must
// not blank the numbers the last real run reported.
const second = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, second);
expect((await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL)).lastResult).toEqual({ success: 7, noExif: 0, failed: 0 });
});
test('a malformed result does not take the status endpoint down', async () => {
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ last_result: 'not json' });
const state = await jobs.read(jobs.JOB_DIMENSION_REPAIR);
expect(state.lastResult).toBeNull();
expect(state.isRunning).toBe(false);
});
test('both status endpoints report the shared row, not process memory', async () => {
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const capToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, capToken, { success: 1, noExif: 0, failed: 0 });
// Written straight to the row, exactly as another replica would have.
const dim = await dimStatus();
expect(dim.status).toBe(200);
expect(dim.body.isRunning).toBe(true);
const cap = await capStatus();
expect(cap.status).toBe(200);
expect(cap.body.isRunning).toBe(false);
expect(cap.body.lastResult).toEqual({ success: 1, noExif: 0, failed: 0 });
});
test('a POST is refused while another replica holds the claim', async () => {
// The claim was taken by "another replica" — this process knows nothing
// about it beyond the row.
await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.status).toBe(409);
const dimRes = await request(app).post('/api/admin/photos/repair-dimensions');
// The other job is untouched by that claim, so it is free to start.
expect(dimRes.status).toBe(200);
});
test('the no-op path releases the claim it took', async () => {
// No photos at all, so both endpoints take their "nothing to do" exit.
await db('photos').del();
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const row = await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).first();
expect(row.is_running).toBeFalsy();
// ...and a second POST is therefore accepted rather than 409ing forever.
expect((await request(app).post('/api/admin/photos/repair-capture-dates')).status).toBe(200);
});
});
@@ -1,102 +0,0 @@
/**
* PostgreSQL checks for the shared maintenance-job state (#1181).
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway database, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_mjs_test" \
* npx jest __tests__/integration/maintenanceJobStatePg.test.js
*
* What SQLite cannot answer: the claim leans on comparing a `timestamp` column
* against an ISO-8601 string, and on an UPDATE ... WHERE guard being atomic
* under real concurrent connections. SQLite compares those strings
* lexicographically and serialises writes anyway, so it would pass either way —
* exactly the shape of divergence that has bitten this repo before.
*/
const knex = require('knex');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('maintenance job state on Postgres', () => {
let pgDb;
let jobs;
const JOB = 'photo_dimension_repair';
beforeAll(async () => {
pgDb = knex({ client: 'pg', connection: PG_URL, pool: { min: 0, max: 10 } });
await pgDb.raw('DROP TABLE IF EXISTS maintenance_jobs');
await require('../../migrations/core/189_maintenance_job_state').up(pgDb);
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
jobs = require('../../src/services/maintenanceJobState');
}, 60000);
afterAll(async () => {
jest.dontMock('../../src/database/db');
if (pgDb) await pgDb.destroy();
});
beforeEach(async () => {
await pgDb('maintenance_jobs').update({
is_running: false, started_at: null, heartbeat_at: null, finished_at: null, last_result: null, owner: null, claim_token: null,
});
});
test('the ISO-string cutoff really compares as a timestamp, not as text', async () => {
expect(await jobs.claim(JOB)).toEqual(expect.any(String));
expect(await jobs.claim(JOB)).toBeNull();
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString() });
// If Postgres had rejected or mis-cast the ISO string this would either
// throw or never match.
expect(await jobs.claim(JOB)).toEqual(expect.any(String));
const row = await pgDb('maintenance_jobs').where({ job_name: JOB }).first();
expect(row.heartbeat_at).toBeInstanceOf(Date);
});
test('concurrent claims on real connections produce exactly one winner', async () => {
// The whole point of the conditional UPDATE. Ten connections race; nine
// must lose. SQLite cannot demonstrate this — it serialises writers.
const results = await Promise.all(Array.from({ length: 10 }, () => jobs.claim(JOB)));
expect(results.filter(Boolean)).toHaveLength(1);
// ...and the winner holds a token nobody else can forge.
expect(results.find(Boolean)).toEqual(expect.any(String));
});
test('a released job can be re-claimed exactly once again', async () => {
const token = await jobs.claim(JOB);
await jobs.release(JOB, token, { success: 2, failed: 0 });
const results = await Promise.all(Array.from({ length: 5 }, () => jobs.claim(JOB)));
expect(results.filter(Boolean)).toHaveLength(1);
expect((await jobs.read(JOB)).lastResult).toEqual({ success: 2, failed: 0 });
});
test('a superseded runner is fenced out on real Postgres', async () => {
const oldToken = await jobs.claim(JOB);
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString() });
const newToken = await jobs.claim(JOB);
expect(await jobs.heartbeat(JOB, oldToken)).toBe(false);
expect(await jobs.release(JOB, oldToken, { success: 999, failed: 0 })).toBe(false);
// The new owner still holds it, with its result unwritten.
expect((await jobs.read(JOB)).isRunning).toBe(true);
expect(await jobs.release(JOB, newToken, { success: 4, failed: 0 })).toBe(true);
});
test('read() reports a live claim as running and a stale one as not', async () => {
await jobs.claim(JOB);
expect((await jobs.read(JOB)).isRunning).toBe(true);
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 1000).toISOString() });
expect((await jobs.read(JOB)).isRunning).toBe(false);
});
});
@@ -1,658 +0,0 @@
/**
* Newsletter campaigns — render, recipients, queue, processor hook (#1264).
*
* These run against a real (temp SQLite) DB because the interesting parts of
* this feature are all row-level: which customers are selected, what
* `scheduled_at` values get written, whether the transaction rolls back, and
* whether an opt-out that lands AFTER queueing still stops the send.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
describe('newsletter campaigns', () => {
let db;
let cleanup;
let adminId;
let newsletterService;
beforeAll(async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'newsletter-test-secret';
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
newsletterService = require('../../src/services/newsletterService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('email_campaign_recipients').del();
await db('email_queue').del();
await db('email_campaigns').del();
await db('customer_accounts').del();
});
async function seedCustomer(overrides = {}) {
const row = {
email: `c${Math.random().toString(36).slice(2, 10)}@example.com`,
first_name: 'Alex',
last_name: 'Sample',
display_name: 'Alex Sample',
company_name: 'Sample & Co',
preferred_language: 'en',
is_active: 1,
marketing_opt_out: 0,
created_at: new Date().toISOString(),
...overrides,
};
const [id] = await db('customer_accounts').insert(row).returning('id');
return { id: typeof id === 'object' ? id.id : id, ...row };
}
async function seedCampaign(overrides = {}) {
return await newsletterService.createCampaign({
name: 'Spring news',
subject: 'Our spring offers',
bodyHtml: '<p>Hi {{first_name}}, welcome!</p>',
...overrides,
}, adminId);
}
// ---- recipients --------------------------------------------------------
describe('resolveRecipients', () => {
it('selects active, opted-in customers with an email', async () => {
await seedCustomer({ email: 'in@example.com' });
const campaign = await seedCampaign();
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
expect(recipients.map((r) => r.email)).toEqual(['in@example.com']);
expect(skippedOptOut).toBe(0);
});
it('skips opted-out customers and counts them', async () => {
await seedCustomer({ email: 'in@example.com' });
await seedCustomer({ email: 'out@example.com', marketing_opt_out: 1 });
const campaign = await seedCampaign();
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
expect(recipients.map((r) => r.email)).toEqual(['in@example.com']);
expect(skippedOptOut).toBe(1);
});
it('skips inactive customers', async () => {
await seedCustomer({ email: 'in@example.com' });
await seedCustomer({ email: 'gone@example.com', is_active: 0 });
const campaign = await seedCampaign();
const { recipients } = await newsletterService.resolveRecipients(campaign);
expect(recipients.map((r) => r.email)).toEqual(['in@example.com']);
});
it('collapses duplicate addresses so one person is mailed once', async () => {
await seedCustomer({ email: 'same@example.com' });
await seedCustomer({ email: 'SAME@example.com' });
const campaign = await seedCampaign();
const { recipients } = await newsletterService.resolveRecipients(campaign);
expect(recipients).toHaveLength(1);
});
it('scopes a manual campaign to the named ids only', async () => {
const a = await seedCustomer({ email: 'a@example.com' });
await seedCustomer({ email: 'b@example.com' });
const campaign = await seedCampaign({
recipientMode: 'manual', customerIds: [a.id],
});
const { recipients } = await newsletterService.resolveRecipients(campaign);
expect(recipients.map((r) => r.email)).toEqual(['a@example.com']);
});
it('still honours opt-out inside a manual id list', async () => {
const a = await seedCustomer({ email: 'a@example.com', marketing_opt_out: 1 });
const campaign = await seedCampaign({ recipientMode: 'manual', customerIds: [a.id] });
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
expect(recipients).toHaveLength(0);
expect(skippedOptOut).toBe(1);
});
it('skips every row sharing an opted-out address', async () => {
// #1285 review: unsubscribing flips only the row whose token was in the
// mail. Filtering row-by-row skipped that one and still delivered to
// the same inbox through the duplicate — so the link appeared to do
// nothing.
await seedCustomer({ email: 'shared@example.com', marketing_opt_out: 1 });
await seedCustomer({ email: 'SHARED@example.com', marketing_opt_out: 0 });
await seedCustomer({ email: 'other@example.com' });
const campaign = await seedCampaign();
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
expect(recipients.map((r) => r.email)).toEqual(['other@example.com']);
// Counted once for the address, not once per row.
expect(skippedOptOut).toBe(1);
});
it('honours an opted-out twin that is NOT in the manual selection', async () => {
// The opted-out set is queried across every active customer, not just
// the selected ids — otherwise picking the opted-in twin of an
// unsubscribed account mails the address that opted out.
await seedCustomer({ email: 'twin@example.com', marketing_opt_out: 1 });
const selected = await seedCustomer({ email: 'TWIN@example.com', marketing_opt_out: 0 });
const campaign = await seedCampaign({
recipientMode: 'manual', customerIds: [selected.id],
});
const { recipients } = await newsletterService.resolveRecipients(campaign);
expect(recipients).toHaveLength(0);
});
it('returns nobody for a manual campaign with no ids', async () => {
await seedCustomer();
const campaign = await seedCampaign({ recipientMode: 'manual', customerIds: [] });
const { recipients } = await newsletterService.resolveRecipients(campaign);
expect(recipients).toHaveLength(0);
});
});
// ---- rendering ---------------------------------------------------------
describe('renderForRecipient', () => {
it('substitutes the customer variables', async () => {
const customer = await seedCustomer({ first_name: 'Jamie', email: 'j@example.com' });
const campaign = await seedCampaign({ bodyHtml: '<p>Hi {{first_name}} at {{company_name}}</p>' });
const { html } = await newsletterService.renderForRecipient(campaign, customer);
expect(html).toContain('Hi Jamie at Sample &amp; Co');
});
it('escapes customer data on substitution', async () => {
// A customer's own company name is untrusted text — it must not be
// able to inject markup by riding in through a variable.
const customer = await seedCustomer({ company_name: '<script>alert(1)</script>' });
const campaign = await seedCampaign({ bodyHtml: '<p>{{company_name}}</p>' });
const { html } = await newsletterService.renderForRecipient(campaign, customer);
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).toContain('&lt;script&gt;');
});
it('resolves {{#if}} blocks', async () => {
const withCompany = await seedCustomer({ company_name: 'Acme' });
const without = await seedCustomer({ company_name: null });
const campaign = await seedCampaign({
bodyHtml: '<p>Hi{{#if company_name}} from {{company_name}}{{/if}}!</p>',
});
expect((await newsletterService.renderForRecipient(campaign, withCompany)).html)
.toContain('Hi from Acme!');
expect((await newsletterService.renderForRecipient(campaign, without)).html)
.toContain('Hi!');
});
it('includes a working unsubscribe URL for the recipient', async () => {
const customer = await seedCustomer();
const campaign = await seedCampaign({ bodyHtml: '<p><a href="{{unsubscribe_url}}">Stop</a></p>' });
const { html } = await newsletterService.renderForRecipient(campaign, customer);
const match = html.match(/\/api\/public\/newsletter\/unsubscribe\/([A-Za-z0-9_-]+)/);
expect(match).not.toBeNull();
expect(newsletterService.verifyUnsubscribeToken(match[1])).toBe(customer.id);
});
it('appends an unsubscribe link when the body omits the placeholder', async () => {
// The opt-out design rests on every campaign carrying the link; a body
// that simply leaves out {{unsubscribe_url}} must not break it.
const customer = await seedCustomer();
const campaign = await seedCampaign({ bodyHtml: '<p>No link in here</p>' });
const { html } = await newsletterService.renderForRecipient(campaign, customer);
const match = html.match(/\/api\/public\/newsletter\/unsubscribe\/([A-Za-z0-9_-]+)/);
expect(match).not.toBeNull();
expect(newsletterService.verifyUnsubscribeToken(match[1])).toBe(customer.id);
});
it('does not double up when the body places the link itself', async () => {
const customer = await seedCustomer();
const campaign = await seedCampaign({
bodyHtml: '<p><a href="{{unsubscribe_url}}">Stop</a></p>',
});
const { html } = await newsletterService.renderForRecipient(campaign, customer);
const links = html.match(/\/api\/public\/newsletter\/unsubscribe\//g) || [];
expect(links).toHaveLength(1);
});
it('prefers the customer language over the campaign language', async () => {
const german = await seedCustomer({ preferred_language: 'de' });
const campaign = await seedCampaign({ language: 'en' });
expect((await newsletterService.renderForRecipient(campaign, german)).language).toBe('de');
});
it('falls back to the campaign language when the customer has none', async () => {
const customer = await seedCustomer({ preferred_language: null });
const campaign = await seedCampaign({ language: 'de' });
expect((await newsletterService.renderForRecipient(campaign, customer)).language).toBe('de');
});
it('inlines the sanitized CSS into the body', async () => {
const customer = await seedCustomer();
const campaign = await seedCampaign({ bodyCss: '.cta { color: #fff; }' });
const { html } = await newsletterService.renderForRecipient(campaign, customer);
expect(html).toContain('.cta { color: #fff; }');
});
it('re-sanitizes a body that was stored unsanitized', async () => {
// Simulates a row written by an older/buggier version: the render path
// must not trust what is in the column.
const customer = await seedCustomer();
const campaign = await seedCampaign();
await db('email_campaigns').where({ id: campaign.id })
.update({ body_html: '<p>hi</p><script>alert(1)</script>' });
const tainted = await newsletterService.getCampaign(campaign.id);
const { html } = await newsletterService.renderForRecipient(tainted, customer);
expect(html).not.toContain('alert(1)');
});
it('wraps the body in the standard email chrome', async () => {
const customer = await seedCustomer();
const campaign = await seedCampaign();
const { html } = await newsletterService.renderForRecipient(campaign, customer);
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('email-footer');
});
});
// ---- unsubscribe tokens ------------------------------------------------
describe('unsubscribe tokens', () => {
it('round-trips a customer id', () => {
const token = newsletterService.unsubscribeToken(4242);
expect(newsletterService.verifyUnsubscribeToken(token)).toBe(4242);
});
it('rejects a tampered signature', () => {
const token = newsletterService.unsubscribeToken(1);
const decoded = Buffer.from(token, 'base64url').toString('utf8');
const tampered = Buffer.from(decoded.replace(/.$/, 'f'), 'utf8').toString('base64url');
expect(newsletterService.verifyUnsubscribeToken(tampered)).toBeNull();
});
it("rejects another customer's id spliced onto a valid signature", () => {
const token = newsletterService.unsubscribeToken(1);
const sig = Buffer.from(token, 'base64url').toString('utf8').split('.')[1];
const forged = Buffer.from(`2.${sig}`, 'utf8').toString('base64url');
expect(newsletterService.verifyUnsubscribeToken(forged)).toBeNull();
});
it.each([['', 'empty'], ['not-a-token', 'garbage'], ['!!!', 'non-base64']])
('rejects %s (%s)', (token) => {
expect(newsletterService.verifyUnsubscribeToken(token)).toBeNull();
});
it('rejects null and non-strings', () => {
expect(newsletterService.verifyUnsubscribeToken(null)).toBeNull();
expect(newsletterService.verifyUnsubscribeToken(undefined)).toBeNull();
expect(newsletterService.verifyUnsubscribeToken(123)).toBeNull();
});
});
// ---- queueing ----------------------------------------------------------
describe('queueCampaign', () => {
it('writes one queue row and one recipient row per recipient', async () => {
await seedCustomer({ email: 'a@example.com' });
await seedCustomer({ email: 'b@example.com' });
const campaign = await seedCampaign();
const result = await newsletterService.queueCampaign(campaign.id, adminId);
expect(result.queued).toBe(2);
const queue = await db('email_queue').where({ campaign_id: campaign.id });
expect(queue).toHaveLength(2);
expect(queue.every((r) => r.email_type === 'newsletter')).toBe(true);
expect(queue.every((r) => r.origin === 'campaign')).toBe(true);
expect(queue.every((r) => r.status === 'pending')).toBe(true);
expect(await db('email_campaign_recipients').where({ campaign_id: campaign.id }))
.toHaveLength(2);
});
it('staggers scheduled_at by the send rate', async () => {
for (let i = 0; i < 5; i += 1) await seedCustomer({ email: `r${i}@example.com` });
const campaign = await seedCampaign({ sendRatePerMinute: 2 });
await newsletterService.queueCampaign(campaign.id, adminId);
const rows = await db('email_queue')
.where({ campaign_id: campaign.id }).orderBy('id', 'asc');
const minutes = rows.map((r) => Math.round(
(new Date(r.scheduled_at).getTime() - new Date(rows[0].scheduled_at).getTime()) / 60000
));
// 2 per minute → minute 0, 0, 1, 1, 2.
expect(minutes).toEqual([0, 0, 1, 1, 2]);
});
it('writes queue timestamps in the engine shape the processor compares', async () => {
// #1285 review: storing ISO TEXT in a column the processor compares
// against a Date-bound value meant SQLite never matched the row — every
// INTEGER sorts below every TEXT — so campaigns silently sent nothing
// on SQLite installs.
//
// The due-predicate itself CANNOT be exercised here: under jest a
// sandbox-created Date binds as a string (the landmine documented in
// CLAUDE.md), so `INTEGER <= TEXT` is trivially true and every row
// reads as due whatever the fix does. So assert the stored SHAPE
// against the production shape utils/queueTimestamps documents for
// this engine instead.
await seedCustomer({ email: 'a@example.com' });
const campaign = await seedCampaign();
await newsletterService.queueCampaign(campaign.id, adminId);
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
// SQLite: epoch ms, exactly what queueEmail's Date becomes through the
// native binding. Never an ISO string, which is what regressed.
expect(typeof row.scheduled_at).toBe('number');
expect(typeof row.created_at).toBe('number');
expect(Number.isFinite(row.scheduled_at)).toBe(true);
// Still a sane instant, not a truncated or NaN value.
expect(Math.abs(row.scheduled_at - Date.now())).toBeLessThan(120000);
});
it('clamps an absurd send rate', async () => {
await seedCustomer();
const campaign = await seedCampaign({ sendRatePerMinute: 100000 });
const result = await newsletterService.queueCampaign(campaign.id, adminId);
expect(result.sendRatePerMinute).toBe(newsletterService.MAX_RATE_PER_MINUTE);
});
it('moves the campaign to queued and records the recipient count', async () => {
await seedCustomer();
const campaign = await seedCampaign();
await newsletterService.queueCampaign(campaign.id, adminId);
const after = await newsletterService.getCampaign(campaign.id);
expect(after.status).toBe('queued');
expect(after.recipient_count).toBe(1);
expect(after.queued_at).toBeTruthy();
});
it('refuses to queue a campaign twice', async () => {
await seedCustomer();
const campaign = await seedCampaign();
await newsletterService.queueCampaign(campaign.id, adminId);
await expect(newsletterService.queueCampaign(campaign.id, adminId))
.rejects.toMatchObject({ statusCode: 409 });
});
it('refuses to queue with no recipients', async () => {
const campaign = await seedCampaign();
await expect(newsletterService.queueCampaign(campaign.id, adminId))
.rejects.toMatchObject({ statusCode: 400 });
});
it('refuses to queue an empty body', async () => {
await seedCustomer();
const campaign = await seedCampaign({ bodyHtml: '' });
await expect(newsletterService.queueCampaign(campaign.id, adminId))
.rejects.toMatchObject({ statusCode: 400 });
});
it('leaves nothing behind when the transaction fails', async () => {
await seedCustomer({ email: 'a@example.com' });
await seedCustomer({ email: 'b@example.com' });
const campaign = await seedCampaign();
// Force the second insert to fail: a unique (campaign_id,
// customer_account_id) row already exists for one of them.
const [{ id: firstId }] = await db('customer_accounts').select('id').orderBy('id').limit(1);
await db('email_campaign_recipients').insert({
campaign_id: campaign.id, customer_account_id: firstId,
email: 'a@example.com', status: 'queued', created_at: new Date().toISOString(),
});
await expect(newsletterService.queueCampaign(campaign.id, adminId)).rejects.toThrow();
// A partial queue — half a customer list mailed — is the outcome the
// transaction exists to prevent.
expect(await db('email_queue').where({ campaign_id: campaign.id })).toHaveLength(0);
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('draft');
});
});
// ---- cancel ------------------------------------------------------------
describe('cancel', () => {
it('removes pending rows and leaves sent ones alone', async () => {
await seedCustomer({ email: 'a@example.com' });
await seedCustomer({ email: 'b@example.com' });
const campaign = await seedCampaign();
await newsletterService.queueCampaign(campaign.id, adminId);
// Pretend the first one already went out.
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
await db('email_queue').where({ id: rows[0].id })
.update({ status: 'sent', sent_at: new Date().toISOString() });
await db('email_campaign_recipients')
.where({ campaign_id: campaign.id, email_queue_id: rows[0].id })
.update({ status: 'sent' });
const result = await newsletterService.cancel(campaign.id, adminId);
expect(result.cancelled).toBe(1);
const remaining = await db('email_queue').where({ campaign_id: campaign.id });
expect(remaining).toHaveLength(1);
expect(remaining[0].status).toBe('sent');
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('cancelled');
});
it('refuses to delete a cancelled campaign that already reached someone', async () => {
// The recipient rows cascade, and they are the only durable record of
// who received the mail once queue rows are pruned (#1285 review).
await seedCustomer({ email: 'a@example.com' });
await seedCustomer({ email: 'b@example.com' });
const campaign = await seedCampaign();
await newsletterService.queueCampaign(campaign.id, adminId);
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
await db('email_queue').where({ id: rows[0].id })
.update({ status: 'sent', sent_at: new Date().toISOString() });
await db('email_campaign_recipients')
.where({ campaign_id: campaign.id, email_queue_id: rows[0].id })
.update({ status: 'sent' });
await newsletterService.cancel(campaign.id, adminId);
await expect(newsletterService.deleteCampaign(campaign.id, adminId))
.rejects.toMatchObject({ statusCode: 409 });
});
it('still deletes a cancelled campaign that reached nobody', async () => {
await seedCustomer({ email: 'a@example.com' });
const campaign = await seedCampaign();
await newsletterService.queueCampaign(campaign.id, adminId);
await newsletterService.cancel(campaign.id, adminId);
await expect(newsletterService.deleteCampaign(campaign.id, adminId))
.resolves.toEqual({ deleted: true });
});
it('refuses to cancel a draft', async () => {
const campaign = await seedCampaign();
await expect(newsletterService.cancel(campaign.id, adminId))
.rejects.toMatchObject({ statusCode: 409 });
});
});
// ---- processor bookkeeping --------------------------------------------
describe('recordRecipientResult / recomputeCounts', () => {
it('moves the campaign to sending on the first result, then to sent', async () => {
await seedCustomer({ email: 'a@example.com' });
await seedCustomer({ email: 'b@example.com' });
const campaign = await seedCampaign();
await newsletterService.queueCampaign(campaign.id, adminId);
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
await newsletterService.recordRecipientResult(rows[0], { status: 'sent' });
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('sending');
await newsletterService.recordRecipientResult(rows[1], { status: 'sent' });
const done = await newsletterService.getCampaign(campaign.id);
expect(done.status).toBe('sent');
expect(done.sent_count).toBe(2);
expect(done.completed_at).toBeTruthy();
});
it('counts a partial failure as a sent campaign, not a failed one', async () => {
await seedCustomer({ email: 'a@example.com' });
await seedCustomer({ email: 'b@example.com' });
const campaign = await seedCampaign();
await newsletterService.queueCampaign(campaign.id, adminId);
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
await newsletterService.recordRecipientResult(rows[0], { status: 'sent' });
await newsletterService.recordRecipientResult(rows[1], {
status: 'failed', errorMessage: 'mailbox full',
});
const done = await newsletterService.getCampaign(campaign.id);
expect(done.status).toBe('sent');
expect(done.sent_count).toBe(1);
expect(done.failed_count).toBe(1);
});
it('marks the campaign failed only when nothing got through', async () => {
await seedCustomer({ email: 'a@example.com' });
const campaign = await seedCampaign();
await newsletterService.queueCampaign(campaign.id, adminId);
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
await newsletterService.recordRecipientResult(row, { status: 'failed', errorMessage: 'nope' });
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('failed');
});
it('does not double-count a repeated result', async () => {
await seedCustomer({ email: 'a@example.com' });
const campaign = await seedCampaign();
await newsletterService.queueCampaign(campaign.id, adminId);
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
await newsletterService.recordRecipientResult(row, { status: 'sent' });
await newsletterService.recordRecipientResult(row, { status: 'sent' });
expect((await newsletterService.getCampaign(campaign.id)).sent_count).toBe(1);
});
});
// ---- send-time opt-out re-check ---------------------------------------
describe('send-time opt-out', () => {
it('skips a customer who unsubscribed after the campaign was queued', async () => {
const customer = await seedCustomer({ email: 'a@example.com' });
const campaign = await seedCampaign();
await newsletterService.queueCampaign(campaign.id, adminId);
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
// The gap this closes: consent withdrawn between queue and send.
await newsletterService.setMarketingOptOut(customer.id, true, 'link');
expect(await newsletterService.shouldSkipForOptOut(customer.id)).toBe(true);
await newsletterService.markSkippedOptOut(row);
const recipient = await db('email_campaign_recipients')
.where({ campaign_id: campaign.id }).first();
expect(recipient.status).toBe('skipped_opt_out');
expect((await db('email_queue').where({ id: row.id }).first()).status).toBe('cancelled');
});
it('skips a customer deactivated after queueing', async () => {
const customer = await seedCustomer();
await db('customer_accounts').where({ id: customer.id }).update({ is_active: 0 });
expect(await newsletterService.shouldSkipForOptOut(customer.id)).toBe(true);
});
it('skips when another account on the same address opted out', async () => {
// Consent belongs to the address: a click by the twin has to stop this
// mail too, or the person who unsubscribed still receives it.
const queued = await seedCustomer({ email: 'shared@example.com', marketing_opt_out: 0 });
await seedCustomer({ email: 'SHARED@example.com', marketing_opt_out: 1 });
expect(await newsletterService.shouldSkipForOptOut(queued.id, 'shared@example.com'))
.toBe(true);
});
it('does not skip an ordinary opted-in customer', async () => {
const customer = await seedCustomer();
expect(await newsletterService.shouldSkipForOptOut(customer.id)).toBe(false);
});
});
// ---- opt-out column ----------------------------------------------------
describe('setMarketingOptOut', () => {
it('stamps a timestamp when opting out and clears it when opting back in', async () => {
const customer = await seedCustomer();
await newsletterService.setMarketingOptOut(customer.id, true, 'link');
let row = await db('customer_accounts').where({ id: customer.id }).first();
expect(row.marketing_opt_out).toBeTruthy();
expect(row.marketing_opt_out_at).toBeTruthy();
await newsletterService.setMarketingOptOut(customer.id, false, 'admin');
row = await db('customer_accounts').where({ id: customer.id }).first();
expect(row.marketing_opt_out).toBeFalsy();
expect(row.marketing_opt_out_at).toBeNull();
});
it('ignores a repeated opt-out and preserves the original timestamp', async () => {
// #1285 review: link scanners, prefetchers and refreshes all re-hit an
// unsubscribe URL. Rewriting the timestamp each time buries the moment
// consent was actually withdrawn, and files a duplicate activity row.
const customer = await seedCustomer();
await newsletterService.setMarketingOptOut(customer.id, true, 'link');
const first = await db('customer_accounts').where({ id: customer.id }).first();
const second = await newsletterService.setMarketingOptOut(customer.id, true, 'link');
expect(second).toBe(false);
const after = await db('customer_accounts').where({ id: customer.id }).first();
expect(after.marketing_opt_out_at).toBe(first.marketing_opt_out_at);
const logs = (await db('activity_logs')
.where({ activity_type: 'customer_marketing_opt_out' }))
.filter((row) => JSON.parse(row.metadata).customerId === customer.id);
expect(logs).toHaveLength(1);
});
it('reports no-op for an unknown customer', async () => {
expect(await newsletterService.setMarketingOptOut(999999, true, 'link')).toBe(false);
});
it('writes an activity log entry naming the source', async () => {
const customer = await seedCustomer();
await newsletterService.setMarketingOptOut(customer.id, true, 'portal');
const log = await db('activity_logs')
.where({ activity_type: 'customer_marketing_opt_out' }).orderBy('id', 'desc').first();
expect(JSON.parse(log.metadata).source).toBe('portal');
});
});
});
@@ -1,284 +0,0 @@
/**
* OIDC logout-to-IdP integration tests (#798 phase 3).
*
* Same full-stack shape as oidcSso.test.js: real routes over a mock
* in-process IdP, genuine discovery/JWKS/PKCE via openid-client. Pins:
*
* - the SSO callback stores the raw ID token in the oidc_id_token cookie
* - /logout with that cookie + oidc_logout_from_idp=true returns the
* IdP end-session URL (id_token_hint, post_logout_redirect_uri,
* client_id) and clears the cookie
* - feature off → no ssoLogoutUrl even for an SSO session
* - no oidc_id_token cookie (local-password session) → no ssoLogoutUrl
* even with the feature on — local sessions never bounce to the IdP
* - IdP without an end_session_endpoint → no ssoLogoutUrl, logout still 200
* - settings surface: GET exposes the flag + post_logout_redirect_uri,
* PUT persists the flag
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const { bootCrmDb } = require('./helpers/crmDb');
const { MockOidcProvider } = require('./helpers/mockOidcProvider');
describe('OIDC logout-to-IdP (#798 phase 3)', () => {
let db;
let cleanup;
let app;
let idp;
let oidcService;
beforeAll(async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-logout-test-secret';
process.env.FRONTEND_URL = 'http://localhost:5199';
({ db, cleanup } = await bootCrmDb());
idp = new MockOidcProvider();
const issuer = await idp.start();
oidcService = require('../../src/services/oidcService');
await oidcService.saveOidcSettings({
oidc_enabled: true,
oidc_issuer_url: issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
oidc_autoprovision: true,
oidc_default_role: 'viewer',
oidc_logout_from_idp: true,
});
const authRouter = require('../../src/routes/auth');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRouter);
}, 120000);
afterAll(async () => {
if (idp) await idp.stop();
if (cleanup) await cleanup();
});
/** Drive login → IdP → callback like a browser; returns the callback response. */
async function ssoRoundTrip() {
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state='))
.split(';')[0];
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
expect(idpRes.status).toBe(302);
const back = new URL(idpRes.headers.get('location'));
return request(app)
.get(`${back.pathname}?${back.searchParams.toString()}`)
.set('Cookie', stateCookie)
.expect(302);
}
/**
* The oidc_id_token cookie pair ("oidc_id_token=<jwt>") from a callback
* response. The callback carries TWO Set-Cookie headers for this name —
* establishAdminSession clears any stale marker, then the callback sets
* the fresh one — and browsers apply them in order, so the LAST wins.
*/
function idTokenCookie(res) {
const cookies = (res.headers['set-cookie'] || []).filter((c) => c.startsWith('oidc_id_token='));
const last = cookies[cookies.length - 1];
return last ? last.split(';')[0] : null;
}
it('stores the raw ID token in the oidc_id_token cookie on SSO login', async () => {
idp.setNextUser({ sub: 'logout-sub-1', email: 'logout@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const cookie = idTokenCookie(res);
expect(cookie).toBeTruthy();
// Raw JWT, HttpOnly, scoped to /api/auth.
const raw = decodeURIComponent(cookie.replace('oidc_id_token=', ''));
expect(raw.split('.')).toHaveLength(3);
const setCookies = (res.headers['set-cookie'] || []).filter((c) => c.startsWith('oidc_id_token='));
const full = setCookies[setCookies.length - 1];
expect(full).toMatch(/HttpOnly/i);
expect(full).toMatch(/Path=\/api\/auth/i);
});
it('returns the IdP end-session URL on logout and clears the cookie', async () => {
idp.setNextUser({ sub: 'logout-sub-2', email: 'logout2@example.com', email_verified: true });
const cbRes = await ssoRoundTrip();
const cookie = idTokenCookie(cbRes);
const rawIdToken = decodeURIComponent(cookie.replace('oidc_id_token=', ''));
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', cookie)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeTruthy();
const url = new URL(res.body.ssoLogoutUrl);
expect(url.href.startsWith(`${idp.issuer}/logout`)).toBe(true);
expect(url.searchParams.get('id_token_hint')).toBe(rawIdToken);
expect(url.searchParams.get('post_logout_redirect_uri')).toBe('http://localhost:5199/admin/login');
expect(url.searchParams.get('client_id')).toBe(idp.clientId);
// Cookie must be cleared so a later local-password logout in the same
// browser doesn't bounce to the IdP again.
const cleared = (res.headers['set-cookie'] || []).find((c) => c.startsWith('oidc_id_token='));
expect(cleared).toBeTruthy();
expect(cleared).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i);
});
it('omits ssoLogoutUrl when the feature is disabled', async () => {
idp.setNextUser({ sub: 'logout-sub-3', email: 'logout3@example.com', email_verified: true });
const cbRes = await ssoRoundTrip();
const cookie = idTokenCookie(cbRes);
await oidcService.saveOidcSettings({ oidc_logout_from_idp: false });
try {
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', cookie)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeUndefined();
} finally {
await oidcService.saveOidcSettings({ oidc_logout_from_idp: true });
}
});
it('omits ssoLogoutUrl without an oidc_id_token cookie (local-password session)', async () => {
const res = await request(app).post('/api/auth/logout').expect(200);
expect(res.body.ssoLogoutUrl).toBeUndefined();
});
it('omits ssoLogoutUrl when the IdP advertises no end_session_endpoint', async () => {
// Separate provider whose discovery document lacks end_session_endpoint;
// repointing the settings invalidates the discovery cache.
const bareIdp = new MockOidcProvider();
bareIdp.advertiseEndSession = false;
const bareIssuer = await bareIdp.start();
try {
await oidcService.saveOidcSettings({
oidc_issuer_url: bareIssuer,
oidc_client_id: bareIdp.clientId,
oidc_client_secret: bareIdp.clientSecret,
});
bareIdp.setNextUser({ sub: 'logout-sub-4', email: 'logout4@example.com', email_verified: true });
const cbRes = await ssoRoundTrip();
const cookie = idTokenCookie(cbRes);
expect(cookie).toBeTruthy();
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', cookie)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeUndefined();
} finally {
await bareIdp.stop();
await oidcService.saveOidcSettings({
oidc_issuer_url: idp.issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
});
}
});
it('stores an issuer-tagged marker for oversized ID tokens; logout still round-trips, without a hint', async () => {
idp.setNextUser({
sub: 'logout-sub-5',
email: 'logout5@example.com',
email_verified: true,
// ~9KB of group claims — far past the 4KB cookie limit.
groups: Array.from({ length: 300 }, (_, i) => `group-${String(i).padStart(4, '0')}-xxxxxxxxxxxxxxxx`),
});
const cbRes = await ssoRoundTrip();
const cookie = idTokenCookie(cbRes);
expect(cookie).toBeTruthy();
// Issuer-tagged marker, not the (oversized) token itself.
const marker = decodeURIComponent(cookie.replace('oidc_id_token=', ''));
expect(marker.startsWith('sso.')).toBe(true);
expect(Buffer.from(marker.split('.')[1], 'base64url').toString('utf8')).toBe(idp.issuer);
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', cookie)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeTruthy();
const url = new URL(res.body.ssoLogoutUrl);
expect(url.searchParams.get('id_token_hint')).toBeNull();
expect(url.searchParams.get('client_id')).toBe(idp.clientId);
});
it('skips the round-trip for an oversized-token marker from a DIFFERENT issuer', async () => {
const foreignMarker = `sso.${Buffer.from('http://other-idp.example').toString('base64url')}`;
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', `oidc_id_token=${foreignMarker}`)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeUndefined();
});
it('a fresh local-password login clears a stale SSO marker', async () => {
const role = await db('roles').where({ name: 'admin' }).first();
await db('admin_users').insert({
username: 'stale-marker-admin',
email: 'stale-marker@example.com',
password_hash: await bcrypt.hash('StaleMarker123!', 4),
role_id: role.id,
is_active: 1,
must_change_password: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
// Stale marker from a dead SSO session rides along on the login request.
const res = await request(app)
.post('/api/auth/admin/login')
.set('Cookie', 'oidc_id_token=stale.jwt.value')
.send({ username: 'stale-marker-admin', password: 'StaleMarker123!' })
.expect(200);
const cleared = (res.headers['set-cookie'] || []).find((c) => c.startsWith('oidc_id_token='));
expect(cleared).toBeTruthy();
expect(cleared).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i);
});
it('skips the round-trip when the stored hint was issued by a DIFFERENT issuer (config changed)', async () => {
// Fake-but-well-formed JWT from another IdP — payload is all that matters,
// buildEndSessionUrl decodes without verification for routing only.
const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const foreignToken = `${b64({ alg: 'none' })}.${b64({ iss: 'http://other-idp.example', aud: idp.clientId })}.sig`;
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', `oidc_id_token=${foreignToken}`)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeUndefined();
});
it('drops only the hint when the issuer matches but the client changed', async () => {
const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const oldClientToken = `${b64({ alg: 'none' })}.${b64({ iss: idp.issuer, aud: 'previous-client-id' })}.sig`;
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', `oidc_id_token=${oldClientToken}`)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeTruthy();
const url = new URL(res.body.ssoLogoutUrl);
expect(url.searchParams.get('id_token_hint')).toBeNull();
expect(url.searchParams.get('client_id')).toBe(idp.clientId);
});
it('exposes the flag and post_logout_redirect_uri via getOidcConfig/getPostLogoutRedirectUri', async () => {
// Settings-route auth chains are covered in oidcSso.test.js; here the
// service surface the routes read from is pinned directly.
const cfg = await oidcService.getOidcConfig();
expect(cfg.logoutFromIdp).toBe(true);
expect(await oidcService.getPostLogoutRedirectUri()).toBe('http://localhost:5199/admin/login');
});
});
@@ -1,416 +0,0 @@
/**
* OIDC role mapping + login policy integration tests (#798, phase 2).
*
* Same harness as oidcSso.test.js: supertest over the real routes, mock
* in-process IdP with genuine RS256/PKCE validation, fresh-SQLite DB. Pins:
*
* - JIT provisioning takes the MAPPED role from a nested dot-path claim
* (Keycloak's realm_access.roles), not the static default
* - roles are re-evaluated on every SSO login (upgrade AND downgrade)
* - several mapped roles → the highest-priority one wins
* - non-strict: unmapped login keeps the current role / default at JIT
* - strict (require_mapped_role): unmapped login → sso_error=no_role
* - the last active super_admin is never demoted by mapping
* - space-separated string claim values work (flat `roles` claim)
* - disable_local_login: password login → 403; OIDC_BREAK_GLASS=true
* re-opens it; flag is inert while SSO is disabled
* - PUT /sso validation: unknown mapping target and
* disable-local-login-without-SSO are rejected
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb } = require('./helpers/crmDb');
const { MockOidcProvider } = require('./helpers/mockOidcProvider');
describe('OIDC role mapping + login policy (#798 phase 2)', () => {
let db;
let cleanup;
let app;
let idp;
let oidcService;
let superAdminToken;
beforeAll(async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-test-secret';
process.env.FRONTEND_URL = 'http://localhost:5199';
delete process.env.OIDC_BREAK_GLASS;
({ db, cleanup } = await bootCrmDb());
idp = new MockOidcProvider();
const issuer = await idp.start();
oidcService = require('../../src/services/oidcService');
await oidcService.saveOidcSettings({
oidc_enabled: true,
oidc_issuer_url: issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
oidc_autoprovision: true,
oidc_default_role: 'viewer',
oidc_role_mapping_enabled: true,
oidc_roles_claim: 'realm_access.roles',
oidc_role_mappings: {
'pp-super': 'super_admin',
'pp-admins': 'admin',
'pp-view': 'viewer',
},
});
const authRouter = require('../../src/routes/auth');
const adminSettingsRouter = require('../../src/routes/adminSettings');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRouter);
app.use('/api/admin/settings', adminSettingsRouter);
// A real super_admin row + token for the settings-validation tests.
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'root-admin',
email: 'root@example.com',
password_hash: await bcrypt.hash('RootPass123', 4),
role_id: superRole.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
superAdminToken = jwt.sign(
{ id: rootId, username: 'root-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
}, 120000);
afterAll(async () => {
delete process.env.OIDC_BREAK_GLASS;
if (idp) await idp.stop();
if (cleanup) await cleanup();
});
/** Drive login → IdP → callback like a browser; returns the callback response. */
async function ssoRoundTrip() {
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state=')).split(';')[0];
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
expect(idpRes.status).toBe(302);
const back = new URL(idpRes.headers.get('location'));
return request(app)
.get(`${back.pathname}?${back.searchParams.toString()}`)
.set('Cookie', stateCookie)
.expect(302);
}
async function roleOf(email) {
const row = await db('admin_users').where({ email }).first();
const role = await db('roles').where({ id: row.role_id }).first();
return role.name;
}
it('JIT-provisions with the role mapped from the nested dot-path claim', async () => {
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['irrelevant', 'pp-admins'] },
});
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('mapped@example.com')).toBe('admin');
});
it('re-evaluates the role on every login — downgrade lands', async () => {
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['pp-view'] },
});
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('mapped@example.com')).toBe('viewer');
});
it('re-evaluates the role on every login — upgrade lands and the session JWT carries it', async () => {
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['pp-admins'] },
});
const res = await ssoRoundTrip();
expect(await roleOf('mapped@example.com')).toBe('admin');
// The freshly-minted session token must already carry the NEW role —
// the sync happens before session establishment.
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
const token = decodeURIComponent(adminCookie.split(';')[0].replace('admin_token=', ''));
const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
expect(decoded.role).toBe('admin');
});
it('picks the highest-priority role when several IdP values map', async () => {
idp.setNextUser({
sub: 'sub-multi',
email: 'multi@example.com',
email_verified: true,
realm_access: { roles: ['pp-view', 'pp-admins'] },
});
await ssoRoundTrip();
expect(await roleOf('multi@example.com')).toBe('admin');
});
it('non-strict: an unmapped login keeps the current role / gets the default at JIT', async () => {
// Existing admin keeps its role.
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['nothing-mapped'] },
});
let res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('mapped@example.com')).toBe('admin');
// JIT falls back to the configured default role.
idp.setNextUser({
sub: 'sub-unmapped-jit',
email: 'unmapped@example.com',
email_verified: true,
realm_access: { roles: ['nothing-mapped'] },
});
res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('unmapped@example.com')).toBe('viewer');
});
it('strict mode refuses unmapped logins with sso_error=no_role and no session', async () => {
await oidcService.saveOidcSettings({ oidc_require_mapped_role: true });
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['nothing-mapped'] },
});
const res = await ssoRoundTrip();
await oidcService.saveOidcSettings({ oidc_require_mapped_role: false });
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=no_role');
expect((res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='))).toBeFalsy();
// Role untouched by the refused attempt.
expect(await roleOf('mapped@example.com')).toBe('admin');
});
it('never demotes the last active super_admin', async () => {
// Make the SSO admin the ONLY active super_admin.
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const ssoAdmin = await db('admin_users').where({ email: 'mapped@example.com' }).first();
await db('admin_users').where({ role_id: superRole.id }).update({ is_active: 0 });
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id, is_active: 1 });
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['pp-view'] },
});
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
// Still super_admin — the demotion was refused, the login was not.
expect(await roleOf('mapped@example.com')).toBe('super_admin');
// Restore: root admin back to active super_admin, SSO admin back to admin.
const adminRole = await db('roles').where({ name: 'admin' }).first();
await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 1 });
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: adminRole.id });
// With ANOTHER active super_admin present the same downgrade goes through.
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['pp-view'] },
});
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id });
await ssoRoundTrip();
expect(await roleOf('mapped@example.com')).toBe('viewer');
});
it('never demotes the last LOCAL-password super_admin even when an OIDC-owned super exists', async () => {
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const viewerRole = await db('roles').where({ name: 'viewer' }).first();
// A local-password super admin, SSO-linked via verified email so role
// sync applies to it.
const [localId] = await db('admin_users').insert({
username: 'local-super',
email: 'local-super@example.com',
password_hash: await bcrypt.hash('LocalSuper123', 4),
role_id: superRole.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
// The only OTHER active super is OIDC-owned (root goes inactive) — the
// plain last-super guard would allow the demotion, the break-glass
// guard must not.
const ssoAdmin = await db('admin_users').where({ email: 'mapped@example.com' }).first();
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id });
await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 0 });
idp.setNextUser({
sub: 'sub-local-super',
email: 'local-super@example.com',
email_verified: true,
realm_access: { roles: ['pp-view'] },
});
const res = await ssoRoundTrip();
const row = await db('admin_users').where({ id: localId }).first();
// Restore the fixture state before asserting.
await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 1 });
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: viewerRole.id });
await db('admin_users').where({ id: localId }).update({ is_active: 0 });
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(row.role_id).toBe(superRole.id); // kept — it is the break-glass account
});
it('treats prototype-property IdP values (constructor/toString) as unmapped, not as an error', async () => {
idp.setNextUser({
sub: 'sub-proto',
email: 'proto@example.com',
email_verified: true,
realm_access: { roles: ['constructor', 'toString', '__proto__'] },
});
const res = await ssoRoundTrip();
// Non-strict: unmapped → JIT with the default role, login succeeds.
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('proto@example.com')).toBe('viewer');
});
it('accepts a space-separated string value on a flat claim', async () => {
await oidcService.saveOidcSettings({ oidc_roles_claim: 'roles' });
idp.setNextUser({
sub: 'sub-flat',
email: 'flat@example.com',
email_verified: true,
roles: 'other pp-admins',
});
const res = await ssoRoundTrip();
await oidcService.saveOidcSettings({ oidc_roles_claim: 'realm_access.roles' });
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('flat@example.com')).toBe('admin');
});
it('refuses local password login while disable_local_login is effective', async () => {
await oidcService.saveOidcSettings({ oidc_disable_local_login: true });
const res = await request(app)
.post('/api/auth/admin/login')
.send({ username: 'root@example.com', password: 'RootPass123' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('LOCAL_LOGIN_DISABLED');
});
it('OIDC_BREAK_GLASS=true re-opens local login despite the policy', async () => {
process.env.OIDC_BREAK_GLASS = 'true';
const res = await request(app)
.post('/api/auth/admin/login')
.send({ username: 'root@example.com', password: 'RootPass123' });
delete process.env.OIDC_BREAK_GLASS;
expect(res.status).toBe(200);
expect(res.body.user).toBeTruthy();
});
it('the stored flag is inert while SSO is disabled', async () => {
// Simulate a torn-down SSO config with the stale flag still set — the
// runtime check must ignore it (no lockout).
await db('app_settings').where({ setting_key: 'oidc_enabled' })
.update({ setting_value: JSON.stringify(false) });
expect(await oidcService.isLocalLoginDisabled()).toBe(false);
await db('app_settings').where({ setting_key: 'oidc_enabled' })
.update({ setting_value: JSON.stringify(true) });
expect(await oidcService.isLocalLoginDisabled()).toBe(true);
await oidcService.saveOidcSettings({ oidc_disable_local_login: false });
});
it('the policy disarms itself when no active local-password super admin remains', async () => {
await oidcService.saveOidcSettings({ oidc_disable_local_login: true });
expect(await oidcService.isLocalLoginDisabled()).toBe(true);
// The break-glass account disappears (e.g. manual demotion/deactivation
// while the policy is on) → local login must re-open by itself.
await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'oidc' });
expect(await oidcService.isLocalLoginDisabled()).toBe(false);
await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'local' });
await oidcService.saveOidcSettings({ oidc_disable_local_login: false });
});
it('PUT /sso rejects a mapping onto an unknown role', async () => {
const res = await request(app)
.put('/api/admin/settings/sso')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({ oidc_role_mappings: { 'pp-admins': 'does_not_exist' } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/does_not_exist/);
// Stored mapping unchanged.
const cfg = await oidcService.getOidcConfig();
expect(cfg.roleMappings['pp-admins']).toBe('admin');
});
it('PUT /sso rejects disabling local login while SSO is (being turned) off', async () => {
const res = await request(app)
.put('/api/admin/settings/sso')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({ oidc_enabled: false, oidc_disable_local_login: true });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/while SSO is enabled/);
});
it('PUT /sso refuses SSO-only mode without an active local-password super admin', async () => {
// Make every active super_admin OIDC-owned — break-glass would then
// re-open a password route that no account can use.
const superRole = await db('roles').where({ name: 'super_admin' }).first();
await db('admin_users').where({ role_id: superRole.id }).update({ auth_provider: 'oidc' });
const denied = await request(app)
.put('/api/admin/settings/sso')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({ oidc_disable_local_login: true });
// Restore the local break-glass account, then the same request passes.
await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'local' });
expect(denied.status).toBe(400);
expect(denied.body.error).toMatch(/break-glass/);
const allowed = await request(app)
.put('/api/admin/settings/sso')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({ oidc_disable_local_login: true });
expect(allowed.status).toBe(200);
await oidcService.saveOidcSettings({ oidc_disable_local_login: false });
});
it('GET /sso returns the phase-2 fields', async () => {
const res = await request(app)
.get('/api/admin/settings/sso')
.set('Authorization', `Bearer ${superAdminToken}`);
expect(res.status).toBe(200);
expect(res.body.oidc_role_mapping_enabled).toBe(true);
expect(res.body.oidc_roles_claim).toBe('realm_access.roles');
expect(res.body.oidc_role_mappings).toEqual({
'pp-super': 'super_admin',
'pp-admins': 'admin',
'pp-view': 'viewer',
});
expect(res.body.oidc_require_mapped_role).toBe(false);
expect(res.body.oidc_disable_local_login).toBe(false);
});
});
@@ -1,302 +0,0 @@
/**
* OIDC SSO integration tests (#798, phase 1).
*
* Full-stack over a mock in-process IdP (mockOidcProvider): supertest drives
* the real /admin/sso/login and /admin/sso/callback routes on a fresh-SQLite
* database, openid-client does genuine discovery/JWKS/PKCE/ID-token
* validation against the mock issuer. Pins:
*
* - happy path: JIT provisioning creates an admin and sets the session cookie
* - JIT off → not_provisioned redirect, no row created
* - repeat login matches by sub, not email (email change ≠ new account)
* - verified-email one-time link onto an existing local admin
* - unverified email must NOT link (falls through to JIT/or error)
* - deactivated admin → inactive redirect
* - missing/forged state cookie → state redirect
* - nonce tamper from the IdP → idp redirect
* - settings endpoints: secret write-only, generic /general upsert cannot
* clobber oidc_client_secret
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb } = require('./helpers/crmDb');
const { MockOidcProvider } = require('./helpers/mockOidcProvider');
describe('OIDC SSO (#798)', () => {
let db;
let cleanup;
let app;
let idp;
let oidcService;
const agentCookies = {};
beforeAll(async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-test-secret';
// The redirect_uri derives from the public base URL — pin it explicitly:
// CI has no backend/.env, and getFrontendBaseUrl() returning '' makes
// buildAuthorizationRequest fail (by design) with OIDC_BAD_CONFIG.
process.env.FRONTEND_URL = 'http://localhost:5199';
({ db, cleanup } = await bootCrmDb());
idp = new MockOidcProvider();
const issuer = await idp.start();
// Require AFTER bootCrmDb so services share this db instance.
oidcService = require('../../src/services/oidcService');
await oidcService.saveOidcSettings({
oidc_enabled: true,
oidc_issuer_url: issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
oidc_autoprovision: true,
oidc_default_role: 'viewer',
});
const authRouter = require('../../src/routes/auth');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRouter);
}, 120000);
afterAll(async () => {
if (idp) await idp.stop();
if (cleanup) await cleanup();
});
/** Drive login → IdP → callback like a browser; returns the callback response. */
async function ssoRoundTrip({ mutateState } = {}) {
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const idpUrl = loginRes.headers.location;
expect(idpUrl.startsWith(idp.issuer)).toBe(true);
let stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state='));
expect(stateCookie).toBeTruthy();
stateCookie = stateCookie.split(';')[0];
if (mutateState === 'drop') stateCookie = null;
if (mutateState === 'forge') {
stateCookie = `oidc_state=${jwt.sign({ type: 'oidc_state', s: 'x', n: 'y', cv: 'z' }, 'wrong-secret', { issuer: 'picpeak-auth' })}`;
}
// "Browser" follows the redirect to the IdP, which instantly bounces back.
const idpRes = await fetch(idpUrl, { redirect: 'manual' });
expect(idpRes.status).toBe(302);
const back = new URL(idpRes.headers.get('location'));
let cb = request(app).get(`${back.pathname}?${back.searchParams.toString()}`);
if (stateCookie) cb = cb.set('Cookie', stateCookie);
return cb.expect(302);
}
it('JIT-provisions an unknown user and establishes an admin session', async () => {
idp.setNextUser({ sub: 'sub-jit-1', email: 'jit@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
expect(adminCookie).toBeTruthy();
const row = await db('admin_users').where({ email: 'jit@example.com' }).first();
expect(row).toBeTruthy();
expect(row.auth_provider).toBe('oidc');
expect(row.external_subject).toBe('sub-jit-1');
const role = await db('roles').where('id', row.role_id).first();
expect(role.name).toBe('viewer');
// The session JWT must be a normal admin token.
const token = adminCookie.split(';')[0].replace('admin_token=', '');
const decoded = jwt.verify(decodeURIComponent(token), process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
expect(decoded.type).toBe('admin');
expect(decoded.id).toBe(row.id);
agentCookies.jitAdminId = row.id;
});
it('matches repeat logins by sub even when the email changed at the IdP', async () => {
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
// No second row — resolved via external_subject.
expect(await db('admin_users').where({ email: 'renamed@example.com' }).first()).toBeFalsy();
const byId = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(byId.external_subject).toBe('sub-jit-1');
});
it('links an existing local admin one-time via VERIFIED email and stamps the sub', async () => {
const role = await db('roles').where({ name: 'admin' }).first();
const [localId] = await db('admin_users').insert({
username: 'local-admin',
email: 'local@example.com',
password_hash: await bcrypt.hash('LocalPass123', 4),
role_id: role.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
idp.setNextUser({ sub: 'sub-local-1', email: 'local@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const row = await db('admin_users').where({ id: localId }).first();
expect(row.external_subject).toBe('sub-local-1');
expect(row.auth_provider).toBe('local'); // password keeps working
});
it('does NOT link by unverified email — provisions a separate account instead', async () => {
const role = await db('roles').where({ name: 'admin' }).first();
await db('admin_users').insert({
username: 'victim-admin',
email: 'victim@example.com',
password_hash: await bcrypt.hash('VictimPass123', 4),
role_id: role.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
});
idp.setNextUser({ sub: 'sub-attacker', email: 'victim@example.com', email_verified: false });
// JIT would need this email but the victim row owns it (unique) — the
// insert fails and the flow must land on an error, never on the
// victim's session.
const res = await ssoRoundTrip();
expect(res.headers.location).toMatch(/sso_error=/);
const victim = await db('admin_users').where({ email: 'victim@example.com' }).first();
expect(victim.external_subject).toBeNull();
});
it('refuses a deactivated admin with sso_error=inactive', async () => {
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 0 });
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=inactive');
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 1 });
});
it('rejects a callback without the state cookie', async () => {
const res = await ssoRoundTrip({ mutateState: 'drop' });
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
});
it('rejects a forged state cookie (wrong signing key)', async () => {
const res = await ssoRoundTrip({ mutateState: 'forge' });
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
});
it('rejects an ID token whose nonce does not match', async () => {
idp.tamperNonce = true;
idp.setNextUser({ sub: 'sub-nonce', email: 'nonce@example.com', email_verified: true });
const res = await ssoRoundTrip();
idp.tamperNonce = false;
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=idp');
expect(await db('admin_users').where({ email: 'nonce@example.com' }).first()).toBeFalsy();
});
it('blocks JIT with sso_error=not_provisioned when autoprovision is off', async () => {
await oidcService.saveOidcSettings({ oidc_autoprovision: false });
idp.setNextUser({ sub: 'sub-new-user', email: 'new@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=not_provisioned');
expect(await db('admin_users').where({ email: 'new@example.com' }).first()).toBeFalsy();
await oidcService.saveOidcSettings({ oidc_autoprovision: true });
});
it('stores the client secret encrypted and survives a config round-trip', async () => {
const row = await db('app_settings').where({ setting_key: 'oidc_client_secret' }).first();
const stored = JSON.parse(row.setting_value);
expect(stored).not.toContain(idp.clientSecret);
expect(oidcService.decryptSecret(stored)).toBe(idp.clientSecret);
const cfg = await oidcService.getOidcConfig();
expect(cfg.clientSecret).toBe(idp.clientSecret);
});
it('refuses local password login for OIDC-owned accounts', async () => {
// Give the JIT admin a KNOWN password hash directly in the DB — the
// auth_provider check must reject the login even with valid credentials
// (otherwise a password reset would mint an IdP-bypassing local login).
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({
password_hash: await bcrypt.hash('KnownPass123', 4),
});
const row = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
const res = await request(app)
.post('/api/auth/admin/login')
.send({ username: row.email, password: 'KnownPass123' });
expect(res.status).toBe(401);
});
it('returns 404 from /sso/login when SSO is disabled', async () => {
await oidcService.saveOidcSettings({ oidc_enabled: false });
await request(app).get('/api/auth/admin/sso/login').expect(404);
await oidcService.saveOidcSettings({ oidc_enabled: true });
});
it('merges email from the UserInfo endpoint when the ID token omits it', async () => {
idp.emailViaUserinfoOnly = true;
idp.setNextUser({ sub: 'sub-userinfo', email: 'userinfo@example.com', email_verified: true });
const res = await ssoRoundTrip();
idp.emailViaUserinfoOnly = false;
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const row = await db('admin_users').where({ email: 'userinfo@example.com' }).first();
expect(row).toBeTruthy();
expect(row.external_subject).toBe('sub-userinfo');
});
it('binds identities per ISSUER — a sub collision on a new IdP must not inherit the old account', async () => {
// The JIT admin from the first test is bound to (issuer A, 'sub-jit-1').
const boundAdmin = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(boundAdmin.external_issuer).toBe(idp.issuer);
// Same sub, DIFFERENT issuer: a second IdP the instance switches to.
const idp2 = new MockOidcProvider();
await idp2.start();
try {
await oidcService.saveOidcSettings({
oidc_issuer_url: idp2.issuer,
oidc_client_id: idp2.clientId,
oidc_client_secret: idp2.clientSecret,
});
idp2.setNextUser({ sub: 'sub-jit-1', email: 'colliding@example.com', email_verified: true });
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state=')).split(';')[0];
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
const back = new URL(idpRes.headers.get('location'));
const res = await request(app)
.get(`${back.pathname}?${back.searchParams.toString()}`)
.set('Cookie', stateCookie)
.expect(302);
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
// A NEW row bound to issuer B — the issuer-A admin is untouched and
// its role was not inherited.
const collider = await db('admin_users').where({ email: 'colliding@example.com' }).first();
expect(collider).toBeTruthy();
expect(collider.id).not.toBe(agentCookies.jitAdminId);
expect(collider.external_issuer).toBe(idp2.issuer);
const original = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(original.external_issuer).toBe(idp.issuer);
} finally {
await idp2.stop();
await oidcService.saveOidcSettings({
oidc_issuer_url: idp.issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
});
}
});
});
@@ -1,361 +0,0 @@
/**
* Backfilling orientation for a library that predates #1185 (#1198).
*
* The orientation fix corrected the generators and every ingest path, but left
* existing rows describing the raw sensor order. Those rows end up worse than
* untouched ones: before the fix a rotated photo was consistently wrong — a
* sideways image in a matching tile — and afterwards the thumbnail is right
* while the stored aspect ratio is not.
*
* A first attempt at this was reverted from #1194 after review. These tests
* pin the five things that went wrong with it:
*
* 1. requeueing faces without clearing the cached preview, so the rescan
* re-read unrotated pixels;
* 2. reading originals in a way that cannot see S3 or RAW;
* 3. deciding "did this change" from a dimension delta, which never fires
* for orientations 2, 3 and 4;
* 4. walking archived events whose originals no longer exist;
* 5. writing dimensions and invalidation non-atomically.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const sharp = require('sharp');
describe('orientation backfill (#1198)', () => {
let tmpDir; let db; let app; let storageRoot;
const status = () => request(app).get('/api/admin/photos/repair-orientation/status');
const run = () => request(app).post('/api/admin/photos/repair-orientation');
const settle = async () => {
for (let i = 0; i < 100; i++) {
await new Promise((r) => setTimeout(r, 50));
const s = await status();
if (!s.body.isRunning) return s;
}
throw new Error('backfill did not settle');
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-orientbf-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'orientbf-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
// bootCrmDb owns STORAGE_PATH; fixtures must live where the app resolves.
storageRoot = process.env.STORAGE_PATH;
await fs.promises.mkdir(path.join(storageRoot, 'events/active/orientbf'), { recursive: true });
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seed({
orientation, storedWidth, storedHeight, faceStatus = null,
previewPath = 'previews/prev_orientbf.jpg', archived = false, filename = 'p.jpg',
thumbnailPath = 'thumbnails/thumb_orientbf.jpg', heroPath = 'heroes/hero_orientbf.jpg',
watermarkPath = 'watermarks/wm_orientbf.jpg', checkedAt = null,
}) {
await db('photos').del();
await db('events').del();
const [e] = await db('events').insert({
slug: 'orientbf', event_type: 'wedding', event_name: 'orientbf', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `orientbf-${Math.random()}`, expires_at: new Date().toISOString(),
is_archived: archived,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const img = sharp({ create: { width: 400, height: 200, channels: 3, background: { r: 7, g: 7, b: 7 } } });
await (orientation ? img.withMetadata({ orientation }) : img)
.jpeg().toFile(path.join(storageRoot, 'events/active/orientbf', filename));
const [p] = await db('photos').insert({
event_id: eventId, filename, path: `orientbf/${filename}`, type: 'individual',
width: storedWidth, height: storedHeight, face_status: faceStatus,
preview_path: previewPath, thumbnail_path: thumbnailPath, hero_path: heroPath,
watermark_path: watermarkPath, orientation_checked_at: checkedAt,
uploaded_at: new Date().toISOString(),
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
it('corrects a row whose dimensions are transposed', async () => {
// The case the dimension repair can never reach: both values present,
// just in the raw sensor order.
const { photoId } = await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
expect((await run()).body.count).toBe(1);
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.width).toBe(200);
expect(row.height).toBe(400);
expect(done.body.lastResult.corrected).toBe(1);
});
it('clears the cached preview before requeueing, not after', async () => {
// The reverted attempt's own-goal: ensurePreviewImage hands back a cached
// preview whenever it is still a valid image, so a rescan against the
// pre-fix preview produced boxes in the old coordinate system and scaled
// them by the corrected dimensions.
const { photoId } = await seed({
orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
});
await run();
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.preview_path).toBeNull();
expect(row.face_status).toBe('pending');
expect(done.body.lastResult.requeuedFaces).toBe(1);
});
it('clears the thumbnail and hero too, not just the preview', async () => {
// The miss that mattered most: ensureThumbnail and ensureHeroImage return
// their cached file whenever it is merely VALID, and a pre-fix sideways
// thumbnail is perfectly valid. Clearing only the preview fixed the face
// data and left the gallery rendering the old sideways image inside a
// newly-corrected portrait tile.
const { photoId } = await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
await run();
await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.preview_path).toBeNull();
expect(row.thumbnail_path).toBeNull();
expect(row.hero_path).toBeNull();
});
it('leaves the renditions of an untransformed photo alone', async () => {
// Nothing moved, so nothing cached is stale — clearing them would make a
// routine run regenerate the whole library for no reason.
const { photoId } = await seed({ orientation: null, storedWidth: 400, storedHeight: 200 });
await run();
await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.thumbnail_path).toBe('thumbnails/thumb_orientbf.jpg');
expect(row.hero_path).toBe('heroes/hero_orientbf.jpg');
expect(row.preview_path).toBe('previews/prev_orientbf.jpg');
});
it('does not touch a row whose file was replaced while it was reading', async () => {
// replacePhoto swaps a new file under an existing row and rewrites
// path/filename (reachable from replace_by_name). The writes are fenced on
// the identity that was measured, so a replacement that lands mid-run is
// left entirely alone rather than being given the previous file's
// dimensions and having its fresh renditions cleared.
const { photoId } = await seed({
orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
});
const res = await run();
expect(res.body.count).toBe(1);
// Simulate the replacement landing before the loop writes.
await db('photos').where({ id: photoId })
.update({ path: 'orientbf/replaced.jpg', filename: 'replaced.jpg' });
await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.width).toBe(400); // untouched
expect(row.face_status).toBe('done'); // not requeued
expect(row.thumbnail_path).toBe('thumbnails/thumb_orientbf.jpg');
});
it('is idempotent — a second run finds nothing left to do', async () => {
// The trigger is the EXIF tag on the ORIGINAL, which correcting a photo
// never changes. Without a marker every re-run would throw away the
// renditions it had just regenerated and requeue every completed face
// scan — on a face-enabled install, re-detecting the whole library.
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: 'done' });
expect((await run()).body.count).toBe(1);
const first = await settle();
expect(first.body.lastResult.corrected).toBe(1);
expect((await run()).body.count).toBe(0);
});
it('force revisits rows it has already checked', async () => {
await seed({
orientation: 6, storedWidth: 200, storedHeight: 400,
checkedAt: new Date().toISOString(),
});
expect((await run()).body.count).toBe(0);
const forced = await request(app).post('/api/admin/photos/repair-orientation').send({ force: true });
expect(forced.body.count).toBe(1);
await settle();
});
it('clears the watermarked rendition, which is what a guest actually sees', async () => {
// gallery.js serves watermark_path ahead of the original when branding
// watermarking is on.
const { photoId } = await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
await run();
await settle();
expect((await db('photos').where({ id: photoId }).first()).watermark_path).toBeNull();
});
it('does not report stale tiers after a clean run', async () => {
// storage.stat() RESOLVES with null for a missing key rather than
// rejecting, so counting "the promise settled" marked every deleted and
// never-created tier as a survivor and told the operator to re-run.
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
await run();
const done = await settle();
expect(done.body.lastResult.staleTiers).toBe(0);
});
it('requeues faces when only the dimensions were wrong', async () => {
// No rotation involved: boxes are scaled by photo.width at read time, so
// any change to the stored dimensions invalidates them.
const { photoId } = await seed({
orientation: null, storedWidth: 999, storedHeight: 111, faceStatus: 'done',
});
await run();
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.width).toBe(400);
expect(row.face_status).toBe('pending');
expect(done.body.lastResult.requeuedFaces).toBe(1);
});
it('requeues an orientation that moves pixels without moving dimensions', async () => {
// Orientation 3 is a 180° turn: every pixel moves, width and height do
// not. A dimension-delta check sees nothing and skips exactly this row.
const { photoId } = await seed({
orientation: 3, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
});
await run();
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.width).toBe(400); // unchanged, correctly
expect(row.face_status).toBe('pending');
expect(row.preview_path).toBeNull();
expect(done.body.lastResult.requeuedFaces).toBe(1);
expect(done.body.lastResult.corrected).toBe(0);
});
it('leaves a post-fix import alone, renditions and all', async () => {
// A 5-8 rotation changes the dimensions, so a tagged photo whose stored
// dimensions are already oriented must have been ingested after #1185.
// Re-clearing its renditions would delete valid files and rescan a
// completed face detection for nothing.
const { photoId } = await seed({
orientation: 6, storedWidth: 200, storedHeight: 400, faceStatus: 'done',
});
await run();
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.thumbnail_path).toBe('thumbnails/thumb_orientbf.jpg');
expect(row.face_status).toBe('done');
expect(done.body.lastResult).toMatchObject({ corrected: 0, requeuedFaces: 0 });
// ...and it is marked, so it is not re-read next time either.
expect(row.orientation_checked_at).toBeTruthy();
});
it('still invalidates a 180-degree rotation, which carries no such evidence', async () => {
// Orientation 3 leaves the dimensions identical whether or not it has been
// processed, so there is nothing to infer from and it must be done once.
const { photoId } = await seed({
orientation: 3, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
});
await run();
await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.thumbnail_path).toBeNull();
expect(row.face_status).toBe('pending');
});
it('leaves an untagged photo completely alone', async () => {
const { photoId } = await seed({
orientation: null, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
});
await run();
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.width).toBe(400);
expect(row.face_status).toBe('done');
expect(row.preview_path).toBe('previews/prev_orientbf.jpg');
expect(done.body.lastResult).toMatchObject({ corrected: 0, requeuedFaces: 0, failed: 0 });
});
it('does not start face scanning on an install that never enabled it', async () => {
const { photoId } = await seed({
orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: null,
});
await run();
const done = await settle();
expect((await db('photos').where({ id: photoId }).first()).face_status).toBeNull();
expect(done.body.lastResult.requeuedFaces).toBe(0);
// ...but the dimensions are still corrected.
expect(done.body.lastResult.corrected).toBe(1);
});
it('skips archived events, whose originals were deleted on archive', async () => {
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200, archived: true });
const res = await run();
expect(res.body.count).toBe(0);
});
it('refuses a second run while one is in flight', async () => {
// Shares the maintenance-lease plumbing, on its own job row so it neither
// blocks nor is blocked by the dimension repair.
const jobs = require('../../src/services/maintenanceJobState');
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
const claim = await jobs.claim(jobs.JOB_ORIENTATION_BACKFILL);
expect(claim).toEqual(expect.any(String));
expect((await run()).status).toBe(409);
// The dimension repair is a different job and is unaffected.
expect((await request(app).post('/api/admin/photos/repair-dimensions')).status).toBe(200);
await jobs.release(jobs.JOB_ORIENTATION_BACKFILL, claim);
});
});
@@ -1,197 +0,0 @@
'use strict';
/**
* Cross-engine .picpeak restore policy (#1041): a SQLite archive restored onto
* a PostgreSQL instance — the official small-install → full-stack upgrade
* path — now allowed by validateManifest's direction rule instead of the
* former CLI-only allowEngineSwitch flag. The coercion engine itself
* (typedColumnsFor / epochToIso / coerceForTargetEngine) landed with #1039;
* these tests pin the direction policy and the coercion's cross-engine
* value-correctness.
*
* Ungated: validateManifest direction rules and the pure coercion units.
* The reverse direction (pg backup onto a sqlite instance) staying blocked is
* pinned by picpeakRoundtrip.test.js, which runs on the real sqlite harness.
*
* Gated on PICPEAK_PG_TEST_URL (same contract as picpeakRestorePg.test.js):
* sqlite-shaped NDJSON rows land in real Postgres with correct stored VALUES,
* not just row counts, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:pw@127.0.0.1:7102/picpeak_xengine_test" \
* npx jest __tests__/integration/picpeakCrossEngine.test.js
*/
const knexLib = require('knex');
describe('validateManifest cross-engine direction (pg target)', () => {
let validateManifest;
beforeAll(() => {
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
// validateManifest wraps its knex_migrations lookup in try/catch — a
// throwing stub simply skips the forward-only check, which is not under
// test here.
jest.doMock('../../src/database/db', () => ({ db: () => { throw new Error('stub'); } }));
({ validateManifest } = require('../../src/services/picpeakImportService'));
});
afterAll(() => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
jest.resetModules();
});
it('allows a sqlite backup onto a pg instance (upgrade direction)', async () => {
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'sqlite' }, tables: {},
});
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
});
it('still allows same-engine pg → pg', async () => {
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
});
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
});
});
describe('epochToIso (landed with #1039)', () => {
let epochToIso;
beforeAll(() => {
jest.resetModules();
({ epochToIso } = require('../../src/services/picpeakImportService'));
});
it('converts epoch milliseconds', () => {
expect(epochToIso(1723400000000)).toBe('2024-08-11T18:13:20.000Z');
});
it('converts epoch SECONDS to the same instant, not January 1970', () => {
expect(epochToIso(1723400000)).toBe('2024-08-11T18:13:20.000Z');
});
it('converts numeric strings', () => {
expect(epochToIso('1723400000000')).toBe('2024-08-11T18:13:20.000Z');
});
it('passes non-numeric values through untouched', () => {
expect(epochToIso('2026-08-12 10:00:00')).toBe('2026-08-12 10:00:00');
});
});
describe('coerceForTargetEngine on sqlite-shaped rows', () => {
let coerceForTargetEngine;
beforeAll(() => {
jest.resetModules();
({ coerceForTargetEngine } = require('../../src/services/picpeakImportService'));
});
const types = { timestamps: ['created_at', 'expires_at'], booleans: ['is_active'] };
it('coerces 0/1 booleans and epoch timestamps, leaves date strings alone', () => {
const [row] = coerceForTargetEngine(
[{ id: 1, is_active: 1, created_at: 1723400000000, expires_at: '2026-09-01 12:00:00' }],
types
);
expect(row.is_active).toBe(true);
expect(row.created_at).toBe('2024-08-11T18:13:20.000Z');
expect(row.expires_at).toBe('2026-09-01 12:00:00'); // pg parses this natively
});
it('coerces falsy variants and passes null/empty through', () => {
const [row] = coerceForTargetEngine(
[{ is_active: 0, created_at: null, expires_at: '' }],
types
);
expect(row.is_active).toBe(false);
expect(row.created_at).toBeNull();
expect(row.expires_at).toBe('');
});
});
// ── Real-Postgres integration (gated) ────────────────────────────────────────
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('sqlite-shaped rows land correctly in real Postgres', () => {
let pgDb;
let svc;
beforeAll(async () => {
pgDb = knexLib({ client: 'pg', connection: PG_URL });
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
await pgDb.schema.createTable('xengine_events', (t) => {
t.increments('id');
t.string('slug');
t.boolean('is_active').defaultTo(true);
t.boolean('allow_downloads').defaultTo(true);
t.timestamp('created_at');
t.timestamp('expires_at');
});
await pgDb.schema.createTable('xengine_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.jsonb('setting_value');
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
svc = require('../../src/services/picpeakImportService');
});
afterAll(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
if (pgDb) {
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
await pgDb.destroy();
}
});
it('typedColumnsFor classifies boolean and timestamp columns via columnInfo()', async () => {
const types = await svc.typedColumnsFor(pgDb, 'xengine_events');
expect(types.booleans.sort()).toEqual(['allow_downloads', 'is_active']);
expect(types.timestamps.sort()).toEqual(['created_at', 'expires_at']);
});
it('inserts a sqlite archive row (0/1 booleans, epoch dates, json text) with correct stored values', async () => {
// Exactly what a sqlite-created .picpeak carries: integers for booleans,
// epoch numbers for #485-shape timestamps (ms here, seconds covered by the
// epochToIso unit), a "YYYY-MM-DD HH:MM:SS" string for clean ones, and
// json columns as TEXT (the crossEngine path skips serialiseJsonColumns —
// the text is already what pg wants).
const epoch = 1723400000000;
const eventRows = [
{ id: 1, slug: 'wedding', is_active: 1, allow_downloads: 0, created_at: epoch, expires_at: '2026-09-01 12:00:00' },
];
const settingRows = [{ id: 1, setting_key: 'brand', setting_value: '{"name":"PicPeak","dark":true}' }];
await pgDb.transaction(async (trx) => {
const evTypes = await svc.typedColumnsFor(trx, 'xengine_events');
await trx.batchInsert('xengine_events', svc.coerceForTargetEngine(eventRows, evTypes), 100);
const stTypes = await svc.typedColumnsFor(trx, 'xengine_settings');
await trx.batchInsert('xengine_settings', svc.coerceForTargetEngine(settingRows, stTypes), 100);
});
const ev = await pgDb('xengine_events').where({ id: 1 }).first();
expect(ev.is_active).toBe(true); // 1 → true, not backwards (#1028 class)
expect(ev.allow_downloads).toBe(false); // 0 → false
expect(new Date(ev.created_at).getTime()).toBe(epoch);
expect(new Date(ev.expires_at).toISOString().slice(0, 10)).toBe('2026-09-01');
const st = await pgDb('xengine_settings').where({ id: 1 }).first();
// jsonb parsed back by the driver — value intact, no double encoding.
expect(st.setting_value).toEqual({ name: 'PicPeak', dark: true });
});
it('id sequence works after explicit-id insert + resync (next natural insert)', async () => {
await svc.resyncSequences(['xengine_events']);
const [next] = await pgDb('xengine_events')
.insert({ slug: 'fresh', is_active: true })
.returning('id');
expect(Number(next.id || next)).toBe(2);
});
});
@@ -21,7 +21,7 @@ beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
({ createPicpeak } = require('../../src/services/picpeakExportService'));
}, 120000);
}, 60000);
afterAll(async () => {
await cleanup();
@@ -1,190 +0,0 @@
/**
* PostgreSQL integration tests for the .picpeak restore robustness fixes.
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway Postgres DB,
* e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_restore_test" \
* npx jest __tests__/integration/picpeakRestorePg.test.js
*
* Validates the Postgres-specific paths that SQLite can't exercise: identity
* sequences left stale by explicit-id inserts, pg_get_serial_sequence raising on
* id-less tables, reinject/role-recreate explicit-id inserts, and FK integrity.
*/
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('picpeak restore on Postgres', () => {
let pgDb;
let svc;
beforeAll(async () => {
pgDb = knex({ client: 'pg', connection: PG_URL });
await pgDb.raw('DROP TABLE IF EXISTS role_permissions, events, admin_users, roles, permissions, app_settings CASCADE');
await pgDb.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name', 50).notNullable().unique();
t.string('display_name', 100);
t.integer('priority').defaultTo(0);
t.boolean('is_system').defaultTo(false);
});
await pgDb.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name', 100).notNullable().unique();
t.string('display_name', 150);
t.string('category', 50);
});
await pgDb.schema.createTable('role_permissions', (t) => {
t.integer('role_id').notNullable().references('id').inTable('roles').onDelete('CASCADE');
t.integer('permission_id').notNullable().references('id').inTable('permissions').onDelete('CASCADE');
t.primary(['role_id', 'permission_id']);
});
await pgDb.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username').notNullable().unique();
t.string('email').notNullable().unique();
t.string('password_hash');
t.boolean('is_active').defaultTo(true);
t.boolean('must_change_password').defaultTo(false);
t.integer('role_id').references('id').inTable('roles').onDelete('SET NULL');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
await pgDb.schema.createTable('events', (t) => {
t.increments('id');
t.string('slug');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
});
await pgDb.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.json('setting_value');
t.string('setting_type');
t.timestamp('updated_at').defaultTo(pgDb.fn.now());
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
svc = require('../../src/services/picpeakImportService');
});
afterAll(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
if (pgDb) await pgDb.destroy();
});
beforeEach(async () => {
await pgDb('role_permissions').del();
await pgDb('events').del();
await pgDb('admin_users').del();
await pgDb('roles').del();
await pgDb('permissions').del();
});
test('resyncSequences fast-forwards stale sequences and skips id-less tables', async () => {
// Simulate a restore: explicit-id inserts leave the sequence at 1.
await pgDb('roles').insert([{ id: 5, name: 'super_admin', display_name: 'SA' }]);
await pgDb('admin_users').insert([{ id: 9, username: 'a', email: 'a@x.io', password_hash: 'h' }]);
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('role_permissions').insert([{ role_id: 5, permission_id: 3 }]); // id-less table
// Must not throw on role_permissions (no `id` column → pg_get_serial_sequence raises unguarded).
await expect(svc.resyncSequences(['roles', 'admin_users', 'permissions', 'role_permissions'])).resolves.toBeUndefined();
// Natural inserts (no explicit id) now avoid the restored ids.
const [adminId] = await pgDb('admin_users').insert({ username: 'b', email: 'b@x.io', password_hash: 'h' }).returning('id');
expect(Number(adminId.id || adminId)).toBe(10); // max(9)+1, no duplicate-key error
const [roleId] = await pgDb('roles').insert({ name: 'editor', display_name: 'Ed' }).returning('id');
expect(Number(roleId.id || roleId)).toBe(6);
});
test('reinjectCurrentAdmin insert branch works with a stale sequence (explicit max+1)', async () => {
await pgDb('admin_users').insert({ id: 9, username: 'backup', email: 'backup@x.io', password_hash: 'h' });
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, created_by: 42 };
await pgDb.transaction((trx) => svc.reinjectCurrentAdmin(trx, operator));
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
expect(op.id).toBe(10); // max(9)+1
expect(op.password_hash).toBe('OP');
expect(op.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
test('preserveOperatorRole re-creates a missing role on Postgres and keeps FK integrity', async () => {
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('roles').insert([{ id: 2, name: 'viewer', display_name: 'V' }]);
await pgDb('admin_users').insert({ id: 1, username: 'admin', email: 'op@x.io', password_hash: 'h', role_id: null });
const snapshot = { role: { name: 'super_admin', display_name: 'SA', priority: 100, is_system: true }, permissions: ['events.create', 'missing.perm'] };
await pgDb.transaction((trx) => svc.preserveOperatorRole(trx, 1, snapshot));
await svc.resyncSequences(['roles']); // post-commit, mirrors importFromPicpeak
const role = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(role).toBeTruthy();
const op = await pgDb('admin_users').where({ id: 1 }).first();
expect(op.role_id).toBe(role.id); // FK valid, operator not downgraded
const grants = await pgDb('role_permissions').where({ role_id: role.id }).pluck('permission_id');
expect(grants).toEqual([3]); // existing perm granted, missing.perm skipped
});
test('full replaceAllTables: cross-instance backup preserves the operator, role, FKs, and sequences', async () => {
// A backup from ANOTHER instance: omits the operator's email AND their
// super_admin role; uses explicit ids that leave sequences stale.
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pgtest-'));
const dataDir = path.join(staging, 'data');
fs.mkdirSync(dataDir);
const write = (t, rows) => fs.writeFileSync(path.join(dataDir, `${t}.ndjson`), rows.map((r) => JSON.stringify(r)).join('\n'));
write('roles', [{ id: 5, name: 'admin', display_name: 'Admin', priority: 50, is_system: true }]);
write('permissions', [{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
write('role_permissions', [{ role_id: 5, permission_id: 3 }]);
write('admin_users', [{ id: 9, username: 'backupadmin', email: 'backup@x.io', password_hash: 'h', role_id: 5, is_active: true }]);
write('events', [{ id: 2, slug: 'restored-ev', created_by: 9 }]);
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, role_id: 999, created_by: null };
const roleSnapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
const tables = ['roles', 'permissions', 'role_permissions', 'admin_users', 'events'];
// replaceAllTables isn't exported, so drive its exact transaction sequence
// (suspend FKs, wipe, batchInsert, reinject, preserve role) through the
// exported units against real Postgres.
const importSvc = svc;
await pgDb.transaction(async (trx) => {
await trx.raw('SET session_replication_role = \'replica\'');
for (const t of tables) await trx(t).del();
for (const t of tables) {
const rows = fs.readFileSync(path.join(dataDir, `${t}.ndjson`), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
if (rows.length) await trx.batchInsert(t, rows, 100);
}
const opId = await importSvc.reinjectCurrentAdmin(trx, operator);
await importSvc.preserveOperatorRole(trx, opId, roleSnapshot);
await trx.raw('SET session_replication_role = \'origin\'');
});
await importSvc.resyncSequences(tables);
// Operator preserved (inserted, since email absent from backup).
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
expect(op).toBeTruthy();
expect(op.password_hash).toBe('OP');
// super_admin role re-created and the operator bound to it.
const sa = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(sa).toBeTruthy();
expect(op.role_id).toBe(sa.id);
expect(await pgDb('role_permissions').where({ role_id: sa.id }).pluck('permission_id')).toEqual([3]);
// Restored event's created_by FK to the backup admin still valid.
const ev = await pgDb('events').where({ slug: 'restored-ev' }).first();
expect(ev.created_by).toBe(9);
// Sequences resynced → natural inserts don't collide.
const [newAdmin] = await pgDb('admin_users').insert({ username: 'fresh', email: 'fresh@x.io', password_hash: 'h' }).returning('id');
expect(Number(newAdmin.id || newAdmin)).toBeGreaterThan(op.id);
fs.rmSync(staging, { recursive: true, force: true });
});
});
@@ -28,7 +28,7 @@ beforeAll(async () => {
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
const role = await db('roles').where({ name: 'super_admin' }).first();
superAdminRoleId = role.id;
}, 120000);
}, 60000);
afterAll(async () => {
await cleanup();
@@ -1,465 +0,0 @@
/**
* Responsive preview tiers (#1095).
*
* A phone can display ~1170px at most, so the single 1920px preview ships
* roughly twice the bytes it can use on every lightbox swipe — and the
* lightbox prefetches neighbours, so a guest flicking through a wedding
* gallery on cellular pays that repeatedly.
*
* The width is whitelisted rather than free-form: every distinct value is a
* permanent cache entry on disk, so an open ?w= is an invitation to fill the
* volume with renditions nobody asked for.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-tiers-'));
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'tiers-test-secret';
process.env.STORAGE_PATH = path.join(tmpRoot, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
const sharp = require('sharp');
const imageProcessor = require('../../src/services/imageProcessor');
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup;
describe('preview tiers (#1095)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
});
describe('normalizeTierWidth', () => {
const { normalizeTierWidth, PREVIEW_WIDTHS, THUMBNAIL_WIDTHS } = imageProcessor;
it('accepts every advertised width', () => {
for (const w of PREVIEW_WIDTHS) {
expect(normalizeTierWidth(String(w), PREVIEW_WIDTHS)).toBe(w);
}
for (const w of THUMBNAIL_WIDTHS) {
expect(normalizeTierWidth(String(w), THUMBNAIL_WIDTHS)).toBe(w);
}
});
it('rejects anything not on the list', () => {
// The disk-filling cases: arbitrary sizes, and a caller walking a range.
for (const bad of ['999', '1921', '0', '-100', '99999']) {
expect(normalizeTierWidth(bad, PREVIEW_WIDTHS)).toBeNull();
}
});
it('rejects junk without throwing', () => {
// Straight off a query string, so it is whatever the client sent.
for (const bad of [undefined, null, '', 'abc', '12abc', {}, [], '1e3', 'NaN']) {
expect(normalizeTierWidth(bad, PREVIEW_WIDTHS)).toBeNull();
}
});
it('does not let a thumbnail width through the preview list', () => {
// The two lists are separate on purpose; 600 is a thumb tier, not a
// preview tier, and vice versa for 1280.
expect(normalizeTierWidth('600', PREVIEW_WIDTHS)).toBeNull();
expect(normalizeTierWidth('1280', THUMBNAIL_WIDTHS)).toBeNull();
});
});
describe('ensurePreviewImageAtWidth', () => {
async function seedPhoto() {
const [e] = await db('events').insert({
slug: `tier-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'tier',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `tier-${Math.random()}`,
expires_at: new Date().toISOString(),
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
// A real image on disk under STORAGE_PATH, since the managed branch
// resolves through storage rather than a mount.
const rel = `events/active/tier/${Math.random().toString(36).slice(2, 8)}.jpg`;
const abs = path.join(process.env.STORAGE_PATH, rel);
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
await sharp({ create: { width: 3000, height: 2000, channels: 3, background: { r: 10, g: 90, b: 160 } } })
.jpeg().toFile(abs);
const [p] = await db('photos').insert({
event_id: eventId,
filename: path.basename(rel),
path: rel.replace(/^events\/active\//, ''),
type: 'individual',
width: 3000,
height: 2000,
processing_status: 'complete',
source_origin: 'managed',
}).returning('id');
return db('photos').where({ id: typeof p === 'object' ? p.id : p }).first();
}
it('scopes keys by photo id so two galleries cannot collide', async () => {
// The leak: managed auto-imports keep camera basenames, so two events can
// each hold an IMG_0001.jpg. A tier is served straight from a cache hit
// without re-reading the source, so a shared key hands one gallery's
// photo to another.
const a = await seedPhoto();
const b = await seedPhoto();
await db('photos').where({ id: a.id }).update({ path: 'wedding-a/IMG_0001.jpg' });
await db('photos').where({ id: b.id }).update({ path: 'wedding-b/IMG_0001.jpg' });
const keyA = imageProcessor.previewTierKeys(await db('photos').where({ id: a.id }).first())[0];
const keyB = imageProcessor.previewTierKeys(await db('photos').where({ id: b.id }).first())[0];
expect(keyA).not.toBe(keyB);
expect(keyA).toContain(`p${a.id}_`);
expect(keyB).toContain(`p${b.id}_`);
});
it('derives every non-default tier key for cleanup', () => {
// Tiers live outside preview_path, so delete/archive/regenerate have no
// other way to find them. 1920 is excluded because that IS preview_path.
// Two candidates per width — the encoder picks `.jpg` or `.webp` and the
// cleanup list cannot know which without probing the source.
const keys = imageProcessor.previewTierKeys({ id: 5, path: 'e/a.jpg', source_origin: 'managed' });
const widths = imageProcessor.PREVIEW_WIDTHS.filter((w) => w !== 1920);
expect(keys).toHaveLength(widths.length * 2);
for (const w of widths) {
expect(keys).toContain(`previews/preview_w${w}_p5_a.jpg`);
expect(keys).toContain(`previews/preview_w${w}_p5_a.webp`);
}
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
expect(keys.every((k) => k.includes('p5_'))).toBe(true);
});
it('deletePreviewTiers removes generated tiers from storage', async () => {
const photo = await seedPhoto();
const key = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
const abs = path.join(process.env.STORAGE_PATH, key);
expect(fs.existsSync(abs)).toBe(true);
await imageProcessor.deletePreviewTiers(await db('photos').where({ id: photo.id }).first());
expect(fs.existsSync(abs)).toBe(false);
});
it('produces a distinct key per width and never touches preview_path', async () => {
const photo = await seedPhoto();
const small = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
expect(small).toContain('preview_w640_');
// The extra tiers are cache, not state. Writing them to the row would
// mean the last size requested silently becomes "the" preview.
const row = await db('photos').where({ id: photo.id }).first();
expect(row.preview_path == null || !String(row.preview_path).includes('w640')).toBe(true);
});
it('resolves the default width to the canonical preview, not a w1920 copy', async () => {
// Otherwise every existing install grows a duplicate of every preview it
// already has, for no benefit.
const photo = await seedPhoto();
const def = await imageProcessor.ensurePreviewImageAtWidth(photo, 1920);
expect(def).not.toContain('preview_w1920_');
});
it('reuses the cached tier instead of regenerating', async () => {
const photo = await seedPhoto();
const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
expect(first).toBeTruthy();
const abs = path.join(process.env.STORAGE_PATH, first);
const before = (await fs.promises.stat(abs)).mtimeMs;
await new Promise((r) => setTimeout(r, 20));
const second = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
expect(second).toBe(first);
expect((await fs.promises.stat(abs)).mtimeMs).toBe(before);
});
it('actually resizes to the requested tier', async () => {
const photo = await seedPhoto();
const key = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
// 3000x2000 constrained to a 640 long edge.
expect(Math.max(meta.width, meta.height)).toBe(640);
expect(meta.height).toBe(Math.round(640 * (2000 / 3000)));
});
});
describe('thumbnail tiers', () => {
async function seedThumbPhoto(w = 3000, h = 2000) {
const [e] = await db('events').insert({
slug: `tt-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding', event_name: 'tt', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: `tt-${Math.random()}`,
expires_at: new Date().toISOString(),
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const rel = `events/active/tt/${Math.random().toString(36).slice(2, 8)}.jpg`;
const abs = path.join(process.env.STORAGE_PATH, rel);
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
await sharp({ create: { width: w, height: h, channels: 3, background: { r: 5, g: 5, b: 5 } } })
.jpeg().toFile(abs);
const [p2] = await db('photos').insert({
event_id: eventId, filename: path.basename(rel),
path: rel.replace(/^events\/active\//, ''), type: 'individual',
width: w, height: h, processing_status: 'complete', source_origin: 'managed',
}).returning('id');
return db('photos').where({ id: typeof p2 === 'object' ? p2.id : p2 }).first();
}
it('scopes thumbnail tier keys by photo id', async () => {
// Same cross-gallery hazard the preview tiers had: a cache hit serves
// without re-reading the source, so a shared basename leaks across events.
const keys = imageProcessor.thumbnailTierKeys({ id: 42, path: 'a/IMG_0001.jpg', source_origin: 'managed' });
expect(keys.every((k) => k.includes('p42_'))).toBe(true);
// Every width, canonical included: which one is canonical depends on the
// thumbnail_width setting, so on a 600-configured install w300 is the
// tier file. Deleting a key that was never written is a no-op; missing
// one strands it forever.
expect(keys).toHaveLength(3);
});
it('tags the tier against the configured width, not the 300 default', async () => {
// Regression: with thumbnail_width=600 a w=300 request wrote
// `thumb_<name>` while the caller probed `thumb_w300_<name>`. The cache
// never hit, so every request re-downloaded the original and ran Sharp,
// and the file it left behind was in no cleanup list.
await db('app_settings').where('setting_key', 'thumbnail_width')
.update({ setting_value: 600 });
try {
const photo = await seedThumbPhoto();
const first = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
expect(first).toContain('thumb_w300_');
// The second call must be a cache hit on the key the first one wrote.
const before = fs.statSync(path.join(process.env.STORAGE_PATH, first)).mtimeMs;
const second = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
expect(second).toBe(first);
expect(fs.statSync(path.join(process.env.STORAGE_PATH, second)).mtimeMs).toBe(before);
// ...and 600 is now the canonical, so it resolves to the plain thumbnail.
const canonical = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
expect(canonical).not.toContain('thumb_w600_');
// Cleanup still reaches the w300 tier this install actually generated.
expect(imageProcessor.thumbnailTierKeys(photo)).toContain(first);
} finally {
await db('app_settings').where('setting_key', 'thumbnail_width')
.update({ setting_value: 300 });
}
});
it('generates a tier at the requested size', async () => {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
expect(key).toContain('thumb_w600_');
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
expect(Math.max(meta.width, meta.height)).toBe(600);
});
it('does not upscale past the source, which is why the tier is clamped', async () => {
// The reason tileThumbnailWidth checks the short edge: ask a 400px
// source for 900 and withoutEnlargement caps it, so the request buys a
// Sharp run and a second cache entry for a file identical to the 300.
const small = await seedThumbPhoto(500, 400);
const key = await imageProcessor.ensureThumbnailAtWidth(small, 900);
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
expect(Math.max(meta.width, meta.height)).toBeLessThan(900);
});
it('resolves the canonical width to the normal thumbnail', async () => {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
expect(key).not.toContain('thumb_w300_');
});
it('keeps the configured aspect ratio instead of forcing a square', async () => {
// Thumbnails are square by default, but the settings API takes any
// width/height in 50..1000. With fit:'cover' a 300x200 canonical and a
// 600x600 tier are two different crops, so the photo would visibly
// reframe as the tile size changed.
await db('app_settings').where('setting_key', 'thumbnail_height')
.update({ setting_value: 200 });
try {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
expect(meta.width).toBe(600);
expect(meta.height).toBe(400); // 600 * (200/300), not 600
} finally {
await db('app_settings').where('setting_key', 'thumbnail_height')
.update({ setting_value: 300 });
}
});
it('never hands a video to Sharp', async () => {
// A video's thumbnail is a poster frame from videoProcessor, not a
// resize of the stored file. Without the short-circuit the tier path
// would download the whole video (withLocalCopy, in full on S3) and
// then fail to decode it — every request, since nothing caches a miss.
const photo = await seedThumbPhoto();
await db('photos').where({ id: photo.id })
.update({ media_type: 'video', mime_type: 'video/mp4' });
const video = await db('photos').where({ id: photo.id }).first();
const key = await imageProcessor.ensureThumbnailAtWidth(video, 900);
expect(key).not.toContain('thumb_w900_');
});
it('drops tiers when a rename moves the basename they are keyed on', async () => {
// The key embeds the basename, so the DB update in renamePhotoFiles is
// the point past which the old keys cannot be derived at all — a later
// delete or archive computes the new ones and leaves these behind.
const renameService = require('../../src/services/eventRenameService');
const photo = await seedThumbPhoto();
const event = await db('events').where({ id: photo.event_id }).first();
// Give it a filename the rename will actually rewrite.
const dir = path.join(process.env.STORAGE_PATH, 'events/active', event.slug, 'individual');
await fs.promises.mkdir(dir, { recursive: true });
await sharp({ create: { width: 1200, height: 900, channels: 3, background: { r: 7, g: 7, b: 7 } } })
.jpeg().toFile(path.join(dir, 'Old_Name_001.jpg'));
await db('photos').where({ id: photo.id }).update({
filename: 'Old_Name_001.jpg',
path: `${event.slug}/individual/Old_Name_001.jpg`,
});
const renamable = await db('photos').where({ id: photo.id }).first();
const key = await imageProcessor.ensureThumbnailAtWidth(renamable, 600);
const abs = path.join(process.env.STORAGE_PATH, key);
expect(fs.existsSync(abs)).toBe(true);
await renameService.renamePhotoFiles(
event.id, 'Old Name', 'New Name', event.slug, event.slug
);
expect(await db('photos').where({ id: photo.id }).first())
.toMatchObject({ filename: 'New_Name_001.jpg' });
expect(fs.existsSync(abs)).toBe(false);
});
it('leaves tiers alone when a rename does not move the basename', async () => {
// Four storage deletes per photo is 20k calls against S3 for a
// 5,000-photo event whose slug merely changed, so the sweep is gated on
// the filename actually moving.
const renameService = require('../../src/services/eventRenameService');
const photo = await seedThumbPhoto();
const event = await db('events').where({ id: photo.event_id }).first();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
const abs = path.join(process.env.STORAGE_PATH, key);
// The photo's filename carries no event-name prefix, so nothing moves.
await renameService.renamePhotoFiles(
event.id, 'Old Name', 'New Name', event.slug, event.slug
);
expect(fs.existsSync(abs)).toBe(true);
});
it('deleteThumbnailTiers removes them', async () => {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
const abs = path.join(process.env.STORAGE_PATH, key);
expect(fs.existsSync(abs)).toBe(true);
await imageProcessor.deleteThumbnailTiers(await db('photos').where({ id: photo.id }).first());
expect(fs.existsSync(abs)).toBe(false);
});
/**
* The crash in #1128 needed two things: a tier that disappears, and a
* reader that dies on it. The reader is fixed in streamResponse; this is
* the half that stops the file disappearing in the first place.
*/
describe('concurrent generation (#1128)', () => {
it('never leaves the tier absent once it has been published', async () => {
const photo = await seedThumbPhoto();
const key = imageProcessor.thumbnailTierKeys(photo).find((k) => k.includes('_w600_'));
const abs = path.join(process.env.STORAGE_PATH, key);
// A grid fires one request per tile at once, and on a cold gallery
// every one of them misses the cache. Previously each carried
// `regenerate: true`, whose first act is to DELETE the target — so a
// later arrival unlinked the file an earlier one had already published
// and handed to a reader.
const watcher = [];
const poll = setInterval(() => watcher.push(fs.existsSync(abs)), 1);
const results = await Promise.all(
Array.from({ length: 12 }, () => imageProcessor.ensureThumbnailAtWidth(photo, 600))
);
clearInterval(poll);
expect(results.every((r) => r === key)).toBe(true);
expect(fs.existsSync(abs)).toBe(true);
// Once true, never false again: no window where a validated file is gone.
const firstSeen = watcher.indexOf(true);
if (firstSeen !== -1) {
expect(watcher.slice(firstSeen).every(Boolean)).toBe(true);
}
});
it('runs one generation for a burst of requests, not one per request', async () => {
const photo = await seedThumbPhoto();
const thumbDir = path.join(process.env.STORAGE_PATH, 'thumbnails');
await fs.promises.mkdir(thumbDir, { recursive: true });
// Counted through the staging files LocalFsStorage writes:
// `<key>.tmp.<pid>.<hex>`, one per put, each a distinct random suffix.
// So distinct temp names == distinct generations, which is the thing
// the dedupe is supposed to collapse. (Spying on generateThumbnail
// would not work — ensureThumbnailAtWidth calls it through the
// module-local binding, so an export spy never sees it.)
const seen = new Set();
const poll = setInterval(() => {
for (const f of fs.readdirSync(thumbDir)) {
if (f.includes('_w900_') && f.includes('.tmp.')) seen.add(f);
}
}, 1);
const results = await Promise.all(
Array.from({ length: 8 }, () => imageProcessor.ensureThumbnailAtWidth(photo, 900))
);
clearInterval(poll);
const abs = path.join(
process.env.STORAGE_PATH,
imageProcessor.thumbnailTierKeys(photo).find((k) => k.includes('_w900_'))
);
expect(fs.existsSync(abs)).toBe(true);
expect(new Set(results).size).toBe(1);
// 8 requests, at most one Sharp pass. Before the dedupe this was 8 —
// and on an external photo, 8 full reads of the original.
expect(seen.size).toBeLessThanOrEqual(1);
});
it('does not cache a failure — a later request retries', async () => {
const photo = await seedThumbPhoto();
// Source removed underneath: generation fails and must not poison the
// key for the lifetime of the process.
const src = path.join(process.env.STORAGE_PATH, 'events/active', photo.path);
const saved = await fs.promises.readFile(src);
await fs.promises.unlink(src);
expect(await imageProcessor.ensureThumbnailAtWidth(photo, 600)).toBeNull();
await fs.promises.writeFile(src, saved);
expect(await imageProcessor.ensureThumbnailAtWidth(photo, 600)).toContain('_w600_');
});
});
});
});
@@ -1,298 +0,0 @@
/**
* PostgreSQL checks for product usage (#1110).
*
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway database, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_usage_pg_test" \
* npx jest __tests__/integration/productUsagePg.test.js
*
* What SQLite cannot answer:
* - `cancel_seq` and `sequence` are bigint, and node-postgres returns bigint
* as a STRING. The withdrawal guard compares that value, so a `'1' !== 1`
* slip would let an activation complete after an opt-out — and SQLite,
* which hands back a number, would never show it.
* - booleans are real booleans here, not 0/1, which is what every
* `configured` signal in a report is built from.
* - markUsed takes SELECT ... FOR UPDATE on this engine only.
*/
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('product usage on Postgres', () => {
let db;
let UsageService;
beforeAll(async () => {
// Its own schema, not `public`. CI hands every gated suite the same
// PICPEAK_PG_TEST_URL and runs jest with parallel workers, and both
// picpeakRestorePg and externalRelpathFoldPg drop and recreate `events`
// and `app_settings` there. Sharing that would have made all three
// intermittently destroy each other's fixtures. The service queries
// unqualified table names, so a searchPath keeps it entirely in here.
const bootstrap = knex({
client: 'pg', connection: PG_URL, pool: { min: 0, max: 2 }
});
await bootstrap.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE');
await bootstrap.raw('CREATE SCHEMA usage_pg_test');
await bootstrap.destroy();
db = knex({
client: 'pg',
connection: PG_URL,
searchPath: ['usage_pg_test'],
pool: { min: 0, max: 10 }
});
// The real migrations, on the real engine.
await require('../../migrations/core/201_product_usage').up(db);
await require('../../migrations/core/202_product_usage_cancel_requested').up(db);
await require('../../migrations/core/203_product_usage_cancel_seq').up(db);
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
await require('../../migrations/core/205_product_usage_consent_version').up(db);
await require('../../migrations/core/206_product_usage_delivery_backoff').up(db);
await require('../../migrations/core/212_product_usage_prompt_shown').up(db);
await db.schema.createTable('app_settings', (t) => {
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
});
await db.schema.createTable('feature_flags', (t) => {
t.string('key').primary(); t.boolean('value');
});
await db.schema.createTable('events', (t) => {
t.increments('id'); t.text('color_theme'); t.string('external_path'); t.integer('css_template_id');
});
await db.schema.createTable('css_templates', (t) => {
t.increments('id'); t.boolean('is_enabled'); t.text('css_content');
});
for (const table of ['email_configs', 'mail_accounts']) {
await db.schema.createTable(table, (t) => { t.increments('id'); t.string('smtp_host'); });
}
await db.schema.createTable('whatsapp_configs', (t) => {
t.increments('id'); t.boolean('enabled'); t.string('phone_number_id'); t.string('access_token');
});
({ UsageService } = require('../../src/usage/UsageService'));
}, 120000);
afterAll(async () => {
if (db) {
await db.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE');
await db.destroy();
}
fs.rmSync(bindingDir, { recursive: true, force: true });
});
beforeEach(async () => {
await db('product_usage_markers').delete();
await db('product_usage_state').delete();
await db('product_usage_state').insert({ id: 1 });
await db('events').delete();
await db('css_templates').delete();
await db('feature_flags').delete();
await db('app_settings').delete();
});
// The instance-binding file defaults to STORAGE_PATH, which is '/storage'
// in a bare test process. Point it at a temp dir so the real binding code
// runs rather than being stubbed out.
const bindingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-usage-pg-'));
const service = (over = {}) =>
new UsageService(db, {
secret: 'p'.repeat(48),
endpoint: 'http://127.0.0.1:9/',
bindingPath: path.join(bindingDir, 'usage-instance.key'),
fetch: async () => { throw new Error('collector unreachable in tests'); },
...over,
});
it('creates the columns with the types the code expects', async () => {
const cols = await db('product_usage_state').columnInfo();
expect(cols.cancel_seq).toBeDefined();
expect(cols.cancel_requested).toBeUndefined(); // dropped by 203
expect(cols.sequence).toBeDefined();
expect(cols.privacy_receipts).toBeDefined();
expect(cols.consent_version).toBeDefined();
// next_attempt_at is a bigint like sequence and cancel_seq, so pg hands it
// back as a STRING — the tick() gate compares it against a number.
expect(cols.attempts).toBeDefined();
expect(cols.next_attempt_at).toBeDefined();
expect(cols.prompt_shown).toBeDefined();
});
it('backfills the prompt for existing participation using PostgreSQL booleans', async () => {
const migration = require('../../migrations/core/212_product_usage_prompt_shown');
await migration.down(db);
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
await migration.up(db);
await migration.up(db);
expect(await service().status()).toMatchObject({ status: 'active', prompt_shown: true, consent_version: 'usage-consent.v2' });
await db('product_usage_state').where({ id: 1 }).update({ status: 'disabled' });
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true });
});
it('persists a fresh installation declining without opting in on PostgreSQL', async () => {
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: false });
await service().markPromptShown();
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
});
it('reruns the backoff migration safely', async () => {
const migration = require('../../migrations/core/206_product_usage_delivery_backoff');
await migration.up(db);
await migration.up(db);
const row = await db('product_usage_state').where({ id: 1 }).first();
expect(Number(row.attempts)).toBe(0);
expect(Number(row.next_attempt_at)).toBe(0);
});
it('honours the retry gate even though pg returns next_attempt_at as a string', async () => {
let clock = 5_000_000;
let calls = 0;
const identity = generateIdentity();
const client = service({
now: () => clock,
fetch: async () => { calls += 1; throw new Error('collector unreachable'); },
});
await db('product_usage_state').where({ id: 1 }).update({
status: 'active',
consent_version: 'usage-consent.v2',
installation_id: identity.installation_id,
public_key: identity.public_key,
private_key_encrypted: client.encrypt(identity.private_key),
sequence: 1,
attempts: 0,
next_attempt_at: 0,
pending_packet: JSON.stringify(makePacket(identity, 'session', 2, {}, 'usage.v2')),
});
await client.tick();
expect(calls).toBe(1);
const paced = await db('product_usage_state').where({ id: 1 }).first();
// A '5000120000' > 5000000 string comparison would be a different answer.
expect(typeof paced.next_attempt_at).toBe('string');
await client.tick();
expect(calls).toBe(1);
clock = Number(paced.next_attempt_at) + 1;
await client.tick();
expect(calls).toBe(2);
await db('product_usage_state').where({ id: 1 }).update({
status: 'disabled', pending_packet: null, attempts: 0, next_attempt_at: 0,
});
});
it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => {
const migration = require('../../migrations/core/204_product_usage_privacy_receipts');
await db('product_usage_state').where({ id: 1 }).update({
last_receipt: JSON.stringify({ status: 'accepted', session_token: 'synthetic-old-token' })
});
await migration.up(db);
await migration.up(db);
const row = await db('product_usage_state').where({ id: 1 }).first();
expect(JSON.parse(row.last_receipt)).toEqual({ status: 'accepted' });
});
it('migration preserves v1 consent and v2 snapshot works with PostgreSQL booleans and optional modules', async () => {
const migration = require('../../migrations/core/205_product_usage_consent_version');
await migration.up(db); await migration.up(db);
const svc = service();
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
await svc.markUsed(['video_uploads']);
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
expect((await svc.status()).schema_version).toBe('usage.v1');
await db('product_usage_state').where({ id: 1 }).update({ consent_version: 'usage-consent.v2' });
await db('feature_flags').insert({ key: 'quotes', value: true });
await db('app_settings').insert({ setting_key: 'general_allowed_file_types', setting_value: '"dng,mp4"' });
await svc.markUsed(['video_uploads', 'gallery_downloads']);
const report = await svc.snapshot();
expect(Object.keys(report.features)).toHaveLength(73);
expect(report.features.video_uploads).toEqual({ configured: true, used: true });
expect(report.features.camera_raw_uploads).toEqual({ configured: true, used: false });
expect(report.features.gallery_downloads).toEqual({ configured: false });
expect(report.features.crm.configured).toBe(true);
expect(report.features.api_integration.configured).toBe(false);
});
it('reads bigint cancel_seq correctly even though pg returns it as a string', async () => {
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 5 });
const row = await db('product_usage_state').where({ id: 1 }).first();
// The thing SQLite hides: this is a string here.
expect(typeof row.cancel_seq).toBe('string');
expect(Number(row.cancel_seq)).toBe(5);
});
it('honours a withdrawal that lands while an activation is starting', async () => {
const svc = service();
const realBinding = svc.binding.bind(svc);
svc.binding = async (create = false) => {
// The withdrawal lands inside the window where the row still reads
// `disabled`, with the real binding write still happening.
if (create) await svc.disable();
return realBinding(create);
};
await svc.enable('usage-consent.v1');
const row = await db('product_usage_state').where({ id: 1 }).first();
expect(row.status).toBe('disabled');
expect(row.installation_id).toBeNull();
});
it('activates when no withdrawal arrives', async () => {
await service().enable('usage-consent.v1');
const row = await db('product_usage_state').where({ id: 1 }).first();
expect(row.status).toBe('activation_pending');
expect(row.installation_id).not.toBeNull();
});
it('records markers only while active, using SELECT ... FOR UPDATE', async () => {
const svc = service();
await svc.markUsed(['crm']);
expect(await db('product_usage_markers').count('* as c').first()).toMatchObject({ c: '0' });
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
await svc.markUsed(['crm', 'newsletters']);
const rows = await db('product_usage_markers').pluck('feature');
expect(rows.sort()).toEqual(['crm', 'newsletters']);
// onConflict().ignore() must not throw on a repeat.
await svc.markUsed(['crm']);
expect((await db('product_usage_markers').pluck('feature')).length).toBe(2);
});
it('builds a report from real booleans, not 0/1', async () => {
await db('feature_flags').insert([
{ key: 'clients', value: true },
{ key: 'newsletters', value: false },
]);
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
await service().markUsed(['crm']);
const report = await service().snapshot();
expect(report.features.crm.configured).toBe(true);
expect(report.features.crm.used).toBe(true);
expect(report.features.newsletters.configured).toBe(false);
});
it('resolves preset layouts and template CSS on this engine too', async () => {
const [tpl] = await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' }).returning('id');
const templateId = typeof tpl === 'object' ? tpl.id : tpl;
await db('events').insert([
{ color_theme: 'modernMasonry' },
{ color_theme: null, css_template_id: templateId },
]);
await db('app_settings').insert({
setting_key: 'theme_config',
setting_value: JSON.stringify({ galleryLayout: 'carousel' }),
});
const report = await service().snapshot();
expect(report.gallery_layouts.sort()).toEqual(['carousel', 'masonry']);
expect(report.features.custom_css.configured).toBe(true);
});
});
@@ -1,396 +0,0 @@
/**
* Publish without notifying, and send the gallery email later (#1235).
*
* Publishing queued the gallery_created email whenever any customer email
* existed, with no opt-out — so a photographer with no address yet had to type
* their OWN into the required field, publish, receive the client-facing email
* themselves, and hand the link over by DM. That is the workaround this
* removes.
*
* The send-later half is the part that makes it a workflow rather than a dead
* end: publishing quietly is only useful if the real email can go out once the
* address arrives.
*
* The default must not move. Every existing caller — the v1 API, an older
* frontend, a script — omits the flag entirely and must keep notifying.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const express = require('express');
const request = require('supertest');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-publish-quiet-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'publish-quiet-test-secret';
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.mock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
let db;
let cleanup;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = express();
app.use(express.json());
app.use('/admin/events', require('../../src/routes/adminEvents'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('email_queue').del();
await db('events').del();
});
async function seedDraft({ slug, customerEmail = 'client@example.com', isDraft = true } = {}) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `Event ${slug}`,
event_date: '2026-09-01',
host_email: customerEmail,
admin_email: 'admin@example.com',
customer_email: customerEmail,
password_hash: 'x',
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-token`,
require_password: 0,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: isDraft ? 1 : 0,
created_at: new Date().toISOString(),
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
const queuedFor = (eventId) =>
db('email_queue').where({ event_id: eventId, email_type: 'gallery_created' });
describe('publish quietly (#1235)', () => {
it('queues the gallery email by default — the flag being absent must not change anything', async () => {
const id = await seedDraft({ slug: 'default-publish' });
const res = await request(app).post(`/admin/events/${id}/publish`).send({});
expect(res.status).toBe(200);
expect(res.body.notified_customer).toBe(true);
expect(await queuedFor(id)).toHaveLength(1);
const event = await db('events').where({ id }).first();
expect(Number(event.is_draft)).toBe(0);
});
it('publishes without queuing anything when notify_customer is false', async () => {
const id = await seedDraft({ slug: 'quiet-publish' });
const res = await request(app)
.post(`/admin/events/${id}/publish`)
.send({ notify_customer: false });
expect(res.status).toBe(200);
expect(res.body.notified_customer).toBe(false);
// The whole point: live gallery, no email.
expect(await queuedFor(id)).toHaveLength(0);
const event = await db('events').where({ id }).first();
expect(Number(event.is_draft)).toBe(0);
});
it('sends the gallery email later, on demand', async () => {
const id = await seedDraft({ slug: 'send-later' });
await request(app).post(`/admin/events/${id}/publish`).send({ notify_customer: false });
expect(await queuedFor(id)).toHaveLength(0);
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
expect(res.status).toBe(200);
expect(res.body.recipient).toBe('client@example.com');
const queued = await queuedFor(id);
expect(queued).toHaveLength(1);
const data = JSON.parse(queued[0].email_data);
expect(data.event_name).toBe('Event send-later');
expect(data.gallery_link).toContain('send-later');
});
it('refuses to send the gallery email for a draft — the link would not work yet', async () => {
const id = await seedDraft({ slug: 'still-draft' });
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/draft/i);
expect(await queuedFor(id)).toHaveLength(0);
});
it('refuses to send when there is no recipient', async () => {
const [row] = await db('events').insert({
slug: 'no-email',
event_type: 'wedding',
event_name: 'No Email',
event_date: '2026-09-01',
host_email: '',
admin_email: 'admin@example.com',
customer_email: null,
password_hash: 'x',
share_link: '/gallery/no-email/share',
share_token: 'no-email-token',
require_password: 0,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
const id = typeof row === 'object' ? row.id : row;
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/no customer email/i);
});
it('still publishes a gallery that has no recipient at all', async () => {
const [row] = await db('events').insert({
slug: 'quiet-no-email',
event_type: 'wedding',
event_name: 'Quiet No Email',
event_date: '2026-09-01',
host_email: '',
admin_email: 'admin@example.com',
customer_email: null,
password_hash: 'x',
share_link: '/gallery/quiet-no-email/share',
share_token: 'quiet-no-email-token',
require_password: 0,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 1,
created_at: new Date().toISOString(),
}).returning('id');
const id = typeof row === 'object' ? row.id : row;
const res = await request(app)
.post(`/admin/events/${id}/publish`)
.send({ notify_customer: false });
expect(res.status).toBe(200);
const event = await db('events').where({ id }).first();
expect(Number(event.is_draft)).toBe(0);
});
it('refuses to send for an archived, inactive or expired gallery', async () => {
// The link in the email would be rejected by the gallery middleware, so
// sending it hands the customer a dead link with no explanation.
const cases = [
{ slug: 'arch-ev', patch: { is_archived: 1 }, match: /archived/i },
{ slug: 'inactive-ev', patch: { is_active: 0 }, match: /inactive/i },
{
slug: 'expired-ev',
patch: { expires_at: new Date(Date.now() - 3600 * 1000).toISOString() },
match: /expired/i,
},
];
for (const c of cases) {
const id = await seedDraft({ slug: c.slug, isDraft: false });
await db('events').where({ id }).update(c.patch);
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(c.match);
expect(await queuedFor(id)).toHaveLength(0);
}
});
it('carries the password the admin supplies, instead of the sentinel', async () => {
// password_hash is a hash, so the plaintext only exists in this request.
// Without it the email says "(set at creation)", which cannot get anyone
// into the gallery — and the send-later action is most useful right after
// a quiet publish, the path that never collected a password.
const id = await seedDraft({ slug: 'with-password', isDraft: false });
await db('events').where({ id }).update({ require_password: 1 });
const res = await request(app)
.post(`/admin/events/${id}/send-gallery-email`)
.send({ password: 'Sup3r-Secret' });
expect(res.status).toBe(200);
const [queued] = await queuedFor(id);
expect(JSON.parse(queued.email_data).gallery_password).toBe('Sup3r-Secret');
});
it('persists a changed password so the emailed one actually works', async () => {
// The dialog invites "or pick a new one". Queueing that plaintext without
// rehashing would email a password the gallery rejects — worse than the
// sentinel, because it looks usable.
const id = await seedDraft({ slug: 'rehash', isDraft: false });
await db('events').where({ id }).update({ require_password: 1, password_hash: 'stale-hash' });
const res = await request(app)
.post(`/admin/events/${id}/send-gallery-email`)
.send({ password: 'Brand-New-Pass1' });
expect(res.status).toBe(200);
const bcrypt = require('bcrypt');
const row = await db('events').where({ id }).first();
expect(row.password_hash).not.toBe('stale-hash');
expect(await bcrypt.compare('Brand-New-Pass1', row.password_hash)).toBe(true);
const [queued] = await queuedFor(id);
expect(JSON.parse(queued.email_data).gallery_password).toBe('Brand-New-Pass1');
});
it('does NOT touch the gallery password when only an account notice goes out', async () => {
// customer_gallery_assigned links to the customer portal and never carries
// a password. Rehashing for it would silently change the live gallery
// password and lock out everyone holding the old one, for nothing.
const [row] = await db('events').insert({
slug: 'account-only',
event_type: 'wedding',
event_name: 'Account Only',
event_date: '2026-09-01',
host_email: '',
admin_email: 'admin@example.com',
customer_email: null,
password_hash: 'original-hash',
require_password: 1,
share_link: '/gallery/account-only/share',
share_token: 'account-only-token',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
const id = typeof row === 'object' ? row.id : row;
const res = await request(app)
.post(`/admin/events/${id}/send-gallery-email`)
.send({ password: 'should-not-be-applied' });
// No inline recipient and no assigned accounts in this fixture, so the
// route refuses — but the password must be untouched either way.
expect(res.status).toBe(400);
const after = await db('events').where({ id }).first();
expect(after.password_hash).toBe('original-hash');
});
it('refuses to send when the only assigned account is passive', async () => {
// A passive customer (created directly, never invited) is active and has
// an address, but password_hash IS NULL — customerAuth rejects the login,
// so the customer_gallery_assigned portal link goes to a door that will
// not open. Reporting success here would leave the admin believing the
// customer was told.
const [evRow] = await db('events').insert({
slug: 'passive-only',
event_type: 'wedding',
event_name: 'Passive Only',
event_date: '2026-09-01',
host_email: '',
admin_email: 'admin@example.com',
customer_email: null,
password_hash: 'original-hash',
require_password: 1,
share_link: '/gallery/passive-only/share',
share_token: 'passive-only-token',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = typeof evRow === 'object' ? evRow.id : evRow;
const [custRow] = await db('customer_accounts').insert({
email: 'passive@example.com',
display_name: 'Passive Person',
password_hash: null, // never invited
is_active: 1,
created_at: new Date().toISOString(),
}).returning('id');
const customerId = typeof custRow === 'object' ? custRow.id : custRow;
await db('event_customer_assignments').insert({
event_id: eventId,
customer_account_id: customerId,
});
const res = await request(app)
.post(`/admin/events/${eventId}/send-gallery-email`)
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/no customer email/i);
});
it('applies the configured gallery policy before rehashing, on both doors', async () => {
// Both endpoints re-hash a plaintext the admin re-types, and both used to
// validate it with nothing but isLength({min:6}) — so the configured
// complexity governed creation and reset while these two accepted
// 'aaaaaa' and made it the live gallery password.
const draftId = await seedDraft({ slug: 'weak-publish' });
await db('events').where({ id: draftId }).update({ require_password: 1 });
const publishRes = await request(app)
.post(`/admin/events/${draftId}/publish`)
.send({ password: 'aaaaaa' });
expect(publishRes.status).toBe(400);
expect(publishRes.body.error).toMatch(/security requirements/i);
// And the same password must not sneak in through send-later, or a
// gallery published quietly could still be weakened afterwards.
const [row] = await db('events').insert({
slug: 'weak-send',
event_type: 'wedding',
event_name: 'Weak Send',
event_date: '2026-09-01',
host_email: '',
admin_email: 'admin@example.com',
customer_email: 'client@example.com',
password_hash: 'original-hash',
require_password: 1,
share_link: '/gallery/weak-send/share',
share_token: 'weak-send-token',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
const sendId = typeof row === 'object' ? row.id : row;
const sendRes = await request(app)
.post(`/admin/events/${sendId}/send-gallery-email`)
.send({ password: 'aaaaaa' });
expect(sendRes.status).toBe(400);
expect(sendRes.body.error).toMatch(/security requirements/i);
// Rejected means untouched — not rejected after the write.
const after = await db('events').where({ id: sendId }).first();
expect(after.password_hash).toBe('original-hash');
});
it('re-sending is allowed — a lost email should not need an unpublish/republish', async () => {
const id = await seedDraft({ slug: 'resend' });
await request(app).post(`/admin/events/${id}/publish`).send({});
expect(await queuedFor(id)).toHaveLength(1);
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
expect(res.status).toBe(200);
expect(await queuedFor(id)).toHaveLength(2);
});
});
@@ -1,256 +0,0 @@
/**
* Issue #866 — the createInvoice-free halves of the re-bill proof + CRM panel
* feature, against a real SQLite schema:
*
* • listCustomerRebills — status DERIVED from the linked invoice lifecycle
* (open / sent / paid; a cancelled/Storno'd cover drops back to open) plus
* cost-vs-rebilled math and mode.
* • collectRebillProofAttachments — the Send-dialog per-file selection, the
* all-or-none default resolution (per-customer override else global), the
* Beleg-<inv#> filename (suffix only when >1), and the missing-file marker.
*
* The invoice-MINTING paths (billCombinedForCustomer / billPendingRebills) call
* createInvoice inside a db.transaction, which deadlocks on the SQLite harness
* (global-db sequence write vs. held write lock) — same limitation the sibling
* incomingInvoiceRebill.test.js documents. They're covered by the existing
* billPendingRebills / billUnbilledEntries suites; here we hand-craft billed
* state instead.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(120000);
describe('#866 re-bill proof attachment + CRM panel', () => {
let db;
let cleanup;
let adminId;
let expenseService;
let rebillProofs;
let flagCache;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const dbModule = require('../../src/database/db');
dbModule.logActivity = async () => {};
({ adminId } = await seedMinimal(db));
expenseService = require('../../src/services/expenseService');
rebillProofs = require('../../src/services/invoice/rebillProofs');
flagCache = require('../../src/middleware/requireFeatureFlag');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
let seq = 0;
async function makeCustomer(overrides = {}) {
seq += 1;
const ins = await db('customer_accounts').insert({
email: `c866-${seq}@example.com`,
display_name: `C866 ${seq}`,
password_hash: 'x',
preferred_language: 'de',
is_active: 1,
billing_cadence: 'per_event',
created_at: new Date(),
...overrides,
}).returning('id');
return unwrapId(ins);
}
async function makeDoc(customerId, overrides = {}) {
const ins = await db('inbound_documents').insert({
source: 'upload', status: 'categorized', parse_status: 'parsed', parse_method: 'none',
supplier_name: 'ACME AG', currency: 'CHF', total_amount_minor: 10000,
invoice_date: '2026-06-01', disposition: 'rebill', customer_account_id: customerId,
created_at: new Date(), updated_at: new Date(),
...overrides,
}).returning('id');
return unwrapId(ins);
}
async function makeInvoice(customerId, status, number) {
const ins = await db('invoices').insert({
invoice_number: number,
customer_account_id: customerId,
status,
currency: 'CHF',
issue_date: '2026-06-01', due_date: '2026-07-01',
vat_rate: 0, net_amount_minor: 10000, vat_amount_minor: 0, total_amount_minor: 10000,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return unwrapId(ins);
}
describe('listCustomerRebills', () => {
it('derives open / sent / paid and open→cost==rebilled for passthrough, +markup for rebill', async () => {
const customerId = await makeCustomer();
// Open re-bill (10% markup): rebilled = 11000.
await makeDoc(customerId, { total_amount_minor: 10000, markup_type: 'percent', markup_percent: 10 });
// Open passthrough: no markup, rebilled == cost.
await makeDoc(customerId, { disposition: 'durchlaufend', total_amount_minor: 5000, markup_type: 'none' });
// Sent (on a 'sent' invoice).
const sentInv = await makeInvoice(customerId, 'sent', 'R-2026-0001');
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: sentInv });
// Paid.
const paidInv = await makeInvoice(customerId, 'paid', 'R-2026-0002');
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: paidInv });
// Cancelled cover → drops back to 'open', no invoice link surfaced.
const cancInv = await makeInvoice(customerId, 'cancelled', 'R-2026-0003');
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: cancInv });
const items = await expenseService.listCustomerRebills(customerId);
const byStatus = (s) => items.filter((r) => r.status === s);
expect(items).toHaveLength(5);
expect(byStatus('open')).toHaveLength(3); // 2 genuinely-open + 1 cancelled-cover
expect(byStatus('sent')).toHaveLength(1);
expect(byStatus('paid')).toHaveLength(1);
const rebill = items.find((r) => r.mode === 'rebill' && r.costMinor === 10000);
expect(rebill.rebilledMinor).toBe(11000);
const passthrough = items.find((r) => r.mode === 'passthrough');
expect(passthrough.rebilledMinor).toBe(passthrough.costMinor);
const sent = byStatus('sent')[0];
expect(sent.invoiceNumber).toBe('R-2026-0001');
expect(sent.invoiceId).toBe(sentInv);
const cancelledCover = items.find((r) => r.status === 'open' && r.invoiceNumber === null && r.costMinor === 8000);
expect(cancelledCover).toBeDefined(); // cancelled cover isn't shown as a live invoice link
});
});
describe('storno releases the re-bill linkage (#866 review)', () => {
it("clears billed_invoice_id so a Storno'd cover returns to the billable pool", async () => {
const invoiceService = require('../../src/services/invoiceService');
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'sent', 'R-2026-9000');
const lineIns = await db('invoice_line_items').insert({
invoice_id: invId, position: 1, quantity: 1, description: 'Rebill',
unit_price_minor: 8000, discount_percent: 0, line_total_minor: 8000,
}).returning('id');
const lineId = unwrapId(lineIns);
const docId = await makeDoc(customerId, {
total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: invId, billed_invoice_line_item_id: lineId,
});
// Storno claims a fresh number from document_sequences; the other tests
// seed explicit R-2026-000x numbers without advancing it, so push the
// counter past them to avoid a number collision (a test artifact — real
// invoices always claim through the sequence).
await db('document_sequences').insert({ kind: 'invoice', year: 2026, current_value: 9000, created_at: new Date(), updated_at: new Date() })
.onConflict(['kind', 'year']).ignore();
await db('document_sequences').where({ kind: 'invoice', year: 2026 }).update({ current_value: 9000 });
// Storno the covering invoice (the issued-cancel path).
await db.transaction(async (trx) => invoiceService.createStorno(invId, adminId, trx));
const doc = await db('inbound_documents').where({ id: docId }).first();
expect(doc.billed_invoice_id).toBeNull();
expect(doc.billed_invoice_line_item_id).toBeNull();
// It now surfaces as a genuinely-open item AND the pending pool picks it up.
const items = await expenseService.listCustomerRebills(customerId);
const row = items.find((r) => r.id === docId);
expect(row.status).toBe('open');
expect(row.invoiceId).toBeNull();
const pending = await db('inbound_documents')
.where({ customer_account_id: customerId }).whereNull('billed_invoice_id')
.whereIn('disposition', ['rebill', 'durchlaufend']).where('status', 'categorized');
expect(pending.map((p) => p.id)).toContain(docId);
});
});
describe('collectRebillProofAttachments', () => {
const businessDocs = () => path.join(process.env.STORAGE_PATH, 'business-docs', 'inbound', '2026');
async function enableIncoming() {
const existing = await db('feature_flags').where({ key: 'incomingInvoices' }).first();
if (existing) await db('feature_flags').where({ key: 'incomingInvoices' }).update({ value: 1 });
else await db('feature_flags').insert({ key: 'incomingInvoices', value: 1 });
flagCache.invalidateFeatureFlagCache();
}
function writeProof(name) {
fs.mkdirSync(businessDocs(), { recursive: true });
const p = path.join(businessDocs(), name);
fs.writeFileSync(p, '%PDF-1.4\n% test proof\n');
return p;
}
it('honours explicit selection, names Beleg-<inv#>, and marks a missing file', async () => {
await enableIncoming();
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-1000');
const invoice = await db('invoices').where({ id: invId }).first();
const good1 = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('p1.pdf') });
const good2 = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('p2.pdf') });
const missing = await makeDoc(customerId, { billed_invoice_id: invId, file_path: path.join(businessDocs(), 'nope.pdf') });
// Select the two good proofs → two attachments, suffixed because >1.
const both = await rebillProofs.collectRebillProofAttachments(invoice, null, [good1, good2]);
expect(both.map((a) => a.filename).sort()).toEqual(['Beleg-R-2026-1000-1.pdf', 'Beleg-R-2026-1000-2.pdf']);
// Select exactly one → single, unsuffixed.
const one = await rebillProofs.collectRebillProofAttachments(invoice, null, [good1]);
expect(one).toHaveLength(1);
expect(one[0].filename).toBe('Beleg-R-2026-1000.pdf');
// Select the missing-file doc → no attachment, but a marker is persisted.
const none = await rebillProofs.collectRebillProofAttachments(invoice, null, [missing]);
expect(none).toHaveLength(0);
const markerRow = await db('inbound_documents').where({ id: missing }).first('proof_attach_error');
expect(markerRow.proof_attach_error).toBeTruthy();
// A successful attach clears any prior marker.
await rebillProofs.collectRebillProofAttachments(invoice, null, [good1]);
const cleared = await db('inbound_documents').where({ id: good1 }).first('proof_attach_error');
expect(cleared.proof_attach_error).toBeNull();
});
it('resolves the all-or-none default from the per-customer override then global', async () => {
await enableIncoming();
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-2000');
const invoice = await db('invoices').where({ id: invId }).first();
await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('d1.pdf') });
// Global default off, no override → none.
const off = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: null }, undefined);
expect(off).toHaveLength(0);
// Per-customer override ON → all, regardless of the (off) global.
const on = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: true }, undefined);
expect(on).toHaveLength(1);
// Global ON (no override) → all.
await db('app_settings').insert({ setting_key: 'accounting_rebill_attach_proof', setting_value: JSON.stringify(true), setting_type: 'accounting' });
const globalOn = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: null }, undefined);
expect(globalOn).toHaveLength(1);
// Override OFF beats global ON.
const overrideOff = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: false }, undefined);
expect(overrideOff).toHaveLength(0);
});
it('attaches nothing when the incoming-invoices flag is off', async () => {
const existing = await db('feature_flags').where({ key: 'incomingInvoices' }).first();
if (existing) await db('feature_flags').where({ key: 'incomingInvoices' }).update({ value: 0 });
else await db('feature_flags').insert({ key: 'incomingInvoices', value: 0 });
flagCache.invalidateFeatureFlagCache();
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-3000');
const invoice = await db('invoices').where({ id: invId }).first();
const doc = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('f1.pdf') });
const res = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: true }, [doc]);
expect(res).toHaveLength(0);
});
});
});
@@ -1,298 +0,0 @@
/**
* scripts/regenerate-thumbnails.js against external photos (#1148).
*
* The same defect #1129 fixed in the admin route, still standing in the CLI
* fallback: the script resolved every source as
* `storage/events/active/<photo.path>` and fs.access'd it. External and
* reference rows do not live there — their originals sit under
* `events.external_path` — so every one failed the check and was counted as an
* error. On an install where all photos are external the script did nothing at
* all, while reporting one error per photo.
*
* Driven against a REAL file on a REAL external mount with the real
* imageProcessor, not a mock: the whole point is that the source resolves off
* the mount, and a mocked ensureThumbnail would assert nothing about that.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const sharp = require('sharp');
const { execFile } = require('child_process');
describe('regenerate-thumbnails script (#1148)', () => {
let tmpDir; let db; let cleanup; let regenerateThumbnails;
let eventId; let externalPhotoId; let videoPhotoId; let watcherVideoId; let repairPhotoId;
let vanishingPhotoId;
let externalRoot;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-script-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
// External sources are sandboxed under EXTERNAL_MEDIA_ROOT. Rows carry a
// path relative to that root (#1163), so the 'wedding/' prefix on each
// external_relpath below is the event folder, not decoration.
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpDir, 'media');
externalRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'wedding');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
await fs.promises.mkdir(externalRoot, { recursive: true });
jest.resetModules();
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
// A real image on the external mount — never under events/active.
await sharp({
create: { width: 1200, height: 800, channels: 3, background: { r: 10, g: 90, b: 160 } },
}).jpeg().toFile(path.join(externalRoot, 'shot.jpg'));
const [ev] = await db('events').insert({
slug: 'regen-script-event',
event_type: 'wedding',
event_name: 'Regen Script',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: '/gallery/regen-script-event/share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
source_mode: 'reference',
external_path: 'wedding',
created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [p] = await db('photos').insert({
event_id: eventId,
filename: 'shot.jpg',
// `path` is what the old script joined onto events/active. Left
// populated on purpose: the fix must ignore it for an external row.
path: 'regen-script-event/shot.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: 'wedding/shot.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
externalPhotoId = typeof p === 'object' ? p.id : p;
const [v] = await db('photos').insert({
event_id: eventId,
filename: 'clip.mp4',
path: 'regen-script-event/clip.mp4',
type: 'individual',
media_type: 'video',
mime_type: 'video/mp4',
source_origin: 'external',
external_relpath: 'wedding/clip.mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
videoPhotoId = typeof v === 'object' ? v.id : v;
// How fileWatcher.processNewPhoto actually writes a video: `type` and
// `mime_type` set, media_type left to its 'image' default. A media_type-only
// filter lets this through and hands the container to Sharp.
//
// The file has to EXIST, otherwise the row fails resolution and looks
// skipped for the wrong reason — the bug is Sharp being handed a video, not
// a missing source. Real MP4 header bytes, no image in sight.
await fs.promises.writeFile(
path.join(externalRoot, 'watched.mp4'),
Buffer.from('00000018667479706d70343200000000', 'hex')
);
const [wv] = await db('photos').insert({
event_id: eventId,
filename: 'watched.mp4',
path: 'regen-script-event/watched.mp4',
type: 'video',
mime_type: 'video/mp4',
source_origin: 'external',
external_relpath: 'wedding/watched.mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
watcherVideoId = typeof wv === 'object' ? wv.id : wv;
expect((await db('photos').where('id', watcherVideoId).first()).media_type).not.toBe('video');
// A photo whose thumbnail_path points at something that is no longer there.
await sharp({
create: { width: 900, height: 600, channels: 3, background: { r: 200, g: 40, b: 40 } },
}).jpeg().toFile(path.join(externalRoot, 'repair.jpg'));
const [rp] = await db('photos').insert({
event_id: eventId,
filename: 'repair.jpg',
path: 'regen-script-event/repair.jpg',
type: 'individual',
thumbnail_path: 'thumbnails/thumb_ext_missing_repair.jpg',
source_origin: 'external',
external_relpath: 'wedding/repair.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
repairPhotoId = typeof rp === 'object' ? rp.id : rp;
// A photo whose source will be removed after its canonical thumbnail is
// cached — the "mount went away" case, where the canonical rendition is
// served from cache but a tier still needs to read the original.
await sharp({
create: { width: 1000, height: 700, channels: 3, background: { r: 30, g: 140, b: 60 } },
}).jpeg().toFile(path.join(externalRoot, 'vanishing.jpg'));
const [vp] = await db('photos').insert({
event_id: eventId,
filename: 'vanishing.jpg',
path: 'regen-script-event/vanishing.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: 'wedding/vanishing.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
vanishingPhotoId = typeof vp === 'object' ? vp.id : vp;
({ regenerateThumbnails } = require('../../scripts/regenerate-thumbnails'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
it('builds a thumbnail for an external photo instead of erroring on events/active', async () => {
// The location the old script computed and fs.access'd. Nothing is there,
// which is the whole defect — it is not where an external original lives.
// (The old script cannot be driven from a test directly: it had no export
// and ran on require, calling process.exit. Making it importable is part
// of this fix.)
const legacyPath = path.join(process.env.STORAGE_PATH, 'events/active', 'regen-script-event/shot.jpg');
expect(fs.existsSync(legacyPath)).toBe(false);
const result = await regenerateThumbnails(eventId, { tiers: false });
// The old script reported an error for this photo and wrote nothing.
expect(result.errorCount).toBe(0);
// The external photo, the repair row and the vanishing one; no video.
expect(result.successCount).toBe(3);
const row = await db('photos').where('id', externalPhotoId).first();
expect(row.thumbnail_path).toBeTruthy();
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
expect(fs.existsSync(onDisk)).toBe(true);
// Named per-photo so two events referencing one NAS basename cannot
// clobber each other — the property ensureThumbnail owns and the reason
// the script must not build this name itself.
expect(path.basename(row.thumbnail_path)).toContain(`ext${externalPhotoId}_`);
});
it('leaves videos alone', async () => {
// A video thumbnail is a poster frame from videoProcessor; handing the
// container to Sharp produced one error per video row.
const row = await db('photos').where('id', videoPhotoId).first();
expect(row.thumbnail_path).toBeFalsy();
});
it('leaves a watcher-imported video alone, which carries no media_type', async () => {
// fileWatcher writes type + mime_type and lets media_type default to
// 'image', so filtering on media_type alone still fed these to Sharp. The
// signal is errorCount: the images are already done by now, so the only
// thing that can fail this run is a video reaching Sharp.
const result = await regenerateThumbnails(eventId, { tiers: false });
expect(result.errorCount).toBe(0);
const row = await db('photos').where('id', watcherVideoId).first();
expect(row.thumbnail_path).toBeFalsy();
});
it('is idempotent — a second run skips instead of rebuilding', async () => {
const before = await db('photos').where('id', externalPhotoId).first();
const result = await regenerateThumbnails(eventId, { tiers: false });
expect(result.errorCount).toBe(0);
expect(result.successCount).toBe(0);
expect(result.skipCount).toBe(3);
const after = await db('photos').where('id', externalPhotoId).first();
expect(after.thumbnail_path).toBe(before.thumbnail_path);
});
it('counts a repaired thumbnail as generated, not skipped', async () => {
// Both images are valid at this point. Destroy ONE thumbnail object while
// leaving thumbnail_path pointing at it — the corrupt/missing case.
const row = await db('photos').where('id', repairPhotoId).first();
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
await fs.promises.rm(onDisk);
const result = await regenerateThumbnails(eventId, { tiers: false });
// On local and external storage the rebuilt key is identical, so inferring
// "skipped" from an unchanged path reports this repair as already valid —
// the one number an operator running this is actually reading.
expect(result.successCount).toBe(1);
expect(result.skipCount).toBe(2);
expect(result.errorCount).toBe(0);
expect(fs.existsSync(onDisk)).toBe(true);
});
it('backfills the responsive tiers, which is what a backfill is for', async () => {
// The tiers (#1095/#1109) are cached separately from thumbnail_path, so a
// gallery can hold every canonical rendition and still serve phones the
// full-size image. The old script only ever produced `thumb_<filename>` at
// a hard-coded 300px and could not backfill them at all.
const { THUMBNAIL_WIDTHS } = require('../../src/services/imageProcessor');
const imageRows = 3; // external, repaired and vanishing; videos excluded
const result = await regenerateThumbnails(eventId, { tiers: true });
expect(result.errorCount).toBe(0);
expect(result.tierCount).toBe(THUMBNAIL_WIDTHS.length * imageRows);
expect(result.tierFailures).toBe(0);
});
it('reports tiers it could not build instead of claiming success', async () => {
// ensureThumbnailAtWidth handles the expected failures itself and returns
// NULL rather than throwing — an unreachable mount, a storage write that
// did not land. A try/catch alone never sees those, so the run counted
// zero errors and printed a clean summary after backfilling nothing.
//
// Reproduced the honest way: cache the canonical rendition, then take the
// source away. The canonical is served from cache; the tiers still need
// the original.
const row = await db('photos').where('id', vanishingPhotoId).first();
expect(row.thumbnail_path).toBeTruthy();
const { deleteThumbnailTiers } = require('../../src/services/imageProcessor');
await deleteThumbnailTiers(row).catch(() => {});
await fs.promises.rm(path.join(externalRoot, 'vanishing.jpg'));
const result = await regenerateThumbnails(eventId, { tiers: true });
expect(result.tierFailures).toBeGreaterThan(0);
// Still not an error against the photo: the canonical rendition is intact
// and the gallery falls back to it.
expect(result.errorCount).toBe(0);
});
/** Run the CLI the way cron does, and hand back its exit status. */
const runCli = (args = []) => new Promise((resolve) => {
execFile(
process.execPath,
[path.join(__dirname, '..', '..', 'scripts', 'regenerate-thumbnails.js'), ...args],
{ env: { ...process.env }, cwd: path.join(__dirname, '..', '..') },
(error, stdout, stderr) => resolve({ code: error?.code ?? 0, stdout, stderr })
);
});
it('exits nonzero when work was left unfinished', async () => {
// Exit status is the only thing a cron job reads. `vanishing.jpg` still
// has no source, so its tiers cannot be built.
const failed = await runCli([String(eventId)]);
expect(failed.code).toBe(1);
expect(failed.stderr).toContain('completed with failures');
}, 120000);
it('exits zero when there is nothing left to do', async () => {
// Same event with tiers switched off: every canonical rendition is already
// valid, so a clean run must not cry wolf at automation.
const ok = await runCli([String(eventId), '--no-tiers']);
expect(ok.code).toBe(0);
expect(ok.stdout).toContain('Script completed successfully');
}, 120000);
});
@@ -13,14 +13,14 @@ const { execFileSync } = require('child_process');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(60000);
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -1,406 +0,0 @@
/**
* Reveal mode integration tests (#838).
*
* Pins the contract:
* - effective visibility is computed at request time (isGalleryHidden):
* reveal_at in the past opens the gate even before the scheduler stamps
* - /photos returns the event shell with photos: [] + hidden_until_reveal
* for plain guests; slideshow / client / admin-preview see everything
* - image + download endpoints 403 with GALLERY_HIDDEN for plain guests
* - the guest upload route is NOT gated (uploading while hidden is the point)
* - the scheduler stamps revealed_at for due events, exactly once
* - POST /events/:id/reveal stamps revealed_at (idempotent, 400 when the
* mode is off); re-enabling reveal_mode clears revealed_at (re-hide)
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'reveal-test-secret';
const SLUG = 'reveal-test-event';
describe('Reveal mode (#838)', () => {
let db;
let cleanup;
let app;
let eventId;
let photoIds;
let adminToken;
const { isGalleryHidden } = require('../../src/utils/revealMode');
const galleryToken = (extra = {}) => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Reveal Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'reveal-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_user_uploads: 1,
reveal_mode: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
photoIds = [];
for (let i = 0; i < 2; i++) {
const p = await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `events/reveal/${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(p[0]?.id ?? p[0]);
}
// Super admin for the admin routes.
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'reveal-admin',
email: 'reveal-admin@example.com',
password_hash: await bcrypt.hash('RevealAdmin123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'reveal-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/secure-images', require('../../src/routes/secureImages'));
app.use('/api/images', require('../../src/routes/protectedImages'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('effective visibility math (isGalleryHidden)', () => {
const base = { reveal_mode: true, revealed_at: null, reveal_at: null };
it('is hidden while armed and unrevealed, visible otherwise', () => {
expect(isGalleryHidden({ ...base })).toBe(true);
expect(isGalleryHidden({ ...base, reveal_mode: false })).toBe(false);
expect(isGalleryHidden({ ...base, revealed_at: new Date() })).toBe(false);
// reveal_at in the past opens the gate WITHOUT any stamp — time-exact.
expect(isGalleryHidden({ ...base, reveal_at: new Date(Date.now() - 60_000) })).toBe(false);
expect(isGalleryHidden({ ...base, reveal_at: new Date(Date.now() + 60_000) })).toBe(true);
// SQLite 0/1 booleans
expect(isGalleryHidden({ reveal_mode: 1, revealed_at: null, reveal_at: null })).toBe(true);
expect(isGalleryHidden({ reveal_mode: 0, revealed_at: null, reveal_at: null })).toBe(false);
});
});
describe('gallery routes while hidden', () => {
it('/photos gives plain guests the shell with no photos and the flag', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(true);
expect(res.body.photos).toEqual([]);
expect(res.body.categories).toEqual([]);
expect(res.body.event.event_name).toBe('Reveal Test');
});
it('/photos serves the slideshow token everything (surprise beamer)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'slideshow' })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('/photos serves client access everything (host review)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'client' })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('/photos serves the admin preview everything (new transport: ?admin_preview=1 + admin cookie, even with a coexisting gallery session)', async () => {
// #868/#981: reveal-mode hiding is bypassed for an admin preview via the
// new transport (explicit flag + httpOnly admin_token cookie), NOT the
// retired ?preview=<jwt>. The coexisting gallery Bearer must not shadow it.
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos?admin_preview=1`)
.set('Cookie', [`admin_token=${adminToken}`])
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('image and download endpoints 403 with GALLERY_HIDDEN for plain guests', async () => {
for (const url of [
`/api/gallery/${SLUG}/thumbnail/${photoIds[0]}`,
`/api/gallery/${SLUG}/photo/${photoIds[0]}`,
`/api/gallery/${SLUG}/download/${photoIds[0]}`,
`/api/gallery/${SLUG}/download-all`,
`/api/gallery/${SLUG}/stats`,
`/api/gallery/${SLUG}/hero/${photoIds[0]}`,
]) {
const res = await request(app).get(url).set('Authorization', `Bearer ${galleryToken()}`);
expect(`${url}:${res.status}`).toBe(`${url}:403`);
expect(res.body.code).toBe('GALLERY_HIDDEN');
}
});
it('image endpoints are NOT reveal-blocked for the slideshow token', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/thumbnail/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'slideshow' })}`);
// The seeded file doesn't exist on disk, so anything but the reveal
// gate's 403 is fine here.
expect(res.body.code).not.toBe('GALLERY_HIDDEN');
});
it('/info exposes the effective hidden state without auth', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/info`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(true);
});
it('the guest upload route is not gated', async () => {
const res = await request(app)
.post(`/api/gallery/${eventId}/upload`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({});
// Fails later for other reasons (no multipart body) — but never on the
// reveal gate.
expect(res.body.code).not.toBe('GALLERY_HIDDEN');
});
it('legacy protected-image routes are reveal-gated for plain guests', async () => {
for (const [method, url] of [
['get', `/api/images/${SLUG}/photo/${photoIds[0]}/view`],
['post', `/api/images/${SLUG}/photo/${photoIds[0]}/generate-secure-token`],
['post', `/api/images/${SLUG}/photo/${photoIds[0]}/generate-url`],
]) {
const res = await request(app)[method](url).set('Authorization', `Bearer ${galleryToken()}`);
expect(`${url}:${res.status}`).toBe(`${url}:403`);
expect(res.body.code).toBe('GALLERY_HIDDEN');
}
});
it('feedback endpoints are reveal-gated; my-feedback degrades to empty', async () => {
// Feedback must be enabled for the routes to get past their own gate.
await db('event_feedback_settings').insert({
event_id: eventId, feedback_enabled: 1, allow_likes: 1,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
});
const getRes = await request(app)
.get(`/api/gallery/${SLUG}/photos/${photoIds[0]}/feedback`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(getRes.status).toBe(403);
expect(getRes.body.code).toBe('GALLERY_HIDDEN');
const postRes = await request(app)
.post(`/api/gallery/${SLUG}/photos/${photoIds[0]}/feedback`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ feedback_type: 'like' });
expect(postRes.status).toBe(403);
expect(postRes.body.code).toBe('GALLERY_HIDDEN');
const mine = await request(app)
.get(`/api/gallery/${SLUG}/my-feedback`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(mine.status).toBe(200);
expect(mine.body).toEqual([]);
});
it('secure-image token minting is reveal-gated for plain guests', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ photoId: photoIds[0] });
expect(res.status).toBe(403);
expect(res.body.code).toBe('GALLERY_HIDDEN');
});
it('customer-portal tokens (via:customer, no accessLevel) bypass reveal mode', async () => {
const acct = await db('customer_accounts').insert({
email: 'portal-customer@example.com',
password_hash: 'x',
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id');
const customerId = acct[0]?.id ?? acct[0];
await db('event_customer_assignments').insert({
event_id: eventId,
customer_account_id: customerId,
});
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ via: 'customer', customerId })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('a reveal_at in the past opens the gate without any stamp', async () => {
await db('events').where('id', eventId).update({ reveal_at: new Date(Date.now() - 60_000).toISOString() });
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
await db('events').where('id', eventId).update({ reveal_at: null });
});
});
describe('scheduler and admin reveal', () => {
it('the scheduler stamps revealed_at for due events exactly once', async () => {
const revealAt = new Date(Date.now() - 5 * 60_000);
await db('events').where('id', eventId).update({ reveal_at: revealAt.toISOString(), revealed_at: null });
const { checkScheduledReveals } = require('../../src/services/revealScheduler');
await checkScheduledReveals();
const asMs = (v) => new Date(v).getTime();
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).not.toBeNull();
expect(asMs(row.revealed_at)).toBe(revealAt.getTime());
expect(row.reveal_at).toBeNull(); // schedule consumed, like "Reveal now"
// Second pass no-ops (revealed_at already set).
await checkScheduledReveals();
const again = await db('events').where('id', eventId).first();
expect(asMs(again.revealed_at)).toBe(revealAt.getTime());
await db('events').where('id', eventId).update({ reveal_at: null, revealed_at: null });
});
it('POST /:id/reveal stamps revealed_at, clears the schedule, and is idempotent', async () => {
await db('events').where('id', eventId).update({ reveal_at: new Date(Date.now() + 3600_000).toISOString() });
const res = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.revealed_at).toBeTruthy();
// "Reveal now" consumes the pending schedule.
const cleared = await db('events').where('id', eventId).first();
expect(cleared.reveal_at).toBeNull();
const first = res.body.revealed_at;
const res2 = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res2.status).toBe(200);
expect(res2.body.revealed_at).toBe(first);
// Guests see photos now.
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(false);
expect(gallery.body.photos).toHaveLength(2);
});
it('re-enabling reveal_mode clears revealed_at (re-hide)', async () => {
await db('events').where('id', eventId).update({ reveal_mode: 0 });
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
});
it('scheduling a FUTURE reveal on a revealed gallery re-arms hiding', async () => {
// State: revealed (previous tests). Saving a future schedule re-hides.
await db('events').where('id', eventId).update({ revealed_at: new Date().toISOString() });
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true, reveal_at: new Date(Date.now() + 3600_000).toISOString() });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
await db('events').where('id', eventId).update({ reveal_at: null });
});
it('re-arming without a schedule clears a stale PAST reveal_at', async () => {
// Legacy/partial-API state: revealed with the old past schedule still
// stored. {reveal_mode:false} then {reveal_mode:true} without
// reveal_at must re-hide, not instantly re-open via the stale date.
await db('events').where('id', eventId).update({
reveal_mode: 0,
revealed_at: new Date().toISOString(),
reveal_at: new Date(Date.now() - 3600_000).toISOString(),
});
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
expect(row.reveal_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
expect(gallery.body.photos).toEqual([]);
});
it('POST /:id/reveal 400s while reveal mode is off', async () => {
await db('events').where('id', eventId).update({ reveal_mode: 0, revealed_at: null });
const res = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(400);
await db('events').where('id', eventId).update({ reveal_mode: 1 });
});
});
});
@@ -27,7 +27,7 @@ beforeAll(async () => {
setupService = require('../../src/services/setupService');
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
}, 120000);
}, 60000);
afterAll(async () => {
await cleanup();
@@ -105,133 +105,6 @@ describe('setupService (first-run bootstrap)', () => {
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
});
it('restores 0600 on a token file that already existed with looser permissions (#1218)', async () => {
// fs.writeFileSync's `mode` applies only when the file is created, so
// writing over a 0644 file left the first-admin credential group- and
// world-readable while the code claimed otherwise. On a NAS the volume is
// often a shared mount, which is exactly where that matters.
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
fs.writeFileSync(canonical, 'stale\n', { mode: 0o644 });
fs.chmodSync(canonical, 0o644);
expect(fs.statSync(canonical).mode & 0o777).toBe(0o644);
const token = await setupService.ensureSetupToken();
expect(fs.readFileSync(canonical, 'utf8').trim()).toBe(token);
expect(fs.statSync(canonical).mode & 0o777).toBe(0o600);
});
it('never publishes a token it cannot make private (#1218)', async () => {
// The CIFS/SMB case this targets: the mount carries no Unix modes, so
// chmod is a silent no-op. The check runs on the temporary file, before
// the rename, so a credential that cannot be protected never reaches the
// published path at all.
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
try { fs.unlinkSync(canonical); } catch (_) { /* start clean */ }
const chmodSpy = jest.spyOn(fs, 'chmodSync').mockImplementation(() => {});
const realLstat = fs.lstatSync;
const lstatSpy = jest.spyOn(fs, 'lstatSync').mockImplementation((target, ...rest) => {
const st = realLstat(target, ...rest);
return String(target).includes('SETUP_TOKEN')
? { ...st, mode: (st.mode & ~0o777) | 0o644 }
: st;
});
try {
const token = await setupService.ensureSetupToken();
// Setup stays completable — server.js prints the token on stdout when no
// file was written — but nothing readable was left on the volume.
expect(token).toBeTruthy();
expect(fs.existsSync(canonical)).toBe(false);
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
} finally {
chmodSpy.mockRestore();
lstatSpy.mockRestore();
}
});
it('keeps the token out of the log files when no private copy is possible (#1218)', async () => {
// LOG_DIR is on the same mount as the token in the all-in-one image, so
// logging the credential would put it in combined.log — as readable as the
// file we just refused to leave, and it outlives setup. stdout is the
// fallback instead, which server.js prints.
const logger = require('../../src/utils/logger');
try { fs.unlinkSync(path.join(tmpDir, 'SETUP_TOKEN')); } catch (_) { /* start clean */ }
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
const chmodSpy = jest.spyOn(fs, 'chmodSync').mockImplementation(() => {});
const realStat = fs.lstatSync;
const statSpy = jest.spyOn(fs, 'lstatSync').mockImplementation((target, ...rest) => {
const st = realStat(target, ...rest);
// The mode check runs on the temporary file, so match the prefix.
return String(target).includes('SETUP_TOKEN')
? { ...st, mode: (st.mode & ~0o777) | 0o644 }
: st;
});
try {
const token = await setupService.ensureSetupToken();
const logged = warnSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(logged).toMatch(/could not write a private setup token file/i);
expect(logged).not.toContain(token);
} finally {
warnSpy.mockRestore();
chmodSpy.mockRestore();
statSpy.mockRestore();
}
});
it('tells the startup banner where the token went, so it is never printed (#1218)', async () => {
// server.js prints the token itself only when no file was written. If this
// reports nothing after a successful write, the banner takes that failure
// branch and puts the live credential into stdout and `docker logs` beside
// a perfectly good 0600 file.
const token = await setupService.ensureSetupToken();
expect(token).toBeTruthy();
expect(setupService.writtenSetupTokenFile()).toBe(path.join(tmpDir, 'SETUP_TOKEN'));
});
it('lets a second worker publish without disturbing the first (#1218)', async () => {
// The shipped PM2 cluster config runs several workers against one DATA_DIR.
// Publishing through rename means they simply overwrite the same value in
// turn — no shared inode to race, and neither worker can end up reporting
// nothing written and printing the live token to its own log.
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
const first = await setupService.ensureSetupToken();
expect(setupService.writtenSetupTokenFile()).toBe(canonical);
const second = await setupService.ensureSetupToken();
expect(second).toBe(first);
expect(setupService.writtenSetupTokenFile()).toBe(canonical);
expect(fs.readFileSync(canonical, 'utf8').trim()).toBe(first);
expect(fs.statSync(canonical).mode & 0o777).toBe(0o600);
// No temporary files left lying about.
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
});
it('revokes the token when it cannot replace an exposed file (#1218)', async () => {
// A restart reuses the token from the database, so a file left at the
// token path may hold the live value. If it cannot be replaced — an
// ACL-backed or read-only directory — that credential is out of our
// control, and /setup/admin would go on accepting it.
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
await setupService.ensureSetupToken();
const renameSpy = jest.spyOn(fs, 'renameSync').mockImplementation(() => {
const err = new Error('EACCES'); err.code = 'EACCES'; throw err;
});
try {
expect(await setupService.ensureSetupToken()).toBeNull();
expect(await getAppSetting('setup_token')).toBeFalsy();
// And the temporary file did not survive the failure.
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
} finally {
renameSpy.mockRestore();
try { fs.unlinkSync(canonical); } catch (_) { /* may be gone */ }
}
});
it('refuses to create a second admin (setup already complete)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
@@ -1,424 +0,0 @@
/**
* The shared colour tag (#1197).
*
* A third identity model, requested in #1178: not "everyone shares a device's
* state" but "everyone, on any device, shares the PHOTO's state". One
* identity-less colour tag per photo, and whoever writes last wins.
*
* Stored as an ordinary photo_feedback row under a reserved identifier rather
* than as a column on photos, which is what keeps the per-colour tallies, the
* filters, the moderation queue and the XMP/CSV export working unchanged: the
* tally simply has exactly one entry.
*
* The mode is scoped to the colour tag. Likes, ratings and the rest stay
* per-guest, so the last test here is as important as the first.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
const { SHARED_COLOR_LABEL_IDENTITY } = require('../../src/constants/colorLabels');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'shared-tag-secret';
const SLUG = 'shared-color-tag';
describe('shared colour tag (#1197)', () => {
let db; let cleanup; let app; let feedbackService;
let eventId; let photoId; let otherPhotoId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
// Two different devices: distinct UA strings give distinct
// generateGuestIdentifier hashes, which is exactly how `simple` mode tells
// two anonymous guests apart.
const asGuest = (ua) => ({ 'Authorization': `Bearer ${galleryToken()}`, 'User-Agent': ua });
const tag = (ua, color, id = photoId) => request(app)
.post(`/api/gallery/${SLUG}/photos/${id}/feedback`)
.set(asGuest(ua))
.send({ feedback_type: 'color_label', color_label: color });
const photosFor = async (ua, query = '') => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos${query}`)
.set(asGuest(ua));
expect(res.status).toBe(200);
return Array.isArray(res.body) ? res.body : res.body.photos;
};
const photoFor = async (ua) => (await photosFor(ua)).find((p) => p.id === photoId);
const feedbackFor = async (ua) => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
.set(asGuest(ua));
expect(res.status).toBe(200);
return res.body;
};
const sharedRows = () => db('photo_feedback').where({
photo_id: photoId, feedback_type: 'color_label',
guest_identifier: SHARED_COLOR_LABEL_IDENTITY,
});
const setMode = (mode) => db('event_feedback_settings')
.where({ event_id: eventId }).update({ identity_mode: mode });
const setSharing = (on) => db('event_feedback_settings')
.where({ event_id: eventId }).update({ show_feedback_to_guests: on });
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
feedbackService = require('../../src/services/feedbackService');
const [ev] = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Shared Colour Tag',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'shared-tag-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [p] = await db('photos').insert({
event_id: eventId, filename: 'a.jpg', path: 'events/shared/a.jpg',
type: 'individual', uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = typeof p === 'object' ? p.id : p;
const [p2] = await db('photos').insert({
event_id: eventId, filename: 'b.jpg', path: 'events/shared/b.jpg',
type: 'individual', uploaded_at: new Date().toISOString(),
}).returning('id');
otherPhotoId = typeof p2 === 'object' ? p2.id : p2;
await db('event_feedback_settings').insert({
event_id: eventId, feedback_enabled: true, allow_likes: true,
allow_color_labels: true, moderate_comments: false,
show_feedback_to_guests: true, identity_mode: 'shared',
});
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('photo_feedback').where({ event_id: eventId }).del();
await db('photos').where('event_id', eventId).update({ color_label_count: 0, like_count: 0 });
await setMode('shared');
await setSharing(true);
});
describe('one tag per photo, last write wins', () => {
it('lets a second guest overwrite the first guest\'s colour', async () => {
// The request in the reporter's words: "if guest A marks a photo green
// and guest B later marks the same photo orange, the shared tag simply
// becomes orange".
expect((await tag('device-A', 'green')).status).toBe(200);
expect((await tag('device-B', 'blue')).status).toBe(200);
const rows = await sharedRows().select('color_label');
expect(rows).toHaveLength(1);
expect(rows[0].color_label).toBe('blue');
});
it('shows the same tag to a guest who never set one', async () => {
await tag('device-A', 'green');
// Different device, different identifier — in simple mode this would
// read back null, which is the whole reason the mode exists.
expect((await photoFor('device-B')).my_color_label).toBe('green');
});
it('stores no attribution against the tag', async () => {
await tag('device-A', 'green');
const row = await sharedRows().first();
expect(row.guest_id).toBeFalsy();
expect(row.guest_name).toBeFalsy();
expect(row.guest_email).toBeFalsy();
});
it('clears the tag when any guest re-sends the colour already on it', async () => {
await tag('device-A', 'green');
// B, not A: clearing is not owned by whoever set it.
expect((await tag('device-B', 'green')).status).toBe(200);
expect(await sharedRows().first()).toBeFalsy();
expect((await photoFor('device-A')).my_color_label).toBeFalsy();
});
it('leaves exactly one tag when two guests write at the same instant', async () => {
// The race the transaction exists for: both writers read "no tag", both
// insert, and the photo ends up carrying two shared tags — a per-guest
// tally in the one mode that is supposed to have none.
await Promise.all([
tag('device-A', 'green'),
tag('device-B', 'red'),
tag('device-C', 'blue'),
]);
const rows = await sharedRows().select('color_label');
expect(rows).toHaveLength(1);
expect(['green', 'red', 'blue']).toContain(rows[0].color_label);
});
it('keeps the tag on the photo it was set on', async () => {
await tag('device-A', 'green');
await tag('device-B', 'red', otherPhotoId);
const photos = await photosFor('device-C');
expect(photos.find((p) => p.id === photoId).my_color_label).toBe('green');
expect(photos.find((p) => p.id === otherPhotoId).my_color_label).toBe('red');
});
});
describe('the aggregates keep their existing shape', () => {
it('reports the tally as a single colour with a count of one', async () => {
await tag('device-A', 'green');
await tag('device-B', 'red');
// Unchanged consumers — grid badge, XMP export, admin filter — read
// this map and dominantColorLabel() over it. One entry, so the dominant
// colour is simply the tag.
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({ red: 1 });
const photo = await db('photos').where('id', photoId).first();
expect(photo.color_label_count).toBe(1);
});
it('does not render the tag a second time as another viewer\'s dot', async () => {
await tag('device-A', 'green');
// The shared row is not filed under device-B, so without the mode check
// it would come back as "someone else's label" and the tile would show
// the same green twice — once as the badge, once as a dot beside it.
const photo = await photoFor('device-B');
expect(photo.my_color_label).toBe('green');
expect(photo.other_color_labels || []).toHaveLength(0);
});
});
describe('with show_feedback_to_guests off', () => {
it('still shows the tag — it is the photo\'s state, not someone else\'s opinion', async () => {
await tag('device-A', 'green');
await setSharing(false);
expect((await photoFor('device-B')).my_color_label).toBe('green');
expect((await feedbackFor('device-B')).my_feedback.color_label).toBe('green');
});
it('still hides the per-colour tallies', async () => {
await tag('device-A', 'green');
await setSharing(false);
expect((await feedbackFor('device-B')).color_labels).toEqual({});
});
it('answers a colour filter from the shared tag', async () => {
await tag('device-A', 'green');
await setSharing(false);
// The aggregate half of this filter is gated on sharing; in shared mode
// the tag counts as the viewer's own, so the filter still works.
const filtered = await photosFor('device-B', '?filter=color:green');
expect(filtered.map((p) => p.id)).toEqual([photoId]);
});
});
describe('switching modes is not destructive', () => {
it('ignores per-guest labels while shared, and gives them back afterwards', async () => {
await setMode('simple');
await tag('device-A', 'green');
await tag('device-B', 'red');
const perGuestRows = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'color_label' }).count('* as c').first();
expect(Number(perGuestRows.c)).toBe(2);
await setMode('shared');
// Nothing collapsed, nothing guessed: the shared tag starts empty.
expect((await photoFor('device-A')).my_color_label).toBeFalsy();
expect(await sharedRows().first()).toBeFalsy();
await setMode('simple');
// ...and every original mark is exactly where its owner left it.
expect((await photoFor('device-A')).my_color_label).toBe('green');
expect((await photoFor('device-B')).my_color_label).toBe('red');
});
it('keeps the shared tag intact across a round trip through simple mode', async () => {
await tag('device-A', 'green');
await setMode('simple');
await setMode('shared');
expect((await photoFor('device-B')).my_color_label).toBe('green');
});
// "Kept but not shown" has to mean every surface, not just the badge. The
// dormant set is still sitting in photo_feedback, so a read that does not
// say which set it means will happily count, tally, filter and export it.
describe('the dormant set stays out of every reading of the live one', () => {
const seedDormantPerGuestLabels = async () => {
await setMode('simple');
await tag('device-A', 'green');
await tag('device-B', 'red', otherPhotoId);
await setMode('shared');
};
it('keeps dormant labels out of the per-colour tallies', async () => {
await seedDormantPerGuestLabels();
// The lightbox renders this map. Green belongs to a per-guest row the
// mode does not use, so the photo reads as untagged.
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({});
await tag('device-C', 'blue');
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({ blue: 1 });
});
it('keeps dormant labels out of color_label_count', async () => {
await seedDormantPerGuestLabels();
await feedbackService.updatePhotoFeedbackStats(photoId);
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
await tag('device-C', 'blue');
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
});
it('keeps dormant labels out of the admin grid and the XMP/CSV export', async () => {
await seedDormantPerGuestLabels();
// getEventColorLabelCounts feeds dominant_color_label, which is what
// the admin badge shows and what xmp:Label round-trips into Lightroom.
const map = await feedbackService.getEventColorLabelCounts(eventId, [photoId, otherPhotoId]);
expect(map[photoId]).toBeUndefined();
expect(map[otherPhotoId]).toBeUndefined();
});
it('keeps dormant labels out of the raw feedback list too', async () => {
// The per-colour tallies were scoped, but the endpoint also returns the
// rows themselves. Those carried both sets, so a dormant per-guest
// label was still visible to anyone reading the list — and with
// sharing off it came back flagged as the caller's own.
await seedDormantPerGuestLabels();
const body = await feedbackFor('device-A');
expect(body.feedback.filter((f) => f.feedback_type === 'color_label')).toHaveLength(0);
expect(body.my_feedback.color_label).toBeFalsy();
await tag('device-A', 'blue');
const after = await feedbackFor('device-B');
const labels = after.feedback.filter((f) => f.feedback_type === 'color_label');
expect(labels.map((f) => f.color_label)).toEqual(['blue']);
});
it('does not answer a guest colour filter from a dormant label', async () => {
await seedDormantPerGuestLabels();
expect(await photosFor('device-A', '?filter=color:green')).toHaveLength(0);
});
it('does not show the shared tag as another guest\'s dot after switching back', async () => {
await tag('device-A', 'green');
await setMode('simple');
// The shared row belongs to nobody, so in a per-guest mode it is not
// "another viewer's label" either — it is simply not in play.
const photo = await photoFor('device-B');
expect(photo.my_color_label).toBeFalsy();
expect(photo.other_color_labels || []).toHaveLength(0);
});
it('recounts the stored per-photo counter when the mode changes', async () => {
// color_label_count is denormalized and recomputed on feedback writes.
// A mode switch changes which rows are live without any write, so
// without an explicit recount the tiles and the admin summary keep
// reporting the old mode's totals until each photo is touched again —
// on a finished gallery, never.
await setMode('simple');
await tag('device-A', 'green');
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
await feedbackService.updateEventFeedbackSettings(eventId, { identity_mode: 'shared' });
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
await feedbackService.updateEventFeedbackSettings(eventId, { identity_mode: 'simple' });
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
});
it('does not count the shared tag as a participant', async () => {
// feedback_count is COUNT(DISTINCT guest identity) and is exported as
// rating_count. The shared tag has a reserved identifier rather than a
// person's, so counting it made tagging a photo look like a second
// guest had left feedback — and inflated the exported rating count.
await request(app)
.post(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
.set(asGuest('device-A'))
.send({ feedback_type: 'rating', rating: 5 });
expect((await db('photos').where('id', photoId).first()).feedback_count).toBe(1);
await tag('device-B', 'green');
expect((await db('photos').where('id', photoId).first()).feedback_count).toBe(1);
});
it('keeps dormant labels out of the event feedback summary', async () => {
await seedDormantPerGuestLabels();
// Feeds the admin analytics total_feedback and the guest
// /feedback-summary response.
const summary = await feedbackService.getEventFeedbackSummary(eventId);
expect(Number(summary.stats.total_color_labels)).toBe(0);
await tag('device-C', 'blue');
expect(Number((await feedbackService.getEventFeedbackSummary(eventId)).stats.total_color_labels)).toBe(1);
});
it('does not count the shared tag once the event is back on per-guest labels', async () => {
await tag('device-A', 'green');
await setMode('simple');
await feedbackService.updatePhotoFeedbackStats(photoId);
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({});
});
});
});
describe('the mode is scoped to the colour tag', () => {
it('keeps likes per-guest in shared mode', async () => {
const like = (ua) => request(app)
.post(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
.set(asGuest(ua))
.send({ feedback_type: 'like' });
expect((await like('device-A')).status).toBe(200);
expect((await photoFor('device-A')).is_liked).toBe(true);
// B has not liked it. If 'shared' leaked past colour labels, this would
// come back true and B's click would un-like A's like.
expect((await photoFor('device-B')).is_liked).toBe(false);
await like('device-B');
const photo = await db('photos').where('id', photoId).first();
expect(photo.like_count).toBe(2);
});
});
describe('the reserved identity cannot be claimed', () => {
it('refuses a per-guest write that arrives under it', async () => {
// Not reachable through the routes — a guest identifier is either a
// sha256 hex or a server-minted UUID — but a future caller must not be
// able to write the photo's shared tag as if it were their own.
await expect(feedbackService.submitFeedback(
photoId, eventId,
{ feedback_type: 'color_label', color_label: 'green' },
SHARED_COLOR_LABEL_IDENTITY,
)).rejects.toThrow('Reserved guest identifier');
});
});
});
@@ -1,150 +0,0 @@
/**
* Slideshow photo source (#1015).
*
* The bug: with `lightbox_preview_enabled` off (the default), /photos emitted
* `preview_url: null`, so the slideshow's `preview_url || hero_url || url`
* chain fell through to `hero_url` — a 1920x1080 `fit: 'cover'` centre crop
* meant for gallery header banners. With the "Black Bars (No crop)" fit the
* show then letterboxed an already-cropped frame: portrait photos lost their
* top and bottom and the setting looked broken.
*
* The contract pinned here: `slideshow_url` points at the aspect-preserved
* preview tier and is emitted for image photos REGARDLESS of the lightbox
* toggle, so the slideshow never has a reason to reach for `hero_url`.
* `preview_url` itself must stay gated — the lightbox opt-in is unchanged.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-src-test-secret';
const SLUG = 'slideshow-source-event';
describe('Slideshow photo source (#1015)', () => {
let db;
let cleanup;
let app;
let eventId;
let imagePhotoId;
let videoPhotoId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const setLightboxPreview = async (on) => {
await db('app_settings').where({ setting_key: 'lightbox_preview_enabled' }).del();
await db('app_settings').insert({
setting_key: 'lightbox_preview_enabled',
setting_value: JSON.stringify(on),
setting_type: 'general',
updated_at: new Date().toISOString(),
});
};
const fetchPhotos = async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`)
.expect(200);
return res.body.photos;
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Slideshow Source Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'slideshow-source-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const img = await db('photos').insert({
event_id: eventId,
filename: 'portrait.jpg',
path: 'events/slideshow-source/portrait.jpg',
type: 'individual',
mime_type: 'image/jpeg',
uploaded_at: new Date().toISOString(),
}).returning('id');
imagePhotoId = img[0]?.id ?? img[0];
const vid = await db('photos').insert({
event_id: eventId,
filename: 'clip.mp4',
path: 'events/slideshow-source/clip.mp4',
type: 'individual',
media_type: 'video',
mime_type: 'video/mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
videoPhotoId = vid[0]?.id ?? vid[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('emits slideshow_url for image photos even when lightbox previews are OFF', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.slideshow_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
// The regression: this is what used to be null, pushing the show to hero.
expect(image.preview_url).toBeNull();
});
it('leaves preview_url gated so the lightbox opt-in is unchanged', async () => {
await setLightboxPreview(true);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.preview_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
expect(image.slideshow_url).toBe(image.preview_url);
});
it('never points the slideshow at the cover-cropped hero tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
// hero_url still ships (the gallery header uses it) — it just must not be
// what the slideshow resolves to.
expect(image.hero_url).toBe(`/api/gallery/${SLUG}/hero/${imagePhotoId}`);
expect(image.slideshow_url).not.toBe(image.hero_url);
});
it('emits slideshow_url: null for videos, which have no preview tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const video = photos.find((p) => p.id === videoPhotoId);
expect(video.slideshow_url).toBeNull();
});
});
@@ -1,136 +0,0 @@
/**
* SQLite epoch-timestamp normalization (#485 follow-up).
*
* On SQLite, timestamp columns written with a raw `new Date()` through knex
* hold epoch-millisecond numbers. Postgres returns ISO strings, so frontend
* code written against Postgres calls parseISO() and crashes on native
* (SQLite) installs — the exact class fixed for admin Users in #485, which
* listed api tokens / photos / activity as an out-of-scope follow-up.
*
* Pins:
* - gallery /photos serializes uploaded_at / captured_at as ISO strings
* even when the row holds an epoch number (pre-fix archive restores)
* - the api-tokens list serializes created_at / expires_at / last_used_at /
* revoked_at as ISO strings for epoch-stored rows
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'epoch-test-secret';
const SLUG = 'epoch-test-event';
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
describe('SQLite epoch timestamp normalization', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Epoch Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'epoch-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
// The pre-fix corruption shape: epoch numbers in timestamp columns.
await db('photos').insert({
event_id: eventId,
filename: 'restored.jpg',
path: 'events/epoch/restored.jpg',
type: 'individual',
uploaded_at: Date.now() - 3600_000,
captured_at: Date.now() - 7200_000,
});
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'epoch-admin',
email: 'epoch-admin@example.com',
password_hash: await bcrypt.hash('EpochAdmin123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'epoch-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
await db('api_tokens').insert({
name: 'epoch-token',
hashed_token: 'x'.repeat(64),
preview: 'pk_test…abcd',
scopes: JSON.stringify(['events:read']),
created_by: rootId,
created_at: Date.now() - 86400_000,
last_used_at: Date.now() - 3600_000,
revoked_at: Date.now() - 60_000,
});
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/admin/api-tokens', require('../../src/routes/adminApiTokens'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('gallery /photos serializes epoch-stored uploaded_at/captured_at as ISO strings', async () => {
const galleryToken = jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken}`);
expect(res.status).toBe(200);
expect(res.body.photos).toHaveLength(1);
const photo = res.body.photos[0];
expect(typeof photo.uploaded_at).toBe('string');
expect(photo.uploaded_at).toMatch(ISO_RE);
expect(photo.captured_at).toMatch(ISO_RE);
});
it('api-tokens list serializes epoch-stored timestamps as ISO strings', async () => {
const res = await request(app)
.get('/api/admin/api-tokens')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
const token = res.body.find((t) => t.name === 'epoch-token');
expect(token).toBeTruthy();
for (const field of ['created_at', 'last_used_at', 'revoked_at']) {
expect(`${field}:${typeof token[field]}`).toBe(`${field}:string`);
expect(token[field]).toMatch(ISO_RE);
}
});
});
@@ -1,96 +0,0 @@
const knex = require('knex');
const jwt = require('jsonwebtoken');
const { randomUUID } = require('crypto');
const migration = require('../../migrations/core/211_revocations_without_expiry');
for (const client of ['sqlite3', 'pg']) {
const enabled = client !== 'pg' || process.env.PICPEAK_PG_TEST_URL;
(enabled ? describe : describe.skip)(`token revocation expiry (${client})`, () => {
let db, owner, schema, revocation;
const sign = claims => jwt.sign({ id: 1, type: 'admin', jti: randomUUID(), ...claims }, process.env.JWT_SECRET);
beforeAll(async () => {
if (client === 'pg') {
schema = `revocation_${randomUUID().replace(/-/g, '')}`;
owner = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL });
await owner.schema.createSchema(schema);
db = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL, searchPath: [schema] });
} else {
db = knex({ client, connection: { filename: ':memory:' }, useNullAsDefault: true });
}
// Exercise the upgrade from the real legacy NOT NULL schema as well as
// repeated migration runs, without sharing another test's database.
await require('../../migrations/legacy/017_add_token_revocation_tables').up(db);
await db('revoked_tokens').insert({ token_id: 'existing', expires_at: '2099-01-01T00:00:00.000Z' });
await migration.up(db);
await migration.up(db);
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
revocation = require('../../src/utils/tokenRevocation');
});
afterAll(async () => {
await db?.destroy();
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
jest.dontMock('../../src/database/db');
});
it('preserves existing revocations and their unique key during upgrade', async () => {
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
await expect(db('revoked_tokens').insert({ token_id: 'existing', expires_at: null })).rejects.toThrow();
});
it.each([true, false])('permanently revokes a token without exp (jti: %s)', async withJti => {
const token = sign(withJti ? {} : { jti: undefined });
const payload = jwt.verify(token, process.env.JWT_SECRET);
expect(await revocation.isTokenRevoked(payload)).toBe(false);
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
await revocation.cleanupExpiredRevocations();
expect(await revocation.isTokenRevoked(payload)).toBe(true);
const rows = await db('revoked_tokens').where({ token_id: revocation.buildTokenId(payload) });
expect(rows).toHaveLength(1);
expect(rows[0].expires_at).toBeNull();
});
it('cleans up expired revocations and retains future ones', async () => {
const expired = sign({ exp: Math.floor(Date.now() / 1000) - 60 });
const future = sign({ exp: Math.floor(Date.now() / 1000) + 3600 });
expect(await revocation.revokeToken(expired, 'logout')).toBe(true);
expect(await revocation.revokeToken(future, 'logout')).toBe(true);
await revocation.cleanupExpiredRevocations();
expect(await revocation.isTokenRevoked(jwt.decode(expired))).toBe(false);
expect(await revocation.isTokenRevoked(jwt.decode(future))).toBe(true);
});
it.each([true, false])('upgrades an expiring entry with the same key permanently (jti: %s)', async withJti => {
const claims = { id: 99, iat: Math.floor(Date.now() / 1000), jti: withJti ? randomUUID() : undefined };
const expiring = sign({ ...claims, exp: claims.iat - 60 });
const permanent = sign(claims);
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
expect(await revocation.revokeToken(permanent, 'logout')).toBe(true);
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
await revocation.cleanupExpiredRevocations();
expect(await revocation.isTokenRevoked(jwt.decode(permanent))).toBe(true);
});
it('retains a signed token whose numeric expiry cannot fit a database timestamp', async () => {
const token = sign({ exp: 1e100 });
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
await revocation.cleanupExpiredRevocations();
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(true);
});
it('refuses a rollback that would remove permanent revocations', async () => {
await expect(migration.down(db)).rejects.toThrow('permanent token revocations');
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(true);
// A rollback with only expiring records remains supported and reversible.
await db('revoked_tokens').whereNull('expires_at').delete();
await migration.down(db);
await migration.down(db);
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(false);
await migration.up(db);
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
});
});
}

Some files were not shown because too many files have changed in this diff Show More