Compare commits

..

33 Commits

Author SHA1 Message Date
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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
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) Failing after 6m11s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 7m30s
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
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) Failing after 6m24s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 6m39s
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
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) Successful in 3s
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) Successful in 2s
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) Successful in 2s
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) Successful in 3s
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
1356 changed files with 24471 additions and 253167 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
+21 -183
View File
@@ -4,26 +4,8 @@
# Environment
NODE_ENV=production
# JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run
# (Docker: the secrets-init service writes it to a private volume and reuses it
# across restarts). Set it explicitly only to pin your own value.
# 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
# JWT Secret (generate with: openssl rand -base64 64)
JWT_SECRET=your_very_long_random_jwt_secret_here
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
@@ -56,100 +38,35 @@ NODE_ENV=production
# Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg
DB_USER=picpeak
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
#DB_PASSWORD=your_secure_postgres_password_here
DB_PASSWORD=your_secure_postgres_password_here
DB_NAME=picpeak_prod
# Redis Configuration
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
# IMPORTANT: Same warning applies - avoid $ or escape as $$
#REDIS_PASSWORD=your_secure_redis_password_here
REDIS_PASSWORD=your_secure_redis_password_here
# 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.
# 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
# Admin Account (initial setup)
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 +79,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 +94,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 +197,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
-4
View File
@@ -1,4 +0,0 @@
# These are supported funding model platforms
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
buy_me_a_coffee: theluap
+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.
+5 -5
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/the-luap/picpeak/blob/main/DEPLOYMENT.md
about: Please read the documentation before opening an issue
- name: 💬 Discussions
url: https://github.com/PicPeak/picpeak/discussions
url: https://github.com/the-luap/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
url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md
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.
@@ -9,7 +9,7 @@ assignees: ''
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
For minor security improvements or questions, you can use this template:
-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)
+10 -18
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
@@ -45,21 +42,16 @@ Once published, images can be pulled using:
```bash
# Pull backend image
docker pull ghcr.io/picpeak/picpeak/backend:latest
docker pull ghcr.io/the-luap/picpeak/backend:latest
# Pull frontend image
docker pull ghcr.io/picpeak/picpeak/frontend:latest
docker pull ghcr.io/the-luap/picpeak/frontend:latest
# Pull specific version
docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0
docker pull ghcr.io/the-luap/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
docker pull --platform linux/arm64 ghcr.io/the-luap/picpeak/backend:latest
```
### Using in Docker Compose
@@ -69,14 +61,14 @@ version: '3.8'
services:
backend:
image: ghcr.io/picpeak/picpeak/backend:latest
image: ghcr.io/the-luap/picpeak/backend:latest
environment:
- NODE_ENV=production
ports:
- "3001:3000"
frontend:
image: ghcr.io/picpeak/picpeak/frontend:latest
image: ghcr.io/the-luap/picpeak/frontend:latest
ports:
- "80:80"
```
@@ -94,7 +86,7 @@ spec:
spec:
containers:
- name: backend
image: ghcr.io/picpeak/picpeak/backend:latest
image: ghcr.io/the-luap/picpeak/backend:latest
imagePullPolicy: Always
```
@@ -157,8 +149,8 @@ If images aren't visible after successful push:
### View Packages
Your Docker images are available at:
- Backend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Ffrontend`
- Backend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Ffrontend`
### Delete Old Versions
-70
View File
@@ -1,70 +0,0 @@
name: Bypass size gate
# Caps how large a PR a "review-bypass" collaborator (e.g. @Luca-Timo) can
# self-merge without a maintainer review. The branch-protection bypass list
# alone is binary — once a user is on it they can merge anything without
# review. This workflow reports a REQUIRED status check that fails when a
# bypass user's PR exceeds the configured size threshold, which blocks the
# merge even with bypass enabled. Other contributors are unaffected (the
# check reports success for them so the required-check gate doesn't trip).
#
# To tune: edit LINE_LIMIT or BYPASS_USERS below.
#
# Trigger note: uses `pull_request_target` so the workflow has the elevated
# permissions of the base repo's GITHUB_TOKEN (read PR metadata, write
# checks). The script never executes code FROM the PR — it only reads
# metadata via the API — so this is safe against fork-PR attacks.
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
permissions:
pull-requests: read
checks: write
jobs:
size-gate:
runs-on: ubuntu-latest
steps:
- name: Compute PR size and report check status
uses: actions/github-script@v7
with:
script: |
// Tune these two constants if the policy shifts.
const LINE_LIMIT = 300;
const BYPASS_USERS = ['Luca-Timo'];
const pr = context.payload.pull_request;
const author = pr.user.login;
const linesChanged = pr.additions + pr.deletions;
const filesChanged = pr.changed_files;
let conclusion, title, summary;
if (!BYPASS_USERS.includes(author)) {
// Not a bypass user — this gate doesn't apply to them. They
// go through normal review. Report success so the required
// check doesn't block their merge.
conclusion = 'success';
title = 'Not applicable';
summary = `This gate only restricts review-bypass for: ${BYPASS_USERS.join(', ')}. PRs from other authors (${author} here) go through the normal review path and are unaffected.`;
} else if (linesChanged <= LINE_LIMIT) {
conclusion = 'success';
title = `OK — within bypass limit (${linesChanged} lines)`;
summary = `Small PR: ${linesChanged} lines changed across ${filesChanged} file(s). Within the ${LINE_LIMIT}-line self-merge limit for @${author}. Can be merged without a maintainer review.`;
} else {
conclusion = 'failure';
title = `Too large for bypass (${linesChanged} lines)`;
summary = `Large PR: ${linesChanged} lines changed across ${filesChanged} file(s). Exceeds the ${LINE_LIMIT}-line self-merge limit for @${author} — needs an approving review from a maintainer before merge. Split into smaller PRs or wait for review.`;
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'bypass-size-gate',
head_sha: pr.head.sha,
status: 'completed',
conclusion,
output: { title, summary }
});
File diff suppressed because it is too large Load Diff
+17 -11
View File
@@ -16,17 +16,24 @@ name: Fresh-install smoke
# don't pay the build cost.
on:
# No `paths:` filter — branch protection on `main` + `stable` lists
# `fresh-install` as a REQUIRED check, and a path-filtered trigger
# that skipped on unrelated PRs (e.g. frontend-only) would leave the
# required check "missing" forever and block the merge. Better to
# pay the boot cost on every PR than maintain a per-path allowlist
# that drifts as the install surface evolves. (Branches also updated
# post-#669 rename: beta → main, old main → stable.)
push:
branches: [main, stable]
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
pull_request:
branches: [main, stable]
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
workflow_dispatch:
permissions:
@@ -54,8 +61,7 @@ jobs:
load: true
tags: picpeak-backend:smoke
cache-from: type=gha,scope=install-smoke
# ignore-error: a flaky GHA cache write must not fail the build.
cache-to: type=gha,mode=max,scope=install-smoke,ignore-error=true
cache-to: type=gha,mode=max,scope=install-smoke
- name: Create Docker network
run: docker network create picpeak-smoke
-36
View File
@@ -1,36 +0,0 @@
name: PR Title Lint
# Release Please derives version bumps and the changelog from Conventional
# Commit prefixes (feat:, fix:, ...). PRs whose title/commits use other
# conventions (e.g. gitmoji) are silently ignored, so their changes ship
# without a version bump or a changelog entry. This check fails a PR whose
# title is not a valid Conventional Commit so the release stays automated.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: read
jobs:
lint-pr-title:
runs-on: ubuntu-latest
steps:
- name: Validate PR title is a Conventional Commit
uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
perf
revert
docs
style
chore
refactor
test
build
ci
+3 -54
View File
@@ -2,7 +2,7 @@ name: Release Please (Beta)
on:
push:
branches: [main]
branches: [beta]
permissions:
contents: write
@@ -20,48 +20,10 @@ jobs:
uses: googleapis/release-please-action@v4
id: release
with:
# A dedicated token (fine-grained PAT) makes the release PR run CI
# automatically (no "workflows awaiting approval") and lets it be
# merged without a manual review. Falls back to GITHUB_TOKEN so the
# workflow still works before the secret is added (#719).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config-beta.json
manifest-file: .release-please-manifest-beta.json
target-branch: main
# Auto-approve + enable auto-merge on the open release PR so betas publish
# with no manual clicks. Approval uses GITHUB_TOKEN (github-actions[bot]) —
# a different identity than the PR author (RELEASE_PLEASE_TOKEN) — so it is
# a valid review (requires the org's "Allow GitHub Actions to approve pull
# requests" + the repo's "Allow auto-merge"). Only meaningful when a PAT is
# set: without it the PR is bot-authored and can't be self-approved, so we
# skip and leave today's manual flow. Best-effort — never blocks the run.
- 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 }}
# This job has no checkout, so gh can't infer the repo from a git
# remote — set it explicitly (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--main --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN) — a different identity
# than the PR author (the PAT) — so it counts as a valid review.
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
# Enable auto-merge as the PAT so the eventual merge commit is
# attributed to a real identity. If enabled via GITHUB_TOKEN the merge
# push is suppressed by recursion prevention and the follow-up run that
# cuts the tag/release never fires (#719).
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
target-branch: beta
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -73,16 +35,3 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
+2 -25
View File
@@ -2,7 +2,7 @@ name: Release Please
on:
push:
branches: [stable]
branches: [main]
permissions:
contents: write
@@ -20,19 +20,9 @@ jobs:
uses: googleapis/release-please-action@v4
id: release
with:
# Dedicated token so the release PR runs CI + can auto-merge without a
# manual review. Falls back to GITHUB_TOKEN before the secret is set (#719).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
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.
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -44,16 +34,3 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
@@ -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
+12 -9
View File
@@ -30,17 +30,20 @@ name: Schema drift (#530)
# the same shape is caught before merge.
on:
# No `paths:` filter — branch protection on `main` + `stable` lists
# `upgrade-from-bootstrap` as a REQUIRED check. A path-filtered
# trigger that skipped on unrelated PRs would leave the required
# check "missing" forever, blocking every PR that doesn't touch
# migrations. The ~75-second cost on every PR buys an unconditional
# safety net. (Branches also updated post-#669 rename: beta → main,
# old main → stable.)
push:
branches: [main, stable]
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
pull_request:
branches: [main, stable]
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
workflow_dispatch:
permissions:
-160
View File
@@ -1,160 +0,0 @@
name: Tests
# Runs the backend Jest suite and the frontend Vitest suite on every PR.
# Both suites already exist and cover the CRM service layer (quoteService,
# contractService, invoiceService.*, customerHoursService, eventService.
# calendar) plus the photo / settings / OG / auth surface — wiring them
# into CI makes regressions visible at PR time instead of post-merge.
#
# 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.
on:
push:
branches: [main, beta, stable]
pull_request:
branches: [main, beta, stable]
workflow_dispatch:
permissions:
contents: read
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
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- name: Install backend deps
working-directory: ./backend
run: npm ci
- name: Run Jest suite
working-directory: ./backend
env:
# backupService tests would otherwise try a real S3 round-trip.
# 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
frontend:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend deps
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
-98
View File
@@ -1,98 +0,0 @@
# What's New highlights — GitHub Models release step (reusable)
#
# Called by the release-please workflows AFTER a release is created
# (release-please.yml for `stable`, release-please-beta.yml for `main`). It runs
# as a job in the SAME workflow run rather than on its own `release: published`
# trigger, because release-please creates the release with the default
# GITHUB_TOKEN and GitHub does not start new workflow runs from token-generated
# events — a standalone `release:` workflow would simply never fire.
#
# What it does: condenses the new release's "### Features" into <=8 short
# bullets via GitHub Models (free tier, `models: read`) and injects a
# `<!-- whatsnew -->` block at the top of the release notes. The app reads that
# block (backend utils/whatsNew.parseWhatsNew) and falls back to the raw
# Features list for releases without it — so this is purely a quality upgrade,
# never a hard dependency. Failure is isolated by `continue-on-error` + the
# deterministic fallback below, so it can never break a release.
#
# GitHub Models is OPTIONAL. If it is disabled/unavailable for the org the AI
# step fails soft (continue-on-error) and the deterministic fallback produces
# the bullets instead — the feature works either way, Models just polishes them.
#
# Validated end-to-end on a fork (extract -> openai/gpt-4o-mini -> inject into
# real release notes; app parseWhatsNew() reads the block back).
name: What's New highlights
on:
workflow_call:
inputs:
tag:
description: Release tag to annotate (e.g. v2.3.0)
required: true
type: string
jobs:
highlights:
runs-on: ubuntu-latest
permissions:
contents: write # to edit the release body
models: read # GitHub Models (free tier)
# GH_REPO at job scope so every `gh` call targets the right repo without
# needing an actions/checkout step. Without this, `gh` falls back to
# parsing `.git/config` in the runner's empty workspace and dies with
# "fatal: not a git repository" — which hard-fails the whole job before
# any continue-on-error can save it.
env:
GH_REPO: ${{ github.repository }}
steps:
- name: Extract Features from the published release
id: feat
# Belt-and-braces: the job-level comment says "never let highlights
# break a release", but the original wiring only marked the AI +
# inject steps as continue-on-error. A hiccup here (rate limit,
# transient API error) would still hard-fail the job. Match the
# design intent and fail soft.
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
run: |
BODY=$(gh release view "$TAG" --json body -q .body)
FEATURES=$(printf '%s\n' "$BODY" | awk '/^#{2,4} +Features/{f=1;next} /^#{1,4} +\S/{f=0} f')
{ echo "features<<EOF"; printf '%s\n' "$FEATURES"; echo EOF; } >> "$GITHUB_OUTPUT"
- name: Summarize with GitHub Models
if: ${{ steps.feat.outputs.features != '' }}
id: ai
continue-on-error: true # Models may be disabled/unavailable for the org; fall back deterministically below
uses: actions/ai-inference@v1
with:
model: openai/gpt-4o-mini # catalog id (verified present); openai/gpt-4.1-mini or openai/gpt-5-nano also work
system-prompt: >
You write release highlights for the admins of a self-hosted
photo-gallery + CRM app. Given raw changelog "Features" lines, output
AT MOST 8 markdown bullets, each 3-4 words, user-facing, no scopes,
no jargon, no issue numbers. One bullet per distinct user-visible
feature. Output ONLY "- " bullets, nothing else.
prompt: ${{ steps.feat.outputs.features }}
- name: Inject the What's New block
if: ${{ steps.feat.outputs.features != '' }}
continue-on-error: true # never let highlights break a release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
AI: ${{ steps.ai.outputs.response }}
FEATURES: ${{ steps.feat.outputs.features }}
run: |
BULLETS="$AI"
# Deterministic fallback if the model returned nothing (e.g. Models not yet enabled).
if [ -z "$BULLETS" ]; then
BULLETS=$(printf '%s\n' "$FEATURES" | head -8 \
| sed -E 's/^\* \*\*[^:]+:\*\* */- /; s/ \(\[[^]]*\]\([^)]*\)\)//g')
fi
BODY=$(gh release view "$TAG" --json body -q .body)
# Idempotent: strip any prior block before re-injecting.
BODY=$(printf '%s' "$BODY" | perl -0pe 's/<!--\s*whatsnew\s*-->.*?<!--\s*\/whatsnew\s*-->\n*//is')
gh release edit "$TAG" --notes "$(printf '<!-- whatsnew -->\n%s\n<!-- /whatsnew -->\n\n%s' "$BULLETS" "$BODY")"
-29
View File
@@ -129,32 +129,3 @@ 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
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.131.3-beta.0"
".": "3.55.0-beta.0"
}
+473 -2294
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -20,7 +20,7 @@ We are committed to providing a welcoming and inspiring community for all photog
## Enforcement
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/PicPeak/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/the-luap/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
## Attribution
+18 -60
View File
@@ -33,12 +33,12 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme
Unsure where to begin? You can start by looking through these issues:
* [Good first issues](https://github.com/PicPeak/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/PicPeak/picpeak/labels/help%20wanted) - issues which need extra attention
* [Good first issues](https://github.com/the-luap/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/the-luap/picpeak/labels/help%20wanted) - issues which need extra attention
### Pull Requests
1. **Fork the repo** and create your branch from `main` (active development)
1. **Fork the repo** and create your branch from `beta`
2. **Install dependencies**:
```bash
cd backend && npm install
@@ -50,16 +50,13 @@ Unsure where to begin? You can start by looking through these issues:
- Linting passes: `npm run lint`
4. **Write tests** if you've added code
5. **Update documentation** if needed
6. **Attach a screenshot for any UI change** (see below)
7. **Create a Pull Request**
> **📸 Screenshots are required for UI changes.** Any PR that changes a user-facing surface — a component, page, layout, style, or in-app copy — must include at least one screenshot of the result in the PR description, showing before/after where it helps reviewers see the difference. PRs that touch the UI without a screenshot will be asked to add one before review. Backend-only or otherwise non-visual changes don't need one.
6. **Create a Pull Request**
## 💻 Development Setup
### Prerequisites
- Node.js 22.12.0 or later (matches `backend/package.json`)
- Node.js 18+
- Docker & Docker Compose
- Git
@@ -73,33 +70,15 @@ 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.
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.
**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.
### Running Tests
```bash
@@ -165,38 +144,17 @@ picpeak/
│ └── public/ # Static assets
```
## 🌿 Branch model
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. |
### 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.
**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.
If you're not sure which branch to target, default to `main` and a maintainer will retarget during review.
## 🔄 Release Process
Releases are cut independently from `main` (pre-release versions for the active channel) and `stable` (semver releases for the curated channel). `release-please` handles version bumps, changelog generation, and Docker image publication automatically — contributors don't update `package.json` or `CHANGELOG.md` by hand.
Periodic `main → stable` merges promote a batch of `main` work to the stable channel. The maintainer chooses when (typically every ~4 weeks, sooner if a hot bug demands it).
See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteria, conflict-resolution checklist for the `main → stable` merge, hotfix backport path, versioning rules).
1. Update version numbers in package.json files
2. Update CHANGELOG.md
3. Create a new release on GitHub
4. Docker images are automatically built and published
## 📮 Contact
- 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)
- Create an [issue](https://github.com/the-luap/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/the-luap/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
Thank you for contributing! 🎉
Thank you for contributing! 🎉
+888
View File
@@ -0,0 +1,888 @@
# 🚀 PicPeak Deployment Guide
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
## 📋 Table of Contents
- [Quick Start](#-quick-start)
- [Prerequisites](#prerequisites)
- [Configuration](#-configuration)
- [Deployment](#-deployment)
- [First Login](#-first-login)
- [Release Channels](#-release-channels)
- [Reverse Proxy Setup](#-reverse-proxy-setup)
- [External Media Library](#-external-media-library)
- [Maintenance](#-maintenance)
- [Troubleshooting](#-troubleshooting)
## 🚀 Quick Start
### Option 1: Automated Setup Script (Easiest)
For the simplest installation, use our unified setup script:
```bash
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
### Option 2: Docker with Pre-built Images (Recommended)
```bash
# Clone repository for configuration files
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy and configure environment
cp .env.example .env
nano .env # Edit with your values
# Create required directories
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
# Deploy using pre-built images
docker compose -f docker-compose.production.yml up -d
# Check logs
docker compose -f docker-compose.production.yml logs -f
```
**Available image tags:**
| Channel | Tags | Description |
|---------|------|-------------|
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
| Branch | `main`, `beta` | Latest from each branch |
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
### Option 3: Build from Source
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
cp .env.example .env
nano .env # Edit with your values
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
docker compose build
docker compose up -d
```
## Prerequisites
- Docker and Docker Compose installed
- Domain name (for production)
- SMTP server credentials for emails
- At least 2GB RAM and 20GB storage
## 🔧 Configuration
### Essential Environment Variables
Generate secure values:
```bash
# JWT Secret
openssl rand -base64 64
# Database Password (avoid $ character - see warning below)
openssl rand -base64 32 | tr -d '$'
# Redis Password (avoid $ character - see warning below)
openssl rand -base64 32 | tr -d '$'
```
⚠️ **PASSWORD WARNING**: Docker Compose interprets `$` as variable substitution. Either:
- Avoid `$` in passwords (recommended - use the commands above)
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
### Public Landing Page
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
### Backend Configuration (.env)
Update `.env` with:
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
- `DB_PASSWORD` - PostgreSQL password
- `REDIS_PASSWORD` - Redis password
- `SMTP_*` - Email configuration
- **URL Configuration** (for backend CORS):
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
- Example (Docker): `http://localhost:3000`
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
- Example (Docker): `http://localhost:3000`
Notes:
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
- Always include the scheme (`http://` or `https://`).
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
#### Authentication Security
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
#### External Database Example
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
```env
DB_HOST=db.example.com
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=change_me
DB_NAME=picpeak_prod
```
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you dont set `DB_HOST` it will use the bundled `postgres` container.
### Frontend Configuration (frontend/.env)
Create `frontend/.env` from `frontend/.env.example`:
```bash
cp frontend/.env.example frontend/.env
```
Update `frontend/.env` with:
- `VITE_API_URL` - Backend API URL
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
⚠️ **IMPORTANT PORT CONFIGURATION**:
- The frontend runs on port **3000** in Docker (exposed via nginx)
- The backend API runs on port **3001**
- The frontend `.env` file MUST point to the correct backend port (3001)
- Default `.env.example` is configured for Docker deployment
### Email Configuration Examples
#### Gmail
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-specific-password
```
#### SendGrid
```env
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
```
## 📦 Deployment
### Using Pre-built Images (Fastest)
```bash
# Pull latest images from GitHub Container Registry
docker pull ghcr.io/the-luap/picpeak/backend:latest
docker pull ghcr.io/the-luap/picpeak/frontend:latest
# Start services using production compose file
docker compose -f docker-compose.production.yml up -d
# View running containers
docker compose ps
```
### Building from Source (For Customization)
```bash
# Build images locally
docker compose build
# Or build with no cache for clean build
docker compose build --no-cache
# Start all services
docker compose up -d
# View running containers
docker compose ps
```
### Access Points
By default, services are exposed on:
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
- Backend/API: http://localhost:3001 (API only; no UI routes)
- PostgreSQL: localhost:5432 (if needed)
- Redis: localhost:6379 (if needed)
### Initial Admin Setup
When deploying for the first time, an admin account is automatically created with a secure, randomly generated password. This password is displayed in the Docker logs during initialization and **must be changed** on first login.
#### Finding the Auto-Generated Admin Password
The admin password is automatically generated during the first startup and displayed in the backend container logs. Here's how to find it:
**Option 1: Search Docker logs for admin password** (recommended)
```bash
# Find the auto-generated admin password in logs
docker compose logs backend | grep "Admin password"
```
You should see output like:
```
✅ Admin password generated: BraveTiger6231!
```
**Option 2: View the complete initialization logs**
```bash
# View the complete admin setup logs
docker compose logs backend | grep -A 10 "Admin user created"
```
**Option 3: Check the saved credentials file**
```bash
# The password is also saved in the backend container
docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
```
**Option 4: Use the helper script**
```bash
# Show current admin username and email (password is hidden)
docker exec picpeak-backend node scripts/show-admin-credentials.js
# Reset the admin password to a new random password (displays new password in console)
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
```
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
#### Important Security Notes
- **Login requires the email address**, not username
- When resetting password, the new password is displayed once in the console - save it immediately
- **Password change is MANDATORY** on first login - the system will force you to change it
- If you lose the password before first login, use the `--reset` option to generate a new one
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
## 🔐 First Login
After deployment, you must complete the first login process which includes mandatory password change for security.
### Step 1: Locate Your Admin Password
1. **Find the auto-generated password** from the credentials file:
```bash
# Docker deployment
docker compose exec backend cat /app/data/ADMIN_CREDENTIALS.txt
# Or directly from the host (if you have access)
cat data/ADMIN_CREDENTIALS.txt
```
2. **Note the admin email** (default: `admin@example.com` unless customized)
### Step 2: Access Admin Panel
1. Navigate to your frontend domain and open the admin section:
- `http://your-domain.com/admin` (behind reverse proxy)
- `http://localhost:3000/admin` (Docker local)
The backend at `:3001` serves API only and does not serve the admin UI.
2. Login using:
- **Email**: `admin@example.com` (or your custom admin email)
- **Password**: The auto-generated password from the logs
### Step 3: Mandatory Password Change
Upon first login, the system will **automatically redirect** you to change your password:
1. **You cannot skip this step** - it's enforced for security
2. Enter the current auto-generated password
3. Create a new secure password meeting these requirements:
- Minimum 12 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one number
- At least one special character (!@#$%^&*)
### Security Best Practices for New Password
- **Use a unique password** not used elsewhere
- **Consider a password manager** for generation and storage
- **Include mixed characters**: `MySecureP@ssw0rd2024!`
- **Avoid personal information** (names, dates, etc.)
- **Save securely** - you cannot recover this password easily
### If You Lose Access
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
```bash
# Native reinstall example
sudo ./picpeak-setup.sh --native --force-admin-password-reset
# Docker reinstall example
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
```
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
#### Configuring Admin Email
By default, the admin email is `admin@example.com`. To use a different email address, set it in your `.env` file before first deployment:
```env
# .env
ADMIN_EMAIL=your-email@yourdomain.com
```
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### 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`
### Configuring Your Channel
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
```yaml
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
```
### Switching Channels
To switch between channels:
```bash
# Edit your .env file
nano .env
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
# Pull the new images and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
- Checks GitHub releases hourly (cached to avoid rate limits)
- Shows updates relevant to your current channel (stable or beta)
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
## 🔒 Reverse Proxy Setup
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
### Routing Schema
PicPeak consists of two services that need to be routed correctly:
| Path | Service | Port | Description |
|------|---------|------|-------------|
| `/api/*` | Backend | 3001 | All API endpoints |
| `/photos/*` | Backend | 3001 | Protected photo files |
| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
| `/uploads/*` | Backend | 3001 | Upload files |
| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
### Option 1: Nginx
Install nginx and create `/etc/nginx/sites-available/picpeak`:
```nginx
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Backend: API endpoints
location /api/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend: Protected media files
location ~ ^/(photos|thumbnails|uploads)/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend: Everything else (React SPA)
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Enable the site:
```bash
sudo ln -s /etc/nginx/sites-available/picpeak /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### Option 2: Traefik
Add labels to `docker-compose.override.yml`:
```yaml
version: '3.8'
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak.rule=Host(`your-domain.com`)"
- "traefik.http.routers.picpeak.entrypoints=websecure"
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
backend:
labels:
- "traefik.enable=true"
# API endpoints
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
# Protected media files
- "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
- "traefik.http.routers.picpeak-media.entrypoints=websecure"
- "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
```
### Option 3: Caddy
Create a `Caddyfile`:
```caddyfile
your-domain.com {
# Backend: API endpoints
handle /api/* {
reverse_proxy localhost:3001
}
# Backend: Protected media files
handle /photos/* {
reverse_proxy localhost:3001
}
handle /thumbnails/* {
reverse_proxy localhost:3001
}
handle /uploads/* {
reverse_proxy localhost:3001
}
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
handle {
reverse_proxy localhost:3000
}
}
```
### SSL Certificates
For any reverse proxy, you can use Let's Encrypt:
```bash
# With Certbot
sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
# Or use your reverse proxy's built-in ACME support
```
## 📂 External Media Library
The External Media Library allows events to reference photos stored directly on your host filesystem instead of uploading them through the admin UI. This is useful for photographers who already have organized photo libraries and want to share them without re-uploading.
### How It Works
- **Managed mode** (default): Photos are uploaded through the admin UI and stored inside PicPeak's storage directory.
- **Reference mode**: Photos remain on your host filesystem. PicPeak reads them directly and generates thumbnails on demand.
Each event can use either mode. Reference mode events point to a folder under the configured external media root.
### Configuration
Add the following to your `.env` file:
```bash
# Path where your photo library is stored on the host
EXTERNAL_MEDIA_ROOT=/path/to/your/photos
```
Then mount this path into the backend container in your `docker-compose.yml` or `docker-compose.production.yml`:
```yaml
services:
backend:
environment:
- EXTERNAL_MEDIA_ROOT=/external-media
volumes:
- /path/to/your/photos:/external-media:ro # read-only is recommended
```
> **Permissions**: Ensure the container user (`PUID`/`PGID`) has read access to the mounted directory. If thumbnails fail to generate, this is usually a permissions issue.
### Folder Structure
Organize your photos with subdirectories for each event. Within each event folder, use `individual/` and `collages/` subdirectories to classify photos:
```
/path/to/your/photos/
├── wedding-smith-2026/
│ ├── individual/
│ │ ├── IMG_0001.jpg
│ │ ├── IMG_0002.jpg
│ │ └── ...
│ └── collages/
│ ├── group-photo.jpg
│ └── ...
├── corporate-event/
│ ├── individual/
│ │ └── ...
│ └── collages/
│ └── ...
```
Supported file formats: `.jpg`, `.jpeg`, `.png`, `.webp`
### Usage
1. **Create an event** in the admin panel as usual (name, date, email, etc.).
2. **Switch source mode** to "Reference external folder" in the event details under Source Mode.
3. **Browse and select** the external folder using the folder picker that appears. Navigate to the event's directory.
4. **Import photos** by clicking "Import from External Folder" in the Photos tab. PicPeak will:
- Recursively scan the selected folder
- Classify photos by subfolder name (`individual/` or `collages/`)
- Deduplicate by filename (keeps the largest file if duplicates exist)
- Extract image dimensions for gallery layout
- Register the photos in the database
5. **Thumbnails** are generated on demand when a guest first views the gallery. There is no upfront processing delay.
### Limitations
- **Images only** — video files are not supported for external media.
- **Read-only** — PicPeak does not modify or delete files in the external media directory.
- **No automatic sync** — If you add new photos to the external folder, you need to re-import from the admin UI.
- **Backup caveat** — External media originals are excluded from PicPeak's built-in backup system. Only thumbnails and database records are backed up. You are responsible for backing up the source files separately.
### Troubleshooting
| Problem | Solution |
|---------|----------|
| Folder picker shows empty directory | Check that the volume is mounted correctly and `EXTERNAL_MEDIA_ROOT` matches the container path |
| "Permission denied" errors | Ensure `PUID`/`PGID` in `.env` match the owner of the external media files on the host |
| Thumbnails not generating | Verify the backend container can read the files: `docker exec picpeak-backend ls /external-media/your-folder/` |
| Import finds 0 photos | Only `.jpg`, `.jpeg`, `.png`, `.webp` files are supported. Check file extensions. |
## 🔧 Maintenance
### Viewing Logs
```bash
# All services
docker compose logs -f
# Specific service
docker compose logs -f backend
docker compose logs -f frontend
```
### Backup
#### Manual Backup
```bash
# Database backup
docker exec picpeak-postgres pg_dump -U picpeak picpeak_prod > backup/db_$(date +%Y%m%d_%H%M%S).sql
# Files backup
tar -czf backup/photos_$(date +%Y%m%d_%H%M%S).tar.gz events/
```
#### Automated Backup
The application includes a built-in backup service. Configure it in the admin panel:
1. Login to admin panel
2. Go to Settings → Backup
3. Configure destination and schedule
4. Enable backup service
### Updates
#### Method 1: Using Pre-built Images (Recommended)
```bash
# Pull latest changes (for configuration updates)
git pull
# Pull latest images from GitHub Container Registry
docker compose -f docker-compose.production.yml pull
# Restart with new images
docker compose -f docker-compose.production.yml down
docker compose -f docker-compose.production.yml up -d
# Verify services are healthy
docker compose -f docker-compose.production.yml ps
```
#### Method 2: Building from Source
```bash
# Pull latest changes
git pull
# Rebuild and restart
docker compose down
docker compose build --no-cache
docker compose up -d
# Verify services are healthy
docker compose ps
```
#### Specific Version or Channel Updates
To use a specific version or switch channels, update your `.env` file:
```bash
# Edit .env to change the channel or pin to a specific version
nano .env
# Options for PICPEAK_CHANNEL:
# - stable (recommended, production-ready)
# - beta (early access to new features)
# - v2.3.0 (pin to specific stable version)
# - v2.3.0-beta.1 (pin to specific beta version)
# Then pull and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
The admin dashboard will notify you when updates are available for your configured channel.
### Database Migrations
Migrations run automatically on startup, but you can run them manually:
```bash
docker exec picpeak-backend npm run migrate
```
## 🚨 Troubleshooting
### Common Issues
#### 502 Bad Gateway / Login Failures
**This is the most common deployment issue!** Usually caused by misconfigured URLs or network problems:
1. **CORS Configuration Errors**:
```bash
# WRONG - Missing port will cause CORS errors
FRONTEND_URL=http://10.0.252.12
# CORRECT - Include the port you're accessing from
FRONTEND_URL=http://10.0.252.12:3000
```
The backend validates Origin headers against `FRONTEND_URL` for CORS. If they don't match exactly, you'll get 500 errors on login.
2. **After Container Restarts**:
- Nginx may have cached old container IPs
- Solution: `docker restart picpeak-frontend`
- Always wait 30-60 seconds for health checks
3. **Backend Not Starting After Migrations**:
- The logs may only show migrations completed
- Check if server is actually running: `docker exec picpeak-backend ps aux | grep node`
- Should see `node server.js` process
4. **Login After Fresh Install**:
- Check backend logs for auto-generated admin password: `docker compose logs backend | grep "Admin password"`
- Email: `admin@example.com` (or your custom admin email from .env)
- Password: Auto-generated and shown in logs (e.g., `BraveTiger6231!`)
- Remember: Password MUST be changed on first login
5. **Complete Fix Sequence**:
```bash
# 1. Fix your .env file URLs
# 2. Full restart
docker-compose down
docker-compose up -d
# 3. Wait for healthy status
sleep 60
docker ps # All should show (healthy)
# 4. Test backend directly
curl http://localhost:3001/health
# 5. Test through frontend
curl http://localhost:3000/api/public/settings
```
#### Port Already in Use
```bash
# Check what's using the port
sudo lsof -i :3000
sudo lsof -i :3001
# Change ports in .env
FRONTEND_PORT=3002
BACKEND_PORT=3003
```
#### Docker Compose Variable Substitution Errors
If you see warnings like:
```
WARN[0000] The "fgbf" variable is not set. Defaulting to a blank string.
```
This means your password contains `$` which Docker Compose interprets as a variable. Solutions:
1. **Best**: Generate passwords without `$`: `openssl rand -base64 32 | tr -d '$'`
2. **Alternative**: Escape `$` as `$$` in your .env file
3. **Example**: `DB_PASSWORD=Pass@#$$fgbf` instead of `DB_PASSWORD=Pass@#$fgbf`
#### Permission Errors
```bash
# Fix ownership
sudo chown -R 1000:1000 events data logs backup storage
chmod -R 755 events data logs backup storage
```
#### Database Connection Issues
```bash
# Check if database is running
docker compose ps
docker compose logs postgres
# Test connection
docker exec picpeak-postgres pg_isready
```
#### Email Not Sending
- Verify SMTP settings in .env
- Check email queue: `docker exec picpeak-backend psql -U picpeak -d picpeak_prod -c "SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;"`
- For Gmail, use app-specific password
- Check logs: `docker compose logs backend | grep email`
### Health Checks
```bash
# Backend health
curl http://localhost:3001/api/health
# Frontend health
curl http://localhost:3000
# Database health
docker exec picpeak-postgres pg_isready
```
### Useful Commands
```bash
# Enter backend container
docker exec -it picpeak-backend sh
# Enter database
docker exec -it picpeak-postgres psql -U picpeak picpeak_prod
# Reset admin password
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
# Check disk usage
df -h
du -sh events/ storage/ backup/
# View running processes
docker compose top
```
## Security Recommendations
1. **Use HTTPS**: Always use a reverse proxy with SSL in production
2. **Firewall**: Only expose necessary ports (80, 443)
3. **Secure passwords**: Use strong, unique passwords for all services
4. **Regular updates**: Keep Docker images and system packages updated
5. **Backup strategy**: Set up automated backups and test restoration
6. **Monitor logs**: Regularly check logs for suspicious activity
7. **Rate limiting**: The app includes built-in rate limiting, configure as needed
## Support
For issues and questions:
- Check logs first: `docker compose logs`
- Review documentation in the repository
- Check existing issues on GitHub
- Create a new issue with:
- Error messages
- Log output
- Environment details (without secrets)
- Steps to reproduce
-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"]
+100 -192
View File
@@ -1,244 +1,152 @@
# PicPeak
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
# 📸 PicPeak
**Open-source, self-hosted photo sharing for events.**
**Self-hosted photo sharing for event photographers.**
[![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/)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-theluap-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/theluap)
[![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/)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support ☕](https://buymeacoffee.com/theluap)
[Live Demo](https://demo.picpeak.app) · [Deployment Guide](DEPLOYMENT_GUIDE.md) · [Homepage](https://www.picpeak.app)
</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 lets you create password-protected, time-limited photo galleries for your clients — hosted on your own server. No subscriptions, no storage limits, no third-party access to your photos.
![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.
## Demo
## Contents
Try it out at [demo.picpeak.app](https://demo.picpeak.app).
- [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)
Admin panel: [demo.picpeak.app/admin](https://demo.picpeak.app/admin) — login with `demo@picpeak.app` / `Demo2026!`
## 🎮 Live Demo
> The demo resets periodically.
Try PicPeak without installing anything — [demo.picpeak.app](https://demo.picpeak.app) · [admin panel](https://demo.picpeak.app/admin)
## Features
| Email | Password |
|---|---|
| `demo@picpeak.app` | `Demo2026!` |
**Gallery Management** — Create galleries, upload photos via drag & drop, set passwords and expiration dates. Galleries auto-archive when they expire. Events start as drafts so you can upload and prepare before notifying the client.
> The demo resets periodically. Uploaded content may be removed without notice.
**Client Experience** — Responsive galleries that look great on any device. Guests can browse, download individual photos or everything at once. Optional guest uploads and feedback (likes, comments, ratings).
## 🚀 Quick Start
**Themes & Branding** — 11 built-in theme presets, custom CSS templates, configurable colors/fonts/layouts. White-label your admin panel and login page with your own logo and company name.
Get PicPeak running in under 5 minutes:
**Email Notifications** — Automated gallery creation, expiration warning, and archive emails. Multilingual templates (EN, DE, NL, PT, RU) editable from the admin UI.
**Photo Protection** — Watermarking, right-click prevention, canvas rendering, DevTools detection. Configurable per gallery.
**External Media** — Reference photos from a mounted folder instead of uploading. PicPeak reads originals in place and generates thumbnails on demand.
**Multi-Language** — Full UI translations for English, German, Dutch, Portuguese, and Russian. Email templates support all languages independently.
**Analytics** — Built-in view/download tracking plus optional Umami integration for privacy-focused analytics.
**Video Support** — Upload and stream MP4, WebM, MOV alongside photos. FFmpeg bundled via npm.
**Multiple Admins** — Role-based access control with super admin, admin, and editor roles.
## Quick Start
```bash
# Clone the repository
git clone https://github.com/PicPeak/picpeak.git
git clone https://github.com/the-luap/picpeak.git
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.
cp .env.example .env
# Start with Docker Compose
# Edit .env — set at least JWT_SECRET and passwords
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)**.
Open `http://localhost:3000` and log in with the credentials from your `.env`.
> **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.
> **Permissions:** Set `PUID` and `PGID` in `.env` to match your host user (`id -u` / `id -g`) so Docker volumes are writable.
### Or: one container, no compose file
See the [Deployment Guide](DEPLOYMENT_GUIDE.md) for reverse proxy setup, SSL, external media, and production configuration.
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:
## Screenshots
<details>
<summary>Admin Dashboard</summary>
<img src="docs/screenshot-dashboard.png" alt="Admin Dashboard" width="800" />
</details>
<details>
<summary>Event Management</summary>
<img src="docs/screenshots-events.png" alt="Event Management" width="800" />
</details>
<details>
<summary>Analytics</summary>
<img src="docs/screenshot-analytics.png" alt="Analytics" width="800" />
</details>
## Comparison
| | PicPeak | PicDrop | Scrapbook.de |
|---|---|---|---|
| Self-hosted | Yes | No | No |
| Monthly cost | $0 | $29-199 | 19-99 EUR |
| Storage | Unlimited | 50-500 GB | 100-1000 GB |
| Custom branding | Full | Limited | Limited |
| Open source | Yes | No | No |
| API | Yes | Paid | No |
## Tech Stack
- **Backend:** Node.js, Express, PostgreSQL (or SQLite)
- **Frontend:** React, TypeScript, Tailwind CSS
- **Infrastructure:** Docker, Nginx, Redis
- **Processing:** Sharp (images), FFmpeg (video)
## Release Channels
**Stable** (`stable` / `latest`) — Production-ready. Use this for real deployments.
**Beta** (`beta`) — Early access to new features. May have rough edges.
```bash
docker run -d --name picpeak -p 3000:3000 \
-v picpeak:/data \
ghcr.io/picpeak/picpeak/aio:main
# Set in .env
PICPEAK_CHANNEL=stable # or beta
# Update
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
No environment variables to set — the JWT secret is generated on first start and kept on the volume.
The admin dashboard notifies you when updates are available.
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.
## Contributing
`: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`.
We welcome contributions — bug fixes, features, translations, documentation. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
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.
## Documentation
### Docker images
- [Deployment Guide](DEPLOYMENT_GUIDE.md) — Installation, configuration, reverse proxy, external media
- [Admin API (OpenAPI)](docs/picpeak-admin-api.openapi.yaml) — Machine-readable API spec
- [Admin API Quickstart](docs/admin-api-quickstart.md) — Authentication and testing guide
- [Security Policy](SECURITY.md)
| | 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) |
## Contributors
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.
Thanks to the people whose code, reports, and feedback have shaped PicPeak:
## 🌟 Why PicPeak?
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, lazy-loaded folder tree picker, admin-email picker, self-hosted webfont system, gallery header/banner decoupling, and several typed-API refactors.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, gallery-loading skeleton work, mobile-lightbox overhaul, admin-events search-counter fix, photo-count column, and bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter.
Unlike expensive SaaS solutions, PicPeak gives you:
If you've contributed and aren't listed here, please open a PR.
- **💰 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
## License
**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.
## 📖 Documentation
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings, API, branding, and more.
| Topic | Link |
|---|---|
| 🚀 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) |
**Project meta:** [Support](SUPPORT.md) · [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
## 📊 Comparison with Alternatives
| Feature | PicPeak | PicDrop | Scrapbook.de | Pixieset |
|---------|---------|---------|--------------|----------|
| Self-Hosted | ✅ | ❌ | ❌ | ❌ |
| Custom Branding | ✅ Full | Limited | Limited | ✅ (paid) |
| Monthly Cost | $0* | $29-199 | €19-99 | ~$60 |
| Storage Limit | Unlimited** | 50-500GB | 100-1000GB | 3GBUnlimited*** |
| Client Uploads | ✅ | ✅ | ✅ | Limited |
| API Access | ✅ | Paid | ❌ | ❌ |
| Open Source | ✅ | ❌ | ❌ | ❌ |
| Customer Accounts | ✅ | ❌ | ❌ | ✅ |
| 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>
## 🏗️ 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](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
## 📸 Screenshots
<details>
<summary>Click to see the admin dashboard, analytics, and event management</summary>
### 🎛️ Admin Dashboard
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
### 📊 Analytics & Insights
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
### 📁 Event Management
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
</details>
## 🤝 Contributing
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.
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.
## ☕ 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.
## 🙏 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.
### 👥 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
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
## 📄 License
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
MIT — use it for personal or commercial projects.
---
<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://github.com/PicPeak/picpeak/issues">Support</a>
<a href="https://www.picpeak.app">Homepage</a> · <a href="https://demo.picpeak.app">Live Demo</a> · <a href="DEPLOYMENT_GUIDE.md">Docs</a> · <a href="https://github.com/the-luap/picpeak/issues">Issues</a>
</p>
-113
View File
@@ -1,113 +0,0 @@
# Release Process
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs pre-release) see the [Release Channels section in README.md](README.md#-release-channels).
## TL;DR
- **`main` branch** receives all merged work (active development). Every push triggers a `release-please` PR that proposes the next `vX.Y.Z-beta.N` pre-release. Merging that PR tags the pre-release and publishes Docker images under the `:main` rolling tag + the version-specific tag.
- **`stable` branch** holds the curated stable channel. Stable releases are cut from a known-good `main` point via a `release/X.Y.Z-merge-from-main` branch and a manual PR to `stable`. Merging that PR triggers `release-please` to propose the stable release.
- Target cadence: **a stable release every 46 weeks**, or sooner if `main` has been quiet and ready for promotion.
> **Branch model background** — `main` (active dev) was previously called `beta`, and `stable` (curated channel) was previously called `main`. The rename happened with #669 to match the convention every other open-source project uses. The mechanics below all reference the post-rename names.
## Cadence target
46 weeks between stable releases is the working target. Reasoning:
- Long enough that each stable carries meaningful changes worth the upgrade burden.
- Short enough that pre-release users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
- Aligns with how release-please surfaces pre-releases (multiple pre-release points usually accumulate inside a 46 week window, which gives natural promotion candidates).
This is a target, not a hard rule. Cut sooner if `main` has been quiet and stable longer than usual. Cut later if `main` is in flux for security or migration reasons.
## Promotion criteria
A `main` tip is eligible for promotion to `stable` when **all** of the following hold:
1. **CI green on the candidate `main` tip.** Specifically: `schema-drift` (`upgrade-from-bootstrap`), `fresh-install`, `Tests` (backend Jest + frontend Vitest), the four `Build and Push Docker Images` arch matrices, and `GitGuardian Security Checks`.
2. **No open `bug`-labelled issues against the candidate for at least 7 days.** Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidate `main` tip before closing them out.
3. **An upgrade walk has been done on real production-shaped data** — apply the candidate's migration chain to a snapshot of the previous stable's DB and verify no manual intervention is required. CI proves fresh-install works; the upgrade walk is what proves the upgrade path works.
4. **Operator-time smoke** on the candidate: log in, create event, upload photos, share gallery, open as a customer, log out. Catches binary-incompatibility regressions and UI-level breaks that unit tests don't see.
If any of the four fail, the promotion waits. File any blockers as `bug`-labelled issues and let them bake on `main` before re-evaluating.
## How a stable release is cut
The actual mechanics, in order:
1. **Pick the `main` tip.** Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting.
2. **Create the release branch from the `main` tip.**
```bash
git push origin <main-tip-sha>:refs/heads/release/X.Y.Z-merge-from-main
```
Naming convention: `release/X.Y.Z-merge-from-main`, where `X.Y.Z` is the stable version you intend to land. release-please will write the actual `X.Y.Z` on merge — the branch name is just a human label.
3. **Open a PR to `stable`.** Title: `chore(release): promote main → stable as vX.Y.Z`. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example (predates the rename; the mechanics are unchanged).
4. **Resolve conflicts.** `stable` almost always has commits `main` doesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:
- **`backend/package.json` / `package-lock.json` + `frontend/package.json` / `package-lock.json`** — usually take `main`'s version (superset), but verify any security-pinned deps (`axios`, `nodemailer`, `i18next-http-backend`, `multer`, `tar`) on `main` are `>=` the pinned versions on `stable`. If `stable` has a newer pinned version (e.g. an emergency CVE backport `main` hasn't picked up), take `stable`'s pin.
- **`README.md`** — keep `stable`'s version if it has had a recent rewrite that `main` didn't pick up; otherwise take `main`'s.
- **`CHANGELOG.md`** — keep `stable`'s; release-please regenerates entries on its next stable cut from the commits going forward.
- **`.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.
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).
7. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
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.
## 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:
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).
## Versioning
PicPeak follows [Semantic Versioning](https://semver.org/) with one project-specific convention:
- **MAJOR** bumps are reserved for breaking schema changes that require operator action on upgrade (e.g. a migration that's not safe to auto-apply, an env-var rename that can't be auto-detected).
- **MINOR** bumps for new features, additive schema changes, and any change to the public HTTP API surface.
- **PATCH** bumps for bug fixes and operator-invisible internal changes.
- **Pre-release suffix** (`-beta.N`) for every `main`-channel cut; the `N` counter resets on each new MINOR or MAJOR target. The suffix kept the historical `-beta` literal even after the branch rename — operators were already pinning to `v3.x.y-beta.N` and changing the literal would have broken those pins.
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.
- **Test-only changes** — same.
- **CI / workflow changes** — same, but be aware they take effect on the branch they land on, so a CI fix targeting `main` won't fix a broken stable-channel workflow until the next promotion.
## When this doc is wrong
If you find yourself working around something here, update the doc before doing the workaround. The point of a written process is that future-you doesn't have to remember the workaround.
+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/the-luap/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/the-luap/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
Thank you for helping keep PicPeak and its users safe!
+3 -18
View File
@@ -8,7 +8,7 @@ This guide provides easy installation instructions for PicPeak on Linux servers
```bash
# Download and run the unified setup script
curl -fsSL https://raw.githubusercontent.com/PicPeak/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
@@ -163,21 +163,6 @@ sudo ./picpeak-setup.sh --native --unattended \
- `picpeak-workers` - Background workers
- `caddy` - Web server (optional)
## 🔑 First Login — Create Your Admin
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
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`):
```bash
docker compose exec backend cat /app/data/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
### Direct Access (Simplest)
@@ -487,7 +472,7 @@ sudo -u picpeak node scripts/reset-admin-password.js
- [Deployment Guide](https://docs.picpeak.app/deployment)
3. **Support:**
- [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- Include: Error messages, system info (`uname -a`), installation method
## 🔒 Security Best Practices
@@ -565,4 +550,4 @@ sudo ./picpeak-setup.sh --native \
---
**PicPeak Setup v1.0** | [Documentation](https://github.com/PicPeak/picpeak) | [Support](https://github.com/PicPeak/picpeak/issues)
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
-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 -34
View File
@@ -9,16 +9,6 @@ PORT=3001
# Generate with: openssl rand -base64 32
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
# Generate with: openssl rand -base64 32
#MFA_ENCRYPTION_KEY=
# Auth cookie Secure flag
# unset - default: 'auto' in production, false in dev (#427)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
@@ -106,23 +96,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 +103,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'] }]
}
};
+6 -54
View File
@@ -7,7 +7,7 @@ ARG VCS_REF
ARG VERSION
# Add labels for GitHub Container Registry
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
LABEL org.opencontainers.image.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT"
@@ -27,35 +27,12 @@ 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
# image always picks up current Alpine security updates instead of reusing a
# stale cached upgrade layer.
ARG CACHEBUST=1
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
RUN apk upgrade --no-cache
# Remove the npm CLI from the final image. Nothing runs npm here: the
# entrypoint is node, runtime deps are COPY'd from the builder stage, and
# wait-for-db.sh invokes the migration runners via node directly. npm's
# bundled node_modules kept tripping Trivy (sigstore, tar 7.5.19,
# brace-expansion 5.0.7 — even npm 12.0.1 still ships the vulnerable
# copies), so shipping no npm ends that alert class instead of chasing
# per-release patches. Note: `docker exec … npm run <script>` no longer
# works in the container — use `node migrations/run-migrations-safe.js`
# and friends instead.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
RUN npm install -g npm@10
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
@@ -66,24 +43,7 @@ RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
# pipeline calls via fluent-ffmpeg.ffprobe()).
# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that
# contain live <text> for the CRM PDFs. Without any font installed, librsvg
# renders text as tofu boxes (□) while the vector artwork still draws — i.e.
# a "corrupted" logo on invoices/quotes. DejaVu/Liberation provide a broad
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
# PDFKit + the web UI use) are registered with fontconfig further down so the
# logo's text renders in its actual typeface, not a fallback.
# poppler-utils provides `pdftoppm`, used to rasterise inbound supplier-invoice
# 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.
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
fc-cache -f
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
@@ -95,14 +55,6 @@ COPY --chown=nodejs:nodejs . .
# Ensure all source files are readable and wait script is executable
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Register picpeak's bundled brand fonts (assets/fonts/<Family>/*.ttf — the
# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg
# rasterises an SVG logo its <text> renders in the actual brand typeface
# rather than a DejaVu/Liberation fallback. fontconfig indexes by each font's
# internal family name and recurses into the per-family subdirectories.
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
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
chown -R nodejs:nodejs storage data logs
+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();
});
});
@@ -1,242 +0,0 @@
/**
* Integration test for GET /api/admin/system-health/backup-coverage.
*
* Pins the Stage C diagnostic that tells admins what the next
* "Run Backup Now" will include, skip, or silently miss.
*
* Test surface:
* 1. Empty / fresh install → default seed (7 paths), inline mode,
* no DB dump on file yet, no drift
* 2. Toggle `include_in_default=false` → coverage flips to
* 'skipped-by-toggle'
* 3. Feature_flag gating reflects the actual app_settings value
* (events/archived ⇄ backup_include_archived)
* 4. Drift detection: a top-level subdir on disk with no
* `backup_paths` row is flagged in `unconfiguredOnDisk`
* 5. Allow-list: `backups/` and `tmp/` are never flagged as drift
* 6. Scheduled-only mode + recent dump → `database.ok = true`
* 7. Scheduled-only mode + stale (>26h) dump → `database.ok = false`
* and `lastDumpStale = true`
*
* Same auth/permission pass-through strategy as
* adminBackupIntegrity.test.js — we exercise the route's logic,
* not the auth middleware.
*/
const fs = require('fs');
const path = require('path');
const express = require('express');
const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
customerAuth: (_req, _res, next) => next(),
galleryAuth: (_req, _res, next) => next(),
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(120000);
describe('GET /api/admin/system-health/backup-coverage', () => {
let db;
let cleanup;
let storagePath;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
const route = require('../../src/routes/adminSystemHealth');
app = express();
app.use(express.json());
app.use('/api/admin/system-health', route);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function mkdir(rel) {
fs.mkdirSync(path.join(storagePath, rel), { recursive: true });
}
function rmdir(rel) {
fs.rmSync(path.join(storagePath, rel), { recursive: true, force: true });
}
async function restoreDefaultPaths() {
await db('backup_paths').del();
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
}
beforeEach(async () => {
await restoreDefaultPaths();
await db('database_backup_runs').del().catch(() => {});
await db('app_settings').where('setting_type', 'backup').del().catch(() => {});
});
it('returns the canonical 7 paths + database block on a fresh install', async () => {
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('report');
const { report } = res.body;
expect(report.paths.map((p) => p.path)).toEqual([
'events/active',
'events/archived',
'thumbnails',
'previews',
'heroes',
'uploads',
'business-docs',
]);
// Default mode is inline — no inline_dump setting present means
// "inline is ON" (matches ensureDatabaseDumpForBackup semantics).
expect(report.database.mode).toBe('inline');
expect(report.database.ok).toBe(true);
expect(report.summary).toMatchObject({
configuredCount: 7,
tableMissingFallbackInUse: false,
});
});
it('flips a path to skipped-by-toggle when include_in_default=false', async () => {
await db('backup_paths').where('path', 'thumbnails').update({
include_in_default: false,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
const thumbnails = res.body.report.paths.find((p) => p.path === 'thumbnails');
expect(thumbnails.coverage).toBe('skipped-by-toggle');
expect(thumbnails.includeInDefault).toBe(false);
});
it('feature_flag gating reflects app_settings (archived path off vs on)', async () => {
// backup_include_archived not set → archived skipped via flag
const off = await request(app).get('/api/admin/system-health/backup-coverage');
const archivedOff = off.body.report.paths.find((p) => p.path === 'events/archived');
expect(archivedOff.coverage).toBe('skipped-by-feature-flag');
expect(archivedOff.featureFlag).toBe('backup_include_archived');
expect(archivedOff.featureFlagValue).toBe(null); // unset
// Now set the flag — but path is missing on disk, so coverage
// resolves to 'missing-on-disk', proving the flag was honoured.
await db('app_settings').insert({
setting_key: 'backup_include_archived',
setting_value: JSON.stringify(true),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const on = await request(app).get('/api/admin/system-health/backup-coverage');
const archivedOn = on.body.report.paths.find((p) => p.path === 'events/archived');
expect(archivedOn.featureFlagValue).toBe(true);
// No on-disk dir → 'missing-on-disk' (not 'skipped-by-feature-flag')
expect(['missing-on-disk', 'will-scan']).toContain(archivedOn.coverage);
});
it('detects unconfigured top-level subdirs as drift', async () => {
mkdir('events/active'); // configured
mkdir('plugin-store/cache'); // DRIFT
mkdir('shiny-new-feature/data'); // DRIFT
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.drift.unconfiguredOnDisk).toEqual(expect.arrayContaining([
'plugin-store',
'shiny-new-feature',
]));
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('events');
rmdir('plugin-store');
rmdir('shiny-new-feature');
});
it('never flags backups/ or tmp/ as drift (allow-list)', async () => {
mkdir('backups');
mkdir('tmp');
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('backups');
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('tmp');
expect(res.body.report.drift.expectedNonBackupDirs).toEqual(
expect.arrayContaining(['backups', 'tmp']),
);
rmdir('backups');
rmdir('tmp');
});
it('scheduled-only mode + recent dump → database.ok=true, not stale', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const recentDump = path.join(storagePath, 'backups', 'recent.sql.gz');
fs.mkdirSync(path.dirname(recentDump), { recursive: true });
fs.writeFileSync(recentDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(), // just now
status: 'completed',
backup_type: 'pg',
file_path: recentDump,
file_size_bytes: fs.statSync(recentDump).size,
destination_path: recentDump,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.database.mode).toBe('scheduled-only');
expect(res.body.report.database.inlineDumpExplicitlyDisabled).toBe(true);
expect(res.body.report.database.lastDumpStale).toBe(false);
expect(res.body.report.database.ok).toBe(true);
});
it('scheduled-only mode + stale dump → database.ok=false, lastDumpStale=true', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const oldDump = path.join(storagePath, 'backups', 'old.sql.gz');
fs.mkdirSync(path.dirname(oldDump), { recursive: true });
fs.writeFileSync(oldDump, 'pretend old dump');
// 48 hours ago — well past the 26h staleness threshold. ISO
// string instead of a Date object because knex-sqlite's datetime
// serialisation has a quirk where some Date instances coerce to
// '[object Object]' on insert (the test 6 "recent dump" case
// passes only because `new Date()` happens to round-trip safely;
// arithmetic Dates don't).
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
await db('database_backup_runs').insert({
started_at: stale,
completed_at: stale,
status: 'completed',
backup_type: 'pg',
file_path: oldDump,
file_size_bytes: fs.statSync(oldDump).size,
destination_path: oldDump,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.database.lastDumpStale).toBe(true);
expect(res.body.report.database.ok).toBe(false);
// Top-level summary reflects the failed DB check.
expect(res.body.report.summary.databaseOk).toBe(false);
expect(res.body.report.summary.overallOk).toBe(false);
});
});
@@ -1,140 +0,0 @@
/**
* Integration test for GET /api/admin/system-health/backup-integrity.
*
* Auth + permission middleware are mocked to pass-through so the test
* focuses on the route's own behaviour: scope-param validation, the
* successResponse envelope, and that the underlying service report
* surfaces correctly in the JSON body.
*
* The verifier service itself is exercised against the real schema
* (bootCrmDb) and real filesystem — only the auth gate is stubbed.
*/
const fs = require('fs');
const path = require('path');
const express = require('express');
const request = require('supertest');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Pass-through auth so we don't need to mint JWTs.
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
customerAuth: (_req, _res, next) => next(),
galleryAuth: (_req, _res, next) => next(),
}));
// Pass-through permissions so settings.view always allows.
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(120000);
describe('GET /api/admin/system-health/backup-integrity', () => {
let cleanup;
let db;
let customerId;
let app;
let storagePath;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
storagePath = process.env.STORAGE_PATH;
// Mount the route on a minimal Express app. Cold-require after
// bootCrmDb so the route's downstream `require('../database/db')`
// sees the same db instance.
const route = require('../../src/routes/adminSystemHealth');
app = express();
app.use(express.json());
app.use('/api/admin/system-health', route);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('contracts').del().catch(() => {});
await db('invoices').del().catch(() => {});
await db('quotes').del().catch(() => {});
});
it('returns a report envelope when nothing references any path', async () => {
const res = await request(app).get('/api/admin/system-health/backup-integrity');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('report');
expect(res.body.report.summary).toMatchObject({
totalRows: 0,
missingFiles: 0,
hashMismatches: 0,
verifiedOk: 0,
existsButNoHash: 0,
});
expect(res.body.report.scopes).toEqual(expect.arrayContaining([
'quote', 'contract', 'contract-signature', 'invoice',
]));
});
it('surfaces a missing file in the response payload', async () => {
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-B7-MISSING',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-B7-MISSING.pdf',
created_at: new Date(),
});
const res = await request(app).get('/api/admin/system-health/backup-integrity');
expect(res.status).toBe(200);
expect(res.body.report.summary.missingFiles).toBe(1);
expect(res.body.report.missing[0]).toMatchObject({
table: 'contracts',
column: 'signed_pdf_path',
expectedPath: 'business-docs/contract/2026/C-B7-MISSING.pdf',
});
});
it('honours the ?scope=invoice filter', async () => {
// Seed both an invoice and a contract with missing files. With
// scope=invoice the contract row must not appear.
await db('invoices').insert({
customer_account_id: customerId,
invoice_number: 'INV-B7-SCOPE',
status: 'sent',
issue_date: '2026-01-01',
due_date: '2026-01-31',
pdf_path: 'business-docs/invoice/2026/INV-B7-SCOPE.pdf',
created_at: new Date(),
});
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-B7-SCOPE',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-B7-SCOPE.pdf',
created_at: new Date(),
});
const res = await request(app)
.get('/api/admin/system-health/backup-integrity')
.query({ scope: 'invoice' });
expect(res.status).toBe(200);
expect(res.body.report.scopes).toEqual(['invoice']);
expect(res.body.report.missing.every((m) => m.table === 'invoices')).toBe(true);
});
it('rejects an unknown scope with 400 + a code', async () => {
const res = await request(app)
.get('/api/admin/system-health/backup-integrity')
.query({ scope: 'gallery' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('BACKUP_INTEGRITY_UNKNOWN_SCOPE');
expect(res.body.validScopes).toEqual(expect.arrayContaining([
'quote', 'contract', 'contract-signature', 'invoice',
]));
});
});
@@ -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');
});
});
@@ -1,88 +0,0 @@
/**
* Regression net for the business-docs coverage gap fixed in this PR.
*
* Prior to the fix, `getFilesToBackupInternal()` enumerated a fixed
* list of storage subdirectories (events/active, events/archived,
* thumbnails, previews, heroes, uploads) and silently omitted the
* entire `business-docs/` tree. That meant every CRM PDF + signature
* drawing — quotes, contracts (system-rendered + wet uploads),
* invoices, Storno, imported historical invoices, and the customer
* signature PNG/JPG drawn on the public signing page — fell outside
* the in-app scheduled backup, leaving every `*_path` column on
* `quotes` / `contracts` / `invoices` as a broken FK after restore.
*
* The fix is a single `scanDirectory(business-docs, ...)` call. This
* suite pins the contract so a future refactor of the walker cannot
* silently drop business-docs again.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
describe('backupService — business-docs is in the backup walker', () => {
let cleanup;
let backupService;
let storagePath;
beforeAll(async () => {
({ cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
// Cold-require after bootCrmDb so backupService picks up the same
// db instance + STORAGE_PATH the test harness configured.
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function seed(relPath, content = 'dummy bytes for backup test') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
it('does not error when business-docs is absent', async () => {
// Fresh harness has no business-docs/ tree at all. The walker
// must short-circuit on ENOENT rather than throw — installs that
// never used CRM features have to keep backing up fine.
await expect(backupService.getFilesToBackup(false)).resolves.toEqual(expect.any(Array));
});
it('picks up every CRM-relevant business-docs subdirectory', async () => {
// Seed one file in each of the five subpaths the renderer + import
// routes write to. The signature path is the one most prone to be
// forgotten — it lives one level deeper than the others (per-
// contract subfolder, not per-year).
seed('business-docs/quote/2026/Q-001.pdf');
seed('business-docs/contract/2026/C-001.pdf');
seed('business-docs/contract/signatures/42/customer-1700000000000.png');
seed('business-docs/invoice/2026/INV-001.pdf');
seed('business-docs/invoice-imports/2026/scan.pdf');
const files = await backupService.getFilesToBackup(false);
const rels = files.map((f) => f.relativePath);
expect(rels).toEqual(expect.arrayContaining([
'business-docs/quote/2026/Q-001.pdf',
'business-docs/contract/2026/C-001.pdf',
'business-docs/contract/signatures/42/customer-1700000000000.png',
'business-docs/invoice/2026/INV-001.pdf',
'business-docs/invoice-imports/2026/scan.pdf',
]));
});
it('walks newly-created business-docs files without needing a restart', async () => {
// The walker reads the filesystem live on every call; this guards
// against a future "cache the scan result at boot" optimisation
// that would miss freshly-written PDFs (which is exactly what
// happens during normal operation — every send writes a new file).
seed('business-docs/invoice/2027/INV-NEW.pdf');
const files = await backupService.getFilesToBackup(false);
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('business-docs/invoice/2027/INV-NEW.pdf');
});
});
@@ -1,379 +0,0 @@
/**
* Pins the Stage-B refactor that lifted the file-backup walker's
* subdirectory list out of hard-coded JS into the `backup_paths`
* table seeded by migration 109.
*
* Scenarios:
* 1. Walker reads canonical seed → all 7 default subdirs walked
* 2. include_in_default=false on one row → that subdir is skipped
* 3. New row inserted at runtime → walker picks it up without restart
* 4. feature_flag gating → row only walked when the named app_settings
* boolean is truthy (mirrors historical `includeArchived` behavior)
* 5. Empty table → walker falls back to LEGACY_BACKUP_PATHS (defense
* in depth — never silently scans nothing)
*
* Why not stub `db('backup_paths')`: the whole point of Stage B is
* that the walker is now data-driven, so the test has to actually
* mutate the table and observe the walker's output change. Stubs
* would re-introduce the hard-coding the refactor is meant to remove.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
describe('backupService — configurable walker (backup_paths)', () => {
let db;
let cleanup;
let storagePath;
let backupService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function seedFile(relPath, content = 'dummy bytes') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
beforeEach(async () => {
// Restore canonical seed before every test. Tests mutate this table
// freely; the next test starts from a known state.
await db('backup_paths').del();
const {
DEFAULT_PATHS,
} = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
});
it('migration 109 seeds the canonical 7 paths', async () => {
const rows = await db('backup_paths').orderBy('display_order', 'asc').select();
expect(rows.map((r) => r.path)).toEqual([
'events/active',
'events/archived',
'thumbnails',
'previews',
'heroes',
'uploads',
'business-docs',
]);
// Only events/archived is gated by a feature flag.
expect(rows.filter((r) => r.feature_flag).map((r) => r.path)).toEqual([
'events/archived',
]);
});
it('walks every default subdir when files are present', async () => {
seedFile('events/active/E1/a.jpg');
seedFile('thumbnails/E1/a.jpg');
seedFile('previews/E1/a.jpg');
seedFile('heroes/E1/hero.jpg');
seedFile('uploads/intake/x.bin');
seedFile('business-docs/quote/2026/Q-001.pdf');
// events/archived is gated — left out of this test; covered below.
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toEqual(expect.arrayContaining([
'events/active/E1/a.jpg',
'thumbnails/E1/a.jpg',
'previews/E1/a.jpg',
'heroes/E1/hero.jpg',
'uploads/intake/x.bin',
'business-docs/quote/2026/Q-001.pdf',
]));
});
it('skips a path when include_in_default is toggled off', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
await db('backup_paths').where('path', 'thumbnails').update({
include_in_default: false,
});
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
});
it('picks up a new path inserted at runtime — no restart needed', async () => {
// Simulates a future feature shipping its own subdirectory and
// self-healing a `backup_paths` row at boot.
await db('backup_paths').insert({
path: 'plugin-store',
include_in_default: true,
feature_flag: null,
display_order: 200,
description: 'Hypothetical future feature payload',
created_at: new Date(),
updated_at: new Date(),
});
seedFile('plugin-store/cache/payload.bin');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('plugin-store/cache/payload.bin');
});
it('respects feature_flag gating (events/archived ⇄ backup_include_archived)', async () => {
seedFile('events/active/E1/active.jpg');
seedFile('events/archived/E2/archived.jpg');
// backup_include_archived=false → archived/ is skipped.
const filesOff = await backupService.getFilesToBackup({ backup_include_archived: false });
const relsOff = filesOff.map((f) => f.relativePath);
expect(relsOff).toContain('events/active/E1/active.jpg');
expect(relsOff).not.toContain('events/archived/E2/archived.jpg');
// backup_include_archived=true → archived/ is included.
const filesOn = await backupService.getFilesToBackup({ backup_include_archived: true });
const relsOn = filesOn.map((f) => f.relativePath);
expect(relsOn).toContain('events/archived/E2/archived.jpg');
});
it('falls back to LEGACY_BACKUP_PATHS when the table is empty', async () => {
// Defense in depth: even if seed-and-self-heal both failed, the
// walker must still cover the historical set so "Run Backup Now"
// cannot silently degrade to no-op.
await db('backup_paths').del();
seedFile('events/active/E1/photo.jpg');
seedFile('business-docs/quote/2026/Q-002.pdf');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).toContain('business-docs/quote/2026/Q-002.pdf');
});
it('legacy boolean call signature still works (backward compat)', async () => {
// Existing call sites (and the businessDocs regression test) pass
// a boolean for `includeArchived`. Refactor must not break them.
seedFile('events/archived/E3/legacy.jpg');
const filesOff = await backupService.getFilesToBackup(false);
expect(filesOff.map((f) => f.relativePath)).not.toContain('events/archived/E3/legacy.jpg');
const filesOn = await backupService.getFilesToBackup(true);
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
});
// Issue #871 — the "What to Backup" checkboxes were stored but never read.
describe('UI opt-out toggles (issue #871)', () => {
it('unchecking Thumbnails excludes thumbnails/', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_thumbnails: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
});
it('unchecking Photos excludes events/active', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_photos: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).not.toContain('events/active/E1/photo.jpg');
});
it('defaults to including everything when the keys were never saved', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).toContain('events/active/E1/photo.jpg');
});
it("accepts the UI's plural backup_include_archives for the archived gate", async () => {
seedFile('events/archived/E4/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archives: true,
});
expect(files.map((f) => f.relativePath)).toContain('events/archived/E4/archived.jpg');
});
it('the UI plural key beats the migration-seeded singular key', async () => {
// Migration seeds backup_include_archived=true on every install; the
// form only ever writes the plural key, so unchecking Archives must
// win over the stale seeded value.
seedFile('events/archived/E5/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archived: true, // seeded default
backup_include_archives: false, // what the admin actually chose
});
expect(files.map((f) => f.relativePath)).not.toContain('events/archived/E5/archived.jpg');
});
it('rsync gets the de-selected paths and noise filters as --exclude args', async () => {
const excluded = await backupService.resolveExcludedBackupPaths({
backup_include_thumbnails: false,
backup_include_archives: false,
});
expect(excluded.map((r) => r.path)).toEqual(
expect.arrayContaining(['thumbnails', 'events/archived'])
);
const args = backupService.buildRsyncArgs(
{ backup_rsync_host: 'backup.example.com', backup_rsync_path: '/srv/backups' },
excluded.map((r) => `/${r.path}/`)
);
const excludes = args
.map((a, i) => (a === '--exclude' ? args[i + 1] : null))
.filter(Boolean);
expect(excludes).toEqual(expect.arrayContaining([
'.nfs*',
'/thumbnails/',
'/events/archived/',
]));
});
it('rows toggled off via include_in_default also become rsync excludes', async () => {
// The enabled-only loader hides these rows from the walker, but rsync
// syncs the whole storage root, so they must still appear as excludes.
await db('backup_paths').where('path', 'previews').update({
include_in_default: false,
});
const excluded = await backupService.resolveExcludedBackupPaths({});
expect(excluded.map((r) => r.path)).toContain('previews');
});
});
// Issue #871 — .nfs* silly-rename artifacts were uploaded to S3.
it('never backs up filesystem noise (.nfs*, .DS_Store)', async () => {
seedFile('thumbnails/E1/.nfs000000000000006600000008');
seedFile('events/active/E1/.DS_Store');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels.some((r) => r.includes('.nfs'))).toBe(false);
expect(rels.some((r) => r.includes('.DS_Store'))).toBe(false);
});
it('the walker honors backup_exclude_patterns (previously rsync-only)', async () => {
seedFile('events/active/E1/photo.jpg');
seedFile('events/active/E1/scratch.tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('events/active/E1/scratch.tmp');
});
it('glob patterns are literal outside the star (.nfs* must not eat anfs-…)', async () => {
seedFile('events/active/E1/anfs-photo.jpg');
seedFile('events/active/E1/notes-tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
// '.nfs*' used to compile to /^.nfs.*$/ whose dot matched any char;
// '*.tmp' used to compile to /^.*.tmp$/ which also matched 'notes-tmp'.
expect(rels).toContain('events/active/E1/anfs-photo.jpg');
expect(rels).toContain('events/active/E1/notes-tmp');
});
// Issue #871 — weekly schedules silently ran daily, and the dashboard's
// "next backup" was a hardcoded "tomorrow 02:00".
describe('schedule resolution + next run (issue #871)', () => {
it('a named label beats the stray default cron the UI used to send', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *', // old UI default, sent unconditionally
})).toBe('0 3 * * 0');
});
it('custom schedules use the cron field', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'custom',
backup_schedule_cron: '15 5 * * 2',
})).toBe('15 5 * * 2');
});
it('falls back to the default daily cron', () => {
expect(backupService.resolveScheduleCron({})).toBe('0 2 * * *');
});
it('getNextScheduledRun is null when backups are disabled', () => {
expect(backupService.getNextScheduledRun(null)).toBeNull();
expect(backupService.getNextScheduledRun({ backup_enabled: false })).toBeNull();
});
it('getNextScheduledRun returns the real next weekly fire time', () => {
const iso = backupService.getNextScheduledRun({
backup_enabled: true,
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *',
});
const next = new Date(iso);
expect(Number.isNaN(next.getTime())).toBe(false);
expect(next.getTime()).toBeGreaterThan(Date.now());
expect(next.getDay()).toBe(0); // Sunday
expect(next.getHours()).toBe(3); // 03:00
});
});
// Issue #871 — "Backup Size: 167.6 TB": file_size_bytes is a bigInteger
// column, node-postgres returns int8 as a string, and the S3 path did
// `backedUpSize += size` — string concatenation.
it('getDatabaseBackupInfo coerces file_size_bytes to a number', async () => {
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
backup_type: 'full',
status: 'completed',
file_path: '/backups/db/dump.sql.gz',
// Simulate the PG int8-as-string driver behaviour (sqlite stores
// whatever it is handed, so the string round-trips).
file_size_bytes: '421988',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
});
const info = await backupService.getDatabaseBackupInfo();
expect(typeof info.size).toBe('number');
expect(info.size).toBe(421988);
});
});
@@ -1,188 +0,0 @@
/**
* Pins the inline-DB-dump + fail-loud guard added to `runBackupInternal`.
*
* The previous behaviour was: file-backup looked up an existing dump via
* `getDatabaseBackupInfo()` and silently shipped a files-only manifest
* when none was found. Admins clicking "Run Backup Now" got an apparent
* success that omitted every customer / quote / invoice / contract row —
* the data-loss footgun that this commit closes.
*
* Five scenarios under test:
* 1. Default (inline dump enabled), dump succeeds → backup proceeds
* 2. Default, dump throws → run aborts, backup_runs row marked failed
* 3. Opt-out + recent DB dump available → backup proceeds
* 4. Opt-out + no DB dump available → fail loud
* 5. Opt-out + DB dump file is 0 bytes on disk → fail loud
*
* Mocking strategy: the underlying `databaseBackupService.backup()` and
* the local-destination writer are stubbed so the test exercises just
* the new guard logic without depending on `pg_dump` / `sqlite3` CLI
* binaries being available in the test environment.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
// Set up mocks BEFORE bootCrmDb so backupService picks them up at require time.
const mockBackupFn = jest.fn();
jest.mock('../../src/services/databaseBackup', () => ({
databaseBackupService: { backup: mockBackupFn },
startScheduledBackups: jest.fn(),
stopScheduledBackups: jest.fn(),
DatabaseBackupService: class {},
}));
jest.setTimeout(120000);
describe('backupService — inline DB dump + fail-loud guard', () => {
let db;
let cleanup;
let storagePath;
let backupService;
let dumpFileAbs;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
// Seed backup destination settings so the run can proceed past the
// "destination not configured" guard.
const dest = path.join(storagePath, 'backups');
fs.mkdirSync(dest, { recursive: true });
// getBackupConfigInternal filters by setting_type='backup', so the
// tests have to seed with that type or the resolver returns
// `{ ... }` with the keys missing — runBackup then sees
// `backup_destination_type === undefined` and bails before our
// new guard runs.
await db('app_settings').insert([
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(dest), setting_type: 'backup' },
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
]).onConflict('setting_key').merge();
// Pre-create a dump file that getDatabaseBackupInfo can resolve to.
// Reused/mutated per-test via the database_backup_runs seed below.
dumpFileAbs = path.join(storagePath, 'backups', 'fake-dump.sql.gz');
fs.writeFileSync(dumpFileAbs, 'pretend this is a pg_dump'.repeat(100));
// Neutralise the file-scan step: we don't care which files would
// be backed up, just whether the run reaches that stage at all.
backupService.getFilesToBackup = jest.fn(async () => []);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
mockBackupFn.mockReset();
// Default to "dump produced this file with this size" — the per-test
// setup overrides as needed.
mockBackupFn.mockResolvedValue({
success: true,
path: dumpFileAbs,
size: fs.statSync(dumpFileAbs).size,
duration: 1,
checksum: 'abc',
});
// Re-seed the database_backup_runs row that getDatabaseBackupInfo
// resolves against (its query is `status='completed'` + most recent).
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: dumpFileAbs,
file_size_bytes: fs.statSync(dumpFileAbs).size,
destination_path: dumpFileAbs,
});
});
it('default behaviour: inline dump runs, then file backup proceeds', async () => {
// Inline-dump setting is unset (undefined) — default is ON.
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
await backupService.runBackup(true);
expect(mockBackupFn).toHaveBeenCalledTimes(1);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
expect(run.error_message).toBeNull();
});
it('aborts the run when the inline dump throws', async () => {
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
mockBackupFn.mockRejectedValueOnce(new Error('pg_dump segfaulted'));
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/pg_dump segfaulted/);
});
it('opt-out: skips inline dump but proceeds when a recent dump exists', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
await backupService.runBackup(true);
expect(mockBackupFn).not.toHaveBeenCalled();
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
});
it('opt-out + no recent dump: fails loud with a clear error', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
// Wipe the dump row so getDatabaseBackupInfo returns backupFile=null.
await db('database_backup_runs').del();
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/No database backup available/);
});
it('opt-out + 0-byte dump file: fails loud', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const emptyDump = path.join(storagePath, 'backups', 'empty-dump.sql.gz');
fs.writeFileSync(emptyDump, '');
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: emptyDump,
file_size_bytes: 0,
destination_path: emptyDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/is empty/);
});
});
@@ -1,180 +0,0 @@
/**
* Per-Stage-B-path tally — Tier 3 of tonight's backup hardening.
*
* Pins the new `computePerPathStats` logic that the Backup History
* "Content Backed Up" pane reads via `backup_runs.statistics.per_path`.
*
* Three scenarios:
* 1. Single file under one path — straightforward attribution
* 2. Multiple paths with overlapping prefixes — longest-prefix wins
* (e.g. `events/active/E1/x.jpg` should attribute to
* `events/active`, not `events`)
* 3. File outside any configured path — silently dropped, doesn't
* throw or contaminate other buckets
*
* Tests exercise the EXPORTED side: write a backup_runs row via the
* service entry point and assert the statistics JSON shape. We don't
* stub `computePerPathStats` directly — the integration view is what
* the frontend actually consumes.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
describe('backupService — per-Stage-B-path statistics', () => {
let db;
let cleanup;
let storagePath;
let backupService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function mkFile(rel, content = 'x'.repeat(100)) {
const abs = path.join(storagePath, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
beforeEach(async () => {
// Clean slate of any artefacts from prior tests
await db('backup_runs').del();
await db('app_settings').where('setting_type', 'backup').del();
await db('app_settings').insert([
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(path.join(storagePath, 'destination')), setting_type: 'backup' },
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
{ setting_key: 'backup_include_archived', setting_value: JSON.stringify(true), setting_type: 'backup' },
]).onConflict('setting_key').merge();
fs.mkdirSync(path.join(storagePath, 'destination'), { recursive: true });
// Restore canonical backup_paths from migration 109
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').del();
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
// Wipe leftover files between tests
for (const dir of ['events', 'business-docs', 'thumbnails', 'previews', 'heroes', 'uploads']) {
const p = path.join(storagePath, dir);
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
}
});
it('attributes files to their owning backup_paths row', async () => {
mkFile('events/active/E1/photo-a.jpg', 'X'.repeat(1000));
mkFile('events/active/E1/photo-b.jpg', 'X'.repeat(2000));
mkFile('business-docs/quote/2026/Q-1.pdf', 'X'.repeat(500));
mkFile('thumbnails/E1/photo-a.jpg', 'X'.repeat(50));
// Disable the inline DB dump so we don't need pg_dump in tests;
// the file walker is what produces per_path.
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
// Seed a fake DB-backup row so the fail-loud guard is satisfied.
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
fs.writeFileSync(fakeDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: fakeDump,
file_size_bytes: fs.statSync(fakeDump).size,
destination_path: fakeDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
const statsRaw = typeof run.statistics === 'string'
? JSON.parse(run.statistics)
: run.statistics;
expect(statsRaw.per_path).toBeDefined();
// events/active should have 2 files (3000 bytes)
expect(statsRaw.per_path['events/active']).toEqual({ count: 2, size: 3000 });
// business-docs should have 1 file (500 bytes)
expect(statsRaw.per_path['business-docs']).toEqual({ count: 1, size: 500 });
// thumbnails should have 1 file (50 bytes)
expect(statsRaw.per_path['thumbnails']).toEqual({ count: 1, size: 50 });
// No spurious buckets for paths that had nothing
expect(statsRaw.per_path['previews']).toBeUndefined();
expect(statsRaw.per_path['heroes']).toBeUndefined();
});
it('archived path attributed separately from active when both have files', async () => {
mkFile('events/active/E1/active.jpg', 'X'.repeat(100));
mkFile('events/archived/E2/archived.jpg', 'X'.repeat(200));
// backup_include_archived already set true in beforeEach so the
// archived walker fires; same opt-out for inline DB dump.
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
fs.writeFileSync(fakeDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: fakeDump,
file_size_bytes: fs.statSync(fakeDump).size,
destination_path: fakeDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
const statsRaw = typeof run.statistics === 'string'
? JSON.parse(run.statistics)
: run.statistics;
// events/active and events/archived attribute separately —
// longest-prefix match prevents `events/active/...` from claiming
// an `events/archived/...` file or vice versa.
expect(statsRaw.per_path['events/active']).toEqual({ count: 1, size: 100 });
expect(statsRaw.per_path['events/archived']).toEqual({ count: 1, size: 200 });
});
});
// NOTE on walker duplication
//
// If two `backup_paths` rows overlap (e.g. one row at `events` AND
// another at `events/active`), the walker scans the same files twice
// — once via each path. Per-path stats then attribute the file to the
// longest-prefix-matching path BOTH times, producing inflated counts.
//
// The canonical seed in migration 109 contains no overlapping pairs,
// so this isn't exercised in practice. But an admin who hand-adds a
// broad row that overlaps an existing nested one will see double
// counts in their next backup's statistics + the destination will
// receive duplicate copies (wasting space). Worth flagging if anyone
// reports it — the fix is to de-dupe `files` in
// `getFilesToBackupInternal` before returning, OR to skip walking a
// path if a longer one has already covered it.
@@ -1,143 +0,0 @@
/**
* Smoke tests for backupService's config resolution + file-collection
* and manifest validation paths — safety net ahead of the god-file
* decomposition.
*
* Uses the same real-SQLite harness as
* backupService.configurableWalker.test.js (bootCrmDb + a temp
* STORAGE_PATH) rather than the broken deep-mock approach in
* backupService.enhanced.test.js.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
describe('backupService — config + file collection + manifest (smoke)', () => {
let db;
let cleanup;
let storagePath;
let backupService;
let backupManifest;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
backupManifest = require('../../src/services/backupManifest');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('app_settings').del();
// Reset the storage tree so each test starts from a pristine walk.
await fs.promises.rm(storagePath, { recursive: true, force: true });
await fs.promises.mkdir(storagePath, { recursive: true });
});
function seedFile(relPath, content = 'dummy bytes') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
return abs;
}
async function insertBackupSetting(key, value) {
await db('app_settings').insert({
setting_key: key,
setting_value: value,
setting_type: 'backup',
});
}
describe('getBackupConfig', () => {
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
await insertBackupSetting('backup_enabled', 'true');
await insertBackupSetting('backup_include_archived', 'false');
await insertBackupSetting('backup_retention_days', '30');
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
// Non-backup settings must not leak into the backup config.
await db('app_settings').insert({
setting_key: 'general_site_name',
setting_value: 'PicPeak',
setting_type: 'general',
});
const config = await backupService.getBackupConfig();
expect(config.backup_enabled).toBe(true);
expect(config.backup_include_archived).toBe(false);
expect(config.backup_retention_days).toBe(30);
expect(config.backup_destination_path).toBe('/backups/picpeak');
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
expect(config).not.toHaveProperty('general_site_name');
// Raw (unparsed) values are preserved on the non-enumerable __raw.
expect(String(config.__raw.backup_retention_days)).toBe('30');
});
it('returns an empty config object (not null) when nothing is configured', async () => {
const config = await backupService.getBackupConfig();
expect(config).not.toBeNull();
expect(Object.keys(config)).toHaveLength(0);
});
});
describe('getFilesToBackup', () => {
it('returns an empty list on a pristine storage tree', async () => {
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
expect(files).toEqual([]);
});
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
const content = 'not really a jpeg';
const abs = seedFile('events/active/E9/pic.jpg', content);
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
expect(entry).toBeDefined();
expect(entry.path).toBe(abs);
expect(entry.size).toBe(Buffer.byteLength(content));
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
// realm under Jest and fails the cross-realm instanceof check.
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
});
});
describe('validateBackupManifest', () => {
it('round-trips a generated manifest as valid', async () => {
seedFile('events/active/E1/a.jpg', 'aaa');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const manifest = await backupManifest.generateManifest({
backupType: 'full',
backupPath: '/backup/run-1',
files,
});
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
await backupManifest.saveManifest(manifest, manifestPath, 'json');
const result = await backupService.validateBackupManifest(manifestPath);
expect(result.valid).toBe(true);
expect(result.manifest.backup.type).toBe('full');
expect(result.manifest.files.count).toBe(files.length);
expect(result.manifest.verification.total_checksum).toBeTruthy();
});
it('flags a manifest missing required sections as invalid', async () => {
const badPath = path.join(storagePath, 'manifest-broken.json');
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
const result = await backupService.validateBackupManifest(badPath);
expect(result.valid).toBe(false);
expect(result.error).toMatch(/Missing required section/);
});
});
});
@@ -1,170 +0,0 @@
/**
* Booking cutover — prepare_invoice's draft seam. convertToInvoiceOnly({draft})
* must create the invoice(s) but leave scheduled_send_at NULL so the scheduler
* never auto-sends them before the workflow's review gate + explicit
* send_document.
*/
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(120000);
describe('booking cutover — draft invoices on hold', () => {
let db; let cleanup; let adminId; let customerId; let quoteService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
quoteService = require('../../src/services/quoteService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function acceptedQuote() {
const dealUuid = crypto.randomUUID();
const [id] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF',
issue_date: '2026-01-01',
net_amount_minor: 100000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 100000,
// A non-delivery installment so the contrast (scheduled date vs null) is meaningful.
payment_term_snapshot: JSON.stringify({ installments: [{ percent: 100, trigger: 'quote_accepted', offset_days: 0, label: 'Total' }], net_days: 30 }),
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
return id;
}
it('draft mode creates the invoice with scheduled_send_at = NULL (held), and returns its id', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
expect(Array.isArray(res.invoiceIds)).toBe(true);
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled'); // editable + sendInvoice can issue it
expect(inv.scheduled_send_at == null).toBe(true); // held — scheduler won't auto-send
});
it('without draft, the same installment IS scheduled (scheduled_send_at set)', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId);
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled');
expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set
});
it('prepare_event path (convertToEvent hold) creates a DRAFT event with held invoices', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true });
expect(res.eventId).toBeGreaterThanOrEqual(1);
expect(Array.isArray(res.invoiceIds)).toBe(true);
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
const ev = await db('events').where({ id: res.eventId }).first();
expect(ev.is_draft == true || ev.is_draft === 1).toBe(true); // created as a draft gallery
// Every invoice the event scheduled is held (no auto-send before the gate).
const invs = await db('invoices').whereIn('id', res.invoiceIds);
for (const inv of invs) expect(inv.scheduled_send_at == null).toBe(true);
// Quote is now linked to the event — convertToInvoiceOnly must NOT be called
// again for it (the flow's prepare_invoice adopts these ids instead).
const q = await db('quotes').where({ id: quoteId }).first();
expect(q.converted_event_id).toBe(res.eventId);
});
it('draft mode with the DEFAULT (after_delivery) payment term yields a SENDABLE scheduled invoice, not pending_delivery', async () => {
// Reproduces the booking_invoice_only flow on a quote with no explicit
// payment timing: the default installment is after_delivery, which would
// otherwise be pending_delivery — a status sendInvoice (send_document) rejects.
const dealUuid = crypto.randomUUID();
const [quoteId] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF',
issue_date: '2026-01-01',
net_amount_minor: 50000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 50000,
// No payment_term_snapshot → spawnInstallmentInvoices falls back to a single
// 100% after_delivery installment.
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled'); // sendInvoice accepts this
expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send
});
it('finalizeQuoteResponses only fires once the 15-min response window has locked', async () => {
const mk = async (lockOffsetMs) => {
const dealUuid = crypto.randomUUID();
const [id] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF', issue_date: '2026-01-01',
net_amount_minor: 1000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 1000,
responded_at: new Date().toISOString(),
response_locked_at: new Date(Date.now() + lockOffsetMs).toISOString(),
accepted_at: new Date().toISOString(),
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
return id;
};
const openId = await mk(15 * 60 * 1000); // still inside the window
const lockedId = await mk(-60 * 1000); // window already closed
const emitted = await quoteService.finalizeQuoteResponses();
expect(emitted).toBeGreaterThanOrEqual(1);
const open = await db('quotes').where({ id: openId }).first();
const locked = await db('quotes').where({ id: lockedId }).first();
expect(open.workflow_response_emitted_at == null).toBe(true); // deferred — not yet fired
expect(locked.workflow_response_emitted_at == null).toBe(false); // fired + stamped
// Idempotent: a second sweep doesn't re-fire the already-stamped one.
const again = await db('quotes').where({ id: lockedId })
.whereNull('workflow_response_emitted_at').update({ workflow_response_emitted_at: new Date() });
expect(again).toBe(0);
});
it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true });
expect(res.eventId).toBeGreaterThanOrEqual(1);
expect(res.invoiceIds).toEqual([]);
const invCount = await db('invoices').where({ event_id: res.eventId }).count({ c: '*' }).first();
expect(Number(invCount.c)).toBe(0); // pure date hold — no money documents
});
it('prepare_quote path (duplicateQuote) creates a new DRAFT quote — no in-trx deadlock', async () => {
const quoteId = await acceptedQuote();
const newId = await quoteService.duplicateQuote(quoteId, adminId);
expect(newId).toBeGreaterThanOrEqual(1);
expect(newId).not.toBe(quoteId);
const q = await db('quotes').where({ id: newId }).first();
expect(q.status).toBe('draft');
});
it('registers prepare_gallery / reserve_date / prepare_quote as real actions', () => {
const { registry } = require('../../src/services/workflows'); // loads actions.js (side-effect registration)
for (const a of ['prepare_gallery', 'reserve_date', 'prepare_quote', 'prepare_event', 'prepare_invoice', 'send_document']) {
expect(typeof registry.getAction(a)).toBe('function');
}
});
it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => {
const contractService = require('../../src/services/contractService');
const quoteId = await acceptedQuote();
const res = await contractService.createFromQuote(quoteId, adminId);
expect(res.contractId).toBeGreaterThanOrEqual(1);
expect(res.alreadyConverted).toBe(false);
const c = await db('contracts').where({ id: res.contractId }).first();
expect(c).toBeTruthy();
});
});
@@ -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);
});
});
@@ -1,116 +0,0 @@
/**
* Schema-shape regression net for the CRM consolidated migration.
*
* Pins the table/column layout that the route + service layer expect
* after `migrations/core/107_crm_consolidated.js` runs. The schema-
* drift workflow (#530) catches Postgres-only FK ordering bugs (the
* forward-reference deferral added in this PR), but it doesn't notice
* if a future edit silently drops a column the service code reads —
* SQLite would just return undefined and the broken behavior would
* land on beta.
*
* Touches the lineage chain (deal_uuid + back-pointer FKs) explicitly
* so a rename or removal there fails the test instead of silently
* breaking the lineage card.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
describe('CRM schema after core migrations', () => {
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('table layout', () => {
const expectedTables = [
'admin_users', 'customer_accounts', 'business_profile', 'business_bank_accounts',
'events', 'document_sequences',
'quotes', 'quote_line_items', 'quote_line_item_presets', 'quote_action_tokens',
'contracts', 'contract_blocks', 'contract_block_inclusions', 'contract_action_tokens',
'invoices', 'invoice_line_items', 'invoice_payment_log', 'invoice_payment_check_tokens',
'customer_hour_entries',
'payment_term_templates', 'payment_net_days_templates', 'payment_timing_templates',
'event_payment_plans',
];
it.each(expectedTables)('has table %s', async (table) => {
expect(await db.schema.hasTable(table)).toBe(true);
});
});
describe('deal_uuid lineage columns', () => {
// Every document in one engagement shares a deal_uuid — the
// lineage card joins on it. Drop the column anywhere in the chain
// and the card silently returns partial data.
it.each(['quotes', 'contracts', 'invoices'])(
'%s has deal_uuid column',
async (table) => {
expect(await db.schema.hasColumn(table, 'deal_uuid')).toBe(true);
}
);
// The back-pointer FKs were the source of the schema-drift bug
// we fixed in this PR (forward references). Pin them.
it('quotes has converted_contract_id back-pointer', async () => {
expect(await db.schema.hasColumn('quotes', 'converted_contract_id')).toBe(true);
});
it('invoices has source_contract_id back-pointer', async () => {
expect(await db.schema.hasColumn('invoices', 'source_contract_id')).toBe(true);
});
it('invoices has source_quote_id back-pointer', async () => {
expect(await db.schema.hasColumn('invoices', 'source_quote_id')).toBe(true);
});
});
describe('Storno discriminator columns', () => {
// kind='storno' + cancels_invoice_id + negative totals are the
// shape every aggregate filter relies on (feedback_storno_filter_
// everywhere). Pin the columns so a rename doesn't silently break
// every revenue report.
it('invoices has kind discriminator', async () => {
expect(await db.schema.hasColumn('invoices', 'kind')).toBe(true);
});
it('invoices has cancels_invoice_id self-ref', async () => {
expect(await db.schema.hasColumn('invoices', 'cancels_invoice_id')).toBe(true);
});
it('invoices has replaces_invoice_id self-ref', async () => {
expect(await db.schema.hasColumn('invoices', 'replaces_invoice_id')).toBe(true);
});
});
describe('Event time columns (migration 137)', () => {
// The admin calendar reads these to render timed vs. full-day
// tiles. Per the feedback_migration_preserve_visuals rule, the
// default has to be `is_full_day=true` so existing rows keep
// their pre-migration visual.
it('events has event_time_start', async () => {
expect(await db.schema.hasColumn('events', 'event_time_start')).toBe(true);
});
it('events has event_time_end', async () => {
expect(await db.schema.hasColumn('events', 'event_time_end')).toBe(true);
});
it('events has is_full_day', async () => {
expect(await db.schema.hasColumn('events', 'is_full_day')).toBe(true);
});
});
describe('seed paths', () => {
it('admin + customer seed inserts cleanly', async () => {
const { adminId, customerId } = await seedMinimal(db);
expect(adminId).toBeTruthy();
expect(customerId).toBeTruthy();
const admin = await db('admin_users').where({ id: adminId }).first();
const customer = await db('customer_accounts').where({ id: customerId }).first();
expect(admin.email).toBe('tester@example.com');
expect(customer.email).toBe('customer@example.com');
});
});
});
@@ -1,79 +0,0 @@
/**
* Negative line items (Rabatt / manual discount lines) are accepted
* end-to-end as long as the resulting total stays ≥ 0. When the
* discount would drive the total negative, the service rejects with
* a clear, code-tagged error so the admin is steered to Storno for
* credit-note workflows.
*
* Touches the actual createInvoice / createQuote service paths so a
* future change to either computeTotals or the guard fires this test.
*/
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);
describe('discount line items (negative unit_price_minor)', () => {
let db;
let cleanup;
let adminId;
let customerId;
let invoiceService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
invoiceService = require('../../src/services/invoiceService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
// Quote-side coverage of the symmetric validator + guard is
// deliberately omitted: createQuote's init path takes ~30 s under
// this harness (something in pdfService / emailProcessor cold-
// require), which would push the suite well past CI's per-test
// budget. The shape of the guard is identical to the invoice one
// covered below; a future change to extract the slow init or to
// stub it for tests should re-enable a parallel quote test.
describe('invoices', () => {
it('accepts a negative-price line and computes the net correctly', async () => {
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 20000, discount_percent: 0 },
{ position: 2, quantity: 1, description: 'Treuerabatt', unit_price_minor: -5000, discount_percent: 0 },
],
}, adminId);
expect(Array.isArray(invoiceIds)).toBe(true);
expect(invoiceIds.length).toBe(1);
const row = await db('invoices').where({ id: invoiceIds[0] }).first();
expect(row.net_amount_minor).toBe(15000);
expect(row.total_amount_minor).toBe(15000);
});
it('rejects when the discount drives the total negative', async () => {
await expect(invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 10000, discount_percent: 0 },
{ position: 2, quantity: 1, description: 'Übergroßer Rabatt', unit_price_minor: -50000, discount_percent: 0 },
],
}, adminId)).rejects.toMatchObject({
code: 'INVOICE_TOTAL_NEGATIVE',
statusCode: 400,
});
});
});
});
@@ -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,98 +0,0 @@
/**
* Boot-time email-template self-heal:
* 1. Seeds the CRM / contract / event-reminder templates on an
* install that's never had them before.
* 2. Recovers email_queue rows that previously exhausted their
* retries because their template was missing.
*
* The failure that triggered this fix (2026-05-27) had Ralf's beta
* box failing every `quote_sent` / `invoice_sent` send for ~14h
* because crmEmailTemplates.ensureCrmEmailTemplatesSeeded was
* defined but never called. After 3 retries the rows sat in
* status='pending' forever; nothing in the admin UI signalled the
* problem. Both halves of that regression are covered here.
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('email template self-heal at boot', () => {
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('seeds crm/contract/event-reminder templates and recovers stuck queue rows', async () => {
// Sanity: a fresh CRM-migrated DB does NOT carry CRM templates —
// 107_crm_consolidated documents the deliberate split (templates
// are self-healed at runtime, not inserted by the migration).
const before = await db('email_templates')
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
.pluck('template_key');
expect(before).toEqual([]);
// Seed a stuck queue row that mirrors what we found on Ralf's box:
// quote_sent send attempted 3 times, each time failed because the
// template didn't exist, queue processor gave up.
const queueRowIds = await db('email_queue').insert({
recipient_email: 'customer@example.com',
email_type: 'quote_sent',
email_data: JSON.stringify({ quote_number: 'Q-2026-0001' }),
status: 'pending',
retry_count: 3,
error_message: "Email template 'quote_sent' not found",
created_at: new Date(),
}).returning('id');
const queueRowId = typeof queueRowIds[0] === 'object' ? queueRowIds[0].id : queueRowIds[0];
// Also seed an UNRELATED stuck row (different template, NOT one
// we're going to insert) to confirm the recovery is targeted —
// it must not blanket-reset every retry-exhausted row.
const unrelatedIds = await db('email_queue').insert({
recipient_email: 'someone@example.com',
email_type: 'some_other_template',
email_data: JSON.stringify({}),
status: 'pending',
retry_count: 3,
error_message: 'SMTP timeout',
created_at: new Date(),
}).returning('id');
const unrelatedId = typeof unrelatedIds[0] === 'object' ? unrelatedIds[0].id : unrelatedIds[0];
// The seeders use module-level caches (`_seeded = true`). When
// jest runs this test in isolation that cache starts fresh; in
// the full suite no other test currently calls these seeders, so
// the first call here also runs the real work. Reset the cache
// defensively in case a future test changes that.
jest.resetModules();
const { seedEmailTemplatesAndRecoverQueue } = require('../../src/services/_emailTemplateBoot');
const result = await seedEmailTemplatesAndRecoverQueue(db, null);
// Templates landed.
expect(result.seeded).toEqual(expect.arrayContaining([
'quote_sent', 'invoice_sent', 'storno_issued',
]));
const after = await db('email_templates')
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
.pluck('template_key');
expect(after.sort()).toEqual(['invoice_sent', 'quote_sent', 'storno_issued']);
// Stuck quote_sent row was recovered.
expect(result.recovered).toBeGreaterThanOrEqual(1);
const recoveredRow = await db('email_queue').where({ id: queueRowId }).first();
expect(recoveredRow.retry_count).toBe(0);
expect(recoveredRow.error_message).toBeNull();
expect(recoveredRow.status).toBe('pending'); // ready for the next tick
// Unrelated stuck row was NOT touched.
const unrelatedRow = await db('email_queue').where({ id: unrelatedId }).first();
expect(unrelatedRow.retry_count).toBe(3);
expect(unrelatedRow.error_message).toBe('SMTP timeout');
});
});
@@ -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);
});
});
@@ -1,64 +0,0 @@
/**
* Renaming an event type's slug_prefix must CASCADE to everything keyed on the
* old slug, so a rename behaves like a rename rather than silently detaching
* existing events/quotes and orphaning the per-type pre-event reminder template.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll.
jest.setTimeout(120000);
describe('event type slug rename cascade', () => {
let db;
let cleanup;
let customerId;
let eventTypeService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
eventTypeService = require('../../src/services/eventTypeService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('re-points events + quotes + the reminder template from old slug to new', async () => {
// A non-system event type with slug 'party'.
const [typeId] = await db('event_types').insert({ name: 'Party', slug_prefix: 'party', is_active: true });
// An authored per-type reminder template + an event + a quote, all on 'party'.
await db('email_templates').insert({ template_key: 'event_reminder_party', subject_en: 'Party reminder' });
await db('events').insert({
event_type: 'party', password_hash: 'x', expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug: 'party-ev', share_link: 'party-ev',
event_name: 'A party', event_date: '2026-09-01',
});
await db('quotes').insert({
quote_number: 'Q-PARTY-1', customer_account_id: customerId, issue_date: '2026-01-01', event_type: 'party',
});
// Rename the slug.
await eventTypeService.updateEventType(typeId, { slug_prefix: 'concert' });
// Event + quote follow the rename.
expect((await db('events').where({ slug: 'party-ev' }).first()).event_type).toBe('concert');
expect((await db('quotes').where({ quote_number: 'Q-PARTY-1' }).first()).event_type).toBe('concert');
// The authored reminder template moved (subject/body preserved), old key gone.
expect(await db('email_templates').where({ template_key: 'event_reminder_party' }).first()).toBeUndefined();
const moved = await db('email_templates').where({ template_key: 'event_reminder_concert' }).first();
expect(moved).toBeTruthy();
expect(moved.subject_en).toBe('Party reminder');
});
it('does not clobber an existing template for the new slug', async () => {
const [typeId] = await db('event_types').insert({ name: 'Gala', slug_prefix: 'gala', is_active: true });
await db('email_templates').insert({ template_key: 'event_reminder_gala', subject_en: 'old gala' });
await db('email_templates').insert({ template_key: 'event_reminder_soiree', subject_en: 'existing soiree' });
await eventTypeService.updateEventType(typeId, { slug_prefix: 'soiree' });
// Target already existed → left intact; source not force-merged over it.
expect((await db('email_templates').where({ template_key: 'event_reminder_soiree' }).first()).subject_en)
.toBe('existing soiree');
});
});
@@ -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);
});
});
@@ -1,231 +0,0 @@
/**
* HTTP-level tests for the `/s/:shortSlug` public resolver (#699).
*
* Verifies the contract the public route is expected to honour:
* - Browser UA 302 to target_path
* - Social crawler UA 200 with OG <meta>, canonical = /s/<slug>
* - Soft-deleted slug 410 Gone (intentional-delete signal)
* - Unknown slug 404 Not Found
* - Hit count increments after successful resolutions (both shapes)
*
* Mirrors the production server.js wiring but doesn't load the whole
* server the surrounding middleware (CORS, helmet, rate limiters)
* isn't part of this route's contract.
*/
const express = require('express');
const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
let db; let cleanup; let service; let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Persist a business_profile + business_name so buildOgMetadata's
// settings-based fields populate consistently.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('branding_company_name', JSON.stringify('Test Studio'), 'string');
service = require('../../src/services/galleryShortUrlService');
const {
isSocialCrawler, buildOgMetadata, renderOgHtml,
} = require('../../src/services/galleryOgService');
app = express();
app.get('/s/:shortSlug', async (req, res) => {
try {
const row = await service.findByShortSlug(req.params.shortSlug);
if (!row) return res.status(404).type('text/plain').send('Short URL not found');
if (row.deleted_at) return res.status(410).type('text/plain').send('Short URL has been removed');
if (isSocialCrawler(req.get('user-agent'))) {
const event = await db('events').where({ id: row.event_id }).first('slug');
if (event?.slug) {
const meta = await buildOgMetadata(event.slug, req.originalUrl);
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
meta.url = `${base}/s/${row.short_slug}`;
res.set('Cache-Control', 'public, max-age=300');
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(renderOgHtml(meta));
service.recordHit(row.id).catch(() => {});
return;
}
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
}
service.recordHit(row.id).catch(() => {});
return res.redirect(302, row.target_path);
} catch (err) {
return res.status(500).type('text/plain').send(err.message);
}
});
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function seedEventAndShortUrl({ slug = `evt-${Date.now()}`, shortSlug }) {
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [eventId] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: 'Test Event',
event_date: '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const row = await service.createShortUrl({
eventId, customSlug: shortSlug,
});
return { eventId, shortUrl: row };
}
// User-agent strings the production `isSocialCrawler` helper matches.
// Snapshot known-true samples here so the test stays in sync if the
// helper's allowlist evolves.
const BOT_UA_WHATSAPP = 'WhatsApp/2.23.20.0';
const BOT_UA_FACEBOOK = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
describe('GET /s/:shortSlug — browser (302 redirect)', () => {
it('redirects to the snapshotted target_path with a 302', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'browser-redirect', shortSlug: 'go-here',
});
const res = await request(app)
.get('/s/go-here')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(302);
expect(res.headers.location).toBe(shortUrl.target_path);
expect(res.headers.location).toMatch(/^\/gallery\//);
});
it('increments hit_count on a browser hit (fire-and-forget — wait briefly)', async () => {
await seedEventAndShortUrl({
slug: 'hit-browser', shortSlug: 'hit-from-browser',
});
await request(app).get('/s/hit-from-browser').set('User-Agent', BROWSER_UA);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-browser');
expect(row.hit_count).toBe(1);
expect(row.last_hit_at).toBeTruthy();
});
});
describe('GET /s/:shortSlug — social crawler (OG metadata)', () => {
it('returns 200 with OG HTML for WhatsApp UA', async () => {
await seedEventAndShortUrl({
slug: 'whatsapp-og', shortSlug: 'wa-preview',
});
const res = await request(app)
.get('/s/wa-preview')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/html/);
expect(res.text).toContain('<meta');
expect(res.text).toMatch(/og:title/);
expect(res.text).toMatch(/og:url/);
});
it('og:url canonical points at /s/<slug>, not the underlying gallery URL', async () => {
await seedEventAndShortUrl({
slug: 'canonical-test', shortSlug: 'canonical-short',
});
const res = await request(app)
.get('/s/canonical-short')
.set('User-Agent', BOT_UA_FACEBOOK);
expect(res.status).toBe(200);
// The og:url meta tag must contain the short-URL path, not the
// /gallery/<slug> path — this is the cache-key invariant from #699.
expect(res.text).toMatch(/property="og:url"\s+content="[^"]*\/s\/canonical-short"/);
expect(res.text).not.toMatch(
/property="og:url"\s+content="[^"]*\/gallery\/canonical-test"/
);
});
it('sets a short cache header so scrapers can re-fetch when admin rotates the preview', async () => {
await seedEventAndShortUrl({
slug: 'cache-header', shortSlug: 'cache-test',
});
const res = await request(app)
.get('/s/cache-test')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.headers['cache-control']).toMatch(/public/);
expect(res.headers['cache-control']).toMatch(/max-age=300/);
});
it('increments hit_count on a crawler hit as well', async () => {
await seedEventAndShortUrl({
slug: 'hit-bot', shortSlug: 'hit-from-bot',
});
await request(app).get('/s/hit-from-bot').set('User-Agent', BOT_UA_WHATSAPP);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-bot');
expect(row.hit_count).toBe(1);
});
});
describe('GET /s/:shortSlug — error states', () => {
it('404 for an unknown slug', async () => {
const res = await request(app)
.get('/s/never-existed')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
it('410 for a soft-deleted slug (intentional-delete signal)', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'gone-test', shortSlug: 'gone-slug',
});
await service.softDelete(shortUrl.id, null);
const res = await request(app)
.get('/s/gone-slug')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(410);
});
it('410 if the event was hard-deleted but the short URL row somehow survives', async () => {
const { eventId } = await seedEventAndShortUrl({
slug: 'orphan-test', shortSlug: 'orphan-slug',
});
// Hard-delete the event row (FK CASCADE would normally clean up the
// short URL too — but if CASCADE didn't fire for whatever reason
// (e.g. SQLite foreign_keys pragma off in a particular runtime), the
// resolver should still degrade safely).
// SQLite's foreign_keys pragma is OFF by default; the migration
// doesn't toggle it, so this delete leaves the short URL row.
await db('events').where({ id: eventId }).delete();
const res = await request(app)
.get('/s/orphan-slug')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(410);
});
it('404 for a malformed slug (rejected at validation, no DB hit)', async () => {
const res = await request(app)
.get('/s/UPPER_CASE')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
});
describe('Regression — existing URL paths must still respond the same', () => {
// The /s/* namespace is additive: it must NOT shadow /gallery/*
// or any of the OG routes. We don't load the whole app here, but we
// can at least pin that the route param doesn't accept slashes —
// i.e. /s/foo/bar must NOT be matched by our handler.
it('the /s/:shortSlug route does not match nested paths', async () => {
const res = await request(app)
.get('/s/foo/bar')
.set('User-Agent', BROWSER_UA);
// Express returns its default 404 when no route matches the path.
expect(res.status).toBe(404);
});
});
@@ -1,282 +0,0 @@
/**
* Integration tests for the branded short-URL service (#699).
*
* Exercises createShortUrl + findByShortSlug + listForEvent + softDelete
* + recordHit against a real SQLite DB, including the contracts that
* matter for production correctness:
*
* - Custom slug + collision detection (409 with `suggested`)
* - Auto-generated slug from event slug + year
* - Soft-delete preserves the row (admin can audit)
* - target_path snapshots at create time (toggling the global
* "Use short gallery URLs" setting later doesn't change existing
* short URLs backward-compat invariant from #699)
* - hit_count increments idempotently
* - findByShortSlug returns soft-deleted rows (caller decides 410 vs 404)
*
* Boots one DB for the whole file (cheap on SQLite); each test seeds
* its own event row to keep scope clean.
*/
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
let db; let cleanup; let service; let adminId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Minimal admin for created_by audit.
const adminInsert = await db('admin_users').insert({
username: 'shorturl-test',
email: 'shorturl@example.com',
password_hash: 'x',
must_change_password: false,
created_at: new Date(),
}).returning('id');
adminId = adminInsert[0]?.id ?? adminInsert[0];
service = require('../../src/services/galleryShortUrlService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
// Each test seeds a fresh event so collisions / counter state don't leak.
async function seedEvent(overrides = {}) {
const slug = overrides.slug || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [id] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: overrides.event_name || 'Test Wedding',
event_date: overrides.event_date || '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: overrides.share_token || `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const event = await db('events').where({ id }).first();
return event;
}
describe('createShortUrl — custom slug', () => {
it('creates with a custom slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-1' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'sofia-graduation-1',
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-1');
expect(row.target_path).toBe(`/gallery/${event.slug}`);
expect(row.event_id).toBe(event.id);
expect(row.hit_count).toBe(0);
});
it('lowercases the input — operators pasting mixed-case still get a clean slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-2' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'Sofia-GraduAtion-2', // mixed case
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-2');
});
it('rejects an invalid slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'invalid-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'invalid slug with spaces',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a reserved slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'reserved-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'admin',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a duplicate slug with SLUG_TAKEN + suggested fallback', async () => {
const event1 = await seedEvent({ slug: 'dup-test-1' });
const event2 = await seedEvent({ slug: 'dup-test-2' });
await service.createShortUrl({ eventId: event1.id, customSlug: 'collide-me' });
await expect(service.createShortUrl({
eventId: event2.id, customSlug: 'collide-me',
})).rejects.toMatchObject({
code: 'SLUG_TAKEN',
suggested: expect.any(String),
});
});
it('throws EVENT_NOT_FOUND when the event id does not exist', async () => {
await expect(service.createShortUrl({
eventId: 9999999, customSlug: 'no-event',
})).rejects.toMatchObject({ code: 'EVENT_NOT_FOUND' });
});
});
describe('createShortUrl — auto-generated slug', () => {
it('uses event slug + year when no custom slug provided', async () => {
const event = await seedEvent({
slug: 'autogen-wedding', event_date: '2026-06-05',
});
const row = await service.createShortUrl({
eventId: event.id,
createdBy: adminId,
});
// First-choice candidate is just the slug; takes that.
expect(row.short_slug).toBe('autogen-wedding');
});
it('falls back to slug-year when the bare slug is already taken', async () => {
// Both events SHARE the same canonical slug so the first-choice
// bare-slug candidate is burned, forcing autoGen to try the
// year-suffixed variant.
const event1 = await seedEvent({
slug: 'collide-base', event_date: '2026-07-01',
});
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base',
});
const event2 = await seedEvent({
slug: 'collide-base-2', event_date: '2026-07-01',
});
// Force the bare candidate of event2 to also collide by burning it.
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base-2',
});
const row = await service.createShortUrl({
eventId: event2.id, // No custom — auto-gen from event2.slug
});
// Bare candidate `collide-base-2` is taken → year-suffixed picks.
expect(row.short_slug).toBe('collide-base-2-2026');
});
});
describe('createShortUrl — target_path snapshotting (#699 backward-compat)', () => {
it('uses /gallery/<slug> when the global short-URLs setting is OFF (default)', async () => {
const event = await seedEvent({ slug: 'snapshot-off' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-off',
});
expect(row.target_path).toBe(`/gallery/${event.slug}`);
});
it('uses /gallery/<share_token> when the global setting is ON at create time', async () => {
// Persist the setting.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(true), 'system');
try {
const event = await seedEvent({ slug: 'snapshot-on', share_token: 'tokenAbc123' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-on',
});
expect(row.target_path).toBe(`/gallery/${event.share_token}`);
// CRITICAL backward-compat invariant: now flip the setting OFF.
// Existing short URLs must still resolve to the same target_path
// they were created with — operator's existing share links don't
// silently change behaviour.
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
const refetched = await service.findByShortSlug('snap-on');
expect(refetched.target_path).toBe(`/gallery/${event.share_token}`);
} finally {
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
}
});
});
describe('findByShortSlug + listForEvent', () => {
it('returns null for an unknown slug', async () => {
expect(await service.findByShortSlug('does-not-exist-xyz')).toBeNull();
});
it('returns null for a malformed slug (no DB hit)', async () => {
expect(await service.findByShortSlug('UPPER_CASE')).toBeNull();
expect(await service.findByShortSlug('with spaces')).toBeNull();
expect(await service.findByShortSlug('')).toBeNull();
});
it('returns soft-deleted rows (caller decides 410 vs 404)', async () => {
const event = await seedEvent({ slug: 'softdel-find' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'find-deleted',
});
await service.softDelete(created.id, adminId);
const fetched = await service.findByShortSlug('find-deleted');
expect(fetched).not.toBeNull();
expect(fetched.deleted_at).toBeTruthy();
});
it('listForEvent excludes soft-deleted rows', async () => {
const event = await seedEvent({ slug: 'list-test' });
const live = await service.createShortUrl({
eventId: event.id, customSlug: 'list-live',
});
const deleted = await service.createShortUrl({
eventId: event.id, customSlug: 'list-deleted',
});
await service.softDelete(deleted.id, adminId);
const list = await service.listForEvent(event.id);
const ids = list.map((r) => r.id);
expect(ids).toContain(live.id);
expect(ids).not.toContain(deleted.id);
});
});
describe('softDelete', () => {
it('returns true on first call, false on second (idempotent admin clicks)', async () => {
const event = await seedEvent({ slug: 'softdel-idem' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'idem-delete',
});
expect(await service.softDelete(created.id, adminId)).toBe(true);
expect(await service.softDelete(created.id, adminId)).toBe(false);
});
it('returns false for an unknown id (caller maps to 404)', async () => {
expect(await service.softDelete(9999999, adminId)).toBe(false);
});
});
describe('createShortUrl after soft-delete — slug rotation', () => {
it('re-creating a soft-deleted slug succeeds (purges the deleted row)', async () => {
const event = await seedEvent({ slug: 'rotate' });
const first = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
await service.softDelete(first.id, adminId);
// The slug is now reclaimable for a fresh row.
const second = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
expect(second.id).not.toBe(first.id);
expect(second.short_slug).toBe('rotate-me');
});
});
describe('recordHit', () => {
it('increments hit_count + stamps last_hit_at', async () => {
const event = await seedEvent({ slug: 'hit-counter' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'count-me',
});
await service.recordHit(row.id);
await service.recordHit(row.id);
const fetched = await service.findByShortSlug('count-me');
expect(fetched.hit_count).toBe(2);
expect(fetched.last_hit_at).toBeTruthy();
});
it('is fire-and-forget — invalid id does not throw', async () => {
await expect(service.recordHit(9999999)).resolves.not.toThrow();
});
});
@@ -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,230 +0,0 @@
/**
* Test harness for CRM integration tests.
*
* Boots a temp-SQLite database, runs every `migrations/core/*.up()`
* directly (bypassing knex's Migrator its exclusive write lock
* deadlocks 001_init's nested `initializeDatabase()` call), and
* exposes a small helper for seeding the minimal row set that the
* quote/contract/invoice services need to operate.
*
* Usage:
*
* const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
*
* beforeAll(async () => {
* ({ db, cleanup } = await bootCrmDb());
* ({ adminId, customerId } = await seedMinimal(db));
* });
* afterAll(async () => { await cleanup(); });
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const bcrypt = require('bcrypt');
async function runCoreMigrations(db) {
await db.schema.createTable('migrations', (t) => {
t.increments('id').primary();
t.string('filename').unique().notNullable();
t.timestamp('applied_at').defaultTo(db.fn.now());
});
const coreDir = path.resolve(__dirname, '..', '..', '..', 'migrations', 'core');
const files = (await fs.promises.readdir(coreDir))
.filter((f) => f.endsWith('.js'))
.sort();
for (const f of files) {
const mod = require(path.join(coreDir, f));
if (typeof mod.up === 'function') {
await mod.up(db);
}
await db('migrations').insert({ filename: f });
}
}
/**
* Boot a clean test DB. Returns { db, cleanup, tmpDir }.
* Caller must invoke cleanup() in afterAll to release the SQLite file
* and the temp directory.
*/
async function bootCrmDb() {
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-crm-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'crm.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
// No jest.resetModules() — every service the test later requires
// must share THIS db instance. Two module copies on one SQLite file
// each open their own knex pool and the SQLite write lock deadlocks
// the second one acquiring a connection. Caller is responsible for
// setting TEST_DATABASE_PATH before the first require of db.js
// (which knexfile reads at module-init time); bootCrmDb only works
// when invoked before any service import.
const { db } = require('../../../src/database/db');
await runCoreMigrations(db);
return {
db,
tmpDir,
cleanup: async () => {
try { await db.destroy(); } catch (_) {}
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch (_) {}
},
};
}
/**
* Seed the minimal row set that quote/contract/invoice services
* dereference on creation: an admin user, an active customer, a
* business_profile row, and the app_settings keys the services read.
*
* Returns the ids the caller will pass into service calls.
*/
async function seedMinimal(db) {
const passwordHash = await bcrypt.hash('test-pass', 4); // low rounds = fast
const adminInsert = await db('admin_users').insert({
username: 'tester', email: 'tester@example.com',
password_hash: passwordHash, must_change_password: false,
created_at: new Date(),
}).returning('id');
const adminId = adminInsert[0]?.id ?? adminInsert[0];
// business_profile is a singleton; the row is seeded by migration 107
// for fresh installs. Defensive: insert if missing.
const profile = await db('business_profile').first();
if (!profile) {
await db('business_profile').insert({
legal_name: 'Test Studio',
default_currency: 'CHF',
default_locale: 'de',
});
}
const customerInsert = await db('customer_accounts').insert({
email: 'customer@example.com',
display_name: 'Test Customer',
password_hash: passwordHash,
preferred_language: 'de',
is_active: 1,
created_at: new Date(),
}).returning('id');
const customerId = customerInsert[0]?.id ?? customerInsert[0];
return { adminId, customerId };
}
// ---------------------------------------------------------------------
// Route-test helpers (#570) — building blocks for the CRM HTTP layer
// tests. Kept here so every supertest suite shares the same minting +
// app-wiring shape and a refactor lands in one place.
// ---------------------------------------------------------------------
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const express = require('express');
const cookieParser = require('cookie-parser');
/**
* Promote a seeded admin into a role (default `super_admin`) so
* `requirePermission(...)` checks pass. seedMinimal creates an admin
* without a role that's good for negative tests (expect 403) but
* happy-path tests need the role assignment.
*
* Returns the role id the admin was assigned to.
*/
async function assignAdminRole(db, adminId, roleName = 'super_admin') {
const role = await db('roles').where({ name: roleName }).first();
if (!role) {
throw new Error(`Role '${roleName}' not seeded — check the test DB`);
}
await db('admin_users').where({ id: adminId }).update({ role_id: role.id });
return role.id;
}
/**
* Mint an admin JWT in the same shape adminAuth middleware expects.
* The tests inject this via `Authorization: Bearer <token>`.
*/
function mintAdminToken(adminId, { expiresIn = '1h', extraClaims = {} } = {}) {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
return jwt.sign(
{ id: adminId, type: 'admin', iat: Math.floor(Date.now() / 1000), ...extraClaims },
process.env.JWT_SECRET,
{ expiresIn, issuer: 'picpeak-auth' }
);
}
/**
* Insert a row into one of the public-token tables for testing the
* loadActionToken guard outcomes. Returns the generated 64-hex token.
*
* Usage:
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: pastDate });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, used_at: new Date() });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: null });
*/
async function createPublicToken(db, tableName, opts = {}) {
const token = opts.token || crypto.randomBytes(32).toString('hex');
const expiresAt = opts.expires_at === null
? null
: (opts.expires_at || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000));
// Serialise Date → ISO string. Bare Date objects round-tripped
// inconsistently through knex+SQLite — sometimes as epoch ms,
// sometimes via .toString() → literal "[object Object]" which then
// parses back to NaN and silently defeats the expiry guard.
const toStorable = (v) => (v instanceof Date ? v.toISOString() : v);
const row = {
...opts,
token,
expires_at: toStorable(expiresAt),
created_at: toStorable(new Date()),
};
await db(tableName).insert(row);
return token;
}
/**
* Build an Express app with the requested route file mounted. Mirrors
* the production app's middleware shape (json + cookies) but skips
* everything else (CORS, helmet, rate limiters) route tests pin the
* handler's contract, not the surrounding cross-cutting concerns.
*
* Example:
* const app = buildRouteApp('/api/public/quotes',
* require('../../src/routes/publicQuotes'));
*/
function buildRouteApp(mount, router) {
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(mount, router);
// Catch-all error handler. Mirrors the real middleware/errorHandler:
// AppError subclasses (ValidationError, NotFoundError, etc.) use
// `.statusCode` (NOT `.status` — getting that wrong silently maps
// every 400 / 404 / 410 to 500 in tests).
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const statusCode = err.statusCode || err.status || 500;
res.status(statusCode).json({
error: err.message || 'Internal error',
code: err.code,
...(err.details ? { details: err.details } : {}),
});
});
return app;
}
module.exports = {
bootCrmDb,
seedMinimal,
assignAdminRole,
mintAdminToken,
createPublicToken,
buildRouteApp,
};
@@ -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 });
});
});
});
@@ -1,211 +0,0 @@
/**
* Incoming-invoice categorisation + re-bill chain (expenseService) against a
* real SQLite schema. Covers the bits unit tests can't: the disposition state
* machine, re-categorisation unwind, the per-event PENDING pool + bundling, and
* the monthly accumulator immediate-bill i.e. that categorizeInbound /
* billPendingRebills actually mint / amend invoice rows correctly.
*
* No date-range comparisons are exercised here, so it's safe on SQLite (the
* usual PG-vs-SQLite date pitfall [[feedback_pg_date_columns_serialize]]
* doesn't apply to this path).
*/
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);
describe('incoming-invoice categorise / re-bill chain', () => {
let db;
let cleanup;
let adminId;
let expenseService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// logActivity writes to activity_logs via the GLOBAL db. createInvoice (and
// appendToMonthlyDraft) call it INSIDE the transaction we pass them, and a
// second write connection deadlocks against the held write lock on
// SQLite. It's fire-and-forget audit noise, irrelevant to these
// assertions, so stub it BEFORE the services destructure it at require
// time. (Production runs Postgres, where the concurrent write is fine.)
const dbModule = require('../../src/database/db');
dbModule.logActivity = async () => {};
({ adminId } = await seedMinimal(db));
expenseService = require('../../src/services/expenseService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
async function captureDoc(overrides = {}) {
const ins = await db('inbound_documents').insert({
source: 'upload',
status: 'unsorted',
parse_status: 'pending',
parse_method: 'none',
supplier_name: 'ACME AG',
currency: 'CHF',
total_amount_minor: 10000,
invoice_date: '2026-06-01',
created_at: new Date(),
updated_at: new Date(),
...overrides,
}).returning('id');
return unwrapId(ins);
}
let customerSeq = 0;
async function makeCustomer(billingCadence) {
customerSeq += 1;
const ins = await db('customer_accounts').insert({
email: `rebill-${billingCadence || 'event'}-${customerSeq}@example.com`,
display_name: `Rebill ${billingCadence || 'event'} ${customerSeq}`,
password_hash: 'x',
preferred_language: 'de',
is_active: 1,
billing_cadence: billingCadence || null,
created_at: new Date(),
}).returning('id');
return unwrapId(ins);
}
it('company expense (eigener_aufwand) categorises with no invoice + no customer', async () => {
const id = await captureDoc();
const doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
expect(doc.disposition).toBe('eigener_aufwand');
expect(doc.status).toBe('categorized');
expect(doc.billedInvoiceId).toBeNull();
expect(doc.customerAccountId).toBeNull();
});
it('rebill REQUIRES a customer', async () => {
const id = await captureDoc();
await expect(expenseService.categorizeInbound(id, { disposition: 'rebill' }, adminId))
.rejects.toMatchObject({ code: 'CUSTOMER_REQUIRED' });
});
it('per-event rebill stays PENDING (customer + markup stored, no invoice yet)', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc({ total_amount_minor: 10000 });
const doc = await expenseService.categorizeInbound(id, {
disposition: 'rebill', customerAccountId: customerId,
markupType: 'percent', markupPercent: 10,
}, adminId);
expect(doc.disposition).toBe('rebill');
expect(doc.customerAccountId).toBe(customerId);
expect(doc.billedInvoiceId).toBeNull(); // pending — not billed until bundled
expect(doc.markupType).toBe('percent');
expect(Number(doc.markupPercent)).toBe(10);
});
it('passthrough never carries a markup, even if one is sent', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc();
const doc = await expenseService.categorizeInbound(id, {
disposition: 'durchlaufend', customerAccountId: customerId,
markupType: 'percent', markupPercent: 25, // should be ignored
}, adminId);
expect(doc.disposition).toBe('durchlaufend');
expect(doc.customerAccountId).toBe(customerId);
expect(doc.markupType).toBe('none');
expect(doc.markupPercent).toBeNull();
expect(doc.billedInvoiceId).toBeNull();
});
it('billPendingRebills refuses monthly/manual customers (they auto-consolidate)', async () => {
const customerId = await makeCustomer('monthly');
await expect(expenseService.billPendingRebills(customerId, adminId))
.rejects.toMatchObject({ code: 'CADENCE_MISMATCH' });
});
// ── The actual invoice-MINTING paths (billPendingRebills bundling a per-event
// customer's pool; monthly-customer immediate-bill onto the running draft)
// both call invoiceService.createInvoice INSIDE a db.transaction. createInvoice
// claims its sequence number via the global db, which DEADLOCKS against the
// held write lock on a SQLite-backed harness (a second write connection blocks
// — verified). Production runs Postgres where the concurrent write is fine, so
// this is a harness limitation, not a product bug. The line-amount math is
// covered by the buildInboundLineItem unit tests, and createInvoice itself by
// discountLineItems.test.js. Below we test the UNWIND path against a
// hand-crafted billed state so we don't have to mint through createInvoice. ──
// Build a billed state directly: an invoice with two lines, with the inbound
// doc stamped onto the first line as a prior re-bill.
async function makeBilledDoc(customerId, { status = 'scheduled', scheduledSendAt = null, isMonthlyDraft = false } = {}) {
const invIns = await db('invoices').insert({
invoice_number: `R-TEST-${customerSeq}-${Math.floor(Math.random() * 1e9)}`,
customer_account_id: customerId,
status,
scheduled_send_at: scheduledSendAt,
is_monthly_draft: isMonthlyDraft,
currency: 'CHF',
issue_date: '2026-06-01',
due_date: '2026-07-01',
vat_rate: 0,
net_amount_minor: 7000, // 4000 (rebill line) + 3000 (sibling)
vat_amount_minor: 0,
total_amount_minor: 7000,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const invoiceId = unwrapId(invIns);
const rebillLineIns = await db('invoice_line_items').insert({
invoice_id: invoiceId, position: 1, quantity: 1, description: 'Rebill Co (Weiterverrechnung)',
unit_price_minor: 4000, discount_percent: 0, line_total_minor: 4000,
}).returning('id');
const rebillLineId = unwrapId(rebillLineIns);
await db('invoice_line_items').insert({
invoice_id: invoiceId, position: 2, quantity: 1, description: 'Other line',
unit_price_minor: 3000, discount_percent: 0, line_total_minor: 3000,
});
const id = await captureDoc({ total_amount_minor: 4000, supplier_name: 'Rebill Co' });
await db('inbound_documents').where({ id }).update({
disposition: 'rebill', status: 'categorized', customer_account_id: customerId,
billed_invoice_id: invoiceId, billed_invoice_line_item_id: rebillLineId,
});
return { id, invoiceId, rebillLineId };
}
it('re-categorising a billed doc UNWINDS its re-bill line + recomputes the (mutable) invoice', async () => {
const customerId = await makeCustomer('per_event');
const { id, invoiceId, rebillLineId } = await makeBilledDoc(customerId); // scheduled, no send-at → mutable
const recat = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
expect(recat.disposition).toBe('eigener_aufwand');
expect(recat.billedInvoiceId).toBeNull();
expect(recat.customerAccountId).toBeNull();
// The re-bill line is gone; the sibling line remains and net recomputes.
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeUndefined();
const after = await db('invoices').where({ id: invoiceId }).first();
expect(Number(after.net_amount_minor)).toBe(3000);
});
it('re-categorising a doc billed on an ISSUED invoice is refused (Storno required)', async () => {
const customerId = await makeCustomer('per_event');
const { id, rebillLineId } = await makeBilledDoc(customerId, { status: 'sent' });
await expect(expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId))
.rejects.toMatchObject({ code: 'INVOICE_LOCKED' });
// Nothing was touched — the line survives.
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeDefined();
});
it('re-categorisation moves a pending item between dispositions without a stray invoice', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc();
// passthrough → pending
let doc = await expenseService.categorizeInbound(id, { disposition: 'durchlaufend', customerAccountId: customerId }, adminId);
expect(doc.customerAccountId).toBe(customerId);
expect(doc.billedInvoiceId).toBeNull();
// → company expense: customer cleared, still no invoice
doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId);
expect(doc.disposition).toBe('eigener_aufwand');
expect(doc.customerAccountId).toBeNull();
expect(doc.billedInvoiceId).toBeNull();
});
});
@@ -1,214 +0,0 @@
/**
* Install-from-backup boot hook pins the trigger-file convention.
*
* The hook itself depends on `restoreService.restore`, which is hard
* to fully exercise in an integration test without a real PG cluster
* (sequence resync, DROP/CREATE, etc.). So we stub the actual restore
* and verify the BOOT HOOK logic:
*
* - No trigger file no-op, ran=false
* - Empty trigger file picks newest manifest from manifests/
* - Non-empty trigger file uses the path inside
* - DB not empty refuses (no restore call)
* - DB not empty + FORCE env proceeds
* - Successful restore deletes trigger file
* - Failed restore leaves trigger file in place
*
* These are the surfaces an admin will hit when actually using the
* feature the docker-compose-on-real-PG end-to-end test belongs in
* the follow-up CI work captured as task #7 earlier today.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
// Stub the heavy lifting so the test stays fast + portable.
const mockRestore = jest.fn();
jest.mock('../../src/services/restoreService', () => ({
restoreService: {
restore: (...args) => mockRestore(...args),
},
}));
jest.setTimeout(120000);
describe('installFromBackupBoot', () => {
let db;
let cleanup;
let storagePath;
let backupRoot;
let manifestsDir;
let tryInstallFromBackup;
let originalBackupRootEnv;
let originalForceEnv;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupRoot = path.join(storagePath, 'backup');
manifestsDir = path.join(backupRoot, 'manifests');
fs.mkdirSync(manifestsDir, { recursive: true });
originalBackupRootEnv = process.env.BACKUP_ROOT;
originalForceEnv = process.env.INSTALL_FROM_BACKUP_FORCE;
process.env.BACKUP_ROOT = backupRoot;
({ tryInstallFromBackup } = require('../../src/services/_installFromBackupBoot'));
}, 120000);
afterAll(async () => {
if (originalBackupRootEnv === undefined) {
delete process.env.BACKUP_ROOT;
} else {
process.env.BACKUP_ROOT = originalBackupRootEnv;
}
if (originalForceEnv === undefined) {
delete process.env.INSTALL_FROM_BACKUP_FORCE;
} else {
process.env.INSTALL_FROM_BACKUP_FORCE = originalForceEnv;
}
if (cleanup) await cleanup();
});
beforeEach(async () => {
mockRestore.mockReset();
mockRestore.mockResolvedValue({ success: true });
delete process.env.INSTALL_FROM_BACKUP_FORCE;
// Clean trigger files + manifests between tests
for (const name of ['RESTORE_ON_INSTALL', 'RESTORE_ON_INSTALL.txt']) {
const p = path.join(backupRoot, name);
if (fs.existsSync(p)) fs.unlinkSync(p);
}
for (const f of fs.readdirSync(manifestsDir)) {
fs.unlinkSync(path.join(manifestsDir, f));
}
// Reset DB to fresh-install state
await db('events').del();
// Leave admin_users alone — fresh-install state has 1 row.
});
it('no trigger file → no-op', async () => {
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(mockRestore).not.toHaveBeenCalled();
});
it('empty trigger file picks the newest manifest from manifests/', async () => {
const older = path.join(manifestsDir, 'backup-manifest-001.json');
const newer = path.join(manifestsDir, 'backup-manifest-002.json');
fs.writeFileSync(older, '{}');
// Set the newer file's mtime slightly later so it wins the sort
const past = new Date(Date.now() - 60_000);
fs.utimesSync(older, past, past);
fs.writeFileSync(newer, '{}');
// Empty trigger
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(result.manifestPath).toBe(newer);
expect(mockRestore).toHaveBeenCalledWith(expect.objectContaining({
source: 'local',
manifestPath: newer,
restoreType: 'full',
force: true,
skipPreBackup: true,
}));
});
it('non-empty trigger file uses the path inside', async () => {
const specific = path.join(manifestsDir, 'backup-manifest-specific.json');
fs.writeFileSync(specific, '{}');
// Relative to backupRoot
fs.writeFileSync(
path.join(backupRoot, 'RESTORE_ON_INSTALL'),
'manifests/backup-manifest-specific.json\n',
);
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(result.manifestPath).toBe(specific);
});
it('deletes the trigger file after a successful restore', async () => {
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
fs.writeFileSync(triggerPath, '');
await tryInstallFromBackup(db);
expect(fs.existsSync(triggerPath)).toBe(false);
});
it('leaves the trigger file in place when restore throws', async () => {
mockRestore.mockRejectedValueOnce(new Error('restore exploded'));
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
fs.writeFileSync(triggerPath, '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(result.error).toMatch(/restore exploded/);
expect(fs.existsSync(triggerPath)).toBe(true);
});
it('refuses to run when the install already has events', async () => {
// Simulate an install with existing data
await db('events').insert({
slug: 'existing-event',
event_name: 'Existing Event',
event_type: 'wedding',
event_date: new Date(),
host_email: 'host@example.com',
admin_email: 'host@example.com',
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
share_link: 'existing-event-token',
password_hash: 'dummy-hash-for-test',
created_at: new Date(),
});
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(result.error).toMatch(/Database not empty/);
expect(mockRestore).not.toHaveBeenCalled();
// Trigger file should NOT be deleted — admin needs to fix + retry
expect(fs.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'))).toBe(true);
});
it('proceeds when INSTALL_FROM_BACKUP_FORCE=true even with existing data', async () => {
await db('events').insert({
slug: 'existing-event-2',
event_name: 'Existing Event 2',
event_type: 'wedding',
event_date: new Date(),
host_email: 'host@example.com',
admin_email: 'host@example.com',
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
share_link: 'existing-event-2-token',
password_hash: 'dummy-hash-for-test-2',
created_at: new Date(),
});
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
process.env.INSTALL_FROM_BACKUP_FORCE = 'true';
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(mockRestore).toHaveBeenCalled();
});
});
@@ -1,115 +0,0 @@
/**
* Dunning / Mahngebühr logic the tax-sensitive bits added in the dunning
* rework. Covers the fee math (flat / percent), the VAT toggle gating
* (incl. the "no-op when the org has no VAT rate" requirement), per-reminder
* accumulation (2nd = 1×, 3rd = 2×), invoice immutability (the fee never
* changes the issued invoice total), and the 3-reminder cap.
*
* The Mahnung PDF render is stubbed PDF rendering (fonts) is flaky in CI and
* is verified manually; here we assert the data/immutability behaviour.
*/
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);
let db;
let cleanup;
let invoiceService;
let ids;
async function setSetting(key, value) {
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting(key, JSON.stringify(value), 'crm');
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
ids = await seedMinimal(db);
try { await db('customer_accounts').where({ id: ids.customerId }).update({ feature_bills: true }); } catch (_) {}
invoiceService = require('../../src/services/invoiceService');
// Stub the (flaky) PDF render so applyReminder exercises its data path.
// eslint-disable-next-line global-require
const pdfService = require('../../src/services/pdfService');
pdfService.renderInvoiceToBuffer = async () => Buffer.from('%PDF-stub');
});
afterAll(async () => { await cleanup(); });
describe('dunning fee resolvers', () => {
test('flat fee, no VAT', async () => {
await setSetting('crm_invoices_late_fee_enabled', true);
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', false);
const inv = { total_amount_minor: 100000 };
expect(await invoiceService.resolveLateFeeNetMinor(inv)).toBe(2000);
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
expect(await invoiceService.resolvePerReminderFeeMinor(inv)).toBe(2000);
});
test('percent fee = % of the invoice gross', async () => {
await setSetting('crm_invoices_late_fee_type', 'percent');
await setSetting('crm_invoices_late_fee_percent', 5);
expect(await invoiceService.resolveLateFeeNetMinor({ total_amount_minor: 100000 })).toBe(5000);
});
test('VAT toggle applies the org rate, but is a NO-OP when the org has no VAT rate', async () => {
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', true);
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 8.1 });
expect(await invoiceService.resolveLateFeeVatRate()).toBeCloseTo(8.1);
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 }))
.toBe(2000 + Math.round(2000 * 8.1 / 100)); // net + VAT
// Org doesn't charge VAT → toggle adds nothing (Mara's requirement).
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 0 });
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })).toBe(2000);
});
});
describe('applyReminder — dunning-document model', () => {
let invoiceId;
let originalTotal;
beforeAll(async () => {
await setSetting('crm_invoices_late_fee_enabled', true);
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', false);
const res = await invoiceService.createInvoice({
customerAccountId: ids.customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [{ description: 'Service', quantity: 1, unit_price_minor: 100000 }],
}, ids.adminId);
invoiceId = res.invoiceIds[0];
originalTotal = Number((await db('invoices').where({ id: invoiceId }).first()).total_amount_minor);
});
test('level 2 tracks one fee and leaves the invoice total immutable', async () => {
const data = await invoiceService.getInvoiceById(invoiceId);
await invoiceService.applyReminder(data.invoice, data.lineItems, 2, ids.adminId);
const inv = await db('invoices').where({ id: invoiceId }).first();
expect(inv.reminder_level).toBe(2);
expect(Number(inv.late_fee_amount_minor)).toBe(2000);
expect(Number(inv.total_amount_minor)).toBe(originalTotal); // never mutated
});
test('level 3 accumulates the fee to 2×, total still immutable', async () => {
const data = await invoiceService.getInvoiceById(invoiceId);
await invoiceService.applyReminder(data.invoice, data.lineItems, 3, ids.adminId);
const inv = await db('invoices').where({ id: invoiceId }).first();
expect(Number(inv.late_fee_amount_minor)).toBe(4000);
expect(Number(inv.total_amount_minor)).toBe(originalTotal);
});
test('sendReminder refuses to exceed level 3', async () => {
await expect(invoiceService.sendReminder(invoiceId, 4, ids.adminId)).rejects.toThrow();
});
});
@@ -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);
});
});
@@ -1,94 +0,0 @@
'use strict';
// Validates the engine-neutral .picpeak export: it must produce a real zip with
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const StreamZip = require('node-stream-zip');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
// bootCrmDb MUST run before requiring the service (which transitively requires
// db.js) so the export reads this test's DB, not the default path.
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);
afterAll(async () => {
await cleanup();
});
async function readZip(filePath) {
const zip = new StreamZip.async({ file: filePath });
const entries = Object.keys(await zip.entries());
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
await zip.close();
return { entries, manifest };
}
describe('picpeak export (.picpeak logical export)', () => {
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
expect(filePath.endsWith('.picpeak')).toBe(true);
expect(fs.existsSync(filePath)).toBe(true);
expect(manifest.format).toBe(1);
expect(manifest.kind).toBe('picpeak-backup');
expect(manifest.database.engine).toBe('sqlite');
expect(manifest.options.includePhotos).toBe(false);
expect(manifest.contains_secrets).toBe(true);
// Migrations seed real tables (e.g. app_settings) — expect several.
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
expect(Object.keys(manifest.tables)).toContain('app_settings');
const { entries, manifest: zipped } = await readZip(filePath);
expect(entries).toContain('manifest.json');
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
expect(entries).toContain('data/app_settings.ndjson');
// Manifest inside the zip matches the returned one.
expect(zipped.tables).toEqual(manifest.tables);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('never exports knex bookkeeping tables', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const names = Object.keys(manifest.tables);
expect(names).not.toContain('knex_migrations');
expect(names).not.toContain('knex_migrations_lock');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('row counts in the manifest match the NDJSON line counts', async () => {
// Insert a couple of settings so at least one table is non-empty.
await db('app_settings')
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
.onConflict('setting_key').merge();
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const zip = new StreamZip.async({ file: filePath });
const buf = await zip.entryData('data/app_settings.ndjson');
await zip.close();
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
});
@@ -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 });
});
});
@@ -1,180 +0,0 @@
'use strict';
// Full .picpeak roundtrip on a temp SQLite DB:
// 1. seed a "backup" instance (admin A + a marker setting)
// 2. export → .picpeak
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
// 4. import the backup with currentAdminId = B
// 5. assert the backup data is restored AND the current account (B) survives,
// while the backup's admin (A) is also present (different email → added).
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
let importFromPicpeak;
let validateManifest;
let superAdminRoleId;
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir;
({ createPicpeak } = require('../../src/services/picpeakExportService'));
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
const role = await db('roles').where({ name: 'super_admin' }).first();
superAdminRoleId = role.id;
}, 120000);
afterAll(async () => {
await cleanup();
});
const adminRow = (email, hash) => ({
username: email,
email,
password_hash: hash,
role_id: superAdminRoleId,
is_active: true,
must_change_password: false,
created_at: new Date(),
updated_at: new Date(),
});
async function setMarker(value) {
await db('app_settings')
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
.onConflict('setting_key').merge();
}
async function getMarker() {
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
return row ? JSON.parse(row.setting_value) : null;
}
describe('.picpeak roundtrip (export → import)', () => {
it('restores backup data and preserves the current account', async () => {
// 1. Seed the "source" instance.
await db('admin_users').del();
await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A'));
await setMarker('from_backup');
// 2. Export.
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// 3. Simulate a reinstall: fresh current admin B, mutated data.
await db('admin_users').del();
const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id');
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
await setMarker('mutated_after_backup');
// 4. Import, preserving the current admin.
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
expect(result.restored).toBe(true);
expect(result.tables).toBeGreaterThan(0);
// 5a. Backup data restored (marker reverted to the backup value).
expect(await getMarker()).toBe('from_backup');
// 5b. The backup's admin is present (different email → added).
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first();
expect(a).toBeTruthy();
expect(a.password_hash).toBe('HASH_A');
// 5c. The current account SURVIVES the override, with its own credentials.
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first();
expect(b).toBeTruthy();
expect(b.password_hash).toBe('HASH_B');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('overwrites a backup admin that collides with the current account email', async () => {
// Source has an admin at the SAME email the current operator will use.
await db('admin_users').del();
await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH'));
await setMarker('collision_case');
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// Reinstall: current admin uses the same email but a NEW password.
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
// Exactly one admin at that email, and it keeps the CURRENT password.
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']);
expect(rows).toHaveLength(1);
expect(rows[0].password_hash).toBe('NEW_HASH');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('restores files/ and reports filesRestored', async () => {
// A business-doc that lives in storage → travels in the backup.
const docDir = path.join(tmpDir, 'business-docs');
const marker = path.join(docDir, 'roundtrip-doc.txt');
fs.mkdirSync(docDir, { recursive: true });
fs.writeFileSync(marker, 'hello');
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
const { filePath } = await createPicpeak({ includePhotos: false });
try {
fs.rmSync(marker); // delete on disk so the restore must bring it back
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
expect(fs.existsSync(marker)).toBe(true);
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
fs.rmSync(docDir, { recursive: true, force: true });
}
});
});
describe('.picpeak manifest validation', () => {
it('rejects a database-engine mismatch', async () => {
// Harness runs on SQLite, so a pg manifest must be refused.
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
});
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
});
it('rejects a backup from a newer schema (forward-only)', async () => {
// validateManifest reads knex_migrations for the target's latest migration;
// the harness has none, so create it with an older migration than the backup.
await db.schema.createTable('knex_migrations', (t) => {
t.increments('id');
t.string('name');
t.integer('batch');
t.timestamp('migration_time');
});
try {
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1,
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
tables: {},
});
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
} finally {
await db.schema.dropTableIfExists('knex_migrations');
}
});
it('rejects a file that is not a PicPeak backup', async () => {
const blockers = await validateManifest({ some: 'random-json' });
expect(blockers.length).toBeGreaterThan(0);
});
});

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