Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d48f59fe1 | |||
| e37d1fac58 | |||
| 9ee3ff45d0 | |||
| 5453152f1c | |||
| b416baec5c | |||
| 38ddd70c12 | |||
| b00a16159e | |||
| dcfcb67f9b | |||
| cde0b465a9 | |||
| 28f69e4bf3 | |||
| 1cf82d81a7 | |||
| ae98e7ad74 | |||
| caa9fe5d56 | |||
| c6e61f64ba | |||
| 3ec0451cbb | |||
| edac463ec3 | |||
| 2d3537f61c | |||
| 6025b3194d | |||
| 8713ab7f60 | |||
| 8994901e4a | |||
| b86669f1e1 | |||
| 80296282e8 | |||
| 5551c89bda | |||
| dbde67c0fa | |||
| 067e460a4d | |||
| 3678193ae2 | |||
| 74eacbc78f | |||
| 37bf894412 | |||
| 506b5c3dc4 | |||
| ab6db37326 | |||
| eb2ce290a7 | |||
| 8a4c1a7c0a | |||
| 4d3836fb2e | |||
| 75499992eb | |||
| 62643f241b | |||
| 64f606152f | |||
| e2a698e892 | |||
| d1d71dba25 | |||
| bb81fa5f4b | |||
| 03e19893b3 | |||
| 279314e4b7 | |||
| 730912a3f4 | |||
| ff9fb64e75 | |||
| 9cbbe74051 | |||
| 2e1c71c1ab | |||
| f6ca713a6e | |||
| 197cd8e1e0 | |||
| 681b440381 | |||
| 3daeac9e53 | |||
| 7febba2d9c | |||
| 0a3a53763c | |||
| 85a60a2dc7 | |||
| e74e73a3a0 |
+1
-32
@@ -5,10 +5,7 @@
|
||||
.env.*
|
||||
docker-compose*.yml
|
||||
.DS_Store
|
||||
# **/ so backend/node_modules and frontend/node_modules are excluded too —
|
||||
# the root-context Dockerfile.aio COPYs those directories and must get its
|
||||
# deps from its builder stages, never from the host checkout.
|
||||
**/node_modules
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
.nyc_output
|
||||
@@ -21,31 +18,3 @@ storage/events/archived/*
|
||||
storage/thumbnails/*
|
||||
data/*.db
|
||||
logs/*
|
||||
|
||||
# Dockerfile.aio builds from the REPOSITORY ROOT and Docker reads only this
|
||||
# file — backend/.dockerignore is never consulted — so the unprefixed rules
|
||||
# above miss backend/data, backend/logs and backend/storage. A checkout that has
|
||||
# been used to run PicPeak would otherwise bake its database, photos, logs and
|
||||
# SETUP_TOKEN into a published image layer.
|
||||
# backend/data wholesale, not a suffix list. It holds only runtime state and is
|
||||
# gitignored in full (.gitignore: `data/`), while suffix rules kept letting real
|
||||
# secrets through: a used checkout here carries ADMIN_CREDENTIALS.txt alongside
|
||||
# the database, plus -journal files and any DATABASE_PATH that does not end in
|
||||
# .db. Any of those in a published layer is a credential leak.
|
||||
backend/data
|
||||
backend/logs
|
||||
backend/storage
|
||||
|
||||
# Same root-context trap, one level deeper: the `.env`, `.env.*` and `data/*.db`
|
||||
# rules above are unanchored only in appearance — Docker matches them against the
|
||||
# path from the build context, so they catch `./.env` and never `backend/.env`.
|
||||
# A checkout that has been used to run PicPeak locally keeps its JWT_SECRET,
|
||||
# DB_PASSWORD and SMTP credentials there, and `COPY backend/ .` puts the file at
|
||||
# /app/.env in the published layer. Match at any depth instead, the way
|
||||
# **/node_modules above already does.
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/*.db
|
||||
**/*.db-journal
|
||||
**/*.sqlite*
|
||||
frontend/dist
|
||||
|
||||
+15
-165
@@ -10,21 +10,6 @@ NODE_ENV=production
|
||||
# Generate one with: openssl rand -base64 64
|
||||
#JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
|
||||
# How long a gallery guest stays recognised (#1210). Default 30d. It was 24h,
|
||||
# which meant a client reviewing a gallery across two weekends registered again
|
||||
# in between — and each re-registration is a separate guest whose likes and
|
||||
# favourites no longer join up with the first visit's. Takes any jsonwebtoken
|
||||
# duration ('7d', '12h'); shorten it if your galleries hold sensitive work.
|
||||
#GUEST_TOKEN_TTL=30d
|
||||
|
||||
# OIDC SSO for admins (#798) — configured in the admin UI; only these two
|
||||
# values live in the environment:
|
||||
# Key encrypting the OIDC client secret at rest (defaults to JWT_SECRET).
|
||||
#OIDC_ENCRYPTION_KEY=
|
||||
# Break-glass: 'true' re-enables local password login even while the SSO
|
||||
# settings disable it (recovery when the IdP is down or misconfigured).
|
||||
#OIDC_BREAK_GLASS=false
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: follows NODE_ENV (production=true, dev=false)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
||||
@@ -71,85 +56,29 @@ DB_NAME=picpeak_prod
|
||||
# Admin Account (initial setup) — OPTIONAL
|
||||
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
|
||||
# open /admin and PicPeak shows a setup screen. The one-time setup token is
|
||||
# written to data/SETUP_TOKEN with mode 0600 — read it with
|
||||
# `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged
|
||||
# unless that write fails, so it never sits in `docker logs`.
|
||||
# The all-in-one image keeps it at /data/db/SETUP_TOKEN — inside the volume,
|
||||
# in the db/ subdirectory (#1218). On a NAS with no shell, set ADMIN_PASSWORD
|
||||
# below instead: it needs no file at all.
|
||||
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
|
||||
# and saved to data/SETUP_TOKEN.
|
||||
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
|
||||
# credentials written to data/ADMIN_CREDENTIALS.txt).
|
||||
#ADMIN_USERNAME=admin
|
||||
#ADMIN_EMAIL=admin@yourdomain.com
|
||||
#ADMIN_PASSWORD=your_secure_admin_password_here
|
||||
|
||||
# Email Configuration — OPTIONAL, and normally left alone.
|
||||
# SMTP is configured in the setup wizard / Settings -> Email and stored in the
|
||||
# database (email_configs); that is what the mail queue actually sends with.
|
||||
# These variables are a legacy path kept for config-as-code deployments: when
|
||||
# SMTP_HOST is set, the initial migration seeds the database row from it.
|
||||
# Developers running the `dev` compose profile want SMTP_HOST=mailhog here so
|
||||
# that seed points at the mailhog container.
|
||||
# Email Configuration
|
||||
# For Gmail: use app-specific password
|
||||
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
|
||||
#SMTP_HOST=smtp.gmail.com
|
||||
#SMTP_PORT=587
|
||||
#SMTP_SECURE=false
|
||||
#SMTP_USER=your-email@gmail.com
|
||||
#SMTP_PASS=your-app-specific-password
|
||||
#EMAIL_FROM=noreply@yourdomain.com
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-specific-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Webhook email transport (#1225) — OPTIONAL, an alternative to SMTP entirely.
|
||||
# When EMAIL_WEBHOOK_URL is set, PicPeak stops sending mail itself and POSTs
|
||||
# each composed message as JSON to that URL instead; something downstream
|
||||
# (n8n, Make, a self-hosted relay) delivers it. Useful when SMTP is the part
|
||||
# you cannot get working — app passwords, blocked ports, a NAS with no
|
||||
# outbound 25.
|
||||
#
|
||||
# Deliberately environment-only, not an admin setting: it redirects every
|
||||
# outbound message including password resets, so it should not be changeable
|
||||
# from a compromised admin session.
|
||||
#
|
||||
# EMAIL_WEBHOOK_SECRET is REQUIRED. The body is signed with it and sent as
|
||||
# X-PicPeak-Signature (HMAC-SHA256, hex) — the same scheme as gallery
|
||||
# webhooks, so a receiver verifies both the same way. Set the URL without a
|
||||
# secret and the transport stays OFF and says so in the log, rather than
|
||||
# posting unauthenticated mail to the internet.
|
||||
#
|
||||
# Payload: { from, to[], cc[], subject, html, text, attachments[] }, where each
|
||||
# attachment is { filename, content_type, content_base64 }. Attachments are
|
||||
# included rather than dropped; a message whose attachments exceed 10 MB fails
|
||||
# and stays in the queue instead of arriving without its invoice.
|
||||
#
|
||||
# The receiver must be a public https:// address unless you opt in — a container or LAN
|
||||
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
|
||||
# normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that.
|
||||
#
|
||||
# Webhook and email-webhook deliveries connect to the DNS answer they just
|
||||
# validated and ignore HTTP_PROXY / HTTPS_PROXY. Behind a mandatory egress
|
||||
# proxy set the *_ALLOW_PRIVATE_URLS flag, which sends through the proxy
|
||||
# without pinning.
|
||||
#
|
||||
# A mail account with its own SMTP host (Settings -> Mail accounts) keeps
|
||||
# sending through it; this replaces the global transport only.
|
||||
#
|
||||
# Set EMAIL_FROM above as well. A webhook-only install never gets an
|
||||
# email_configs row (that is seeded only when SMTP_HOST is set), so EMAIL_FROM
|
||||
# is where the sender address comes from.
|
||||
#EMAIL_WEBHOOK_URL=https://n8n.example.com/webhook/picpeak-mail
|
||||
#EMAIL_WEBHOOK_SECRET=generate-a-long-random-string
|
||||
#EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=false
|
||||
|
||||
# Application URLs — OPTIONAL. Leave unset for the normal install.
|
||||
# The public origin is captured by the setup wizard (it proposes the address
|
||||
# you opened the browser at) and stored as the `general_site_url` setting, so
|
||||
# you can change it later in Settings -> General without touching this file.
|
||||
# Setting FRONTEND_URL here OVERRIDES that setting and makes the field
|
||||
# read-only in the admin UI - use it only for config-as-code deployments.
|
||||
# Application URLs
|
||||
# Use full origin with scheme, no trailing slash.
|
||||
# Admin UI is served by the frontend at /admin.
|
||||
#FRONTEND_URL=https://yourdomain.com
|
||||
#ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
|
||||
# Static HTML title + description used for social link previews when the
|
||||
# fetcher doesn't trigger the per-event OG endpoint — most notably the
|
||||
@@ -162,10 +91,9 @@ BRAND_TITLE=PicPeak
|
||||
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
|
||||
|
||||
# API URL for email assets (logos, images in notification emails)
|
||||
# OPTIONAL: when unset this is derived from the resolved public origin + /api,
|
||||
# so the wizard's answer covers it. Set it only for split-origin deployments
|
||||
# where the API lives on a different host than the gallery.
|
||||
#API_URL=https://yourdomain.com/api
|
||||
# This must be the publicly accessible URL where email recipients can load images.
|
||||
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
|
||||
API_URL=https://yourdomain.com/api
|
||||
|
||||
# Frontend API base
|
||||
# For pre-built images and production behind a reverse proxy, keep '/api'.
|
||||
@@ -178,22 +106,6 @@ VITE_API_URL=/api
|
||||
# DB_PORT=5432
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# File watcher (watch-folder auto-import, local storage only)
|
||||
# Max photos processed in parallel — raise on hosts with memory headroom,
|
||||
# lower to 1 on very small hosts. Default: 2
|
||||
# FILE_WATCHER_CONCURRENCY=2
|
||||
|
||||
# External-media folder watcher (issue 1187). Reference-mode events can opt in
|
||||
# per event (Event → Source Mode → "Watch folder for new files"); new images in
|
||||
# the folder are then imported without pressing Import. Deleted files are
|
||||
# never removed from the gallery.
|
||||
# EXTERNAL_MEDIA_WATCH=true # global kill switch
|
||||
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
|
||||
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
|
||||
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
|
||||
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
|
||||
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
|
||||
|
||||
# Release Channel
|
||||
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
|
||||
# 'stable' uses the :stable tag (same as :latest on main)
|
||||
@@ -297,70 +209,8 @@ LOGS=./logs
|
||||
# attempts is exponential: 1m, 5m, 30m, 2h, 12h.
|
||||
# WEBHOOK_MAX_ATTEMPTS=5
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Face recognition — "People in this gallery" (#1074, optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Requires the optional picpeak-ml sidecar container:
|
||||
# docker compose --profile faces up -d
|
||||
#
|
||||
# NONE of these variables do anything until the `faces` feature flag is
|
||||
# enabled in Admin → Settings, AND the per-event "Detect people in this
|
||||
# gallery" toggle is switched on. Both default to OFF. With the flag off the
|
||||
# backend never contacts the sidecar, so leaving these at their defaults on an
|
||||
# install without the container is completely inert.
|
||||
#
|
||||
# Face embeddings are biometric data (GDPR Art. 9 special category in the EU).
|
||||
# The photographer is the controller and needs a lawful basis for the people
|
||||
# in their photos — read https://docs.picpeak.app/features/face-recognition
|
||||
# before enabling.
|
||||
#
|
||||
# NOT AVAILABLE ON THE ALL-IN-ONE IMAGE. The single-container build sets
|
||||
# PICPEAK_SINGLE_CONTAINER=true and the backend refuses to enable face
|
||||
# recognition there regardless of these variables or the feature flag: that
|
||||
# image runs the backend, frontend, database and every worker in one
|
||||
# container, with no ML sidecar to talk to, and face detection would compete
|
||||
# with image processing for the same CPU and memory. Use the standard
|
||||
# multi-container deployment if you want this feature.
|
||||
#
|
||||
# FACE_ML_TOKEN (no default — REQUIRED to run the sidecar)
|
||||
# Shared secret between the backend and the sidecar. The sidecar refuses to
|
||||
# start without it rather than serving anonymously, so an accidentally
|
||||
# published port is never a free face-detection API. Generate with:
|
||||
# openssl rand -hex 32
|
||||
# FACE_ML_TOKEN=
|
||||
#
|
||||
# FACE_ML_URL (default: http://picpeak-ml:8000)
|
||||
# Defaults to the sidecar's compose service name, so the standard
|
||||
# deployment needs no configuration here. Only change it if you run the
|
||||
# sidecar outside the default compose network.
|
||||
# FACE_ML_URL=http://picpeak-ml:8000
|
||||
#
|
||||
# FACE_PROCESSOR_CONCURRENCY (default: 1)
|
||||
# Face-detection workers in the backend. Defaults to 1 deliberately: face
|
||||
# scanning shares a host with Sharp image processing, which is the real
|
||||
# memory pressure (see UPLOAD_PROCESSOR_CONCURRENCY). Raise only on hosts
|
||||
# with headroom to spare.
|
||||
# FACE_PROCESSOR_CONCURRENCY=1
|
||||
#
|
||||
# FACE_ORT_THREADS (default: 1)
|
||||
# ONNX Runtime threads inside the sidecar. More threads mean faster
|
||||
# per-photo inference and higher RSS.
|
||||
# FACE_ORT_THREADS=1
|
||||
|
||||
# Note on FRONTEND_API_URL (documentation only):
|
||||
# When using pre-built frontend images, runtime env vars cannot override the built JS.
|
||||
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
||||
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
|
||||
# should you change VITE_API_URL at build time.
|
||||
|
||||
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
|
||||
# Keep this value stable until participation has been deleted.
|
||||
# USAGE_ENCRYPTION_KEY=
|
||||
|
||||
# Graceful shutdown budget in milliseconds. On SIGTERM the server stops
|
||||
# accepting requests, drains workers and closes the pool; whatever is still
|
||||
# running after this long is abandoned so the process exits before Docker's
|
||||
# 10 s stop grace period (raise stop_grace_period together with this value).
|
||||
#SHUTDOWN_TIMEOUT_MS=8000
|
||||
|
||||
@@ -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.
|
||||
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📚 Documentation
|
||||
url: https://docs.picpeak.app
|
||||
about: Installation, configuration and feature guides
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
|
||||
about: Please read the documentation before opening an issue
|
||||
- name: 💬 Discussions
|
||||
url: https://github.com/PicPeak/picpeak/discussions
|
||||
about: Ask questions and discuss with the community
|
||||
- name: 🔒 Security Issues
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
@@ -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.
|
||||
@@ -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**
|
||||
@@ -1,56 +0,0 @@
|
||||
# picpeak — ML sidecar (face detection)
|
||||
|
||||
**picpeak** is an open-source, self-hosted **photo-sharing platform for photographers**. This image is the **optional face-detection sidecar**: it detects faces in one image and returns a bounding box, five landmarks, quality signals and a 512-d embedding per face.
|
||||
|
||||
**Nothing else.** No database, no volumes, no state, no egress, no model download at runtime. Clustering, person identity, thresholds and every privacy decision live in the picpeak backend, where the data already is — this service forgets each image the moment it answers.
|
||||
|
||||
If you don't run this container, the feature does not exist.
|
||||
|
||||
- 📦 **Source, docs & issues:** https://github.com/PicPeak/picpeak
|
||||
- 🧩 **Runs with:** [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) + [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend)
|
||||
|
||||
## Supported tags
|
||||
- `latest` / `stable` — latest stable release
|
||||
- `x.y.z` — a pinned release (**recommended for production** — keep it on the **same** tag as the backend)
|
||||
- `beta` / `main` — latest build from `main` (may be unstable)
|
||||
- **Architectures:** `linux/amd64`, `linux/arm64`
|
||||
|
||||
> The sidecar's API contract is versioned with the backend that calls it, so `PICPEAK_CHANNEL` resolves the same string across all picpeak images.
|
||||
|
||||
## Turning it on
|
||||
The maintained compose file already contains this service behind a profile — you do not write it by hand:
|
||||
|
||||
docker compose --profile faces up -d
|
||||
|
||||
Then two deliberate actions in the app, neither of which is installing this container:
|
||||
|
||||
1. Enable the **`faces`** feature flag in admin settings.
|
||||
2. Enable **"Detect people in this gallery"** per event.
|
||||
|
||||
**Nothing in the backend touches this service while the flag is off**, so an install without this container never attempts a connection.
|
||||
|
||||
## Configuration
|
||||
| | |
|
||||
|---|---|
|
||||
| `FACE_ML_TOKEN` | **Required.** The container **refuses to start** without it, so an accidentally published port is never a free face-detection API. Must match the backend's `FACE_ML_TOKEN`. |
|
||||
| `FACE_ORT_THREADS` | ONNX Runtime threads (default `1`). |
|
||||
|
||||
Port **8000**, no volumes, no published ports needed — the backend reaches it on the compose network. `FACE_ML_URL` defaults to `http://picpeak-ml:8000` (the compose service name), so the standard deployment needs no URL configuration.
|
||||
|
||||
## API
|
||||
All endpoints except `/health` require the `X-Face-ML-Token` header.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /health` | `{"status": "ok"}` — unauthenticated, used by the healthcheck |
|
||||
| `GET /info` | `{detector, embedder, model_version, dim}` |
|
||||
| `POST /faces` | multipart `image` → `{model_version, faces: [...]}` |
|
||||
|
||||
## Models
|
||||
YuNet (detection) + FaceNet-512 (embedding), **both MIT**, baked into the image and verified by SHA-256 at build time — never downloaded at runtime, so airgapped installs work and a model cannot change under a running deployment. See [`ml/LICENSES.md`](https://github.com/PicPeak/picpeak/blob/main/ml/LICENSES.md) for why these and not InsightFace's non-commercial weights.
|
||||
|
||||
## Not available on the all-in-one image
|
||||
[`picpeak/aio`](https://hub.docker.com/r/picpeak/aio) sets `PICPEAK_SINGLE_CONTAINER=true` and the backend refuses to enable face recognition there — a second image-processing pipeline competing with thumbnailing for one small container's CPU would not fail loudly, it would just make the install slow. Run the multi-container deployment for this feature.
|
||||
|
||||
## Docs
|
||||
**https://docs.picpeak.app** · sidecar internals, model conversion and the alignment/threshold contract: [`ml/README.md`](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)
|
||||
@@ -1,8 +1,6 @@
|
||||
# Docker Build and Push Workflow
|
||||
|
||||
This GitHub Actions workflow automatically builds and pushes Docker images for the backend, the frontend, the all-in-one image and the optional ML sidecar to GitHub Container Registry (ghcr.io). On the canonical org repo every one of them is mirrored to Docker Hub as `docker.io/picpeak/{backend,frontend,aio,ml}`; forks build the same images GHCR-only.
|
||||
|
||||
The **all-in-one image** (`<repo>/aio`, built from `Dockerfile.aio` at the repo root, #1042) bundles the backend and the built frontend into a single container with SQLite as the default engine — one `docker run`, no compose. It follows the same per-arch build → digest-merge → per-version tag scheme as the other two images, is mirrored to Docker Hub (`docker.io/picpeak/aio`) alongside GHCR on the canonical org repo, and every PR additionally runs a `smoke-aio` job that boots the image and asserts the SPA shell, brand-title rendering, immutable asset caching, and the SQLite engine resolution.
|
||||
This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -12,7 +10,6 @@ The **all-in-one image** (`<repo>/aio`, built from `Dockerfile.aio` at the repo
|
||||
- 🔒 **Security scanning** with Trivy vulnerability scanner
|
||||
- 💾 **Build caching** for faster subsequent builds
|
||||
- 📊 **Build summaries** in GitHub Actions UI
|
||||
- 📝 **Docker Hub pages** for `aio` and `ml` synced from `.github/dockerhub/*.md` on every `main` merge (`dockerhub-descriptions` job). `backend` and `frontend` pages are still hand-maintained in the Hub UI — add `.github/dockerhub/{backend,frontend}.md` with their current text before putting them under the same job.
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -55,11 +52,6 @@ docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0
|
||||
|
||||
# Pull for specific architecture
|
||||
docker pull --platform linux/arm64 ghcr.io/picpeak/picpeak/backend:latest
|
||||
|
||||
# The same images on Docker Hub (identical tags, identical digests)
|
||||
docker pull picpeak/backend:latest
|
||||
docker pull picpeak/aio:stable
|
||||
docker pull picpeak/ml:stable
|
||||
```
|
||||
|
||||
### Using in Docker Compose
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,12 +27,32 @@ jobs:
|
||||
manifest-file: .release-please-manifest.json
|
||||
target-branch: stable
|
||||
|
||||
# NOTE: stable release PRs are intentionally NOT auto-merged here
|
||||
# anymore. Fixes accumulate in the rolling release PR and are cut as
|
||||
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
|
||||
# or on demand via workflow_dispatch / a manual merge of the release
|
||||
# PR). Beta keeps instant releases — see release-please-beta.yml —
|
||||
# because same-day reporter verification depends on it.
|
||||
# Auto-approve + auto-merge the open stable release PR. See the beta
|
||||
# workflow for the full rationale. Skipped on the release-cutting run and
|
||||
# whenever no PAT is configured.
|
||||
- name: Auto-approve and enable auto-merge on the release PR
|
||||
if: ${{ steps.release.outputs.release_created != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# No checkout in this job — set the repo explicitly so gh works
|
||||
# without a git remote (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
|
||||
exit 0
|
||||
fi
|
||||
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
|
||||
# is a valid review; enable auto-merge as the PAT so the merge commit is
|
||||
# attributed to a real identity and triggers the tag-cutting run (#719).
|
||||
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
|
||||
else
|
||||
echo "No open release PR to auto-merge."
|
||||
fi
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
name: Cut Stable Release (daily batch)
|
||||
|
||||
# Stable fixes accumulate in release-please's rolling release PR instead of
|
||||
# each cutting its own patch version (the old per-merge auto-merge produced
|
||||
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
|
||||
# stable release PR once a day, so a day of N bugfixes ships as ONE version
|
||||
# with all N changelog entries — and one Docker build instead of N.
|
||||
#
|
||||
# - schedule only fires from the default branch (main); the stable copy of
|
||||
# this file is inert and exists to keep the branches in sync.
|
||||
# - Need a release NOW? Run this via workflow_dispatch, or merge the
|
||||
# release PR by hand — the schedule is a default, not a gate.
|
||||
# - Approval/merge mechanics mirror the old inline step (#719): approve as
|
||||
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
|
||||
# auto-merge as the PAT so the merge attributes to a real identity and
|
||||
# triggers the tag-cutting run. --auto waits for green checks.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
merge-stable-release-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Approve and enable auto-merge on the open stable release PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# No checkout — set the repo explicitly so gh works without a
|
||||
# git remote (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
|
||||
exit 0
|
||||
fi
|
||||
# Strict selection (review P1): this job runs daily even without a
|
||||
# stable push, and `gh pr list --head` matches the branch NAME only
|
||||
# — a fork PR can spoof `release-please--branches--stable`. Pin the
|
||||
# base to stable AND require a same-repo head (isCrossRepository
|
||||
# == false); a fork PR is cross-repository, so it can never be
|
||||
# picked and auto-merged with the privileged PAT.
|
||||
pr=$(gh pr list \
|
||||
--base stable \
|
||||
--head release-please--branches--stable \
|
||||
--state open \
|
||||
--json number,isCrossRepository \
|
||||
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
|
||||
if [ -z "$pr" ]; then
|
||||
echo "No open same-repo stable release PR — nothing to cut today."
|
||||
exit 0
|
||||
fi
|
||||
# Approve is tolerant — a pre-existing approval already satisfies
|
||||
# branch protection and re-approving can return non-zero.
|
||||
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
|
||||
# But the auto-merge enable is the load-bearing step: this scheduled
|
||||
# job is the ONLY automatic stable cut, so DON'T swallow its failure
|
||||
# (review P2) — an expired/under-scoped PAT would otherwise stop
|
||||
# releases while the workflow stays green.
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
|
||||
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
|
||||
# are already green — the normal case at 18:00, since the fixes
|
||||
# merged hours earlier and CI passed. So success is EITHER the PR is
|
||||
# already merged OR an auto-merge request is now pending; only a PR
|
||||
# that is still open with no auto-merge request is a real failure
|
||||
# (expired/under-scoped PAT) worth failing the job on (review round 2).
|
||||
# One snapshot of both fields (review round 3): querying state and
|
||||
# autoMergeRequest separately races — auto-merge can complete
|
||||
# between the two calls, so the first sees OPEN and the second sees
|
||||
# the request already cleared on the now-merged PR → false failure.
|
||||
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
|
||||
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
|
||||
if [ "$state" = "MERGED" ]; then
|
||||
echo "Stable release PR #$pr merged immediately (checks were already green)."
|
||||
elif [ "$automerge" = "true" ]; then
|
||||
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
|
||||
else
|
||||
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
|
||||
exit 1
|
||||
fi
|
||||
+18
-89
@@ -6,6 +6,11 @@ name: Tests
|
||||
# calendar) plus the photo / settings / OG / auth surface — wiring them
|
||||
# into CI makes regressions visible at PR time instead of post-merge.
|
||||
#
|
||||
# Six backend suites are excluded via --testPathIgnorePatterns. They
|
||||
# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM
|
||||
# regressions). Excluding them here keeps CI green from day 1; revisit
|
||||
# each individually as its own fix.
|
||||
#
|
||||
# Triggers on any change that could affect either suite. The backend
|
||||
# job intentionally omits frontend paths and vice versa so unrelated
|
||||
# PRs don't pay both build costs.
|
||||
@@ -23,41 +28,7 @@ permissions:
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
# 20, not 10. This job normally finishes in ~3 minutes, but it is the only
|
||||
# one that boots Postgres and runs the full integration suite, so it is the
|
||||
# only one exposed to runner contention — observed spread has reached 9.2
|
||||
# minutes, and a release PR (#1088) was cancelled at 10.3 with every test
|
||||
# passing and jest still running. A cancelled job reads as a red X on a
|
||||
# green branch, which costs a re-run and a diagnosis every time it happens.
|
||||
#
|
||||
# The cap is a runaway guard, not a performance budget; 20 leaves real
|
||||
# headroom over the worst observed run while still killing a hung suite
|
||||
# well inside the hour GitHub would otherwise allow. frontend and ml keep
|
||||
# 10 — they take seconds and have never come close.
|
||||
timeout-minutes: 20
|
||||
|
||||
# The .picpeak restore suites gate their real-Postgres cases behind
|
||||
# PICPEAK_PG_TEST_URL and `describe.skip` themselves out when it is
|
||||
# unset — so until now they never ran here. That hid the half that
|
||||
# matters: sequence resync, operator/role preservation across a
|
||||
# cross-instance restore, and (with #1041) whether a SQLite-shaped
|
||||
# row actually lands in Postgres with the right STORED VALUES rather
|
||||
# than merely not throwing. Everything else in the suite still runs
|
||||
# on SQLite; this service only un-gates those cases.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_USER: picpeak
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: picpeak_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U picpeak -d picpeak_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 2s
|
||||
--health-retries 30
|
||||
ports:
|
||||
- 5432:5432
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -81,10 +52,18 @@ jobs:
|
||||
# The S3 path itself is covered separately by the integration
|
||||
# suite when MinIO is provisioned.
|
||||
SKIP_S3_TESTS: 'true'
|
||||
# Un-gates the real-Postgres cases in the .picpeak restore suites
|
||||
# (see the `services:` note above). Absent it they silently skip.
|
||||
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
|
||||
run: npx jest --ci
|
||||
run: |
|
||||
# Excluded suites — fail on upstream/beta too, tracked
|
||||
# separately as test-infra debt:
|
||||
# adminSettings.logo — supertest fixture
|
||||
# integration/adminPhotos.reference — supertest fixture
|
||||
# integration/webhookDelivery — supertest fixture
|
||||
# services/backupService.enhanced — knex mock chain
|
||||
# routes/__tests__/adminAuth — supertest fixture
|
||||
# (adminNotifications was excluded; #597 fix re-enables it.)
|
||||
npx jest \
|
||||
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \
|
||||
--ci
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -105,56 +84,6 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint frontend (including Rules of Hooks)
|
||||
working-directory: ./frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Run Vitest suite
|
||||
working-directory: ./frontend
|
||||
run: npm test -- --run
|
||||
|
||||
nginx:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
strategy:
|
||||
matrix:
|
||||
# Match the two shipped frontend Dockerfiles.
|
||||
image: ['nginx:1.28-alpine', 'nginx:1.30-alpine']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify token-safe nginx logging
|
||||
env:
|
||||
NGINX_TEST_IMAGE: ${{ matrix.image }}
|
||||
run: python3 tests/nginx/test_request_logging.py
|
||||
|
||||
# Optional face-detection sidecar (#1074). Runs on every PR regardless of
|
||||
# whether the feature is enabled anywhere — these tests need no model
|
||||
# weights (they stub the pipeline out) and cover the auth boundary, the
|
||||
# request guards and the alignment geometry, which is where a mistake is a
|
||||
# security problem or a silent accuracy problem rather than a visible bug.
|
||||
ml:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
# Matches ml/Dockerfile's base image, so a wheel that resolves here
|
||||
# resolves in the image too.
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: ml/requirements.txt
|
||||
|
||||
- name: Install ml deps
|
||||
working-directory: ./ml
|
||||
run: pip install -r requirements.txt pytest httpx
|
||||
|
||||
- name: Run pytest suite
|
||||
working-directory: ./ml
|
||||
run: python -m pytest tests/ -q
|
||||
|
||||
+2
-28
@@ -130,31 +130,5 @@ docker-compose.dev.yml
|
||||
# New layout development files
|
||||
new-layouts/
|
||||
|
||||
# Backend runtime storage (generated media, previews, thumbnails,
|
||||
# CRM/accounting documents) — never commit
|
||||
backend/storage/
|
||||
|
||||
# Python artifacts — the picpeak-ml sidecar (#1074) is the only Python in
|
||||
# this tree, but bytecode and virtualenvs must never be committed.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
ml/.venv/
|
||||
ml/venv/
|
||||
# Locally produced model weights. The image fetches these by pinned URL and
|
||||
# SHA-256 at build time; a 90MB blob must not end up in git history.
|
||||
ml/*.onnx
|
||||
ml/*.h5
|
||||
|
||||
# Issue / PR screenshots belong on a `screenshots/*` branch, never on main or
|
||||
# stable — that is what those branches exist for. Two landed at the repo root
|
||||
# in #1241 and shipped as part of the source tree.
|
||||
#
|
||||
# Anchored with a leading slash so docs/ keeps its own images.
|
||||
/issue-*.png
|
||||
/issue-*.jpg
|
||||
/screenshot-*.png
|
||||
/screenshot-*.jpg
|
||||
/*-screenshot.png
|
||||
/*-screenshot.jpg
|
||||
|
||||
# Generated CRM/accounting documents (runtime) — never commit
|
||||
backend/storage/business-docs/
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.131.0-beta.0"
|
||||
".": "3.83.0-beta.0"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"3.44.0"}
|
||||
{".":"3.45.2"}
|
||||
|
||||
+832
-2257
File diff suppressed because it is too large
Load Diff
+16
-26
@@ -59,7 +59,7 @@ Unsure where to begin? You can start by looking through these issues:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 22.12.0 or later (matches `backend/package.json`)
|
||||
- Node.js 18+
|
||||
- Docker & Docker Compose
|
||||
- Git
|
||||
|
||||
@@ -73,32 +73,23 @@ cd picpeak
|
||||
# Install dependencies
|
||||
cd backend && npm install
|
||||
cd ../frontend && npm install
|
||||
cd ..
|
||||
|
||||
# Start Postgres and Redis (the app itself runs on the host, see below)
|
||||
docker compose up -d postgres redis
|
||||
# Set up environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
|
||||
# Backend config — note this is backend/.env, not the root one
|
||||
cp backend/.env.example backend/.env
|
||||
# JWT_SECRET must be set: the host process validates it and exits without one.
|
||||
# (The containers generate it themselves; `npm run dev` does not.)
|
||||
|
||||
# Backend, with nodemon hot reload — http://localhost:3001
|
||||
cd backend && npm run dev
|
||||
|
||||
# Frontend, with Vite hot reload, in a second shell — http://localhost:5173
|
||||
cd frontend && npm run dev
|
||||
# Start development servers
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
Open **http://localhost:5173**. Vite proxies `/api` to the backend on `3001`, so
|
||||
you do not need the root `.env` for this loop at all — that one configures the
|
||||
compose stack.
|
||||
**After pulling changes that touch `backend/package.json` / `backend/package-lock.json` (or the frontend equivalents)**, rebuild the affected image so the live-mounted source can `require()` the new deps:
|
||||
|
||||
Running the two Node processes on the host is the fastest loop: both reload on save, and you get a real debugger and stack traces without rebuilding an image.
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d --build backend
|
||||
# (or `frontend`, or both)
|
||||
```
|
||||
|
||||
**Prefer everything in containers?** `docker compose up -d` builds `backend`, `frontend` and `ml` from source using the production Dockerfiles. That works, but there is no hot reload — you rebuild on every change (`docker compose up -d --build backend`).
|
||||
|
||||
> `docker-compose.dev.yml` is listed in `.gitignore` and is not part of the repo. If you keep a local one for live-mounting `./backend/src` and `./frontend/src` against `backend/Dockerfile.dev` / `frontend/Dockerfile.dev`, remember it bakes `node_modules` into the image: after pulling a change to `backend/package.json`, rebuild that image or you will get a `MODULE_NOT_FOUND` restart loop.
|
||||
The dev compose bakes `node_modules` into the image while live-mounting `./backend/src` and `./frontend/src` from disk. A dep added on disk won't be picked up until the image is rebuilt — typical symptom is a `MODULE_NOT_FOUND` restart loop on the affected container.
|
||||
|
||||
### Running Tests
|
||||
|
||||
@@ -172,14 +163,13 @@ PicPeak runs on two long-lived branches:
|
||||
| Branch | Role | What targets it |
|
||||
|---|---|---|
|
||||
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
|
||||
| **`stable`** | Curated release channel. Production-recommended. | Security fixes and regular bugfix backports, kept small and free of unrelated features. |
|
||||
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
|
||||
|
||||
### Which branch should my PR target?
|
||||
|
||||
- **New feature** → target `main`.
|
||||
- **Bugfix that ONLY affects active dev** → target `main`.
|
||||
- **Bugfix that current stable users need** → target `main`; regular bug fixes are generally backported automatically to `stable`. Maintainers handle conflicts or create a separate focused backport PR when needed.
|
||||
- **Security vulnerability** → report privately using [SECURITY.md](SECURITY.md). Security fixes are always released on both `stable` and `main`; coordinate any fix with the maintainers before opening a public PR.
|
||||
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
|
||||
|
||||
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
|
||||
|
||||
@@ -197,6 +187,6 @@ See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteri
|
||||
|
||||
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
|
||||
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
|
||||
- Security vulnerabilities: Follow the [security policy](SECURITY.md) and use [private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
Thank you for contributing! 🎉
|
||||
-178
@@ -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"]
|
||||
@@ -1,48 +1,90 @@
|
||||
# 📸 PicPeak - Open Source Photo Sharing for Events
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **PicPeak has moved to its own GitHub organization.**
|
||||
>
|
||||
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
|
||||
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
|
||||
>
|
||||
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||
|
||||
# 📸 PicPeak
|
||||
|
||||
**Open-source, self-hosted photo sharing for events.**
|
||||
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://buymeacoffee.com/theluap)
|
||||
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support ☕](https://buymeacoffee.com/theluap)
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support the project ☕](https://buymeacoffee.com/theluap)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||
|
||||

|
||||
|
||||
> [!IMPORTANT]
|
||||
> **PicPeak has moved to its own GitHub organization.** Docker images are now at `ghcr.io/picpeak/picpeak/{backend,frontend,aio,ml}` (and on Docker Hub as `picpeak/{backend,frontend,aio,ml}`) and active development is on `main`. The old `ghcr.io/the-luap/...` path still responds but its tags are **frozen** at 2026-05-27 — if updates never arrive, check your image path first. See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Live Demo](#-live-demo)
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Why PicPeak?](#-why-picpeak)
|
||||
- [Features](#-features)
|
||||
- [Documentation](#-documentation)
|
||||
- [Comparison](#-comparison-with-alternatives)
|
||||
- [Tech Stack](#️-tech-stack)
|
||||
- [Contributing & Support](#-contributing)
|
||||
- [License](#-license)
|
||||
|
||||
## 🎮 Live Demo
|
||||
|
||||
Try PicPeak without installing anything — [demo.picpeak.app](https://demo.picpeak.app) · [admin panel](https://demo.picpeak.app/admin)
|
||||
Try PicPeak without installing anything:
|
||||
|
||||
| Email | Password |
|
||||
| | |
|
||||
|---|---|
|
||||
| `demo@picpeak.app` | `Demo2026!` |
|
||||
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
|
||||
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
|
||||
| **Email** | `demo@picpeak.app` |
|
||||
| **Password** | `Demo2026!` |
|
||||
|
||||
> The demo resets periodically. Uploaded content may be removed without notice.
|
||||
|
||||
## 🌟 Why Choose PicPeak?
|
||||
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
|
||||
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
|
||||
- **🔒 Complete Data Control** - Your photos stay on your server
|
||||
- **🎨 White-Label Ready** - Full branding customization
|
||||
- **📱 Mobile-First Design** - Beautiful on all devices
|
||||
- **🚀 Lightning Fast** - Optimized performance and caching
|
||||
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
### For Photographers
|
||||
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
|
||||
- 🔗 **External Media (Reference Mode)** - Browse and import from a read‑only external folder library without copying originals
|
||||
- ⏰ **Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
|
||||
- 🔐 **Password Protection** - Secure client galleries
|
||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||
- 📽️ **Live Slideshow** - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options ([guide](docs/live-slideshow.md))
|
||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
|
||||
|
||||
### For Clients
|
||||
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
||||
- 📱 **Mobile Optimized** - Swipe through photos on any device
|
||||
- ⬇️ **Bulk Downloads** - Download all photos with one click
|
||||
- 🔍 **Smart Search** - Find photos quickly
|
||||
- 📤 **Guest Uploads** - Optional client photo uploads
|
||||
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
|
||||
|
||||
### Technical Excellence
|
||||
- 🐳 **Docker Ready** - Deploy in minutes
|
||||
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
||||
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
|
||||
- 💾 **Smart Storage** - Automatic archiving of expired galleries
|
||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||
- 📈 **Scalable** - From small studios to large agencies
|
||||
|
||||
### For Studios — CRM & Accounting (Beta · off by default)
|
||||
- 📝 **Quotes → Contracts → Invoices** - One deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
|
||||
- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
|
||||
- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
|
||||
- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only
|
||||
- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
|
||||
- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
Get PicPeak running in under 5 minutes:
|
||||
@@ -54,8 +96,8 @@ cd picpeak
|
||||
|
||||
# Copy the environment template — the defaults work out of the box.
|
||||
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
|
||||
# admin account is created in the browser. Edit .env only to customise
|
||||
# (domain, SMTP, storage paths, …) — nothing is required.
|
||||
# admin account is created in the browser (see below). Edit .env only to
|
||||
# customise (domain, SMTP, storage paths, …) — nothing is required.
|
||||
cp .env.example .env
|
||||
|
||||
# Start with Docker Compose
|
||||
@@ -64,92 +106,288 @@ docker compose up -d
|
||||
# Access at http://localhost:3000
|
||||
```
|
||||
|
||||
On first start, open **http://localhost:3000/admin** and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in **[First-run setup](https://docs.picpeak.app/getting-started/first-login)**.
|
||||
### First run — create your admin account
|
||||
|
||||
> **Updating / release channels:** set `PICPEAK_CHANNEL` (`stable` default, or `beta`) in `.env`, then `docker compose pull && docker compose up -d`. See [RELEASING.md](RELEASING.md) for the promotion cadence.
|
||||
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
|
||||
|
||||
### Or: one container, no compose file
|
||||
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
|
||||
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
|
||||
```bash
|
||||
docker compose logs backend | grep -i "setup token"
|
||||
```
|
||||
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
|
||||
|
||||
For a home server, a NAS, or a single small studio, the all-in-one image runs the whole app as one process with SQLite — no compose file, no separate database, no reverse proxy to wire up:
|
||||
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
|
||||
|
||||
Note on Docker file permissions
|
||||
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
|
||||
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
|
||||
|
||||
**ARM64 (aarch64) systems:** Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](docker-compose.amd64.override.yml) for a transitional fallback.
|
||||
|
||||
## 🔄 Release Channels
|
||||
|
||||
PicPeak offers two release channels for different needs. Stable promotions are cut from a known-good beta point every 4–6 weeks — see [RELEASING.md](RELEASING.md) for the maintainer's promotion criteria and cadence policy.
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
|
||||
### Switching Channels
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
|
||||
```bash
|
||||
docker run -d --name picpeak -p 3000:3000 \
|
||||
-v picpeak:/data \
|
||||
ghcr.io/picpeak/picpeak/aio:main
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
No environment variables to set — the JWT secret is generated on first start and kept on the volume.
|
||||
Then update your containers:
|
||||
|
||||
Then open **http://localhost:3000/admin** and read the setup token with `docker exec picpeak cat /data/db/SETUP_TOKEN`, or open `db/SETUP_TOKEN` on the volume with any file manager if the host has no shell.
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
`:main` is the active-development tag, and today it is the only one the all-in-one image has — `Dockerfile.aio` landed after the current stable release, so `:stable` and `:latest` first appear for this image once the aio build reaches the `stable` branch. Switch to `:stable` then, or pin a published version tag if you would rather not track `main`.
|
||||
### Update Notifications
|
||||
|
||||
The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a `.picpeak` backup and restore it into the full stack. See **[Single-container install](https://docs.picpeak.app/deployment/single-container)** for the volume layout, the external-Postgres variant, TLS, and the limits.
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
|
||||
|
||||
### Docker images
|
||||
|
||||
| | GHCR | Docker Hub |
|
||||
|---|---|---|
|
||||
| Backend | `ghcr.io/picpeak/picpeak/backend` | [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) |
|
||||
| Frontend | `ghcr.io/picpeak/picpeak/frontend` | [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend) |
|
||||
| All-in-one | `ghcr.io/picpeak/picpeak/aio` | [`picpeak/aio`](https://hub.docker.com/r/picpeak/aio) |
|
||||
| ML sidecar (optional) | `ghcr.io/picpeak/picpeak/ml` | [`picpeak/ml`](https://hub.docker.com/r/picpeak/ml) |
|
||||
|
||||
Both registries get the same digests and the same tags — `stable`/`latest`, a pinned `x.y.z`, and `beta`/`main` for the active development channel — for `linux/amd64` and `linux/arm64`. Keep every image in one install on the **same** tag.
|
||||
|
||||
## 🌟 Why PicPeak?
|
||||
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
|
||||
- **💰 No Monthly Fees** — one-time setup, unlimited galleries
|
||||
- **🔒 Complete Data Control** — your photos stay on your server
|
||||
- **🎨 White-Label Ready** — full branding customization
|
||||
- **📱 Mobile-First Design** — beautiful on all devices
|
||||
- **🌍 Multi-Language** — built-in i18n (EN, DE)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
**For photographers** — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a [Live Slideshow](https://docs.picpeak.app/features/live-slideshow) projector view that auto-picks-up new uploads during live events.
|
||||
|
||||
**For clients** — clean mobile-optimized galleries, one-click bulk downloads, smart search, **[People in this gallery](https://docs.picpeak.app/features/face-recognition)** face grouping (opt-in per gallery, needs the optional [ML sidecar](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)), optional guest uploads, and download protection (watermarking + right-click prevention).
|
||||
|
||||
**Technical** — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible [storage backends](https://docs.picpeak.app/features/storage-backends), [webhooks](https://docs.picpeak.app/features/webhooks), and security-first defaults (JWT, rate limiting, CORS).
|
||||
|
||||
<details>
|
||||
<summary><strong>🧾 For studios — CRM & Accounting (Beta, off by default)</strong></summary>
|
||||
|
||||
- 📝 **Quotes → Contracts → Invoices** — one deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
|
||||
- ⏱️ **Hours Logging & Calendar** — per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
|
||||
- 🧾 **Inbound Supplier Invoices & Expenses** — capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
|
||||
- 📊 **Tax Report & Accountant Export** — period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export
|
||||
- 🌍 **VAT & Multi-currency** — single VAT-code registry snapshotted onto each document
|
||||
|
||||
</details>
|
||||
|
||||
> [!WARNING]
|
||||
> **CRM & Accounting — examples only, verify locally.** Feature-flagged off by default. Seeded contract blocks are written by the maintainer, **not a lawyer**; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are **jurisdiction-specific guidance only**. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read **[the CRM disclaimers](https://docs.picpeak.app/features/crm/disclaimers)** first.
|
||||
```bash
|
||||
UPDATE_CHECK_ENABLED=false
|
||||
```
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings, API, branding, and more.
|
||||
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:
|
||||
|
||||
| Topic | Link |
|
||||
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
|
||||
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
|
||||
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
|
||||
- 📽️ [**Live Slideshow**](https://docs.picpeak.app/features/live-slideshow) - Fullscreen projector view that auto-updates during live events
|
||||
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Backup configuration, restore wizard, full disaster recovery
|
||||
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
|
||||
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
|
||||
|
||||
Project meta:
|
||||
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||
|
||||
## 🌐 Public Landing Page
|
||||
|
||||
Spotlight your studio with a customizable marketing page at `/`:
|
||||
|
||||
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
|
||||
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
|
||||
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
|
||||
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
|
||||
- Use **Reset to default** anytime to restore the bundled template.
|
||||
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
|
||||
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
Perfect for:
|
||||
- 💒 **Wedding Photographers** - Share ceremony photos securely
|
||||
- 🎂 **Event Photography** - Birthday parties, corporate events
|
||||
- 📸 **Portrait Studios** - Client galleries with download limits
|
||||
- 🏢 **Corporate Events** - Internal photo sharing with branding
|
||||
- 🎓 **School Photography** - Secure parent access with expiration
|
||||
- 📽️ **Live Events** - Put a [Live Slideshow](docs/live-slideshow.md) on the venue projector that updates as you shoot
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
|
||||
- **Backend**: Node.js, Express, SQLite/PostgreSQL
|
||||
- **Frontend**: React, Tailwind CSS, Framer Motion
|
||||
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](#storage-backends)
|
||||
- **Email**: SMTP with customizable templates
|
||||
- **Analytics**: Privacy-focused with Umami integration
|
||||
|
||||
## 💾 Storage Backends
|
||||
|
||||
PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
|
||||
|
||||
| Capability | `STORAGE_BACKEND=local` (default) | `STORAGE_BACKEND=s3` |
|
||||
|---|---|---|
|
||||
| Photo / thumbnail / hero storage | Local filesystem under `STORAGE_PATH` | Bucket on any S3-compatible service |
|
||||
| Admin UI upload | ✅ | ✅ |
|
||||
| Filesystem auto-import (chokidar watcher) | ✅ | ❌ — disabled (use the upload API) |
|
||||
| Watermarks, fingerprinting, fragmentation | ✅ | ✅ (materialized to a tmp file just-in-time) |
|
||||
| Bulk download zips (cached + on-the-fly) | ✅ | ✅ |
|
||||
| Backups | ✅ | ✅ |
|
||||
| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
|
||||
|
||||
### Switching to an S3-compatible backend
|
||||
|
||||
1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
|
||||
2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
|
||||
3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
|
||||
4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
|
||||
|
||||
Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
|
||||
|
||||
## 🔔 Webhooks
|
||||
|
||||
PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
|
||||
|
||||
### Event types
|
||||
|
||||
| Event | Fires when |
|
||||
|---|---|
|
||||
| 🚀 Deployment (Docker, env, reverse proxy, SSL) | [docs.picpeak.app/deployment](https://docs.picpeak.app/deployment) |
|
||||
| 📦 Single-container install (one `docker run`, SQLite) | [docs.picpeak.app/deployment/single-container](https://docs.picpeak.app/deployment/single-container) |
|
||||
| ⚙️ Admin settings reference | [docs.picpeak.app/guides/admin-settings](https://docs.picpeak.app/guides/admin-settings) |
|
||||
| 🎯 Creating events | [docs.picpeak.app/guides/creating-events](https://docs.picpeak.app/guides/creating-events) |
|
||||
| 📽️ Live Slideshow | [docs.picpeak.app/features/live-slideshow](https://docs.picpeak.app/features/live-slideshow) |
|
||||
| 🙂 People in galleries (face grouping) | [docs.picpeak.app/features/face-recognition](https://docs.picpeak.app/features/face-recognition) |
|
||||
| 💾 Backup & Restore | [docs.picpeak.app/guides/backup-restore](https://docs.picpeak.app/guides/backup-restore) |
|
||||
| 🔌 API reference | [docs.picpeak.app/api](https://docs.picpeak.app/api) |
|
||||
| 🪝 Webhooks | [docs.picpeak.app/features/webhooks](https://docs.picpeak.app/features/webhooks) |
|
||||
| 💾 Storage backends (local / S3) | [docs.picpeak.app/features/storage-backends](https://docs.picpeak.app/features/storage-backends) |
|
||||
| 💻 System requirements & tuning | [docs.picpeak.app/deployment/system-requirements](https://docs.picpeak.app/deployment/system-requirements) |
|
||||
| 🧾 CRM & Accounting | [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm) · [disclaimers](https://docs.picpeak.app/features/crm/disclaimers) |
|
||||
| 🗺️ Roadmap | [GitHub Issues](https://github.com/PicPeak/picpeak/issues) |
|
||||
| `event.created` | Gallery created (admin or API) |
|
||||
| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
|
||||
| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
|
||||
| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
|
||||
| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
|
||||
| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
|
||||
|
||||
**Project meta:** [Support](SUPPORT.md) · [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
### Payload shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "delivery-uuid",
|
||||
"type": "event.published",
|
||||
"created_at": "2026-04-28T05:25:00.000Z",
|
||||
"data": {
|
||||
"event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also sent on every request:
|
||||
- `X-PicPeak-Signature` — `HMAC-SHA256(secret, raw_body)` as hex
|
||||
- `X-PicPeak-Event` — the event type (handy for routing without parsing the body)
|
||||
- `X-PicPeak-Delivery` — UUID for idempotency on the receiver side
|
||||
- `User-Agent: PicPeak-Webhooks/1.0`
|
||||
|
||||
### Verifying signatures
|
||||
|
||||
**Node.js**
|
||||
```js
|
||||
const crypto = require('crypto');
|
||||
function verify(secret, rawBody, signature) {
|
||||
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const a = Buffer.from(expected, 'hex');
|
||||
const b = Buffer.from(signature, 'hex');
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
```
|
||||
|
||||
**Python**
|
||||
```python
|
||||
import hmac, hashlib
|
||||
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
|
||||
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
```
|
||||
|
||||
**curl + openssl** (one-liner for a quick replay)
|
||||
```sh
|
||||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
|
||||
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH
|
||||
```
|
||||
|
||||
### Retries + observability
|
||||
|
||||
- `2xx` → success, recorded with latency
|
||||
- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
|
||||
- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
|
||||
- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
|
||||
- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
|
||||
|
||||
The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
|
||||
|
||||
### SSRF protection
|
||||
|
||||
Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
|
||||
|
||||
For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
|
||||
|
||||
## 💻 System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
- **CPU**: 2 CPU cores
|
||||
- **RAM**: **4 GB minimum** for a normal photo-upload workload — sharp/libvips
|
||||
decodes the full uncompressed frame before resize, and the default two
|
||||
worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a
|
||||
batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the
|
||||
backend mid-batch (surfaces as 503s on thumbnails — see [Low-memory
|
||||
hosts](#low-memory-hosts) below for the recipe to run on 2 GB).
|
||||
- **Storage**: 20GB minimum (plus photo storage needs)
|
||||
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
|
||||
- **Node.js**: v18.0.0 or higher
|
||||
- **Database**: SQLite (included) or PostgreSQL 12+
|
||||
|
||||
### Docker Requirements (Recommended)
|
||||
- **Docker**: v20.10.0+
|
||||
- **Docker Compose**: v2.0.0+
|
||||
|
||||
### Low-memory hosts
|
||||
|
||||
Running on 2 GB RAM (e.g. an entry-level VPS) is workable but requires
|
||||
tuning the upload-processor concurrency down. The backend auto-detects
|
||||
total RAM at startup via `os.totalmem()` — on a host that reports < 3 GB,
|
||||
it defaults `UPLOAD_PROCESSOR_CONCURRENCY` to **1** instead of 2 and logs
|
||||
a one-shot warning. You can pin the value explicitly in `.env`:
|
||||
|
||||
```env
|
||||
# Single worker loop — slower batch processing, lower peak RSS
|
||||
UPLOAD_PROCESSOR_CONCURRENCY=1
|
||||
```
|
||||
|
||||
The trade-off is throughput: a single worker processes one photo at a
|
||||
time, so a 100-photo batch takes ~2× as long but won't OOM. **Health-check
|
||||
note**: if the backend dies under memory pressure, the gallery serves
|
||||
`503 Service Unavailable` on thumbnails until Docker's
|
||||
`restart: unless-stopped` brings the container back. Persistent 503s
|
||||
during/after an upload batch on a low-memory host are almost always this.
|
||||
|
||||
### Video Support Requirements
|
||||
When enabling video uploads, consider these additional resources:
|
||||
|
||||
| Resource | Recommendation | Notes |
|
||||
|----------|----------------|-------|
|
||||
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
|
||||
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
|
||||
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
|
||||
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
|
||||
|
||||
**Technical Notes:**
|
||||
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
|
||||
- Maximum upload size: **10GB per video file**
|
||||
- Chunked upload support for files >100MB (resumable uploads)
|
||||
- Supported formats: MP4, WebM, MOV, AVI
|
||||
- Video thumbnails are automatically generated from the first few seconds
|
||||
|
||||
**For Nginx/Reverse Proxy:**
|
||||
If using Nginx, increase the client max body size:
|
||||
```nginx
|
||||
client_max_body_size 10G;
|
||||
proxy_read_timeout 3600;
|
||||
proxy_send_timeout 3600;
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
||||
|
||||
See our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
## 📊 Comparison with Alternatives
|
||||
|
||||
@@ -166,79 +404,168 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
|
||||
| Quotes / Contracts / Invoices | 🧪 Beta | ❌ | ❌ | ✅ |
|
||||
| Incoming Invoices & Accounting | 🧪 Beta | ❌ | ❌ | ❌ |
|
||||
|
||||
<sub>*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.</sub>
|
||||
*You still bring your own server (own hardware or a VPS) and, if you want one, a domain.
|
||||
**Limited only by your server storage.
|
||||
***Pixieset's "unlimited" is photos only; video is capped by plan (roughly 0–10 h depending on tier).
|
||||
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
## 🛡️ Security
|
||||
|
||||
- **Backend**: Node.js, Express, SQLite/PostgreSQL
|
||||
- **Frontend**: React, Tailwind CSS, Framer Motion
|
||||
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](https://docs.picpeak.app/features/storage-backends)
|
||||
- **Email**: SMTP with customizable templates
|
||||
- **Analytics**: Privacy-focused with Umami integration
|
||||
- **External media**: point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals read-only, index quickly, and generate thumbnails on demand
|
||||
PicPeak takes security seriously:
|
||||
- 🔐 Password hashing with bcrypt
|
||||
- 🎫 JWT-based authentication
|
||||
- 🚦 Rate limiting on all endpoints
|
||||
- 🛡️ CORS protection
|
||||
- 📝 Activity logging
|
||||
- 🔒 Secure file access
|
||||
|
||||
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<details>
|
||||
<summary>Click to see the admin dashboard, analytics, and event management</summary>
|
||||
### 🎛️ **Admin Dashboard**
|
||||
Get a complete overview of your photo galleries, analytics, and system status.
|
||||
|
||||
### 🎛️ Admin Dashboard
|
||||
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
|
||||
|
||||
### 📊 Analytics & Insights
|
||||
### 📊 **Analytics & Insights**
|
||||
Track gallery performance, view statistics, and monitor user engagement.
|
||||
|
||||
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
|
||||
|
||||
### 📁 Event Management
|
||||
### 📁 **Event Management**
|
||||
Organize and manage your photo galleries with intuitive event management tools.
|
||||
|
||||
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
|
||||
|
||||
### ✨ **Key Interface Highlights**
|
||||
|
||||
<details>
|
||||
<summary>👆 Click to see more interface details</summary>
|
||||
|
||||
#### What makes PicPeak's interface special:
|
||||
|
||||
- **🎨 Clean Design**: Modern, photographer-friendly interface
|
||||
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
|
||||
- **⚡ Fast Loading**: Optimized for quick photo browsing
|
||||
- **🔒 Secure Access**: Password-protected galleries with expiration
|
||||
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
|
||||
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
|
||||
|
||||
</details>
|
||||
|
||||
## 🤝 Contributing
|
||||
## 🗺️ Roadmap
|
||||
|
||||
We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the [Contributing Guide](CONTRIBUTING.md) to get started.
|
||||
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
|
||||
|
||||
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security). See [SECURITY.md](SECURITY.md) for the policy.
|
||||
### 🚧 Beta Features (Use at your own risk)
|
||||
|
||||
These features are currently in beta testing and may have limited functionality or stability:
|
||||
|
||||
| Feature | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
|
||||
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
|
||||
|
||||
### 📋 Future Enhancements
|
||||
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|---------|
|
||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||
| **External Media Library (Reference Mode)** | Use an external folder library as a read‑only source with import and on‑demand thumbnail generation | High | ✅ Implemented |
|
||||
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
|
||||
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
|
||||
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
|
||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
|
||||
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
|
||||
|
||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||
|
||||
## ☕ Support the Project
|
||||
|
||||
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider [buying me a coffee](https://buymeacoffee.com/theluap) — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also ⭐ star the repo, share it, file good bug reports, or open a PR.
|
||||
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
|
||||
|
||||
<p align="left">
|
||||
<a href="https://buymeacoffee.com/theluap" target="_blank">
|
||||
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.
|
||||
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
|
||||
|
||||
### 👥 Contributors
|
||||
|
||||
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
**[@the-luap](https://github.com/the-luap)** — creator and lead maintainer
|
||||
- Gallery foundation (events, uploads, sharing, download protection, templates)
|
||||
- Backup & restore, analytics, branding/theming
|
||||
- The architecture every later feature builds on
|
||||
|
||||
**[@Luca-Timo](https://github.com/Luca-Timo)**
|
||||
- Native Apple Silicon multi-arch images
|
||||
- CRM & accounting suite (quotes/contracts/invoices)
|
||||
- Hours logging & Treuhänder/Banana tax export
|
||||
- Gallery header/banner decoupling
|
||||
|
||||
**[@Rekoo-PS](https://github.com/Rekoo-PS)** — bug reports & product feedback
|
||||
- Login-loop fix, mobile-lightbox overhaul, bulk-delete workflow
|
||||
- Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter
|
||||
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
|
||||
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
|
||||
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
|
||||
|
||||
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
|
||||
|
||||
### 🤖 AI-Assisted Development
|
||||
|
||||
This project was generated with the assistance of AI technology, but has been:
|
||||
- ✅ **Fully tested end-to-end** by human developers
|
||||
- 🔒 **Security audited** with comprehensive security checks
|
||||
- 👨💻 **Human-reviewed** for code quality and best practices
|
||||
- 🧪 **Production-tested** in real-world scenarios
|
||||
|
||||
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
|
||||
|
||||
## ⚠️ CRM & Accounting disclaimers — examples only, verify locally
|
||||
|
||||
The CRM & accounting modules (contracts, invoices, QR-bills, the tax
|
||||
report and the accountant exports) ship seeded content and computed
|
||||
figures that are intended as a **starting point only**:
|
||||
|
||||
- **Contract blocks** (image rights, NDA, model release, cancellation,
|
||||
jurisdiction, …) are written by the maintainer, **not by a lawyer**.
|
||||
Every operator must have their lawyer review and adapt them before
|
||||
sending any contract to a customer.
|
||||
- **QR-bills and SEPA EPC payloads** are rendered from the data you
|
||||
typed. Picpeak is open source — please scan a test invoice with your
|
||||
bank's app to check the QR actually works. We are not responsible for
|
||||
any mistakes that come from sending an invoice with bad data on it.
|
||||
- **Tax, VAT & accounting figures** (the tax report, VAT-payable, the
|
||||
per-rate breakdown, the Treuhänder / Banana export, etc.) are computed
|
||||
from the data you enter and the defaults you configure. They are
|
||||
**guidance only and jurisdiction-specific** — tax rules, VAT rates,
|
||||
deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat
|
||||
rate) and filing duties differ by country and change over time. **Every
|
||||
operator must check their own tax / VAT regulations and verify the
|
||||
numbers with their accountant / Treuhänder / tax authority before
|
||||
relying on any figure or export.** Picpeak makes no warranty that the
|
||||
output is correct for your jurisdiction or situation.
|
||||
|
||||
Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before
|
||||
enabling the Contracts, Invoices or Accounting features.
|
||||
|
||||
## 📄 License
|
||||
|
||||
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
|
||||
|
||||
## 🚀 Ready to Get Started?
|
||||
|
||||
1. ⭐ **Star this repository** to show your support
|
||||
2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app)
|
||||
3. 🐛 Report issues or request features
|
||||
4. 🤝 Join our community and contribute!
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ by photographers, for photographers
|
||||
<br>
|
||||
<a href="https://www.picpeak.app">Homepage</a> ·
|
||||
<a href="https://demo.picpeak.app">Live Demo</a> ·
|
||||
<a href="https://docs.picpeak.app">Documentation</a> ·
|
||||
<a href="https://www.picpeak.app">Homepage</a> •
|
||||
<a href="https://demo.picpeak.app">Live Demo</a> •
|
||||
<a href="https://github.com/PicPeak/picpeak">GitHub</a> •
|
||||
<a href="https://docs.picpeak.app">Documentation</a> •
|
||||
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
|
||||
</p>
|
||||
|
||||
+5
-24
@@ -52,34 +52,23 @@ The actual mechanics, in order:
|
||||
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
|
||||
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
|
||||
|
||||
5. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
|
||||
```bash
|
||||
git commit --allow-empty -m "chore: release X.Y.Z" -m "Release-As: X.Y.Z"
|
||||
```
|
||||
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
|
||||
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
|
||||
|
||||
6. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
|
||||
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
|
||||
|
||||
7. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
|
||||
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
|
||||
|
||||
8. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
|
||||
|
||||
9. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
|
||||
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
|
||||
|
||||
## Hotfix path (backport to current stable)
|
||||
|
||||
Regular bug fixes are generally backported automatically from `main` to `stable`. Keep backports focused on the fix, without unrelated features, and resolve conflicts manually when needed.
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** Do not wait for a full promotion to deliver a security update. A fix first applied to `stable` must also be forward-ported to `main`; a fix first applied to `main` must also reach `stable`. See [SECURITY.md](SECURITY.md) for the support policy.
|
||||
|
||||
When a backport needs manual handling:
|
||||
If a critical bug or security issue affects the current stable and `main` has moved too far for a full promotion to be appropriate, backport just the fix:
|
||||
|
||||
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
|
||||
2. Cherry-pick or hand-write the minimal fix.
|
||||
3. Open a PR to `stable` with the smallest possible diff.
|
||||
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
|
||||
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
|
||||
6. For security fixes, verify that the fix has been published through **both** release channels; merging the code is only part of delivery.
|
||||
|
||||
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
|
||||
|
||||
@@ -94,14 +83,6 @@ PicPeak follows [Semantic Versioning](https://semver.org/) with one project-spec
|
||||
|
||||
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
|
||||
|
||||
### Stable ↔ pre-release number alignment
|
||||
|
||||
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
|
||||
|
||||
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
|
||||
|
||||
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
|
||||
|
||||
## Things that don't go through this process
|
||||
|
||||
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
|
||||
|
||||
+68
-61
@@ -1,81 +1,88 @@
|
||||
# Security Policy
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the PicPeak backend, frontend, all-in-one (AIO) image, optional
|
||||
ML component, and the Docker images published by the PicPeak project. Other
|
||||
PicPeak repositories define their own supported versions and release channels.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security support follows the current release channels:
|
||||
We release patches for security vulnerabilities. Currently supported versions:
|
||||
|
||||
| Version or channel | Security support |
|
||||
| --- | --- |
|
||||
| Latest stable release from `stable` | Supported; security fixes are published through this channel |
|
||||
| Latest beta release from `main` | Supported; security fixes are published through this channel |
|
||||
| Superseded stable or beta releases | Upgrade to the latest release in the same channel; older releases are not maintained separately |
|
||||
| 2.x and earlier | No longer supported |
|
||||
|
||||
See the [latest stable release](https://github.com/PicPeak/picpeak/releases/latest)
|
||||
and [all releases, including betas](https://github.com/PicPeak/picpeak/releases).
|
||||
Version numbers differ between channels; each channel receives its own updates.
|
||||
|
||||
### Security fixes and bug backports
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** A fix that
|
||||
lands on one branch must also reach the other branch and be published through
|
||||
both release channels. Security updates do not wait for the next full
|
||||
`main`-to-`stable` promotion.
|
||||
|
||||
Regular bug fixes are also generally backported automatically to `stable`.
|
||||
Backports remain focused on the fix, without pulling in unrelated features.
|
||||
Maintainers resolve conflicts or handle a backport manually when necessary.
|
||||
|
||||
The [release process](RELEASING.md) describes backports, forward-ports and
|
||||
publication. Operators must apply the published updates to their installations.
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x.x | :white_check_mark: |
|
||||
| < 2.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Do not report vulnerabilities in public issues, discussions or pull requests.**
|
||||
We take the security of PicPeak seriously. If you have discovered a security vulnerability, please follow these steps:
|
||||
|
||||
Report privately through:
|
||||
### 1. **Do NOT create a public GitHub issue**
|
||||
|
||||
- [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) (preferred).
|
||||
- Email **info@picpeak.app** if you cannot use GitHub's private reporting form.
|
||||
### 2. Report the vulnerability privately by:
|
||||
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- **Alternative:** Email us at **info@picpeak.app** with the details
|
||||
- Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
|
||||
Include the affected component, version or image tag, deployment method,
|
||||
reproduction steps, expected impact and any suggested fix. Share only the
|
||||
information needed to reproduce the problem; remove credentials and personal
|
||||
data from logs or examples.
|
||||
### 3. You can expect:
|
||||
- Acknowledgment within 48 hours
|
||||
- Regular updates on our progress
|
||||
- Credit in the fix announcement (unless you prefer to remain anonymous)
|
||||
|
||||
We aim to acknowledge reports within 48 hours. This is a response target, not a
|
||||
guaranteed service level or a promised resolution time. We will provide progress
|
||||
updates and coordinate disclosure with the reporter. Reporter credit is optional;
|
||||
tell us if you prefer to remain anonymous.
|
||||
## Security Measures
|
||||
|
||||
## Deployment Security
|
||||
PicPeak implements several security measures:
|
||||
|
||||
Security depends on both the software and its configuration. Operators should:
|
||||
### Authentication & Authorization
|
||||
- JWT-based authentication with secure token storage
|
||||
- bcrypt password hashing with configurable rounds
|
||||
- Role-based access control for admin functions
|
||||
- Session timeout management
|
||||
|
||||
- Use HTTPS and configure the reverse proxy and trusted proxy settings correctly.
|
||||
- Use strong credentials and keep deployment secrets private.
|
||||
- Apply updates for the chosen release channel and restrict unnecessary network access.
|
||||
- Keep backups and verify that they can be restored.
|
||||
### Input Validation
|
||||
- All user inputs are validated and sanitized
|
||||
- SQL injection prevention through parameterized queries
|
||||
- XSS protection via Content Security Policy
|
||||
- File upload restrictions and validation
|
||||
|
||||
See the deployment guides for [HTTPS](https://docs.picpeak.app/deployment/ssl-certificates),
|
||||
[reverse proxies](https://docs.picpeak.app/deployment/reverse-proxy),
|
||||
[security settings](https://docs.picpeak.app/guides/admin-settings/security)
|
||||
and [backup and restore](https://docs.picpeak.app/guides/backup-restore).
|
||||
### Rate Limiting
|
||||
- API rate limiting to prevent abuse
|
||||
- Brute force protection on authentication endpoints
|
||||
- Configurable limits per endpoint
|
||||
|
||||
### Data Protection
|
||||
- HTTPS enforcement in production
|
||||
- Secure cookie settings
|
||||
- CORS configuration
|
||||
- Sensitive data encryption
|
||||
|
||||
### Infrastructure
|
||||
- Regular dependency updates
|
||||
- Security headers (HSTS, X-Frame-Options, etc.)
|
||||
- Activity logging for audit trails
|
||||
- Automated backups
|
||||
|
||||
## Best Practices for Deployment
|
||||
|
||||
1. **Always use HTTPS** in production
|
||||
2. **Change default passwords** immediately
|
||||
3. **Keep dependencies updated** regularly
|
||||
4. **Configure firewall rules** appropriately
|
||||
5. **Monitor logs** for suspicious activity
|
||||
6. **Backup regularly** and test restoration
|
||||
|
||||
## Vulnerability Disclosure
|
||||
|
||||
We coordinate disclosure with the reporter while preparing fixes. Security fixes
|
||||
are published through both supported channels. Advisories and release notes
|
||||
identify affected versions, the fixed version in each channel, the impact and
|
||||
any required mitigation or upgrade steps. Reporter credit is included with
|
||||
permission.
|
||||
We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
|
||||
For ordinary bugs and support requests, use
|
||||
[GitHub Issues](https://github.com/PicPeak/picpeak/issues) or
|
||||
[GitHub Discussions](https://github.com/PicPeak/picpeak/discussions).
|
||||
1. We'll publish a security advisory
|
||||
2. Credit researchers (with permission)
|
||||
3. Detail the impact and mitigation steps
|
||||
4. Release patches for all supported versions
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
|
||||
|
||||
Thank you for helping keep PicPeak and its users safe!
|
||||
+2
-4
@@ -170,12 +170,10 @@ If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your ad
|
||||
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
|
||||
|
||||
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
|
||||
2. Read the **one-time setup token** from the 0600 file the backend writes it to
|
||||
(it is not logged — that would leave a live credential in `docker logs`):
|
||||
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
|
||||
```bash
|
||||
docker compose exec backend cat /app/data/SETUP_TOKEN
|
||||
docker compose logs backend | grep -i "setup token"
|
||||
```
|
||||
Only if that write fails does the backend log the token instead.
|
||||
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
|
||||
|
||||
## 🌐 Access Methods
|
||||
|
||||
-33
@@ -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.
|
||||
@@ -1,7 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
storage
|
||||
storage/events/active/*
|
||||
storage/events/archived/*
|
||||
storage/thumbnails/*
|
||||
data/*.db
|
||||
logs/*
|
||||
coverage
|
||||
|
||||
+1
-24
@@ -106,23 +106,6 @@ ARCHIVE_PATH=/app/storage/events/archived
|
||||
# EVENTS_PATH=./storage/events
|
||||
# ARCHIVE_PATH=./storage/events/archived
|
||||
|
||||
# File watcher (auto-import from the events/active folder, local storage only)
|
||||
# Max photos processed in parallel by the watcher. The boot scan and bulk
|
||||
# folder drops fire one handler per file — this bound keeps thumbnail
|
||||
# generation from exhausting memory on small hosts. Default: 2
|
||||
# FILE_WATCHER_CONCURRENCY=2
|
||||
|
||||
# External-media folder watcher (issue 1187). Reference-mode events can opt in
|
||||
# per event (Event → Source Mode → "Watch folder for new files"); new images in
|
||||
# the folder are then imported without pressing Import. Deleted files are
|
||||
# never removed from the gallery.
|
||||
# EXTERNAL_MEDIA_WATCH=true # global kill switch
|
||||
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
|
||||
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
|
||||
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
|
||||
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
|
||||
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
|
||||
|
||||
# Analytics Backend Configuration (OPTIONAL)
|
||||
# Used for server-side tracking only
|
||||
# Primary configuration should be done through Admin UI > Settings > Analytics
|
||||
@@ -130,10 +113,4 @@ ARCHIVE_PATH=/app/storage/events/archived
|
||||
# UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||
# Encryption material for the backend-only signing key (32+ characters).
|
||||
# Defaults to JWT_SECRET; keep it stable until participation has been deleted.
|
||||
# USAGE_ENCRYPTION_KEY=
|
||||
LOG_LEVEL=info
|
||||
@@ -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'] }]
|
||||
}
|
||||
};
|
||||
|
||||
+10
-24
@@ -27,15 +27,6 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# knexfile.js picks its config block by NODE_ENV, and the `development` block
|
||||
# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that
|
||||
# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
|
||||
# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while
|
||||
# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in
|
||||
# the same log. The compose files still override this, so nothing changes for
|
||||
# compose users. See #1038.
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
|
||||
# stage's declaration never reached this stage. Consuming it in the RUN below
|
||||
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
|
||||
@@ -46,16 +37,14 @@ ARG CACHEBUST=1
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN echo "cachebust=${CACHEBUST}" && 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 the npm CLI in the final image so its bundled deps are patched
|
||||
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
|
||||
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
|
||||
# dependencies come from the builder stage (COPY --from=builder node_modules
|
||||
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
|
||||
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
|
||||
# Node >=22.9, satisfied by node:22-alpine.
|
||||
RUN npm install -g npm@11
|
||||
|
||||
# Install dumb-init for proper signal handling, postgresql-client for database
|
||||
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
|
||||
@@ -77,12 +66,9 @@ RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
|
||||
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
|
||||
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
|
||||
# documents (see https://docs.picpeak.app/features/accounting/incoming-invoices).
|
||||
# exiftool extracts the embedded full-res JPEG preview from RAW/DNG uploads
|
||||
# (Apple ProRAW etc.) — sharp's libvips has no raw loader, so the pipeline
|
||||
# thumbnails/displays that preview while keeping the original for download.
|
||||
# documents (see docs/accounting-inbound-invoices.md).
|
||||
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
|
||||
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
|
||||
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
|
||||
fc-cache -f
|
||||
|
||||
# Create non-root user
|
||||
|
||||
@@ -8,10 +8,7 @@ RUN apk upgrade --no-cache
|
||||
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
|
||||
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
|
||||
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
|
||||
# exiftool: extract embedded JPEG previews from RAW/DNG uploads (#821) — kept in
|
||||
# sync with the production Dockerfile so dev/native runtimes don't accept a DNG
|
||||
# and then fail it with ENOENT.
|
||||
RUN apk add --no-cache dumb-init ffmpeg exiftool
|
||||
RUN apk add --no-cache dumb-init ffmpeg
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -110,11 +110,6 @@ describe('Admin settings logo upload flow', () => {
|
||||
}
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (req, res, next) => next(),
|
||||
userHasAnyPermission: jest.fn().mockResolvedValue(true)
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/publicSiteService', () => ({
|
||||
clearPublicSiteCache: jest.fn(),
|
||||
getDefaultPublicSitePayload: jest.fn(),
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
/**
|
||||
* Restoring an archive must put the photos back into their categories.
|
||||
*
|
||||
* The archive writer already persists `category_name` per photo in
|
||||
* `photos_manifest.json` — that is why the manifest exists, and the comment
|
||||
* above it says so: "(and category linkage) can't be derived from the
|
||||
* extracted files alone". The restore route then read only
|
||||
* `original_filename` from it and kept deriving the category from the ZIP's
|
||||
* first path segment.
|
||||
*
|
||||
* Archives store photos exactly as they sit on disk, so an event whose photos
|
||||
* live in the gallery root produces a FLAT zip. `path.dirname()` is '.' for
|
||||
* every entry, no category is resolved, and every restored photo lands with
|
||||
* `category_id = null` — silently, with a 200 response.
|
||||
*
|
||||
* These pin the manifest as the source of truth, with the directory as the
|
||||
* fallback that keeps foldered and legacy archives working.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('archive restore restores categories (flat archives included)', () => {
|
||||
let tmpDir; let db; let cleanup; let app; let storagePath;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-restore-cat-'));
|
||||
storagePath = path.join(tmpDir, 'storage');
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = storagePath;
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
// bootCrmDb points STORAGE_PATH at its own tmp dir; follow it rather than
|
||||
// fighting it, so the archives the tests write are where the route looks.
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin/archives', require('../../src/routes/adminArchives'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photos').del();
|
||||
await db('photo_categories').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
/** A one-pixel JPEG is enough; the route only stats the extracted file. */
|
||||
const PIXEL = Buffer.from(
|
||||
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a'
|
||||
+ 'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA'
|
||||
+ 'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
async function writeArchive(name, entries) {
|
||||
// Required lazily: the suite calls jest.resetModules() in beforeAll, and
|
||||
// archiver's readable-stream copy does not survive being split across the
|
||||
// two module registries.
|
||||
const archiver = require('archiver');
|
||||
const archivePath = path.join(storagePath, 'archives', name);
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(archivePath);
|
||||
const zip = archiver('zip', { zlib: { level: 0 } });
|
||||
output.on('close', resolve);
|
||||
zip.on('error', reject);
|
||||
zip.pipe(output);
|
||||
for (const [entryName, buffer] of Object.entries(entries)) {
|
||||
zip.append(buffer, { name: entryName });
|
||||
}
|
||||
zip.finalize();
|
||||
});
|
||||
return path.join('archives', name);
|
||||
}
|
||||
|
||||
async function seedArchivedEvent(archiveRelPath, slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-06-27',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
is_archived: 1, // sqlite stores booleans as 0/1, see utils/dbCompat
|
||||
archive_path: archiveRelPath,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
const categoryOf = async (filename) => {
|
||||
const photo = await db('photos').where('filename', filename).first();
|
||||
if (!photo || !photo.category_id) return null;
|
||||
const category = await db('photo_categories').where('id', photo.category_id).first();
|
||||
return category ? category.name : null;
|
||||
};
|
||||
|
||||
it('takes the category from the manifest when the archive is flat', async () => {
|
||||
// Exactly the shape a gallery-root event archives to: no directories.
|
||||
const manifest = JSON.stringify([
|
||||
{ filename: 'a.jpg', original_filename: 'DSC_0001.jpg', category_name: 'Polterabend' },
|
||||
{ filename: 'b.jpg', original_filename: 'DSC_0002.jpg', category_name: 'Ceremony' },
|
||||
]);
|
||||
const archiveRelPath = await writeArchive('flat.zip', {
|
||||
'a.jpg': PIXEL,
|
||||
'b.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'flat-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The whole bug: both of these used to be null.
|
||||
expect(await categoryOf('a.jpg')).toBe('Polterabend');
|
||||
expect(await categoryOf('b.jpg')).toBe('Ceremony');
|
||||
});
|
||||
|
||||
it('reuses an existing category row instead of creating a duplicate', async () => {
|
||||
const archiveRelPath = await writeArchive('reuse.zip', {
|
||||
'c.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'c.jpg', original_filename: 'DSC_0003.jpg', category_name: 'Party' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'reuse-event');
|
||||
await db('photo_categories').insert({
|
||||
event_id: eventId, name: 'Party', slug: 'party', created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('c.jpg')).toBe('Party');
|
||||
const rows = await db('photo_categories').where({ event_id: eventId, name: 'Party' });
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still falls back to the directory for legacy archives with no manifest', async () => {
|
||||
// No manifest at all — the shape every archive had before the manifest
|
||||
// landed. The directory is the only signal left, and it must keep working.
|
||||
//
|
||||
// `individual/` is what a REAL archive contains: entry names are the
|
||||
// storage key minus `events/active/{slug}`, and that layout is
|
||||
// `individual/` / `collages/`. Categories have never been directories, so
|
||||
// the fallback invents a category with that name — not useful, but better
|
||||
// than losing every category, and this pins what actually happens rather
|
||||
// than a category-shaped folder no archive produces.
|
||||
const archiveRelPath = await writeArchive('foldered.zip', {
|
||||
'individual/d.jpg': PIXEL,
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'foldered-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('d.jpg')).toBe('individual');
|
||||
});
|
||||
|
||||
it('reuses a GLOBAL category instead of cloning it into the event', async () => {
|
||||
// Seeded categories (Ceremony, Reception, ...) have event_id NULL. An
|
||||
// event-only lookup misses them, so the restore used to create a second
|
||||
// "Ceremony" — and because is_global defaults to TRUE, that duplicate then
|
||||
// appeared in every other event's category list.
|
||||
const [g] = await db('photo_categories').insert({
|
||||
event_id: null, name: 'Ceremony', slug: 'ceremony', is_global: true, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const globalId = typeof g === 'object' ? g.id : g;
|
||||
|
||||
const archiveRelPath = await writeArchive('global.zip', {
|
||||
'individual/gl.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'gl.jpg', original_filename: 'DSC_1.jpg', category_name: 'Ceremony' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'global-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'gl.jpg').first();
|
||||
expect(photo.category_id).toBe(globalId);
|
||||
// No clone, global or otherwise.
|
||||
const all = await db('photo_categories').where('name', 'Ceremony');
|
||||
expect(all).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not create a GLOBAL category when it has to invent one', async () => {
|
||||
// is_global defaults to true on this column, so an unqualified insert would
|
||||
// leak a restore's category name into every gallery on the instance.
|
||||
const archiveRelPath = await writeArchive('newcat.zip', {
|
||||
'individual/nc.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'nc.jpg', original_filename: 'DSC_2.jpg', category_name: 'Polterabend' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'newcat-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const created = await db('photo_categories').where('name', 'Polterabend').first();
|
||||
expect(created.event_id).toBe(eventId);
|
||||
expect(created.is_global === false || created.is_global === 0).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the manifest when the ZIP was written with original filenames', async () => {
|
||||
// With general_use_original_filenames_for_downloads on at archive time,
|
||||
// archiveService names entries after the ORIGINAL filename while the
|
||||
// manifest stays keyed by photos.filename. Looking up the extracted
|
||||
// basename missed every entry, so categories were lost on exactly those
|
||||
// archives.
|
||||
const archiveRelPath = await writeArchive('original-names.zip', {
|
||||
'individual/DSC_4242.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'stored_9f8e7d.jpg', original_filename: 'DSC_4242.jpg', category_name: 'Drohne' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'original-names-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('DSC_4242.jpg')).toBe('Drohne');
|
||||
});
|
||||
|
||||
it('prefers the event-scoped category when a global shares its name', async () => {
|
||||
// The category API permits both. A single OR-lookup with .first() returned
|
||||
// whichever the engine chose, so a photo could be reassigned to the global
|
||||
// row and lose event-local settings such as allow_downloads.
|
||||
const archiveRelPath = await writeArchive('collide.zip', {
|
||||
'individual/co.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'co.jpg', original_filename: 'DSC_3.jpg', category_name: 'Reception' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'collide-event');
|
||||
|
||||
await db('photo_categories').insert({
|
||||
event_id: null, name: 'Reception', slug: 'reception-global', is_global: true, created_at: new Date(),
|
||||
});
|
||||
const [own] = await db('photo_categories').insert({
|
||||
event_id: eventId, name: 'Reception', slug: 'reception-own', is_global: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const ownId = typeof own === 'object' ? own.id : own;
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'co.jpg').first();
|
||||
expect(photo.category_id).toBe(ownId);
|
||||
});
|
||||
|
||||
it('matches a sanitized original filename, as the ZIP would have written it', async () => {
|
||||
// archiveService runs original names through sanitizeForZipEntry() before
|
||||
// writing the entry, so the emitted name differs from the manifest column.
|
||||
const archiveRelPath = await writeArchive('sanitized.zip', {
|
||||
'individual/od_dr_DSC_5.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'stored_abc.jpg', original_filename: 'od/dr/DSC_5.jpg', category_name: 'Strand' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'sanitized-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('od_dr_DSC_5.jpg')).toBe('Strand');
|
||||
});
|
||||
|
||||
it('ignores a legacy event-owned row when falling back to globals', async () => {
|
||||
// The bug fixed here left rows behind on upgraded instances: event-owned
|
||||
// AND is_global true, because the column defaults true. Matching on the
|
||||
// flag alone would let one event's leftover be adopted by another event's
|
||||
// restore, tying photos to a category that vanishes with someone else's
|
||||
// gallery.
|
||||
const otherEventId = await seedArchivedEvent('archives/none.zip', 'legacy-owner-event');
|
||||
await db('photo_categories').insert({
|
||||
event_id: otherEventId, name: 'Sunset', slug: 'sunset-legacy',
|
||||
is_global: true, created_at: new Date(),
|
||||
});
|
||||
|
||||
const archiveRelPath = await writeArchive('legacy-global.zip', {
|
||||
'individual/lg.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'lg.jpg', original_filename: 'DSC_6.jpg', category_name: 'Sunset' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'legacy-global-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'lg.jpg').first();
|
||||
const cat = await db('photo_categories').where('id', photo.category_id).first();
|
||||
// Its own row, not the other event's leftover.
|
||||
expect(cat.event_id).toBe(eventId);
|
||||
});
|
||||
|
||||
it('drops an ambiguous original-name alias rather than guessing', async () => {
|
||||
// Two photos in different ZIP folders can share an original basename;
|
||||
// archiveService treats the paths as distinct and suffixes neither. Both
|
||||
// would collapse onto one alias, and whichever won would hand the other
|
||||
// photo someone else's category.
|
||||
const archiveRelPath = await writeArchive('ambiguous.zip', {
|
||||
'individual/SHARED.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'a_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Alpha' },
|
||||
{ filename: 'b_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Beta' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'ambiguous-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Falls back to the directory rather than picking Alpha or Beta at random.
|
||||
expect(await categoryOf('SHARED.jpg')).toBe('individual');
|
||||
for (const name of ['Alpha', 'Beta']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it('honours a manifest that says UNCATEGORIZED, instead of inventing one from the directory', async () => {
|
||||
// The case the manifest-first change was for. A real archive puts every
|
||||
// photo under `individual/`, so a photo the manifest records as having no
|
||||
// category used to come back filed under a category called "individual" —
|
||||
// the manifest being authoritative for "category X" but not for "none".
|
||||
const manifest = JSON.stringify([
|
||||
{ filename: 'u.jpg', original_filename: 'DSC_7000.jpg', category_name: null },
|
||||
]);
|
||||
const archiveRelPath = await writeArchive('uncategorized.zip', {
|
||||
'individual/u.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'uncategorized-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('u.jpg')).toBeNull();
|
||||
// And no junk category row was created as a side effect.
|
||||
const rows = await db('photo_categories').where({ event_id: eventId });
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops a canonical filename that two photos claim, rather than guessing', async () => {
|
||||
// photos.filename is not unique within an event: s3AutoImporter takes
|
||||
// path.basename(entry.key) and dedupes by path, so two imported files in
|
||||
// different subfolders both land as IMG_1234.jpg. Both ZIP entries reduce
|
||||
// to the same basename at restore, so keeping the last row seen would give
|
||||
// one photo the other's category.
|
||||
const archiveRelPath = await writeArchive('dup-canonical.zip', {
|
||||
'individual/IMG_1234.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'IMG_1234.jpg', original_filename: 'a.jpg', category_name: 'Alpha' },
|
||||
{ filename: 'IMG_1234.jpg', original_filename: 'b.jpg', category_name: 'Beta' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'dup-canonical-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('IMG_1234.jpg')).toBe('individual');
|
||||
for (const name of ['Alpha', 'Beta']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a name that one row owns canonically and another claims as an alias", async () => {
|
||||
// Undecidable: with original-filename archiving ON the ZIP entry under
|
||||
// this name is the ALIAS owner's file, with it OFF it is the canonical
|
||||
// owner's, and the manifest does not record which mode was used. The
|
||||
// point of the two-pass split is that this now resolves the same way
|
||||
// every run — the archive query has no ORDER BY, so it used to be a coin
|
||||
// flip between dropping the name and overwriting it.
|
||||
const archiveRelPath = await writeArchive('alias-vs-canonical.zip', {
|
||||
'individual/CANON.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'CANON.jpg', original_filename: 'unrelated.jpg', category_name: 'Canonical' },
|
||||
{ filename: 'other_stored.jpg', original_filename: 'CANON.jpg', category_name: 'Aliased' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'alias-vs-canonical-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Falls back to the directory rather than guessing either row.
|
||||
expect(await categoryOf('CANON.jpg')).toBe('individual');
|
||||
for (const name of ['Canonical', 'Aliased']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it('picks the lowest id and warns when two categories share a name', async () => {
|
||||
// Allowed: two event-scoped categories with the same display name and
|
||||
// different slugs. .first() used to pick either, so a re-run could move
|
||||
// photos between them and inherit the wrong allow_downloads.
|
||||
const archiveRelPath = await writeArchive('dupe-category.zip', {
|
||||
'individual/DUPE.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'DUPE.jpg', original_filename: 'DUPE.jpg', category_name: 'Ceremony' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'dupe-category-event');
|
||||
|
||||
const [first] = await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony-a', is_global: 0, event_id: eventId,
|
||||
}).returning('id');
|
||||
await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony-b', is_global: 0, event_id: eventId,
|
||||
});
|
||||
const firstId = typeof first === 'object' ? first.id : first;
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Stable, not arbitrary: the same run twice lands on the same row.
|
||||
const photo = await db('photos').where({ event_id: eventId, filename: 'DUPE.jpg' }).first();
|
||||
expect(photo.category_id).toBe(firstId);
|
||||
// And no third "Ceremony" was invented.
|
||||
expect((await db('photo_categories').where({ event_id: eventId, name: 'Ceremony' })).length)
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
it('does not invent a category for a photo row that already exists', async () => {
|
||||
// archiveEvent retains photo rows, so a restore can skip every insert.
|
||||
// Resolving categories before that check created one from the stale
|
||||
// manifest name that nothing then used — renaming a category while its
|
||||
// event was archived left the old name behind as an empty duplicate.
|
||||
const archiveRelPath = await writeArchive('existing-rows.zip', {
|
||||
'individual/KEPT.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'KEPT.jpg', original_filename: 'KEPT.jpg', category_name: 'OldName' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'existing-rows-event');
|
||||
await db('photos').insert({
|
||||
event_id: eventId, filename: 'KEPT.jpg', path: 'whatever/KEPT.jpg', type: 'jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name: 'OldName' }).first())
|
||||
.toBeFalsy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -40,7 +40,7 @@ jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-coverage', () => {
|
||||
let db;
|
||||
|
||||
@@ -29,7 +29,7 @@ jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-integrity', () => {
|
||||
let cleanup;
|
||||
|
||||
@@ -40,15 +40,6 @@ describe('Admin photos in reference mode', () => {
|
||||
}
|
||||
}));
|
||||
|
||||
// The routes gained requirePermission() after this fixture was written.
|
||||
// It resolves the caller's role through admin_users/roles, which this
|
||||
// minimal schema does not create, so every request died in the RBAC
|
||||
// lookup before reaching the handler. RBAC is not what this suite is
|
||||
// about — stub it out the same way adminAuth already is.
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
|
||||
ensureThumbnail: jest.fn()
|
||||
@@ -107,21 +98,8 @@ describe('Admin photos in reference mode', () => {
|
||||
table.string('type').notNullable();
|
||||
table.integer('size_bytes');
|
||||
table.integer('category_id');
|
||||
// Mirrors migration 041: the upload route never writes this column, it
|
||||
// relies on the NOT NULL DEFAULT 'managed' to mark managed originals.
|
||||
table.string('source_origin').notNullable().defaultTo('managed');
|
||||
table.string('source_origin');
|
||||
table.string('external_relpath');
|
||||
// Columns the upload insert writes (migrations 048, 062, 071, 085, 193)
|
||||
// and the PATCH handler writes (migration 178). Without them the insert
|
||||
// and the update both fail on "no such column".
|
||||
table.string('original_filename', 512);
|
||||
table.string('source_filename', 255);
|
||||
table.datetime('captured_at').nullable();
|
||||
table.string('media_type').defaultTo('image');
|
||||
table.string('mime_type');
|
||||
table.string('processing_status', 16).notNullable().defaultTo('complete');
|
||||
table.string('upload_id', 64).nullable();
|
||||
table.boolean('auto_categorized');
|
||||
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
||||
table.float('average_rating').defaultTo(0);
|
||||
table.integer('like_count').defaultTo(0);
|
||||
@@ -175,10 +153,7 @@ describe('Admin photos in reference mode', () => {
|
||||
.field('category_id', String(categoryId))
|
||||
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
|
||||
|
||||
// 202 Accepted since the upload route went async (851744c3): the files are
|
||||
// stored and a pending row is inserted, thumbnails/EXIF follow in the
|
||||
// background worker. This assertion still said 200 from before that.
|
||||
expect(uploadResponse.status).toBe(202);
|
||||
expect(uploadResponse.status).toBe(200);
|
||||
expect(uploadResponse.body).toHaveProperty('photos');
|
||||
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
|
||||
|
||||
@@ -228,24 +203,5 @@ describe('Admin photos in reference mode', () => {
|
||||
|
||||
const updated = await db('photos').where({ id: photo.id }).first();
|
||||
expect(updated.category_id).toBeNull();
|
||||
|
||||
// A real id still round-trips — the '0' guard must not swallow it.
|
||||
await request(app)
|
||||
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||
.send({ category_id: String(categoryId) })
|
||||
.expect(200);
|
||||
expect((await db('photos').where({ id: photo.id }).first()).category_id).toBe(categoryId);
|
||||
|
||||
// Numeric 0 and unparseable input clear the category too, rather than
|
||||
// writing a category id that can never exist.
|
||||
for (const value of [0, 'not-a-category']) {
|
||||
await request(app)
|
||||
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||
.send({ category_id: value })
|
||||
.expect(200);
|
||||
expect((await db('photos').where({ id: photo.id }).first()).category_id).toBeNull();
|
||||
|
||||
await db('photos').where({ id: photo.id }).update({ category_id: categoryId });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* POST /admin/thumbnails/regenerate for external/reference photos (#1129).
|
||||
*
|
||||
* The route used to resolve every source as `storage/events/active/<path>` and
|
||||
* `fs.access` it. External and reference rows do not live there — their
|
||||
* originals sit under `events.external_path` — so every one of them failed the
|
||||
* check and was counted as an error.
|
||||
*
|
||||
* That alone would be inert. What made it destructive is that the tier
|
||||
* deletion runs FIRST (deliberately, so S3 and external rows are not skipped):
|
||||
* on a reference install the button dropped every ?w= tier and rebuilt
|
||||
* nothing, while the UI reported success — the response is sent before the
|
||||
* background loop starts.
|
||||
*
|
||||
* The background work is fired with setImmediate, so every assertion here has
|
||||
* to wait for it to drain rather than trusting the response.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('admin thumbnail regeneration (#1129)', () => {
|
||||
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage; let logInfo;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
// One instance, not a fresh object per call — the route and the
|
||||
// assertions have to be looking at the same mock.
|
||||
jest.doMock('../../src/services/storage', () => {
|
||||
const instance = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
return { getStorage: () => instance };
|
||||
});
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
ensureThumbnail: jest.fn().mockResolvedValue('thumbnails/thumb_ext1_shot.jpg'),
|
||||
ensurePreviewImage: jest.fn().mockResolvedValue('previews/p.jpg'),
|
||||
deleteThumbnailTiers: jest.fn().mockResolvedValue(undefined),
|
||||
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Same module registry as the route, so the spy sees its calls. The
|
||||
// completion line is what drain() below waits for.
|
||||
logInfo = jest.spyOn(require('../../src/utils/logger'), 'info');
|
||||
|
||||
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
|
||||
// success, which ends the jest worker mid-suite.
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
storage = require('../../src/services/storage').getStorage();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin/thumbnails', require('../../src/routes/adminThumbnails'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
const [row] = await db('events').insert({
|
||||
slug: 'nas-wedding', event_type: 'wedding', event_name: 'nas',
|
||||
event_date: '2026-01-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'x', share_link: 'nas-share', expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference', external_path: 'weddings/2026-08',
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function seedPhoto(eventId, overrides = {}) {
|
||||
const [row] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'shot.jpg', path: 'nas-wedding/shot.jpg',
|
||||
type: 'individual', ...overrides,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/**
|
||||
* The work runs in setImmediate, after the response. Wait for the loop's
|
||||
* "regeneration complete" log line rather than a fixed 150 ms: under a
|
||||
* loaded machine (fifteen suites in parallel, each booting a migrated
|
||||
* SQLite) the loop occasionally took longer than that, and the assertions
|
||||
* then ran against a half-finished mock call list.
|
||||
*/
|
||||
const drain = async () => {
|
||||
const deadline = Date.now() + 10000;
|
||||
const done = () => logInfo.mock.calls.some((c) => /regeneration complete/.test(String(c[0])));
|
||||
while (!done() && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
|
||||
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
thumbnail_path: 'thumbnails/stale.jpg',
|
||||
});
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
expect(res.status).toBe(200);
|
||||
await drain();
|
||||
|
||||
// The whole bug: this used to be zero calls and one logged
|
||||
// "Original file not found" per photo.
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('nulls thumbnail_path so the valid-thumbnail short-circuit cannot skip the rebuild', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
thumbnail_path: 'thumbnails/still-on-disk.jpg',
|
||||
});
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// Without this the endpoint is a no-op whenever the OLD thumbnail is still
|
||||
// readable — which is the normal case after a settings change, and exactly
|
||||
// when the admin pressed the button.
|
||||
const [photoArg] = imageProcessor.ensureThumbnail.mock.calls[0];
|
||||
expect(photoArg.thumbnail_path).toBeNull();
|
||||
expect(photoArg.source_origin).toBe('external');
|
||||
// Carried through so ensureThumbnail can resolve off the mount rather than
|
||||
// under events/active.
|
||||
expect(photoArg.external_relpath).toBe('shot.jpg');
|
||||
});
|
||||
|
||||
it('still drops the responsive tiers first', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'external', external_relpath: 'shot.jpg' });
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// They are keyed by width outside thumbnail_path and carry no settings
|
||||
// version, so leaving them serves the old fit to phones indefinitely.
|
||||
expect(imageProcessor.deleteThumbnailTiers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves videos alone rather than handing a container file to Sharp', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'managed', media_type: 'video', filename: 'clip.mp4' });
|
||||
await seedPhoto(eventId, { source_origin: 'managed', filename: 'still.jpg' });
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
expect(imageProcessor.ensureThumbnail.mock.calls[0][0].filename).toBe('still.jpg');
|
||||
});
|
||||
|
||||
/**
|
||||
* On S3, ensureThumbnail downloads the source to a randomly-named temp file,
|
||||
* and for non-RAW input withProcessableImage passes no outputBasename — so
|
||||
* generateThumbnail derives the key from that random name and it differs on
|
||||
* every run. Nulling thumbnail_path hides the old key from everything that
|
||||
* would otherwise clean it up, so each regeneration would strand a full
|
||||
* thumbnail in the bucket, once per photo per run.
|
||||
*/
|
||||
describe('superseded canonical renditions', () => {
|
||||
it('removes the old thumbnail when the key moved', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_OLDRANDOM_shot.jpg',
|
||||
});
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEWRANDOM_shot.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).toHaveBeenCalledWith('thumbnails/thumb_OLDRANDOM_shot.jpg');
|
||||
});
|
||||
|
||||
it('does NOT delete when the key is unchanged — that is the new file', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_stable.jpg',
|
||||
});
|
||||
// Local storage resolves to a stable path, so the key is identical.
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_stable.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a Windows-style legacy path', 'thumbnails\\thumb_ext1_shot.jpg'],
|
||||
['a leading ./', './thumbnails/thumb_ext1_shot.jpg'],
|
||||
['a doubled separator', 'thumbnails//thumb_ext1_shot.jpg'],
|
||||
])('does not delete the file it just wrote when the old path is %s', async (_name, stored) => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'managed', thumbnail_path: stored });
|
||||
// Both storage backends fold these to the same key, so this is the SAME
|
||||
// object — deleting it would remove the freshly generated thumbnail and
|
||||
// leave the row pointing at nothing.
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_ext1_shot.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts the photo as regenerated even if the old object cannot be removed', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_OLD.jpg',
|
||||
});
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEW.jpg');
|
||||
storage.delete.mockRejectedValueOnce(new Error('bucket said no'));
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// Losing the old object is untidy; the regeneration itself succeeded.
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes to one event when asked', async () => {
|
||||
const a = await seedEvent();
|
||||
await seedPhoto(a, { source_origin: 'external', external_relpath: 'a.jpg' });
|
||||
const [b] = await db('events').insert({
|
||||
slug: 'other', event_type: 'wedding', event_name: 'other', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: 'other-share', expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
await seedPhoto(typeof b === 'object' ? b.id : b, { source_origin: 'managed' });
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({ eventId: a });
|
||||
await drain();
|
||||
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Backup credential exposure regression tests.
|
||||
*
|
||||
* The generic settings reads (GET /admin/settings, GET /admin/settings/:type)
|
||||
* masked the recaptcha/umami/rybbit keys but returned backup_s3_secret_key
|
||||
* and backup_rsync_ssh_key (an SSH PRIVATE KEY) in plaintext to any
|
||||
* settings.view holder; GET /admin/backup/config returned them too. Both now
|
||||
* mask, and PUT /admin/backup/config skips the mask sentinel so the edit
|
||||
* form round-trips without clobbering stored credentials.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => {
|
||||
req.admin = { id: 1, username: 'test-admin' };
|
||||
next();
|
||||
},
|
||||
}));
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
requireSuperAdmin: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
describe('backup credential masking', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
// Upsert: several backup_* keys are pre-seeded by the backup migrations.
|
||||
const seed = [
|
||||
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('s3'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_endpoint', setting_value: JSON.stringify('https://s3.example.com'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: JSON.stringify('backups'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: JSON.stringify('AKIAEXAMPLE'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: JSON.stringify('super-secret-s3-key'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_rsync_ssh_key', setting_value: JSON.stringify('-----BEGIN OPENSSH PRIVATE KEY-----abc'), setting_type: 'backup' },
|
||||
];
|
||||
for (const row of seed) {
|
||||
await db('app_settings').insert(row).onConflict('setting_key').merge();
|
||||
}
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
|
||||
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('masks the credentials in GET /admin/backup/config', async () => {
|
||||
const res = await request(app).get('/api/admin/backup/config').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
// Non-secret fields stay readable for the form.
|
||||
expect(res.body.backup_s3_bucket).toBe('backups');
|
||||
});
|
||||
|
||||
it('masks the credentials in the generic GET /admin/settings/:type read', async () => {
|
||||
const res = await request(app).get('/api/admin/settings/backup').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('masks the credentials in the generic GET /admin/settings read', async () => {
|
||||
const res = await request(app).get('/api/admin/settings').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('PUT /admin/backup/config keeps the stored secret when the sentinel round-trips', async () => {
|
||||
await request(app)
|
||||
.put('/api/admin/backup/config')
|
||||
.send({
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_endpoint: 'https://s3.example.com',
|
||||
backup_s3_bucket: 'renamed-bucket',
|
||||
backup_s3_access_key: 'AKIAEXAMPLE',
|
||||
backup_s3_secret_key: '••••••••',
|
||||
backup_rsync_ssh_key: '••••••••',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
||||
expect(JSON.parse(secret.setting_value)).toBe('super-secret-s3-key');
|
||||
const sshKey = await db('app_settings').where({ setting_key: 'backup_rsync_ssh_key' }).first();
|
||||
expect(JSON.parse(sshKey.setting_value)).toBe('-----BEGIN OPENSSH PRIVATE KEY-----abc');
|
||||
const bucket = await db('app_settings').where({ setting_key: 'backup_s3_bucket' }).first();
|
||||
expect(JSON.parse(bucket.setting_value)).toBe('renamed-bucket');
|
||||
});
|
||||
|
||||
it('PUT /admin/backup/config stores a genuinely new secret', async () => {
|
||||
await request(app)
|
||||
.put('/api/admin/backup/config')
|
||||
.send({ backup_s3_secret_key: 'rotated-s3-key' })
|
||||
.expect(200);
|
||||
|
||||
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
||||
expect(JSON.parse(secret.setting_value)).toBe('rotated-s3-key');
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — configurable walker (backup_paths)', () => {
|
||||
let db;
|
||||
@@ -177,203 +177,4 @@ describe('backupService — configurable walker (backup_paths)', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ jest.mock('../../src/services/databaseBackup', () => ({
|
||||
DatabaseBackupService: class {},
|
||||
}));
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — inline DB dump + fail-loud guard', () => {
|
||||
let db;
|
||||
|
||||
@@ -23,7 +23,7 @@ const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — per-Stage-B-path statistics', () => {
|
||||
let db;
|
||||
|
||||
@@ -14,7 +14,7 @@ const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — config + file collection + manifest (smoke)', () => {
|
||||
let db;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
const crypto = require('crypto');
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('booking cutover — draft invoices on hold', () => {
|
||||
let db; let cleanup; let adminId; let customerId; let quoteService;
|
||||
|
||||
@@ -1,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 event’s category', async () => {
|
||||
const eventId = await insertEvent('scope-ev');
|
||||
const own = await insertCat('Own', { event_id: eventId });
|
||||
const global = (await db('photo_categories').where('is_global', 1).first()).id;
|
||||
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
|
||||
|
||||
// A global id is allowed (globals can be arranged per event).
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, global] })
|
||||
.expect(200);
|
||||
|
||||
// A foreign event's category is out of scope.
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, foreign] })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset (DELETE /reorder/:eventId)', () => {
|
||||
it('clears the override and reverts to the global default', async () => {
|
||||
const eventId = await insertEvent('reset-ev');
|
||||
const c1 = await insertCat('R-One', { event_id: eventId });
|
||||
const list = (await getEvent(eventId)).body;
|
||||
const globals = list.filter((c) => c.is_global).map((c) => c.id);
|
||||
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
|
||||
.expect(200);
|
||||
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
|
||||
|
||||
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
|
||||
expect(res.body.every((c) => c.override_position == null)).toBe(true);
|
||||
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('event ownership (PR #790 review)', () => {
|
||||
let limitedToken;
|
||||
let foreignEventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
// A non-super_admin role that DOES hold settings.view + settings.edit —
|
||||
// the exact case the review flagged (settings.edit is grantable).
|
||||
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
|
||||
const roleId = roleRes[0]?.id ?? roleRes[0];
|
||||
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
|
||||
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
|
||||
|
||||
const a2 = await db('admin_users').insert({
|
||||
username: 'limited', email: 'limited@example.com',
|
||||
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
|
||||
must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
|
||||
|
||||
// An event owned by a DIFFERENT admin (the seeded super_admin).
|
||||
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x',
|
||||
expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
|
||||
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
|
||||
});
|
||||
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
|
||||
});
|
||||
|
||||
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
|
||||
|
||||
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
|
||||
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
|
||||
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
|
||||
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST / (create) appends to the end of its scope', () => {
|
||||
it('assigns display_order = max + 1 within the event', async () => {
|
||||
const eventId = await insertEvent('append-ev');
|
||||
await insertCat('First', { event_id: eventId, display_order: 1 });
|
||||
await insertCat('Second', { event_id: eventId, display_order: 2 });
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories'))
|
||||
.send({ name: 'Third', is_global: false, event_id: eventId })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.display_order).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* CRM mint-and-send paths — integration tests (#587).
|
||||
*
|
||||
* Pins the three document "mint" flows end-to-end through the real
|
||||
* HTTP → route → service → DB → email-queue → file pipeline:
|
||||
*
|
||||
* 1. POST /api/admin/quotes/:id/send (draft → sent + PDF + token + email)
|
||||
* 2. POST /api/admin/invoices/:id/cancel (issued → cancelled + Storno row)
|
||||
* — the issue spec named this /:id/storno; the real route is
|
||||
* /:id/cancel (invoiceService.cancelInvoice → createStorno).
|
||||
* 3. POST /api/admin/contracts/:id/countersign
|
||||
* (signed_by_customer → fully_signed + stamped PDF + sha256 + email)
|
||||
*
|
||||
* Real SQLite with the full core-migration run (helpers/crmDb), real
|
||||
* pdfkit/pdf-lib rendering — no mock-fs, no network.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
// Full migration run + cold-requiring pdfService/emailProcessor is slow
|
||||
// under CI load; match the other CRM integration suites.
|
||||
jest.setTimeout(120000);
|
||||
|
||||
const CUSTOMER_EMAIL = 'customer@example.com';
|
||||
|
||||
// 1x1 transparent PNG — smallest valid signature pad output.
|
||||
const SIGNATURE_DATA_URL = 'data:image/png;base64,'
|
||||
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
|
||||
// SQLite round-trips dates inconsistently (epoch ms number, numeric
|
||||
// string, or ISO string) — parse robustly before comparing.
|
||||
const toMillis = (v) => {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'string' && /^\d+$/.test(v)) return Number(v);
|
||||
return Date.parse(v);
|
||||
};
|
||||
|
||||
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
||||
|
||||
// Count embedded image XObjects per page via pdf-lib — used to prove BOTH
|
||||
// signature stamps (customer + admin) made it into the final PDF instead of
|
||||
// only asserting file existence/hash (codex review of #850 round 2).
|
||||
async function countImagesPerPage(pdfPath) {
|
||||
const { PDFDocument, PDFName, PDFDict } = require('pdf-lib');
|
||||
const doc = await PDFDocument.load(fs.readFileSync(pdfPath));
|
||||
return doc.getPages().map((page) => {
|
||||
const resources = page.node.Resources();
|
||||
const xobjects = resources && resources.lookupMaybe(PDFName.of('XObject'), PDFDict);
|
||||
if (!xobjects) return 0;
|
||||
let images = 0;
|
||||
for (const [, ref] of xobjects.entries()) {
|
||||
const stream = page.doc.context.lookup(ref);
|
||||
const subtype = stream && stream.dict && stream.dict.get(PDFName.of('Subtype'));
|
||||
if (subtype && subtype.toString() === '/Image') images += 1;
|
||||
}
|
||||
return images;
|
||||
});
|
||||
}
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
// Real (symlink-resolved) storage root — on macOS os.tmpdir() returns
|
||||
// /var/... while the services persist under process.cwd() which
|
||||
// resolves to /private/var/....
|
||||
let storageRoot;
|
||||
let adminId;
|
||||
let customerId;
|
||||
let token;
|
||||
let quoteApp;
|
||||
let invoiceApp;
|
||||
let contractApp;
|
||||
let quoteService;
|
||||
let invoiceService;
|
||||
let contractService;
|
||||
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
const auth = { get Authorization() { return `Bearer ${token}`; } };
|
||||
|
||||
async function enableFlag(key) {
|
||||
const updated = await db('feature_flags').where({ key }).update({ value: true });
|
||||
if (!updated) await db('feature_flags').insert({ key, value: true });
|
||||
}
|
||||
|
||||
// ----- per-path seed helpers -----------------------------------------
|
||||
|
||||
async function seedQuote() {
|
||||
const id = await quoteService.createQuote({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
eventName: 'Testshooting',
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Photo package', unit_price_minor: 150000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function seedIssuedInvoice(status = 'sent') {
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 7.7,
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Wedding coverage', unit_price_minor: 200000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId);
|
||||
const id = invoiceIds[0];
|
||||
// Fast-forward past the send step — Storno only applies to issued
|
||||
// documents (sent/paid/overdue), and rendering+sending the original
|
||||
// is covered by the quote path already.
|
||||
await db('invoices').where({ id }).update({
|
||||
status, sent_at: new Date(), updated_at: new Date(),
|
||||
});
|
||||
return db('invoices').where({ id }).first();
|
||||
}
|
||||
|
||||
async function seedCustomerSignedContract() {
|
||||
const id = await contractService.createContract({
|
||||
customerAccountId: customerId,
|
||||
title: 'Fotografie-Vertrag',
|
||||
}, adminId);
|
||||
// Real send + customer-sign flow (codex review of #850): a direct
|
||||
// status UPDATE skipped the customer's signature asset and stamped
|
||||
// PDF, so countersign exercised its unsigned-PDF fallback and a
|
||||
// regression dropping the customer's signature would stay green.
|
||||
const { token } = await contractService.sendContract(id, adminId);
|
||||
await contractService.recordCustomerSignature({
|
||||
token,
|
||||
name: 'Custo Mer',
|
||||
ip: '127.0.0.1',
|
||||
signatureDataUrl: SIGNATURE_DATA_URL,
|
||||
accepted: true,
|
||||
});
|
||||
return db('contracts').where({ id }).first();
|
||||
}
|
||||
|
||||
// ----- suite ----------------------------------------------------------
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
// Business-doc PDFs (quotes/invoices/contracts) persist under
|
||||
// `getStoragePath()/business-docs/...`, and safePath also allows a
|
||||
// `process.cwd()/storage/business-docs/...` root — chdir into the temp
|
||||
// dir so every test artifact lands isolated and gets cleaned up.
|
||||
process.chdir(tmpDir);
|
||||
// Mirror what the services store: the raw STORAGE_PATH bootCrmDb
|
||||
// exported, NOT a symlink-resolved variant. On macOS os.tmpdir() is
|
||||
// /var/... while realpath is /private/var/..., so canonicalizing here
|
||||
// would make every stored path fail the prefix check.
|
||||
storageRoot = path.join(process.env.STORAGE_PATH, 'business-docs');
|
||||
|
||||
// Fail-fast on the pre-existing logActivity-inside-transaction
|
||||
// deadlock: createContract and createStorno call logActivity() from
|
||||
// inside a knex transaction WITHOUT passing the trx as executor, so
|
||||
// the audit insert tries to grab a second connection from the
|
||||
// single-connection SQLite pool while the trx holds it. In
|
||||
// production that stalls each call for the full 60 s acquire
|
||||
// timeout (the error is then swallowed by logActivity's catch);
|
||||
// here we shrink the timeout so the same swallowed failure costs
|
||||
// 2 s instead of blowing the per-test budget. Behaviour under test
|
||||
// is unchanged — the mint paths themselves never wait on this.
|
||||
db.client.pool.acquireTimeoutMillis = 2000;
|
||||
|
||||
// node-sqlite3 detects Date bind params via `InstanceOf(global.Date)`
|
||||
// against the NATIVE realm's Date — under jest's vm sandbox the
|
||||
// service code's `new Date()` is a different constructor, the check
|
||||
// fails, and the value stringifies to the literal "[object Object]"
|
||||
// (the exact pathology helpers/crmDb.js documents for
|
||||
// createPublicToken). Normalize Date bindings to ISO strings before
|
||||
// they reach the driver so the real service inserts round-trip the
|
||||
// same way they do outside jest.
|
||||
// Patch on the prototype — knex mints transaction clients via
|
||||
// Object.create(prototype), so an instance-level patch would miss
|
||||
// every query issued inside a db.transaction().
|
||||
const clientProto = Object.getPrototypeOf(db.client);
|
||||
const origQuery = clientProto._query;
|
||||
clientProto._query = function patchedQuery(connection, obj) {
|
||||
if (obj && Array.isArray(obj.bindings)) {
|
||||
obj.bindings = obj.bindings.map(
|
||||
(b) => (b && typeof b === 'object' && typeof b.toISOString === 'function' ? b.toISOString() : b),
|
||||
);
|
||||
}
|
||||
return origQuery.call(this, connection, obj);
|
||||
};
|
||||
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
// CRM surfaces are feature-flagged; migration 107 seeds them OFF.
|
||||
await enableFlag('quotes');
|
||||
await enableFlag('bills');
|
||||
await enableFlag('contracts');
|
||||
|
||||
quoteService = require('../../src/services/quoteService');
|
||||
invoiceService = require('../../src/services/invoiceService');
|
||||
contractService = require('../../src/services/contractService');
|
||||
|
||||
quoteApp = buildRouteApp('/api/admin/quotes', require('../../src/routes/adminQuotes'));
|
||||
invoiceApp = buildRouteApp('/api/admin/invoices', require('../../src/routes/adminInvoices'));
|
||||
contractApp = buildRouteApp('/api/admin/contracts', require('../../src/routes/adminContracts'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
process.chdir(prevCwd);
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('POST /api/admin/quotes/:id/send', () => {
|
||||
test('draft quote: 200 → sent + sent_at + PDF on disk + action token + quote_sent email', async () => {
|
||||
const quoteId = await seedQuote();
|
||||
await db('email_queue').del();
|
||||
|
||||
const res = await request(quoteApp)
|
||||
.post(`/api/admin/quotes/${quoteId}/send`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.sent).toBe(true);
|
||||
expect(res.body.token).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
// DB state
|
||||
const quote = await db('quotes').where({ id: quoteId }).first();
|
||||
expect(quote.status).toBe('sent');
|
||||
expect(quote.sent_at).toBeTruthy();
|
||||
|
||||
// PDF persisted inside the isolated storage root
|
||||
expect(quote.pdf_path).toBeTruthy();
|
||||
expect(quote.pdf_path.startsWith(path.join(storageRoot, 'quote'))).toBe(true);
|
||||
expect(fs.existsSync(quote.pdf_path)).toBe(true);
|
||||
expect(fs.statSync(quote.pdf_path).size).toBeGreaterThan(0);
|
||||
|
||||
// Action token row: right quote, future expiry
|
||||
const tokenRow = await db('quote_action_tokens').where({ token: res.body.token }).first();
|
||||
expect(tokenRow).toBeTruthy();
|
||||
expect(tokenRow.quote_id).toBe(quoteId);
|
||||
expect(toMillis(tokenRow.expires_at)).toBeGreaterThan(Date.now());
|
||||
|
||||
// Email queued to the customer's primary address
|
||||
const emails = await db('email_queue').where({ email_type: 'quote_sent' });
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0].recipient_email).toBe(CUSTOMER_EMAIL);
|
||||
const emailData = JSON.parse(emails[0].email_data);
|
||||
expect(emailData.quote_number).toBe(quote.quote_number);
|
||||
});
|
||||
|
||||
test('already-sent quote: 409 (spec said 400; service throws 409)', async () => {
|
||||
const quoteId = await seedQuote();
|
||||
await request(quoteApp).post(`/api/admin/quotes/${quoteId}/send`).set(auth).expect(200);
|
||||
|
||||
const res = await request(quoteApp)
|
||||
.post(`/api/admin/quotes/${quoteId}/send`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/cannot send a quote with status 'sent'/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/invoices/:id/cancel (Storno mint)', () => {
|
||||
test('sent invoice: original cancelled, Storno row minted with negated totals + lineage', async () => {
|
||||
const original = await seedIssuedInvoice('sent');
|
||||
await db('email_queue').del();
|
||||
|
||||
const res = await request(invoiceApp)
|
||||
.post(`/api/admin/invoices/${original.id}/cancel`)
|
||||
.set(auth);
|
||||
// Route responds via successResponse default — 200, not the 201
|
||||
// the issue spec assumed.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cancelled).toBe(true);
|
||||
expect(res.body.stornoId).toBeGreaterThan(0);
|
||||
|
||||
const storno = await db('invoices').where({ id: res.body.stornoId }).first();
|
||||
expect(storno.kind).toBe('storno');
|
||||
expect(storno.cancels_invoice_id).toBe(original.id);
|
||||
expect(storno.deal_uuid).toBe(original.deal_uuid);
|
||||
|
||||
// Negated amounts
|
||||
expect(storno.net_amount_minor).toBe(-original.net_amount_minor);
|
||||
expect(storno.vat_amount_minor).toBe(-original.vat_amount_minor);
|
||||
expect(storno.total_amount_minor).toBe(-original.total_amount_minor);
|
||||
|
||||
// Freshly sequenced number from the same series
|
||||
expect(typeof storno.invoice_number).toBe('string');
|
||||
expect(storno.invoice_number.length).toBeGreaterThan(0);
|
||||
expect(storno.invoice_number).not.toBe(original.invoice_number);
|
||||
|
||||
// Line items snapshotted onto the Storno
|
||||
const originalItems = await db('invoice_line_items').where({ invoice_id: original.id });
|
||||
const stornoItems = await db('invoice_line_items').where({ invoice_id: storno.id });
|
||||
expect(stornoItems).toHaveLength(originalItems.length);
|
||||
|
||||
// Original flipped + back-linked
|
||||
const refreshed = await db('invoices').where({ id: original.id }).first();
|
||||
expect(refreshed.status).toBe('cancelled');
|
||||
expect(refreshed.cancellation_storno_id).toBe(storno.id);
|
||||
|
||||
// sendStorno side effects (codex review of #850): cancelInvoice
|
||||
// swallows a sendStorno failure by design, so without these
|
||||
// assertions a broken render/persist/queue leg would stay green.
|
||||
const sentStorno = await db('invoices').where({ id: storno.id }).first();
|
||||
expect(sentStorno.status).toBe('sent');
|
||||
expect(sentStorno.pdf_path).toBeTruthy();
|
||||
expect(fs.existsSync(sentStorno.pdf_path)).toBe(true);
|
||||
const stornoEmails = await db('email_queue').where({ email_type: 'storno_issued' });
|
||||
expect(stornoEmails.length).toBeGreaterThanOrEqual(1);
|
||||
expect(stornoEmails[0].recipient_email).toBe(CUSTOMER_EMAIL);
|
||||
});
|
||||
|
||||
test('paid invoice can be cancelled via Storno too (refund document leg)', async () => {
|
||||
const original = await seedIssuedInvoice('paid');
|
||||
|
||||
const res = await request(invoiceApp)
|
||||
.post(`/api/admin/invoices/${original.id}/cancel`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.stornoId).toBeGreaterThan(0);
|
||||
|
||||
const refreshed = await db('invoices').where({ id: original.id }).first();
|
||||
expect(refreshed.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
test('already-cancelled invoice: 409 ALREADY_CANCELLED', async () => {
|
||||
const original = await seedIssuedInvoice('sent');
|
||||
await request(invoiceApp).post(`/api/admin/invoices/${original.id}/cancel`).set(auth).expect(200);
|
||||
|
||||
const res = await request(invoiceApp)
|
||||
.post(`/api/admin/invoices/${original.id}/cancel`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('ALREADY_CANCELLED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/contracts/:id/countersign', () => {
|
||||
test('customer-signed contract: 200 → fully_signed + stamped PDF + sha256 + signature asset + email with attachment', async () => {
|
||||
const contract = await seedCustomerSignedContract();
|
||||
await db('email_queue').del();
|
||||
|
||||
const res = await request(contractApp)
|
||||
.post(`/api/admin/contracts/${contract.id}/countersign`)
|
||||
.set(auth)
|
||||
.send({ name: 'Admin Tester', signatureDataUrl: SIGNATURE_DATA_URL });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('fully_signed');
|
||||
|
||||
const row = await db('contracts').where({ id: contract.id }).first();
|
||||
expect(row.status).toBe('fully_signed');
|
||||
expect(row.signed_admin_name).toBe('Admin Tester');
|
||||
expect(row.signed_by_admin_at).toBeTruthy();
|
||||
|
||||
// The customer's own signature (from the real sign flow in the seed)
|
||||
// must survive countersigning — layered, not replaced.
|
||||
expect(row.signed_customer_signature_path).toBeTruthy();
|
||||
expect(fs.existsSync(row.signed_customer_signature_path)).toBe(true);
|
||||
expect(row.signed_customer_name).toBe('Custo Mer');
|
||||
|
||||
// Admin signature image persisted under the storage root
|
||||
expect(row.signed_admin_signature_path).toBeTruthy();
|
||||
expect(row.signed_admin_signature_path.startsWith(
|
||||
path.join(storageRoot, 'contract', 'signatures'),
|
||||
)).toBe(true);
|
||||
expect(fs.existsSync(row.signed_admin_signature_path)).toBe(true);
|
||||
|
||||
// Stamped, fully-signed PDF written and hashed. The issue spec
|
||||
// called this `integrity_hash`; the real column is
|
||||
// `signed_pdf_sha256` (plus `pdf_sha256` for the unsigned base).
|
||||
expect(row.signed_pdf_render_failed_at).toBeFalsy();
|
||||
expect(row.signed_pdf_path).toBeTruthy();
|
||||
expect(fs.existsSync(row.signed_pdf_path)).toBe(true);
|
||||
expect(row.signed_pdf_sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(sha256(fs.readFileSync(row.signed_pdf_path))).toBe(row.signed_pdf_sha256);
|
||||
|
||||
// BOTH stamps must be embedded in the final document — a regression
|
||||
// stamping the admin onto the unsigned base PDF would keep every
|
||||
// path/hash assertion above green (codex review of #850 round 2).
|
||||
const imagesPerPage = await countImagesPerPage(row.signed_pdf_path);
|
||||
const maxImagesOnAPage = Math.max(...imagesPerPage);
|
||||
expect(maxImagesOnAPage).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// contract_fully_signed email to the customer's primary address,
|
||||
// carrying the signed PDF as attachment (plus the audit cert).
|
||||
const emails = await db('email_queue').where({ email_type: 'contract_fully_signed' });
|
||||
const customerCopy = emails.find((e) => e.recipient_email === CUSTOMER_EMAIL);
|
||||
expect(customerCopy).toBeTruthy();
|
||||
const emailData = JSON.parse(customerCopy.email_data);
|
||||
expect(emailData.contract_number).toBe(contract.contract_number);
|
||||
expect(Array.isArray(emailData.attachments)).toBe(true);
|
||||
const pdfAttachment = emailData.attachments.find(
|
||||
(a) => a.filename === `${contract.contract_number}-signed.pdf`,
|
||||
);
|
||||
expect(pdfAttachment).toBeTruthy();
|
||||
expect(pdfAttachment.contentType).toBe('application/pdf');
|
||||
expect(fs.existsSync(pdfAttachment.contentPath)).toBe(true);
|
||||
});
|
||||
|
||||
test('draft contract: 409 — countersign requires sent/signed_by_customer', async () => {
|
||||
const draftId = await contractService.createContract({
|
||||
customerAccountId: customerId,
|
||||
title: 'Noch nicht versendet',
|
||||
}, adminId);
|
||||
|
||||
const res = await request(contractApp)
|
||||
.post(`/api/admin/contracts/${draftId}/countersign`)
|
||||
.set(auth)
|
||||
.send({ name: 'Admin Tester' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/cannot counter-sign a contract with status 'draft'/i);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
// Service-level CRM calls cold-require heavy modules (pdfService,
|
||||
// nodemailer, etc.) on first use; the global 5 s per-test budget is
|
||||
// too tight for that. Bump it for this file only.
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('discount line items (negative unit_price_minor)', () => {
|
||||
let db;
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* Download resolutions (#858).
|
||||
*
|
||||
* Pins the contracts that are easy to break later:
|
||||
*
|
||||
* - the global → per-event cascade, including NULL = inherit
|
||||
* - the picker never offers a size ABOVE the standard (a photographer who
|
||||
* lowers the standard is not silently handing out full-res), and 'Original'
|
||||
* only reappears when the admin explicitly allows it
|
||||
* - `fit: 'inside'` + no-upscaling resize semantics, which is exactly what
|
||||
* the requester asked for on the issue
|
||||
* - a guest-supplied resolution is validated against the policy rather than
|
||||
* trusted
|
||||
*/
|
||||
|
||||
const sharp = require('sharp');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Both modules under test pull in src/database/db.js transitively. bootCrmDb
|
||||
// only works when it runs BEFORE the first require of db.js (it sets
|
||||
// TEST_DATABASE_PATH, which knexfile reads at module-init time), so these are
|
||||
// required lazily in beforeAll rather than at module scope — otherwise knex
|
||||
// binds to the shared default SQLite file and every run after the first one
|
||||
// fails with "table `migrations` already exists".
|
||||
let resolveEventDownloadPolicy;
|
||||
let pickRequestedResolution;
|
||||
let parseResolution;
|
||||
let invalidateDownloadGlobals;
|
||||
let resizeToBox;
|
||||
|
||||
describe('Download resolutions (#858)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
const setGlobal = async (key, value) => {
|
||||
await db('app_settings').where({ setting_key: key }).del();
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'download',
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
invalidateDownloadGlobals();
|
||||
};
|
||||
|
||||
const PRESETS = [
|
||||
{ label: 'Large', width: 3000, height: 2000 },
|
||||
{ label: 'Medium', width: 1500, height: 1000 },
|
||||
{ label: 'Small', width: 800, height: 600 },
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
({
|
||||
resolveEventDownloadPolicy,
|
||||
pickRequestedResolution,
|
||||
parseResolution,
|
||||
invalidateDownloadGlobals,
|
||||
} = require('../../src/utils/downloadResolutions'));
|
||||
({ resizeToBox } = require('../../src/services/imageProcessor'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await setGlobal('download_resolutions', PRESETS);
|
||||
await setGlobal('download_standard_resolution', 'original');
|
||||
await setGlobal('download_resolution_picker_enabled', false);
|
||||
await setGlobal('download_allow_original', false);
|
||||
});
|
||||
|
||||
describe('cascade', () => {
|
||||
it('inherits the global standard when the event has no override', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({ download_standard_resolution: null });
|
||||
expect(policy.standard).toBe('1500x1000');
|
||||
expect(policy.standardBox).toEqual({ width: 1500, height: 1000 });
|
||||
});
|
||||
|
||||
it('lets an event override the global standard', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({ download_standard_resolution: '800x600' });
|
||||
expect(policy.standard).toBe('800x600');
|
||||
});
|
||||
|
||||
it('treats a NULL picker flag as inherit and an explicit false as override', async () => {
|
||||
await setGlobal('download_resolution_picker_enabled', true);
|
||||
expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: null })).pickerEnabled).toBe(true);
|
||||
expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: false })).pickerEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('choice list', () => {
|
||||
it('never offers a size larger than the standard', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices.map((c) => c.id)).toEqual(['1500x1000', '800x600']);
|
||||
// The regression that matters: 3000x2000 must not be reachable.
|
||||
expect(choices.some((c) => c.id === '3000x2000')).toBe(false);
|
||||
});
|
||||
|
||||
it('bounds EACH dimension, not the pixel area (codex review round 2)', async () => {
|
||||
// 2000x700 is 1.4MP — under 1500x1000's 1.5MP — so an area comparison
|
||||
// would offer it and hand back a 2000px-wide file despite a 1500px cap.
|
||||
await setGlobal('download_resolutions', [
|
||||
...PRESETS,
|
||||
{ label: 'Wide', width: 2000, height: 700 },
|
||||
]);
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices.some((c) => c.id === '2000x700')).toBe(false);
|
||||
});
|
||||
|
||||
it('omits Original when the standard is capped and the admin has not allowed it', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices.some((c) => c.id === 'original')).toBe(false);
|
||||
});
|
||||
|
||||
it('re-adds Original when the admin explicitly allows it', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
await setGlobal('download_allow_original', true);
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices[0].id).toBe('original');
|
||||
});
|
||||
|
||||
it('offers Original when the standard already is original', async () => {
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices[0].id).toBe('original');
|
||||
expect(choices.map((c) => c.id)).toContain('3000x2000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request validation', () => {
|
||||
it('falls back to the standard when nothing is requested', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({});
|
||||
expect(pickRequestedResolution(policy, undefined)).toBe('1500x1000');
|
||||
});
|
||||
|
||||
it('refuses any explicit request while the picker is off', async () => {
|
||||
const policy = await resolveEventDownloadPolicy({});
|
||||
expect(policy.pickerEnabled).toBe(false);
|
||||
expect(pickRequestedResolution(policy, '800x600')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a size that is not on the offered list', async () => {
|
||||
await setGlobal('download_resolution_picker_enabled', true);
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({});
|
||||
// Above the standard → not offered → rejected rather than silently served.
|
||||
expect(pickRequestedResolution(policy, '3000x2000')).toBeNull();
|
||||
expect(pickRequestedResolution(policy, '9999x9999')).toBeNull();
|
||||
expect(pickRequestedResolution(policy, '800x600')).toBe('800x600');
|
||||
});
|
||||
|
||||
it('parses only well-formed resolution ids', () => {
|
||||
expect(parseResolution('original')).toBeNull();
|
||||
expect(parseResolution(null)).toBeNull();
|
||||
expect(parseResolution('abc')).toBeNull();
|
||||
expect(parseResolution('0x0')).toBeNull();
|
||||
expect(parseResolution('1500x1000')).toEqual({ width: 1500, height: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('job dedup identity (codex review round 1)', () => {
|
||||
// The leak this pins: a PIN client's archive contains hidden photos. If the
|
||||
// dedup key ignored the visibility scope, a guest asking for the same size
|
||||
// would be handed the client's job token — and the delivery route only
|
||||
// checked the event id.
|
||||
let jobService;
|
||||
|
||||
beforeAll(() => {
|
||||
jobService = require('../../src/services/downloadJobService');
|
||||
});
|
||||
|
||||
it('separates client and guest archives of the same size and photo set', () => {
|
||||
const guest = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public');
|
||||
const client = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'hidden');
|
||||
expect(guest).not.toBe(client);
|
||||
});
|
||||
|
||||
it('keys on the RESOLVED photo set, so a stale archive is not reused', () => {
|
||||
const before = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public');
|
||||
const afterUpload = jobService.dedupKey(1, '1500x1000', [1, 2, 3, 4], false, 'public');
|
||||
const afterHide = jobService.dedupKey(1, '1500x1000', [1, 2], false, 'public');
|
||||
expect(new Set([before, afterUpload, afterHide]).size).toBe(3);
|
||||
});
|
||||
|
||||
it('is order-independent for the same set', () => {
|
||||
expect(jobService.dedupKey(1, 'original', [3, 1, 2], true, 'public'))
|
||||
.toBe(jobService.dedupKey(1, 'original', [1, 2, 3], true, 'public'));
|
||||
});
|
||||
|
||||
it('maps access levels onto the two visibility scopes', () => {
|
||||
expect(jobService.visibilityScopeFor('client')).toBe('hidden');
|
||||
expect(jobService.visibilityScopeFor('guest')).toBe('public');
|
||||
expect(jobService.visibilityScopeFor(undefined)).toBe('public');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resize semantics', () => {
|
||||
const make = (w, h) => sharp({
|
||||
create: { width: w, height: h, channels: 3, background: { r: 10, g: 100, b: 200 } },
|
||||
}).jpeg().toBuffer();
|
||||
|
||||
const box = { width: 1500, height: 1000 };
|
||||
|
||||
it('fits a 3:2 photo exactly into a 3:2 box', async () => {
|
||||
const out = await sharp(await resizeToBox(await make(6000, 4000), box)).metadata();
|
||||
expect([out.width, out.height]).toEqual([1500, 1000]);
|
||||
});
|
||||
|
||||
it('treats the box as an "up to" bound for other aspect ratios', async () => {
|
||||
// Portrait: height is the binding edge, width comes out smaller.
|
||||
const portrait = await sharp(await resizeToBox(await make(4000, 6000), box)).metadata();
|
||||
expect(portrait.height).toBe(1000);
|
||||
expect(portrait.width).toBeLessThan(1500);
|
||||
|
||||
const fourThree = await sharp(await resizeToBox(await make(4000, 3000), box)).metadata();
|
||||
expect(fourThree.height).toBe(1000);
|
||||
expect(fourThree.width).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
it('never upscales an image already smaller than the box', async () => {
|
||||
const out = await sharp(await resizeToBox(await make(800, 600), box)).metadata();
|
||||
expect([out.width, out.height]).toEqual([800, 600]);
|
||||
});
|
||||
|
||||
it('passes the buffer through untouched for the original size', async () => {
|
||||
const src = await make(4000, 3000);
|
||||
expect(await resizeToBox(src, null)).toBe(src);
|
||||
});
|
||||
|
||||
it('keeps the source format so the filename and mime type stay honest', async () => {
|
||||
// A .gif re-encoded as JPEG would ship mislabelled bytes, since the
|
||||
// download routes keep the original filename and mime type.
|
||||
const gif = await sharp({
|
||||
create: { width: 4000, height: 3000, channels: 3, background: { r: 1, g: 2, b: 3 } },
|
||||
}).gif().toBuffer();
|
||||
const out = await sharp(await resizeToBox(gif, box)).metadata();
|
||||
expect(out.format).toBe('gif');
|
||||
expect(out.width).toBe(1333);
|
||||
});
|
||||
|
||||
it('returns the input rather than throwing on an undecodable source', async () => {
|
||||
const junk = Buffer.from('not an image');
|
||||
expect(await resizeToBox(junk, box)).toBe(junk);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
/**
|
||||
* Global email footer signature (migration 198, issue #1264).
|
||||
*
|
||||
* The signature is rendered by `wrapEmailHtml` and nowhere else, which is
|
||||
* the whole point of the design: every template, preview, test mail and
|
||||
* manual send passes through that one wrapper, so none of them needed a
|
||||
* per-template change. These tests pin that contract at the wrapper.
|
||||
*
|
||||
* The load-bearing case is the DISABLED one — an upgraded install must keep
|
||||
* a byte-identical footer until an admin opts in.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('wrapEmailHtml — business-profile signature footer', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let wrapEmailHtml;
|
||||
let renderEmailSignatureText;
|
||||
let businessProfileService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ wrapEmailHtml, renderEmailSignatureText } = require('../../src/services/emailProcessor'));
|
||||
businessProfileService = require('../../src/services/businessProfileService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('business_profile').where({ id: 1 }).update({
|
||||
company_name: null,
|
||||
address_line1: null,
|
||||
address_line2: null,
|
||||
postal_code: null,
|
||||
city: null,
|
||||
country_code: null,
|
||||
country_name: null,
|
||||
phone: null,
|
||||
mobile: null,
|
||||
email: null,
|
||||
website: null,
|
||||
vat_id: null,
|
||||
email_signature_enabled: false,
|
||||
email_signature_extra: null,
|
||||
});
|
||||
businessProfileService.invalidateEmailSignatureCache();
|
||||
});
|
||||
|
||||
const fullProfile = {
|
||||
company_name: 'Müller Fotografie GmbH',
|
||||
address_line1: 'Bahnhofstrasse 1',
|
||||
address_line2: 'Postfach 42',
|
||||
postal_code: '9494',
|
||||
city: 'Schaan',
|
||||
country_code: 'li',
|
||||
country_name: 'Liechtenstein',
|
||||
phone: '+41 79 123 45 67',
|
||||
mobile: '+41 78 000 11 22',
|
||||
email: 'hello@example.com',
|
||||
website: 'example.com',
|
||||
vat_id: 'CHE-123.456.789',
|
||||
email_signature_enabled: true,
|
||||
email_signature_extra: 'Handelsregister Vaduz\nFL-0002.123.456-7',
|
||||
};
|
||||
|
||||
async function enable(overrides = {}) {
|
||||
await db('business_profile').where({ id: 1 }).update({ ...fullProfile, ...overrides });
|
||||
businessProfileService.invalidateEmailSignatureCache();
|
||||
}
|
||||
|
||||
it('renders nothing extra when the toggle is off', async () => {
|
||||
// Profile fully populated, signature switched OFF: the operator's
|
||||
// address must not leak into mail just because they filled in the
|
||||
// invoice issuer block.
|
||||
await enable({ email_signature_enabled: false });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).not.toContain('Bahnhofstrasse 1');
|
||||
expect(html).not.toContain('hello@example.com');
|
||||
expect(html).not.toContain('CHE-123.456.789');
|
||||
});
|
||||
|
||||
it('produces a byte-identical footer to a no-profile install when disabled', async () => {
|
||||
const withEmptyProfile = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
await enable({ email_signature_enabled: false });
|
||||
const withDisabledSignature = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(withDisabledSignature).toBe(withEmptyProfile);
|
||||
});
|
||||
|
||||
it('renders address, contacts, VAT id and the legal line when enabled', async () => {
|
||||
await enable();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).toContain('Müller Fotografie GmbH');
|
||||
expect(html).toContain('Bahnhofstrasse 1');
|
||||
expect(html).toContain('Postfach 42');
|
||||
// "LI-9494 Schaan / Liechtenstein" — same shape as the PDF issuer block.
|
||||
expect(html).toContain('LI-9494 Schaan / Liechtenstein');
|
||||
expect(html).toContain('VAT ID: CHE-123.456.789');
|
||||
expect(html).toContain('Handelsregister Vaduz<br />FL-0002.123.456-7');
|
||||
});
|
||||
|
||||
it('links phone, mobile, email and website with safe schemes', async () => {
|
||||
await enable();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
// Separators stripped from the tel: href, kept in the visible text.
|
||||
expect(html).toContain('href="tel:+41791234567"');
|
||||
expect(html).toContain('href="tel:+41780001122"');
|
||||
expect(html).toContain('href="mailto:hello@example.com"');
|
||||
// A bare hostname is promoted to https:// rather than left relative.
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('keeps an already-absolute website URL as typed', async () => {
|
||||
await enable({ website: 'http://legacy.example.org/studio' });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).toContain('href="http://legacy.example.org/studio"');
|
||||
});
|
||||
|
||||
it('neutralises a javascript: website into an inert https URL', async () => {
|
||||
await enable({ website: 'javascript:alert(1)' });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).not.toContain('href="javascript:');
|
||||
expect(html).toContain('href="https://javascript:alert(1)"');
|
||||
});
|
||||
|
||||
it('HTML-escapes every signature field', async () => {
|
||||
await enable({
|
||||
company_name: '<script>alert(1)</script>',
|
||||
address_line1: 'Rue "des" Fleurs & Co',
|
||||
vat_id: '<img src=x onerror=alert(1)>',
|
||||
email_signature_extra: '</p><script>alert(2)</script>',
|
||||
});
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
// Escaped, so the markup is inert text — the tags never open.
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('<script>alert(2)</script>');
|
||||
expect(html).not.toContain('<img src=x');
|
||||
expect(html).toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
||||
expect(html).toContain('Rue "des" Fleurs & 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(/·\s*·/);
|
||||
});
|
||||
|
||||
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('·');
|
||||
expect(text).not.toContain('&');
|
||||
});
|
||||
|
||||
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('·');
|
||||
// The separator survives as a real character, not an entity.
|
||||
expect(text).toContain('Bahnhofstrasse 1 \u00b7 Postfach 42');
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Catalog-driven event-type defaults (#800 follow-up).
|
||||
*
|
||||
* The contract→event conversion used to hardcode `event_type: 'wedding'` and
|
||||
* the v1 API validated against a fixed whitelist. Both now follow the live
|
||||
* event_types catalog; these tests pin the shared resolver.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('resolveDefaultEventType follows the catalog', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventTypeService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Require AFTER bootCrmDb so the service shares this db instance
|
||||
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it("prefers the 'other' catch-all while it is active", async () => {
|
||||
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
|
||||
});
|
||||
|
||||
it('falls over to the first active type when other is deactivated', async () => {
|
||||
const other = await db('event_types').where({ slug_prefix: 'other' }).first();
|
||||
await db('event_types').where({ id: other.id }).update({ is_active: 0 });
|
||||
|
||||
const resolved = await eventTypeService.resolveDefaultEventType();
|
||||
expect(resolved).not.toBe('other');
|
||||
expect(await db('event_types').where({ slug_prefix: resolved }).first()).toBeTruthy();
|
||||
|
||||
await db('event_types').where({ id: other.id }).update({ is_active: 1 });
|
||||
});
|
||||
|
||||
it("returns the literal 'other' only for an empty catalog", async () => {
|
||||
const rows = await db('event_types').select('*');
|
||||
await db('event_types').del();
|
||||
|
||||
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
|
||||
|
||||
await db('event_types').insert(rows);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// bootCrmDb runs the full core-migration set in beforeAll.
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('event type slug rename cascade', () => {
|
||||
let db;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Setup-window event type deletion (#800).
|
||||
*
|
||||
* The first-run setup wizard may delete the seeded SYSTEM event types —
|
||||
* but ONLY while the `setup_wizard_completed` flag is unset (migration 161
|
||||
* seeds it false on a fresh install, true when an admin already exists).
|
||||
* These tests pin the whole contract:
|
||||
*
|
||||
* - fresh install → flag false → system types deletable (in-use checks
|
||||
* still apply), and the per-type reminder template goes with the type
|
||||
* - reminder-template self-heal does NOT resurrect templates for slugs
|
||||
* that no longer exist in the catalog
|
||||
* - after markSetupWizardCompleted() → system deletion is refused again
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('event type deletion during the setup window (#800)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventTypeService;
|
||||
let setupService;
|
||||
let ensureEventReminderTemplatesSeeded;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Require AFTER bootCrmDb so every service shares this db instance
|
||||
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ ensureEventReminderTemplatesSeeded } = require('../../src/services/eventReminderTemplates'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('migration 161 seeds the flag false on a fresh (admin-less) install', async () => {
|
||||
const row = await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(JSON.parse(row.setting_value)).toBe(false);
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to delete a system type that events already use, even in the window', async () => {
|
||||
const corporate = await db('event_types').where({ slug_prefix: 'corporate' }).first();
|
||||
await db('events').insert({
|
||||
slug: 'corporate-test-2026-01-01',
|
||||
event_name: 'Test',
|
||||
event_type: 'corporate',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: 'share-corporate-test',
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
});
|
||||
|
||||
await expect(eventTypeService.deleteEventType(corporate.id))
|
||||
.rejects.toMatchObject({ code: 'IN_USE' });
|
||||
});
|
||||
|
||||
it('deletes an unused system type in the window, taking its reminder template along', async () => {
|
||||
// Seed the per-type reminder templates first so there is something to clean up.
|
||||
await ensureEventReminderTemplatesSeeded(db);
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeTruthy();
|
||||
|
||||
const wedding = await db('event_types').where({ slug_prefix: 'wedding' }).first();
|
||||
expect(wedding.is_system).toBeTruthy();
|
||||
|
||||
const result = await eventTypeService.deleteEventType(wedding.id);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy();
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
|
||||
|
||||
// The deleted slug must NOT stay creatable through the legacy fallback —
|
||||
// the live catalog is authoritative while it has rows.
|
||||
expect(await eventTypeService.isValidEventType('wedding')).toBe(false);
|
||||
expect(await eventTypeService.isValidEventType('birthday')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => {
|
||||
// The seeder caches success per process — reset the module to force a
|
||||
// genuine second pass, exactly what a backend restart would run.
|
||||
jest.resetModules();
|
||||
const fresh = require('../../src/services/eventReminderTemplates');
|
||||
await fresh.ensureEventReminderTemplatesSeeded(db);
|
||||
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
|
||||
// Types still in the catalog keep their templates.
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_birthday' }).first()).toBeTruthy();
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_default' }).first()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('re-locks system types once the wizard is marked complete', async () => {
|
||||
await setupService.markSetupWizardCompleted();
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(true);
|
||||
|
||||
const birthday = await db('event_types').where({ slug_prefix: 'birthday' }).first();
|
||||
await expect(eventTypeService.deleteEventType(birthday.id))
|
||||
.rejects.toMatchObject({ code: 'SYSTEM_TYPE' });
|
||||
|
||||
// Custom (non-system) types remain deletable as before.
|
||||
const custom = await eventTypeService.createEventType({ name: 'Family', slug_prefix: 'family' });
|
||||
const result = await eventTypeService.deleteEventType(custom.id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when the completion marker row is missing', async () => {
|
||||
// A portable-backup restore can replace app_settings with a set that
|
||||
// predates migration 161 (which will not rerun) — absence must mean
|
||||
// "configured instance", never an open deletion window.
|
||||
await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(true);
|
||||
await setupService.markSetupWizardCompleted();
|
||||
});
|
||||
|
||||
it('refuses to delete the last remaining event type', async () => {
|
||||
// Reduce the catalog to a single custom type via direct db writes (the
|
||||
// service paths are already covered above), then hit the guard.
|
||||
const solo = await eventTypeService.createEventType({ name: 'Solo', slug_prefix: 'solo' });
|
||||
await db('events').del();
|
||||
await db('event_types').whereNot('id', solo.id).del();
|
||||
|
||||
await expect(eventTypeService.deleteEventType(solo.id))
|
||||
.rejects.toMatchObject({ code: 'LAST_TYPE' });
|
||||
|
||||
// Deactivating it would empty the ACTIVE catalog just the same.
|
||||
await expect(eventTypeService.updateEventType(solo.id, { is_active: false }))
|
||||
.rejects.toMatchObject({ code: 'LAST_ACTIVE' });
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* External imports must record captured_at (#1172).
|
||||
*
|
||||
* Managed uploads get it from photoProcessor, which external media never goes
|
||||
* through — so every externally imported photo carried captured_at NULL, and
|
||||
* the gallery's "Date Taken" sort fell back to uploaded_at through its
|
||||
* COALESCE. On a library imported in two batches that ordered a 12-day trip by
|
||||
* which folder was imported first: the reporter's first two days landed at
|
||||
* positions 4204-5296 of 5555.
|
||||
*
|
||||
* Driven through the real route against real files carrying real EXIF, because
|
||||
* the whole question is whether the import reads the file it already has open.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const sharp = require('sharp');
|
||||
|
||||
describe('external import capture dates (#1172)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot;
|
||||
|
||||
/**
|
||||
* A real JPEG carrying DateTimeOriginal.
|
||||
*
|
||||
* IFD2, not IFD0 — DateTimeOriginal lives in the Exif IFD, and exifr does not
|
||||
* see it anywhere else (IFD0 takes plain DateTime, which surfaces as
|
||||
* ModifyDate instead).
|
||||
*/
|
||||
const writeJpegWithExif = async (rel, iso) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
|
||||
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 10, g: 20, b: 30 } } })
|
||||
.withExif({ IFD2: { DateTimeOriginal: exifDate } })
|
||||
.jpeg()
|
||||
.toFile(full);
|
||||
return full;
|
||||
};
|
||||
|
||||
const writeJpegNoExif = async (rel) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 200, g: 10, b: 10 } } })
|
||||
.jpeg().toFile(full);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capdate-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capdate-secret';
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/services/imageProcessor', () => {
|
||||
const actual = jest.requireActual('../../src/services/imageProcessor');
|
||||
return { ...actual, generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'), ensureThumbnail: jest.fn() };
|
||||
});
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
const [e] = await db('events').insert({
|
||||
slug: `capdate-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding', event_name: 'capdate', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: `capdate-${Math.random()}`, expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
const runImport = (eventId, external_path) => request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path, recursive: true });
|
||||
|
||||
it('records the EXIF capture date on import', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await writeJpegWithExif('trip/a.jpg', '2026-06-01T09:45:03Z');
|
||||
|
||||
await runImport(eventId, 'trip');
|
||||
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(photo.captured_at).toBeTruthy();
|
||||
// NOT asserted as an absolute instant. EXIF carries a naive wall-clock
|
||||
// time and exifr resolves it against the HOST timezone, so the stored UTC
|
||||
// value differs between a CEST developer machine and a UTC runner. What
|
||||
// this fix is about is that the field is populated and orders correctly;
|
||||
// that captured_at is not a true instant is a separate, pre-existing
|
||||
// problem shared with managed uploads (#1172's own footnote).
|
||||
expect(new Date(photo.captured_at).getUTCFullYear()).toBe(2026);
|
||||
expect(new Date(photo.captured_at).getUTCMonth()).toBe(5); // June
|
||||
});
|
||||
|
||||
it('imports a photo with no EXIF date rather than failing it', async () => {
|
||||
// Plenty of sources carry none; that must stay an import, not an error.
|
||||
const eventId = await seedEvent();
|
||||
await writeJpegNoExif('trip/plain.jpg');
|
||||
|
||||
const res = await runImport(eventId, 'trip');
|
||||
|
||||
expect(res.body.imported).toBe(1);
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(photo.captured_at).toBeNull();
|
||||
});
|
||||
|
||||
it('orders a two-batch import by capture time, not by batch', async () => {
|
||||
// The reported shape: the FIRST days of the trip imported second. Sorting
|
||||
// on COALESCE(captured_at, uploaded_at) put them after the last days,
|
||||
// because uploaded_at is the import timestamp.
|
||||
const eventId = await seedEvent();
|
||||
await writeJpegWithExif('late/day12.jpg', '2026-06-12T10:00:00Z');
|
||||
await runImport(eventId, 'late');
|
||||
await writeJpegWithExif('early/day01.jpg', '2026-06-01T10:00:00Z');
|
||||
await runImport(eventId, 'early');
|
||||
|
||||
const rows = await db('photos')
|
||||
.where({ event_id: eventId })
|
||||
.orderByRaw('COALESCE(captured_at, uploaded_at) asc')
|
||||
.select('filename');
|
||||
|
||||
expect(rows.map((r) => r.filename)).toEqual(['day01.jpg', 'day12.jpg']);
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* Two overlapping external imports insert every file twice (#1162).
|
||||
*
|
||||
* The route checked for an existing external_relpath and then inserted, with
|
||||
* an fs.stat and a `sharp().metadata()` read sitting in between. A reporter
|
||||
* double-clicked a slow import of a 6012-file tree and got 8004 rows.
|
||||
*
|
||||
* Both halves of the fix are driven here through the real route:
|
||||
*
|
||||
* - the in-flight guard, which turns the second click into a 409 instead of
|
||||
* a second full walk of the tree;
|
||||
* - convergence when the guard cannot help (another replica, another
|
||||
* process), which is the unique index from migration 186 firing and the
|
||||
* loop counting a skip rather than dying or duplicating.
|
||||
*
|
||||
* The second is exercised by inserting a competing row from inside the mocked
|
||||
* `sharp().metadata()` call — literally inside the window the bug lived in.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('concurrent external imports (#1162)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot;
|
||||
// When set, the mocked sharp metadata read inserts this row first — the
|
||||
// other run winning the race between our SELECT and our INSERT.
|
||||
let stealDuringMetadata = null;
|
||||
let thumbnailDelayMs = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extdup-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
|
||||
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
|
||||
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extdup-secret';
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// The window. In production this is a real decode of a NAS-hosted file —
|
||||
// hundreds of milliseconds during which the row we just proved absent can
|
||||
// appear. Standing in for the other run here makes that deterministic.
|
||||
jest.doMock('sharp', () => () => ({
|
||||
metadata: async () => {
|
||||
if (stealDuringMetadata) {
|
||||
const { db: liveDb } = require('../../src/database/db');
|
||||
await liveDb('photos').insert(stealDuringMetadata);
|
||||
stealDuringMetadata = null;
|
||||
}
|
||||
return { width: 100, height: 200 };
|
||||
},
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn(async () => {
|
||||
if (thumbnailDelayMs) await new Promise((r) => setTimeout(r, thumbnailDelayMs));
|
||||
return 'thumbnails/mock.jpg';
|
||||
}),
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
stealDuringMetadata = null;
|
||||
thumbnailDelayMs = 0;
|
||||
const [e] = await db('events').insert({
|
||||
slug: `extdup-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'extdup',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `extdup-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
const runImport = (eventId) => request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path: 'nas', recursive: true });
|
||||
|
||||
async function relpathCounts(eventId) {
|
||||
const rows = await db('photos').where({ event_id: eventId }).select('external_relpath');
|
||||
const counts = new Map();
|
||||
for (const r of rows) counts.set(r.external_relpath, (counts.get(r.external_relpath) || 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
it('rejects a second import while the first is still running', async () => {
|
||||
const eventId = await seedEvent();
|
||||
// Enough to keep the first request inside its loop while the second
|
||||
// arrives — the "slow import looks hung, so I clicked again" case.
|
||||
thumbnailDelayMs = 20;
|
||||
|
||||
const [first, second] = await Promise.all([runImport(eventId), runImport(eventId)]);
|
||||
|
||||
const statuses = [first.status, second.status].sort();
|
||||
expect(statuses).toEqual([200, 409]);
|
||||
const rejected = first.status === 409 ? first : second;
|
||||
expect(rejected.body.error).toMatch(/already running/i);
|
||||
});
|
||||
|
||||
it('leaves exactly one row per file after both runs', async () => {
|
||||
const eventId = await seedEvent();
|
||||
thumbnailDelayMs = 20;
|
||||
|
||||
await Promise.all([runImport(eventId), runImport(eventId)]);
|
||||
|
||||
const counts = await relpathCounts(eventId);
|
||||
expect(counts.size).toBe(3);
|
||||
expect([...counts.values()]).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it('releases the event once the import finishes, so a re-import still works', async () => {
|
||||
const eventId = await seedEvent();
|
||||
|
||||
expect((await runImport(eventId)).status).toBe(200);
|
||||
// Not 409 — the guard is per run, not a permanent lock on the event.
|
||||
const second = await runImport(eventId);
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.imported).toBe(0);
|
||||
expect(second.body.skipped).toBe(3);
|
||||
});
|
||||
|
||||
it('converges when another writer wins the race mid-file', async () => {
|
||||
// The guard is in-process, so it cannot see a second replica. This is what
|
||||
// the unique index is for: the insert bounces, and the file is counted as
|
||||
// skipped rather than duplicated or lost to a 500.
|
||||
const eventId = await seedEvent();
|
||||
stealDuringMetadata = {
|
||||
event_id: eventId,
|
||||
filename: 'a.jpg',
|
||||
path: 'x/a.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
// Root-relative, as the route now writes it (#1163) — the competing
|
||||
// writer has to target the same value for the race to be real.
|
||||
external_relpath: path.join('nas', 'individual', 'a.jpg'),
|
||||
};
|
||||
|
||||
const res = await runImport(eventId);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const counts = await relpathCounts(eventId);
|
||||
expect(counts.get(path.join('nas', 'individual', 'a.jpg'))).toBe(1);
|
||||
// Two imported by us, one lost to the other writer and reported honestly.
|
||||
expect(res.body.imported).toBe(2);
|
||||
expect(res.body.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('does not let one contended file abort the rest of the import', async () => {
|
||||
const eventId = await seedEvent();
|
||||
stealDuringMetadata = {
|
||||
event_id: eventId,
|
||||
filename: 'a.jpg',
|
||||
path: 'x/a.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
// Root-relative, as the route now writes it (#1163) — the competing
|
||||
// writer has to target the same value for the race to be real.
|
||||
external_relpath: path.join('nas', 'individual', 'a.jpg'),
|
||||
};
|
||||
|
||||
await runImport(eventId);
|
||||
|
||||
// All three files present — the contended one via the other writer's row.
|
||||
expect((await relpathCounts(eventId)).size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* Importing a second folder must not move the photos already in the event (#1163).
|
||||
*
|
||||
* events.external_path is overwritten by every import, and external_relpath
|
||||
* used to be stored relative to it — so a second import silently rebased every
|
||||
* existing row onto the new folder. The reporter had 7547 of 8004 originals
|
||||
* pointing at files that do not exist, and nothing said so: thumbnails are
|
||||
* written to local storage during the import while the base path is still
|
||||
* correct, so the grid carries on rendering.
|
||||
*
|
||||
* Driven through the real route and the real resolver, against a real
|
||||
* directory tree — the failure is entirely about whether a file is where the
|
||||
* app looks for it.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('a second external import (#1163)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot; let resolvePhotoFilePath;
|
||||
|
||||
const touch = async (rel) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.promises.writeFile(full, 'not-a-real-jpeg');
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-ext2nd-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'ext2nd-secret';
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('sharp', () => () => ({ metadata: async () => ({ width: 100, height: 200 }) }));
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'),
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
({ resolvePhotoFilePath } = require('../../src/services/photoResolver'));
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
const [e] = await db('events').insert({
|
||||
slug: `ext2nd-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'ext2nd',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `ext2nd-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
const runImport = (eventId, external_path) => request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path, recursive: true });
|
||||
|
||||
/** Where the app would go looking for this photo's original, right now. */
|
||||
async function resolved(eventId, filename) {
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const photo = await db('photos').where({ event_id: eventId, filename }).first();
|
||||
return resolvePhotoFilePath(event, photo);
|
||||
}
|
||||
|
||||
it('stores paths relative to the media root, not to the imported folder', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Leknes/old.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(photo.external_relpath).toBe(path.join('Trip', 'Leknes', 'old.jpg'));
|
||||
});
|
||||
|
||||
it('leaves the first folder’s 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 parent’s name', async () => {
|
||||
// The old resolver stripped the relpath's first segment when it matched the
|
||||
// base path's last one, which broke exactly this layout.
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Trip/x.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(resolvePhotoFilePath(event, photo)).toBe(path.join(mediaRoot, 'Trip', 'Trip', 'x.jpg'));
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* PostgreSQL integration test for the external-path fold (#1163).
|
||||
*
|
||||
* Gated the same way as picpeakRestorePg: runs only when PICPEAK_PG_TEST_URL
|
||||
* points at a throwaway Postgres DB, e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_fold_test" \
|
||||
* npx jest __tests__/integration/externalRelpathFoldPg.test.js
|
||||
*
|
||||
* This exists because of a defect SQLite could not have caught. The two-pass
|
||||
* rewrite parks each row on a temporary value, and that value was first written
|
||||
* with a leading NUL. SQLite stores NUL in TEXT without complaint; Postgres
|
||||
* rejects it outright ("invalid byte sequence for encoding UTF8"), so migration
|
||||
* 187 would have rolled back on exactly the installs needing the repair — and
|
||||
* only on the engine most of them run.
|
||||
*
|
||||
* The staging value is therefore an engine-level contract, not an
|
||||
* implementation detail, and it is pinned here on the engine that constrains it.
|
||||
*/
|
||||
|
||||
const knex = require('knex');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('external relpath fold on Postgres', () => {
|
||||
let pgDb; let mediaRoot; let fold;
|
||||
|
||||
const touch = async (rel, bytes) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.promises.writeFile(full, Buffer.alloc(bytes));
|
||||
return bytes;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-foldpg-'));
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
jest.resetModules();
|
||||
({ foldExternalRelpaths: fold } = require('../../src/services/externalRelpathFold'));
|
||||
|
||||
pgDb = knex({ client: 'pg', connection: PG_URL });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (pgDb) await pgDb.destroy();
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true }).catch(() => {});
|
||||
delete process.env.EXTERNAL_MEDIA_ROOT;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await pgDb.raw('DROP TABLE IF EXISTS photos, events, app_settings CASCADE');
|
||||
await pgDb.schema.createTable('events', (t) => {
|
||||
t.increments('id');
|
||||
t.text('external_path');
|
||||
});
|
||||
await pgDb.schema.createTable('photos', (t) => {
|
||||
t.increments('id');
|
||||
t.integer('event_id');
|
||||
t.text('external_relpath');
|
||||
t.bigInteger('size_bytes');
|
||||
t.string('source_origin').defaultTo('managed');
|
||||
});
|
||||
await pgDb.schema.createTable('app_settings', (t) => {
|
||||
t.increments('id');
|
||||
t.string('setting_key');
|
||||
t.text('setting_value');
|
||||
t.string('setting_type');
|
||||
t.string('updated_at');
|
||||
});
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
});
|
||||
|
||||
const relpaths = async () =>
|
||||
(await pgDb('photos').orderBy('id').select('external_relpath')).map((r) => r.external_relpath);
|
||||
|
||||
it('completes the two-pass repair that a NUL staging value would abort', async () => {
|
||||
// The exact shape that forces staging: `photo.jpg` repairs up to
|
||||
// `Trip/photo.jpg`, while the row already holding `Trip/photo.jpg` folds
|
||||
// deeper. Every final value is distinct, but a final value equals another
|
||||
// row's current one, so the rewrite has to park first.
|
||||
const a = await touch('Trip/photo.jpg', 11);
|
||||
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
||||
await pgDb('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'photo.jpg', size_bytes: a, source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'Trip/photo.jpg', size_bytes: b, source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await fold(pgDb);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
|
||||
});
|
||||
|
||||
it('leaves no staging value behind', async () => {
|
||||
await touch('Trip/a.jpg', 8);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
|
||||
|
||||
await fold(pgDb);
|
||||
|
||||
const rows = await relpaths();
|
||||
expect(rows).toEqual(['Trip/a.jpg']);
|
||||
expect(rows.some((r) => r.includes('staging'))).toBe(false);
|
||||
});
|
||||
|
||||
it('folds and marks in one transaction', async () => {
|
||||
await touch('Trip/a.jpg', 8);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
|
||||
|
||||
await fold(pgDb);
|
||||
// Second run is a no-op: the marker committed with the rewrites.
|
||||
await fold(pgDb);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/a.jpg']);
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Auto-category rule engine (#1074 phase 3).
|
||||
*
|
||||
* The rules themselves are simple enough to read. What needs testing is the
|
||||
* promise around them: this engine may only ever fill an EMPTY category, and
|
||||
* everything it touches must be reversible. A photographer's own assignment
|
||||
* is a decision; this is a heuristic, and the heuristic never wins.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-autocat-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'autocat-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let engine;
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** A scanned photo with `faceCount` faces, each `faceSide` px square. */
|
||||
async function addScannedPhoto(eventId, faceCount, { faceSide = 400, categoryId = null } = {}) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
processing_status: 'complete',
|
||||
face_status: 'done',
|
||||
face_count: faceCount,
|
||||
category_id: categoryId,
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
for (let i = 0; i < faceCount; i++) {
|
||||
await db('photo_faces').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
bbox_x: 10, bbox_y: 10, bbox_w: faceSide, bbox_h: faceSide,
|
||||
det_score: 0.95,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return photoId;
|
||||
}
|
||||
|
||||
async function enable(on) {
|
||||
const existing = await db('app_settings')
|
||||
.where('setting_key', 'face_auto_categorize_enabled').first();
|
||||
if (existing) {
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'face_auto_categorize_enabled')
|
||||
.update({ setting_value: JSON.stringify(on) });
|
||||
}
|
||||
}
|
||||
|
||||
async function categoryOf(photoId) {
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
if (!photo.category_id) return null;
|
||||
const cat = await db('photo_categories').where({ id: photo.category_id }).first();
|
||||
return cat?.slug ?? null;
|
||||
}
|
||||
|
||||
describe('faceAutoCategories (#1074 phase 3)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
engine = require('../../src/services/faceAutoCategories');
|
||||
await enable(true);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('rules', () => {
|
||||
it('sorts by face count, and by face size for portraits', async () => {
|
||||
const eventId = await seedEvent('rules');
|
||||
// 400px face in a 1000x1000 frame = 16% of the frame, over the 8% floor.
|
||||
const portrait = await addScannedPhoto(eventId, 1, { faceSide: 400 });
|
||||
const details = await addScannedPhoto(eventId, 0);
|
||||
const small = await addScannedPhoto(eventId, 3);
|
||||
const group = await addScannedPhoto(eventId, 9);
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(await categoryOf(details)).toBe('details');
|
||||
expect(await categoryOf(portrait)).toBe('portraits');
|
||||
expect(await categoryOf(small)).toBe('small-groups');
|
||||
expect(await categoryOf(group)).toBe('groups');
|
||||
});
|
||||
|
||||
it('does not call a distant single face a portrait', async () => {
|
||||
// One person in a wide landscape is not a portrait of them. 60px in a
|
||||
// 1000x1000 frame is 0.36% — far below the 8% floor.
|
||||
const eventId = await seedEvent('small-face');
|
||||
const distant = await addScannedPhoto(eventId, 1, { faceSide: 60 });
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(await categoryOf(distant)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores photos that have not been scanned', async () => {
|
||||
const eventId = await seedEvent('unscanned');
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'u.jpg', path: '/tmp/u.jpg', type: 'individual',
|
||||
processing_status: 'complete', face_status: 'pending',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
expect(await categoryOf(photoId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the promise', () => {
|
||||
it('NEVER overwrites a category a person chose', async () => {
|
||||
// The single most important behaviour in this file.
|
||||
const eventId = await seedEvent('no-overwrite');
|
||||
const [c] = await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony', is_global: false, event_id: eventId,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const ceremonyId = typeof c === 'object' ? c.id : c;
|
||||
|
||||
// 9 faces — the rules would call this "groups" if they were allowed to.
|
||||
const claimed = await addScannedPhoto(eventId, 9, { categoryId: ceremonyId });
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(await categoryOf(claimed)).toBe('ceremony');
|
||||
const row = await db('photos').where({ id: claimed }).first();
|
||||
expect(row.auto_categorized).toBeFalsy();
|
||||
});
|
||||
|
||||
it('marks only what it assigned, so undo is exact', async () => {
|
||||
const eventId = await seedEvent('undo');
|
||||
const [c] = await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony-2', is_global: false, event_id: eventId,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const ceremonyId = typeof c === 'object' ? c.id : c;
|
||||
|
||||
const manual = await addScannedPhoto(eventId, 4, { categoryId: ceremonyId });
|
||||
const auto = await addScannedPhoto(eventId, 4);
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
expect(await categoryOf(auto)).toBe('small-groups');
|
||||
|
||||
const result = await engine.undoEvent(eventId);
|
||||
|
||||
expect(result.cleared).toBe(1);
|
||||
// The automatic one is cleared...
|
||||
expect(await categoryOf(auto)).toBeNull();
|
||||
// ...and the photographer's own choice survives untouched.
|
||||
expect(await categoryOf(manual)).toBe('ceremony-2');
|
||||
});
|
||||
|
||||
it('is a no-op while the setting is off', async () => {
|
||||
const eventId = await seedEvent('disabled');
|
||||
const photoId = await addScannedPhoto(eventId, 0);
|
||||
|
||||
await enable(false);
|
||||
const result = await engine.categorizeEvent(eventId);
|
||||
await enable(true);
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(await categoryOf(photoId)).toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent — a second run assigns nothing new', async () => {
|
||||
const eventId = await seedEvent('idempotent');
|
||||
await addScannedPhoto(eventId, 0);
|
||||
await addScannedPhoto(eventId, 7);
|
||||
|
||||
const first = await engine.categorizeEvent(eventId);
|
||||
const second = await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(first.assigned).toBe(2);
|
||||
expect(second.assigned).toBe(0);
|
||||
});
|
||||
|
||||
it('reuses one category per slug rather than creating duplicates', async () => {
|
||||
const eventId = await seedEvent('reuse');
|
||||
await addScannedPhoto(eventId, 0);
|
||||
await addScannedPhoto(eventId, 0);
|
||||
await addScannedPhoto(eventId, 0);
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
const details = await db('photo_categories')
|
||||
.where({ slug: 'details' })
|
||||
.where(function () { this.where('event_id', eventId).orWhere('is_global', true); });
|
||||
expect(details).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,325 +0,0 @@
|
||||
/**
|
||||
* Clustering engine (#1074).
|
||||
*
|
||||
* Uses synthetic embeddings with known identities rather than real faces: the
|
||||
* question here is whether the ALGORITHM groups vectors correctly, which is
|
||||
* separable from whether the model produces good vectors. Model quality is
|
||||
* the spike's job.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceclust-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceclust-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering;
|
||||
|
||||
/** Deterministic unit vector for identity `id`, jittered by `variant`. */
|
||||
function makeEmbedding(id, variant = 0, dim = 64) {
|
||||
const vec = new Float32Array(dim);
|
||||
for (let i = 0; i < dim; i++) {
|
||||
vec[i] = Math.sin((i + 1) * (id + 1) * 0.7) + variant * 0.02 * Math.cos(i * 3.1);
|
||||
}
|
||||
let norm = 0;
|
||||
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
|
||||
norm = Math.sqrt(norm);
|
||||
for (let i = 0; i < dim; i++) vec[i] /= norm;
|
||||
return vec;
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function insertFace(eventId, embedding, overrides = {}) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const row = {
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(embedding),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
const [f] = await db('photo_faces').insert(row).returning('id');
|
||||
return { ...row, id: typeof f === 'object' ? f.id : f };
|
||||
}
|
||||
|
||||
describe('faceClustering (#1074)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('embedding round-trip', () => {
|
||||
it('survives pack/unpack through the BLOB column exactly', async () => {
|
||||
const original = makeEmbedding(1);
|
||||
const eventId = await seedEvent('roundtrip');
|
||||
const face = await insertFace(eventId, original);
|
||||
|
||||
const stored = await db('photo_faces').where({ id: face.id }).first();
|
||||
const restored = clustering.unpackEmbedding(stored.embedding);
|
||||
|
||||
expect(restored).toHaveLength(original.length);
|
||||
for (let i = 0; i < original.length; i++) {
|
||||
expect(restored[i]).toBeCloseTo(original[i], 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null for a corrupt blob rather than throwing', () => {
|
||||
expect(clustering.unpackEmbedding(Buffer.from([1, 2, 3]))).toBeNull();
|
||||
expect(clustering.unpackEmbedding(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignment', () => {
|
||||
it('groups the same identity and separates different ones', async () => {
|
||||
const eventId = await seedEvent('grouping');
|
||||
const faces = [];
|
||||
// Three identities, four shots each, interleaved so assignment order
|
||||
// is not conveniently grouped.
|
||||
for (let variant = 0; variant < 4; variant++) {
|
||||
for (const identity of [1, 2, 3]) {
|
||||
faces.push(await insertFace(eventId, makeEmbedding(identity, variant)));
|
||||
}
|
||||
}
|
||||
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId });
|
||||
expect(people).toHaveLength(3);
|
||||
|
||||
// Every face of one identity must share a person id.
|
||||
const rows = await db('photo_faces').where({ event_id: eventId }).select('id', 'person_id');
|
||||
const byPerson = new Map();
|
||||
for (const r of rows) {
|
||||
byPerson.set(r.person_id, (byPerson.get(r.person_id) || 0) + 1);
|
||||
}
|
||||
expect([...byPerson.values()].sort()).toEqual([4, 4, 4]);
|
||||
});
|
||||
|
||||
it('leaves low-quality faces unassigned instead of spawning junk people', async () => {
|
||||
const eventId = await seedEvent('quality-floor');
|
||||
const good = await insertFace(eventId, makeEmbedding(5));
|
||||
// Tiny bbox — below the 40px floor.
|
||||
const tiny = await insertFace(eventId, makeEmbedding(6), { bbox_w: 12, bbox_h: 12 });
|
||||
// Weak detection score.
|
||||
const weak = await insertFace(eventId, makeEmbedding(7), { det_score: 0.2 });
|
||||
|
||||
await clustering.assignFaces(eventId, [good, tiny, weak]);
|
||||
|
||||
const rows = await db('photo_faces')
|
||||
.whereIn('id', [good.id, tiny.id, weak.id])
|
||||
.select('id', 'person_id');
|
||||
const map = Object.fromEntries(rows.map((r) => [r.id, r.person_id]));
|
||||
|
||||
expect(map[good.id]).not.toBeNull();
|
||||
// Still stored — they show in "this photo contains" — just unassigned.
|
||||
expect(map[tiny.id]).toBeNull();
|
||||
expect(map[weak.id]).toBeNull();
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never mixes embedding spaces from different model versions', async () => {
|
||||
const eventId = await seedEvent('model-version');
|
||||
const a = await insertFace(eventId, makeEmbedding(9), { model_version: 'v1' });
|
||||
await clustering.assignFaces(eventId, [a]);
|
||||
|
||||
// Same vector, different model. Comparable numerically, meaningless
|
||||
// semantically — it must NOT join the v1 cluster.
|
||||
const b = await insertFace(eventId, makeEmbedding(9), { model_version: 'v2' });
|
||||
await clustering.assignFaces(eventId, [b]);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId });
|
||||
expect(people).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge and split', () => {
|
||||
it('merge moves every face and removes the source person', async () => {
|
||||
const eventId = await seedEvent('merge');
|
||||
const f1 = await insertFace(eventId, makeEmbedding(11));
|
||||
const f2 = await insertFace(eventId, makeEmbedding(21));
|
||||
await clustering.assignFaces(eventId, [f1, f2]);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
expect(people).toHaveLength(2);
|
||||
|
||||
await clustering.mergePeople(eventId, [people[1].id], people[0].id);
|
||||
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
|
||||
const remaining = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(remaining.face_count_total).toBe(2);
|
||||
const orphaned = await db('photo_faces')
|
||||
.where({ event_id: eventId }).whereNull('person_id');
|
||||
expect(orphaned).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('split pulls the named faces into a new person', async () => {
|
||||
const eventId = await seedEvent('split');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 4; v++) faces.push(await insertFace(eventId, makeEmbedding(13, v)));
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(person.face_count_total).toBe(4);
|
||||
|
||||
const newId = await clustering.splitPerson(eventId, person.id, [faces[0].id, faces[1].id]);
|
||||
expect(newId).toBeTruthy();
|
||||
|
||||
const original = await db('event_people').where({ id: person.id }).first();
|
||||
const created = await db('event_people').where({ id: newId }).first();
|
||||
expect(original.face_count_total).toBe(2);
|
||||
expect(created.face_count_total).toBe(2);
|
||||
});
|
||||
|
||||
it('deletes a person left with no faces rather than keeping a ghost', async () => {
|
||||
const eventId = await seedEvent('empty-person');
|
||||
const f = await insertFace(eventId, makeEmbedding(15));
|
||||
await clustering.assignFaces(eventId, [f]);
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
|
||||
await db('photo_faces').where({ id: f.id }).update({ person_id: null });
|
||||
await clustering.recomputeCentroid(person.id);
|
||||
|
||||
expect(await db('event_people').where({ id: person.id }).first()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('regressions from external review', () => {
|
||||
it('merge carries a name and suppression onto the survivor', async () => {
|
||||
// A merge used to move the faces and delete the source outright, so a
|
||||
// photographer-entered name vanished and a person they had hidden came
|
||||
// back guest-visible.
|
||||
const eventId = await seedEvent('merge-metadata');
|
||||
const a = await insertFace(eventId, makeEmbedding(61));
|
||||
const b = await insertFace(eventId, makeEmbedding(62));
|
||||
await clustering.assignFaces(eventId, [a, b]);
|
||||
|
||||
const [p1, p2] = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
// Target is unnamed and visible; the SOURCE carries the human state.
|
||||
await db('event_people').where({ id: p2.id }).update({ label: 'Anna', is_hidden: true });
|
||||
|
||||
await clustering.mergePeople(eventId, [p2.id], p1.id);
|
||||
|
||||
const survivor = await db('event_people').where({ id: p1.id }).first();
|
||||
expect(survivor.label).toBe('Anna');
|
||||
expect(!!survivor.is_hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('recluster keeps hidden/ignored on people that were never named', async () => {
|
||||
// The old query remembered only rows with a label, so a suppressed
|
||||
// bystander came back visible after one "Re-group people".
|
||||
const eventId = await seedEvent('recluster-suppression');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) faces.push(await insertFace(eventId, makeEmbedding(71, v)));
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(person.label).toBeNull();
|
||||
await db('event_people').where({ id: person.id }).update({ is_ignored: true });
|
||||
|
||||
await clustering.recluster(eventId);
|
||||
|
||||
const after = await db('event_people').where({ event_id: eventId });
|
||||
expect(after.length).toBeGreaterThan(0);
|
||||
expect(after.every((p) => !!p.is_ignored)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recluster', () => {
|
||||
it('re-derives clusters and preserves photographer-assigned names', async () => {
|
||||
// This is the property that makes re-clustering safe to offer as a
|
||||
// button: without it, one click silently discards every typed name.
|
||||
const eventId = await seedEvent('recluster');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push(await insertFace(eventId, makeEmbedding(31, v)));
|
||||
faces.push(await insertFace(eventId, makeEmbedding(32, v)));
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
expect(people).toHaveLength(2);
|
||||
await db('event_people').where({ id: people[0].id }).update({ label: 'Anna' });
|
||||
await db('event_people').where({ id: people[1].id }).update({ label: 'Ben' });
|
||||
|
||||
const count = await clustering.recluster(eventId);
|
||||
expect(count).toBe(2);
|
||||
|
||||
const after = await db('event_people').where({ event_id: eventId });
|
||||
const labels = after.map((p) => p.label).filter(Boolean).sort();
|
||||
expect(labels).toEqual(['Anna', 'Ben']);
|
||||
});
|
||||
|
||||
it('is stable across repeated runs', async () => {
|
||||
const eventId = await seedEvent('recluster-stable');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
for (const id of [41, 42]) faces.push(await insertFace(eventId, makeEmbedding(id, v)));
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const first = await clustering.recluster(eventId);
|
||||
const second = await clustering.recluster(eventId);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consolidate', () => {
|
||||
it('refuses to merge two people the photographer named differently', async () => {
|
||||
// A human assertion this heuristic does not get to overrule.
|
||||
const eventId = await seedEvent('consolidate-labels');
|
||||
const a = await insertFace(eventId, makeEmbedding(51));
|
||||
await clustering.assignFaces(eventId, [a]);
|
||||
const first = await db('event_people').where({ event_id: eventId }).first();
|
||||
|
||||
// A near-identical centroid that would otherwise merge.
|
||||
const [inserted] = await db('event_people').insert({
|
||||
event_id: eventId,
|
||||
centroid: clustering.packEmbedding(makeEmbedding(51, 0.01)),
|
||||
face_count_total: 1,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const secondId = typeof inserted === 'object' ? inserted.id : inserted;
|
||||
|
||||
await db('event_people').where({ id: first.id }).update({ label: 'Anna' });
|
||||
await db('event_people').where({ id: secondId }).update({ label: 'Ben' });
|
||||
|
||||
await clustering.consolidate(eventId);
|
||||
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* External imports are queued for face scanning, in the right order (#1090).
|
||||
*
|
||||
* Managed uploads are enqueued by photoProcessor, which writes face_status
|
||||
* 'pending' once a photo is processed (photoProcessor.js:573 — "the only
|
||||
* correct place to enqueue"). External media never goes through photoProcessor:
|
||||
* adminExternalMedia inserts rows directly, so they stayed NULL and were only
|
||||
* ever picked up by a manual Re-scan.
|
||||
*
|
||||
* The ordering matters as much as the enqueue. events.external_path is written
|
||||
* only AFTER the whole import loop, so marking rows 'pending' as they are
|
||||
* inserted publishes claimable work while the event still points at the old
|
||||
* directory — or none at all, on a first import. The face worker polls
|
||||
* continuously, would resolve those photos against the wrong path, and mark
|
||||
* them permanently 'failed', a state only an explicit Re-scan clears.
|
||||
*
|
||||
* This drives the real route rather than re-implementing it, so removing the
|
||||
* enqueue fails the first test and moving it back onto the insert fails the
|
||||
* second.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('external import queues faces (#1090)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot;
|
||||
// Recorded from inside the per-photo thumbnail call, i.e. mid-loop.
|
||||
let pendingSeenDuringLoop = 0;
|
||||
let externalPathDuringLoop;
|
||||
// When set to an event id, the mocked thumbnail call turns detection on
|
||||
// mid-loop, standing in for an admin flipping the toggle during an import.
|
||||
let flipFacesOnDuringLoop = null;
|
||||
// Stands in for a concurrent Re-scan completing a row mid-import.
|
||||
let markDoneDuringLoop = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extenq-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
|
||||
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
|
||||
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extenq-secret';
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// Runs once per photo, inside the import loop — the only hook that can
|
||||
// observe the intermediate state the ordering bug would expose.
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn(async () => {
|
||||
const { db: liveDb } = require('../../src/database/db');
|
||||
const rows = await liveDb('photos').where({ face_status: 'pending' });
|
||||
pendingSeenDuringLoop += rows.length;
|
||||
const ev = await liveDb('events').first();
|
||||
externalPathDuringLoop = ev ? ev.external_path : undefined;
|
||||
if (markDoneDuringLoop) {
|
||||
const rows = await liveDb('photos').orderBy('id', 'asc').limit(1);
|
||||
if (rows.length) {
|
||||
await liveDb('photos').where({ id: rows[0].id }).update({ face_status: 'done' });
|
||||
}
|
||||
}
|
||||
if (flipFacesOnDuringLoop) {
|
||||
await liveDb('events').where({ id: flipFacesOnDuringLoop })
|
||||
.update({ face_recognition_enabled: true });
|
||||
}
|
||||
return 'thumbnails/mock.jpg';
|
||||
}),
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
// bootCrmDb runs every migrations/core/*.up() directly — knex's Migrator
|
||||
// deadlocks on 001_init's nested initializeDatabase() call.
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent({ facesEnabled, flagOn }) {
|
||||
await db('feature_flags').insert({ key: 'faces', value: flagOn })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: flagOn }); });
|
||||
// The flag read is TTL-cached (requireFeatureFlag.js:26-34); production
|
||||
// invalidates after every write, and so must this.
|
||||
require('../../src/middleware/requireFeatureFlag').invalidateFeatureFlagCache();
|
||||
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
const [e] = await db('events').insert({
|
||||
slug: `extenq-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'extenq',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `extenq-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: facesEnabled,
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
|
||||
pendingSeenDuringLoop = 0;
|
||||
externalPathDuringLoop = undefined;
|
||||
markDoneDuringLoop = false;
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
async function runImport(eventId) {
|
||||
return request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path: 'nas', recursive: true });
|
||||
}
|
||||
|
||||
it('queues imported photos when detection is on', async () => {
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
|
||||
const res = await runImport(eventId);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
// The regression: these stayed NULL and waited for a manual Re-scan.
|
||||
expect(photos.every((p) => p.face_status === 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not publish claimable rows before events.external_path is written', async () => {
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
|
||||
await runImport(eventId);
|
||||
|
||||
// Observed from inside the loop: nothing is claimable yet. The event path
|
||||
// is already committed (see the test above), so this is no longer load
|
||||
// bearing for correctness — but keeping the enqueue at the end is what lets
|
||||
// the feature setting be read after the loop, so the invariant stays.
|
||||
expect(pendingSeenDuringLoop).toBe(0);
|
||||
|
||||
// ...and afterwards both are in place.
|
||||
const ev = await db('events').where({ id: eventId }).first();
|
||||
expect(ev.external_path).toBe('nas');
|
||||
expect((await db('photos').where({ event_id: eventId, face_status: 'pending' })).length)
|
||||
.toBe((await db('photos').where({ event_id: eventId })).length);
|
||||
});
|
||||
|
||||
it('honours a toggle flipped DURING the import', async () => {
|
||||
// The setting is read after the loop, not before: on a large library the
|
||||
// loop runs for minutes, and the toggle endpoint only queues rows that
|
||||
// already existed when it fired. Reading it up front would strand every
|
||||
// photo imported after that moment at NULL forever.
|
||||
const eventId = await seedEvent({ facesEnabled: false, flagOn: true });
|
||||
flipFacesOnDuringLoop = eventId;
|
||||
|
||||
await runImport(eventId);
|
||||
flipFacesOnDuringLoop = null;
|
||||
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos.every((p) => p.face_status === 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
it('commits events.external_path before the first row is inserted', async () => {
|
||||
// enqueueEvent accepts processing_status NULL (faceProcessor.js:243-246),
|
||||
// which these inserts leave unset — so a toggle or Re-scan firing mid-import
|
||||
// can queue partial rows. If the event still pointed at the old directory
|
||||
// they would resolve against it and burn to 'failed'. Setting the path
|
||||
// first also means a half-finished import leaves rows that still resolve,
|
||||
// instead of rows stranded against the previous path.
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
|
||||
await runImport(eventId);
|
||||
|
||||
// Sampled from inside the per-photo thumbnail call, i.e. while rows are
|
||||
// still being inserted.
|
||||
expect(externalPathDuringLoop).toBe('nas');
|
||||
});
|
||||
|
||||
it('does not re-queue rows a concurrent scan already handled', async () => {
|
||||
// Committing the event path before the loop means a toggle or Re-scan
|
||||
// firing mid-import can now genuinely queue and even finish some of these
|
||||
// rows. A blanket update at the end would drag 'done' rows back to
|
||||
// 'pending' for a duplicate sidecar scan and knock 'processing' rows out
|
||||
// from under the worker.
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
markDoneDuringLoop = true;
|
||||
|
||||
await runImport(eventId);
|
||||
markDoneDuringLoop = false;
|
||||
|
||||
const done = await db('photos').where({ event_id: eventId, face_status: 'done' });
|
||||
expect(done.length).toBeGreaterThan(0); // the concurrent scan's work survived
|
||||
});
|
||||
|
||||
it('leaves face_status untouched when the per-event toggle is off', async () => {
|
||||
const eventId = await seedEvent({ facesEnabled: false, flagOn: true });
|
||||
await runImport(eventId);
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos.every((p) => p.face_status === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves face_status untouched when the global flag is off', async () => {
|
||||
// Installs without the feature must never accumulate face_status rows —
|
||||
// the same invariant photoProcessor's guard protects.
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: false });
|
||||
await runImport(eventId);
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos.every((p) => p.face_status === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* External / reference photos are scannable (#1090).
|
||||
*
|
||||
* faceProcessor used to short-circuit every photo with source_origin
|
||||
* 'external' or 'reference' to 'skipped', because resolvePhotoStorageKey
|
||||
* returns null for anything outside managed storage and ensurePreviewImage
|
||||
* could not build a preview for it. #1078 removed that limitation —
|
||||
* ensurePreviewImage now reads externals straight off the mount and writes
|
||||
* the preview into managed storage — but the guard stayed, so the whole
|
||||
* feature was a no-op on external-media installs. The reporter's gallery sat
|
||||
* at 0/3230 with every row 'skipped' and no error.
|
||||
*
|
||||
* These pin both halves: the guard is gone, and a photo whose source is
|
||||
* genuinely missing still fails rather than being quietly skipped — the blanket
|
||||
* skip used to absorb that case too, so a real breakage looked like an
|
||||
* unsupported one.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceext-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceext-test-secret';
|
||||
// A real, existing media root. getExternalMediaRoot only honours the env var
|
||||
// if the directory exists and caches it on first call, so this has to be set
|
||||
// up before anything requires externalMediaService.
|
||||
process.env.EXTERNAL_MEDIA_ROOT = path.join(path.dirname(process.env.TEST_DATABASE_PATH), 'media');
|
||||
fs.mkdirSync(path.join(process.env.EXTERNAL_MEDIA_ROOT, 'share', 'individual'), { recursive: true });
|
||||
// Non-empty on purpose: an empty directory is read as an unmounted share
|
||||
// (faceTransientSource.test.js), so a "healthy storage, dead photo" fixture
|
||||
// needs a sibling present or it defers instead of failing.
|
||||
fs.writeFileSync(path.join(process.env.EXTERNAL_MEDIA_ROOT, 'share', 'individual', 'sibling.jpg'), 'x');
|
||||
|
||||
const sharp = require('sharp');
|
||||
|
||||
let mockPreviewBuffer;
|
||||
// Set per-test: what ensurePreviewImage returns for the photo under test.
|
||||
let previewKeyResult; // eslint-disable-line prefer-const
|
||||
const mockEnsurePreviewImage = jest.fn(async () => previewKeyResult);
|
||||
const mockDetectFaces = jest.fn();
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => ({
|
||||
...jest.requireActual('../../src/services/imageProcessor'),
|
||||
ensurePreviewImage: (...args) => mockEnsurePreviewImage(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({
|
||||
getStorage: () => ({ get: async () => mockPreviewBuffer }),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/faceClient', () => ({
|
||||
detectFaces: (...args) => mockDetectFaces(...args),
|
||||
SidecarUnavailableError: class extends Error {},
|
||||
}));
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceProcessor;
|
||||
|
||||
async function seedPhoto({ sourceOrigin = 'managed', sourceMode = 'managed' } = {}) {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `ext-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'ext',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `ext-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
source_mode: sourceMode,
|
||||
external_path: 'share',
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'ext.jpg',
|
||||
path: '/tmp/ext.jpg',
|
||||
type: 'individual',
|
||||
width: 1920,
|
||||
height: 1440,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
source_origin: sourceOrigin,
|
||||
external_relpath: sourceOrigin === 'managed' ? null : 'individual/ext.jpg',
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
describe('face scanning of external/reference photos (#1090)', () => {
|
||||
beforeAll(async () => {
|
||||
mockPreviewBuffer = await sharp({
|
||||
create: { width: 1920, height: 1440, channels: 3, background: { r: 20, g: 40, b: 80 } },
|
||||
}).jpeg().toBuffer();
|
||||
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(() => {
|
||||
mockEnsurePreviewImage.mockClear();
|
||||
mockDetectFaces.mockClear();
|
||||
previewKeyResult = 'previews/preview_ext.jpg';
|
||||
mockDetectFaces.mockResolvedValue({
|
||||
model_version: 'test-v1',
|
||||
faces: [{
|
||||
bbox: [100, 100, 50, 50],
|
||||
score: 0.99,
|
||||
landmarks: [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
|
||||
yaw: 0, pitch: 0, blur: 500,
|
||||
embedding: Array.from({ length: 64 }, (_, i) => (i === 0 ? 1 : 0)),
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['external', 'reference'])('scans a %s photo instead of skipping it', async (origin) => {
|
||||
const { photoId } = await seedPhoto({ sourceOrigin: origin, sourceMode: 'reference' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
// The regression: this used to return 'skipped' without ever building a
|
||||
// preview or contacting the sidecar.
|
||||
expect(result.status).not.toBe('skipped');
|
||||
expect(mockEnsurePreviewImage).toHaveBeenCalled();
|
||||
expect(mockDetectFaces).toHaveBeenCalled();
|
||||
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).toBe('done');
|
||||
expect(await db('photo_faces').where({ photo_id: photoId }).first()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fails, not skips, when the external source is genuinely gone', async () => {
|
||||
// A missing file is a property of that photo, so it should be visible as a
|
||||
// failure the admin can act on — not silently absorbed the way the old
|
||||
// blanket skip did.
|
||||
//
|
||||
// The containing directory exists here on purpose. An absent directory is
|
||||
// a dropped mount, which defers rather than fails
|
||||
// (faceTransientSource.test.js); this is the other case — healthy storage,
|
||||
// dead photo.
|
||||
previewKeyResult = null;
|
||||
const { photoId } = await seedPhoto({ sourceOrigin: 'external', sourceMode: 'reference' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(mockDetectFaces).not.toHaveBeenCalled();
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).toBe('failed');
|
||||
expect(photo.face_error).toMatch(/preview/i);
|
||||
});
|
||||
});
|
||||
@@ -1,452 +0,0 @@
|
||||
/**
|
||||
* Automatic consolidation reporting and the suggestion band (#1107).
|
||||
*
|
||||
* Centroids are built to an EXACT cosine similarity rather than jittered
|
||||
* towards one, because every assertion here is about which side of a threshold
|
||||
* a pair falls on. `pairAtSimilarity` returns two unit vectors whose dot
|
||||
* product is the requested number to floating-point precision, and each pair
|
||||
* is built on its own orthogonal basis so two different pairs are never
|
||||
* accidentally similar to each other.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facesuggest-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facesuggest-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering;
|
||||
|
||||
// Mirrors the service: merge at match + 0.08, so with a 0.60 floor the
|
||||
// suggestion band is [0.60, 0.68).
|
||||
const THRESHOLDS = {
|
||||
face_match_threshold: 0.6,
|
||||
face_quality_min_score: 0.7,
|
||||
face_quality_min_px: 40,
|
||||
};
|
||||
|
||||
const DIM = 64;
|
||||
|
||||
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
|
||||
function pairAtSimilarity(target, basis) {
|
||||
const a = new Float32Array(DIM);
|
||||
const b = new Float32Array(DIM);
|
||||
const orth = Math.sqrt(1 - target * target);
|
||||
a[basis] = 1;
|
||||
b[basis] = target;
|
||||
b[basis + 1] = orth;
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function insertPerson(eventId, centroid, overrides = {}) {
|
||||
const [row] = await db('event_people').insert({
|
||||
event_id: eventId,
|
||||
centroid: clustering.packEmbedding(centroid),
|
||||
face_count_total: 5,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** One person with one real face, so merge/split have something to move. */
|
||||
async function insertPersonWithFace(eventId, centroid, overrides = {}) {
|
||||
const personId = await insertPerson(eventId, centroid, overrides);
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
await db('photo_faces').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
person_id: personId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(centroid),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
return personId;
|
||||
}
|
||||
|
||||
/** An additional face on an existing person, so a split has something to move. */
|
||||
async function addFaceTo(eventId, personId, centroid) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
person_id: personId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(centroid),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof f === 'object' ? f.id : f;
|
||||
}
|
||||
|
||||
const suggest = (eventId) => clustering.suggestMerges(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
describe('face merge suggestions (#1107)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('the band', () => {
|
||||
it('suggests a pair between the match and auto-merge thresholds', async () => {
|
||||
const eventId = await seedEvent('band-inside');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
const out = await suggest(eventId);
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
expect([out[0].person_a_id, out[0].person_b_id].sort()).toEqual([idA, idB].sort());
|
||||
expect(out[0].score).toBeCloseTo(0.64, 4);
|
||||
});
|
||||
|
||||
it('stays silent above the auto-merge threshold — consolidate() owns that pair', async () => {
|
||||
const eventId = await seedEvent('band-above');
|
||||
const [a, b] = pairAtSimilarity(0.75, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('stays silent below the match threshold — further apart than one face would join', async () => {
|
||||
const eventId = await seedEvent('band-below');
|
||||
const [a, b] = pairAtSimilarity(0.5, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what it refuses to ask about', () => {
|
||||
it('never questions two people the photographer named differently', async () => {
|
||||
const eventId = await seedEvent('named-apart');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a, { label: 'Anna' });
|
||||
await insertPerson(eventId, b, { label: 'Beatrix' });
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('still asks when only one of the two is named', async () => {
|
||||
const eventId = await seedEvent('one-named');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a, { label: 'Anna' });
|
||||
await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips a person marked "not a real person" — that answer was already given', async () => {
|
||||
const eventId = await seedEvent('ignored');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b, { is_ignored: true });
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('never crosses embedding spaces', async () => {
|
||||
const eventId = await seedEvent('model-skew');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b, { model_version: 'test-v2' });
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissal', () => {
|
||||
it('stops suggesting a pair the photographer rejected, and survives a repeat', async () => {
|
||||
const eventId = await seedEvent('dismissal');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toHaveLength(1);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idB, idA); // reversed on purpose
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
|
||||
// A second dismissal hits the UNIQUE constraint. Dismissing twice is a
|
||||
// double-click, not an error.
|
||||
await expect(clustering.dismissMergeSuggestion(eventId, idA, idB)).resolves.toEqual({
|
||||
dismissed: true,
|
||||
});
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The swallow-the-duplicate branch has to discriminate, because the failure
|
||||
* it must NOT swallow looks identical to the caller: returning
|
||||
* "kept separate" for a decision that was never written means the pair
|
||||
* silently comes back after the next scan.
|
||||
*
|
||||
* Tested on the predicate directly — provoking a read-only database or a
|
||||
* dropped table mid-suite would corrupt the shared fixture for every other
|
||||
* case in this file.
|
||||
*/
|
||||
it.each([
|
||||
['postgres unique violation', { code: '23505', message: 'duplicate key value violates unique constraint' }, true],
|
||||
['sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT', message: 'UNIQUE constraint failed: event_people_merge_dismissals.event_id' }, true],
|
||||
['better-sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT_UNIQUE', message: 'UNIQUE constraint failed' }, true],
|
||||
['sqlite foreign-key violation', { code: 'SQLITE_CONSTRAINT', message: 'FOREIGN KEY constraint failed' }, false],
|
||||
['sqlite busy', { code: 'SQLITE_BUSY', message: 'database is locked' }, false],
|
||||
['missing table', { code: 'SQLITE_ERROR', message: 'no such table: event_people_merge_dismissals' }, false],
|
||||
['postgres read-only transaction', { code: '25006', message: 'cannot execute INSERT in a read-only transaction' }, false],
|
||||
['no error at all', null, false],
|
||||
])('%s → swallowed: %s', (_name, err, expected) => {
|
||||
expect(clustering.isUniqueViolation(err)).toBe(expected);
|
||||
});
|
||||
|
||||
/**
|
||||
* The dismissal read is the only thing standing between the automatic pass
|
||||
* and a pair the photographer explicitly separated. If it fails open, a
|
||||
* timeout silently restores the merge that "Not the same" was supposed to
|
||||
* prevent — so anything other than a missing table must stop the pass.
|
||||
*/
|
||||
it('refuses to consolidate when the dismissal list cannot be read', async () => {
|
||||
const eventId = await seedEvent('dismissals-unreadable');
|
||||
// Well above the auto-merge threshold, so only a refusal keeps them apart.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
await insertPersonWithFace(eventId, a);
|
||||
await insertPersonWithFace(eventId, b);
|
||||
|
||||
// Break the read for real rather than mocking knex: dropping a selected
|
||||
// column makes the query fail with something that is NOT "missing
|
||||
// table", which is exactly the class that must not fail open.
|
||||
await db.schema.alterTable('event_people_merge_dismissals', (t) => t.dropColumn('person_b_id'));
|
||||
try {
|
||||
await expect(clustering.consolidate(eventId, { thresholds: THRESHOLDS }))
|
||||
.rejects.toThrow();
|
||||
|
||||
// Nothing merged: the pass gave up rather than overriding a decision
|
||||
// it could not read.
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
} finally {
|
||||
await db.schema.alterTable('event_people_merge_dismissals', (t) => {
|
||||
t.integer('person_b_id').notNullable().defaultTo(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['postgres undefined_table', { code: '42P01', message: 'relation "x" does not exist' }, true],
|
||||
['sqlite missing table', { code: 'SQLITE_ERROR', message: 'no such table: x' }, true],
|
||||
// The one that matters: a missing COLUMN is a broken query, not a
|
||||
// pre-migration install, and must NOT be allowed to fail open.
|
||||
['postgres undefined_column', { code: '42703', message: 'column "x" does not exist' }, false],
|
||||
['sqlite missing column', { code: 'SQLITE_ERROR', message: 'no such column: x' }, false],
|
||||
['statement timeout', { code: '57014', message: 'canceling statement due to statement timeout' }, false],
|
||||
])('missing-table check — %s → %s', (_name, err, expected) => {
|
||||
expect(clustering.isMissingTable(err)).toBe(expected);
|
||||
});
|
||||
|
||||
it('normalizes the pair so one row covers both orderings', async () => {
|
||||
const eventId = await seedEvent('dismissal-normalized');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idB, idA);
|
||||
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].person_a_id).toBe(Math.min(idA, idB));
|
||||
expect(rows[0].person_b_id).toBe(Math.max(idA, idB));
|
||||
});
|
||||
});
|
||||
|
||||
describe('one suggestion per person per round', () => {
|
||||
it('does not offer A-B, A-C and B-C for a three-way fragment', async () => {
|
||||
const eventId = await seedEvent('three-way');
|
||||
// Three mutually similar centroids, all inside the band.
|
||||
const base = new Float32Array(DIM); base[0] = 1;
|
||||
const people = [];
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const v = new Float32Array(DIM);
|
||||
v[0] = 0.9;
|
||||
v[1 + k] = Math.sqrt(1 - 0.81);
|
||||
people.push(await insertPerson(eventId, v));
|
||||
}
|
||||
await insertPerson(eventId, base);
|
||||
|
||||
const out = await suggest(eventId);
|
||||
|
||||
// Every returned pair must name people not already spoken for: accepting
|
||||
// the first suggestion must never leave a second one pointing at a person
|
||||
// that the merge just deleted.
|
||||
const seen = new Set();
|
||||
for (const s of out) {
|
||||
expect(seen.has(s.person_a_id)).toBe(false);
|
||||
expect(seen.has(s.person_b_id)).toBe(false);
|
||||
seen.add(s.person_a_id);
|
||||
seen.add(s.person_b_id);
|
||||
}
|
||||
});
|
||||
|
||||
it('offers the most similar pair first', async () => {
|
||||
const eventId = await seedEvent('ordering');
|
||||
const [a1, b1] = pairAtSimilarity(0.62, 0);
|
||||
const [a2, b2] = pairAtSimilarity(0.67, 10);
|
||||
await insertPerson(eventId, a1);
|
||||
await insertPerson(eventId, b1);
|
||||
await insertPerson(eventId, a2);
|
||||
await insertPerson(eventId, b2);
|
||||
|
||||
const out = await suggest(eventId);
|
||||
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0].score).toBeGreaterThan(out[1].score);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual splits survive the automatic pass', () => {
|
||||
/**
|
||||
* The regression that matters most once consolidation runs on every scan:
|
||||
* a photographer splitting a wrongly-merged cluster produces two people
|
||||
* who are look-alikes BY CONSTRUCTION, so their centroids sit above the
|
||||
* merge threshold and the very next scan would put them straight back.
|
||||
*/
|
||||
it('records a split as a separation, so consolidation leaves it alone', async () => {
|
||||
const eventId = await seedEvent('split-protected');
|
||||
const base = new Float32Array(DIM); base[0] = 1;
|
||||
|
||||
// One cluster holding two near-identical faces.
|
||||
const personId = await insertPersonWithFace(eventId, base);
|
||||
const extraFaceId = await addFaceTo(eventId, personId, base);
|
||||
|
||||
const newPersonId = await clustering.splitPerson(eventId, personId, [extraFaceId]);
|
||||
expect(newPersonId).toBeTruthy();
|
||||
|
||||
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect([rows[0].person_a_id, rows[0].person_b_id].sort())
|
||||
.toEqual([personId, newPersonId].sort());
|
||||
|
||||
// Identical centroids — nothing but the recorded separation can stop
|
||||
// this merge.
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
expect(merged).toEqual([]);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consolidation reporting', () => {
|
||||
it('records what an automatic pass merged, so it is not silent', async () => {
|
||||
const eventId = await seedEvent('report-merged');
|
||||
// 0.97 is above the 0.68 auto-merge threshold — consolidate() acts.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
await insertPersonWithFace(eventId, a);
|
||||
await insertPersonWithFace(eventId, b);
|
||||
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
expect(merged).toHaveLength(1);
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
expect(Number(event.faces_last_consolidated_count)).toBe(1);
|
||||
expect(event.faces_last_consolidated_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('never absorbs an ignored cluster — that would mark a real person ignored', async () => {
|
||||
const eventId = await seedEvent('consolidate-ignored');
|
||||
// Well above the auto-merge threshold: only the is_ignored flag can
|
||||
// stop this pair.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const real = await insertPersonWithFace(eventId, a);
|
||||
const junk = await insertPersonWithFace(eventId, b, { is_ignored: true });
|
||||
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
expect(merged).toEqual([]);
|
||||
// Both still standing, and the real person is still guest-visible —
|
||||
// mergePeople ORs is_ignored onto the survivor, so absorbing the junk
|
||||
// cluster would have hidden a real person from the gallery.
|
||||
const survivors = await db('event_people').where({ event_id: eventId }).select('id', 'is_ignored');
|
||||
expect(survivors.map((p) => p.id).sort()).toEqual([real, junk].sort());
|
||||
const realRow = survivors.find((p) => p.id === real);
|
||||
expect(realRow.is_ignored === true || realRow.is_ignored === 1).toBe(false);
|
||||
});
|
||||
|
||||
it('never merges a pair the photographer said was not the same person', async () => {
|
||||
const eventId = await seedEvent('consolidate-dismissed');
|
||||
// Also above the auto-merge threshold: the dismissal is the only thing
|
||||
// standing between these two, which is the point — a human "no" has to
|
||||
// outrank the automatic pass, not just the suggestion list.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPersonWithFace(eventId, a);
|
||||
const idB = await insertPersonWithFace(eventId, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
expect(merged).toEqual([]);
|
||||
expect(await db('event_people').where({ event_id: eventId }).count({ c: '*' }).first())
|
||||
.toEqual(expect.objectContaining({ c: 2 }));
|
||||
});
|
||||
|
||||
// NOT covered by a test: reporting what a pass merged before it died
|
||||
// partway. `consolidate` calls `mergePeople` through the module-local
|
||||
// binding, so a spy on the export cannot intercept it, and no realistic
|
||||
// database failure lands on the second merge only. The recording therefore
|
||||
// sits in a `finally` — each mergePeople is its own transaction, so a pass
|
||||
// that throws has still committed what it did, and the alternative is a
|
||||
// real merge going unreported. Verified by reading, not by assertion.
|
||||
|
||||
it('clears a previous count when a later pass merges nothing', async () => {
|
||||
const eventId = await seedEvent('report-cleared');
|
||||
await db('events').where({ id: eventId }).update({ faces_last_consolidated_count: 7 });
|
||||
|
||||
const [a, b] = pairAtSimilarity(0.5, 0);
|
||||
await insertPersonWithFace(eventId, a);
|
||||
await insertPersonWithFace(eventId, b);
|
||||
|
||||
await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
expect(Number(event.faces_last_consolidated_count)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,415 +0,0 @@
|
||||
/**
|
||||
* Privacy and visibility guarantees for face recognition (#1074).
|
||||
*
|
||||
* These are the tests that matter most in this feature. Two of them cover
|
||||
* defects that would be invisible in normal use:
|
||||
*
|
||||
* - The people strip is computed from face rows, which have no concept of
|
||||
* photo visibility. Handing a guest a raw count leaks how many hidden
|
||||
* photos someone appears in, and a cover face picked without scoping
|
||||
* renders a crop of a photo the guest may not open.
|
||||
*
|
||||
* - Face embeddings are biometric data. They must not ride along in a
|
||||
* .picpeak export, which gets handed to clients and moved between
|
||||
* operators.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceprivacy-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceprivacy-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering; let peopleService; let faceProcessor;
|
||||
|
||||
function makeEmbedding(id, variant = 0, dim = 64) {
|
||||
const vec = new Float32Array(dim);
|
||||
for (let i = 0; i < dim; i++) {
|
||||
vec[i] = Math.sin((i + 1) * (id + 1) * 0.7) + variant * 0.02 * Math.cos(i * 3.1);
|
||||
}
|
||||
let norm = 0;
|
||||
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
|
||||
norm = Math.sqrt(norm);
|
||||
for (let i = 0; i < dim; i++) vec[i] /= norm;
|
||||
return vec;
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function addPhotoWithFace(eventId, embedding, { visibility = 'visible', score = 0.99 } = {}) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
visibility,
|
||||
processing_status: 'complete',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const row = {
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: score,
|
||||
embedding: clustering.packEmbedding(embedding),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
const [f] = await db('photo_faces').insert(row).returning('id');
|
||||
return { photoId, face: { ...row, id: typeof f === 'object' ? f.id : f } };
|
||||
}
|
||||
|
||||
describe('face privacy and visibility (#1074)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
peopleService = require('../../src/services/facePeopleService');
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('visibility scoping', () => {
|
||||
it('counts only photos the audience can actually see', async () => {
|
||||
const eventId = await seedEvent('visibility-count');
|
||||
const faces = [];
|
||||
// Same person: 3 visible photos, 4 hidden ones.
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(1, v))).face);
|
||||
}
|
||||
for (let v = 3; v < 7; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(1, v), { visibility: 'hidden' })).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
const clientView = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
|
||||
expect(guestView).toHaveLength(1);
|
||||
// The leak this test exists to prevent: 3, never 7.
|
||||
expect(guestView[0].face_count).toBe(3);
|
||||
expect(clientView[0].face_count).toBe(7);
|
||||
});
|
||||
|
||||
it('never returns face_count_total to a guest', async () => {
|
||||
const eventId = await seedEvent('no-total-leak');
|
||||
const { face } = await addPhotoWithFace(eventId, makeEmbedding(2));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
|
||||
const [person] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(person).not.toHaveProperty('total_face_count');
|
||||
expect(person).not.toHaveProperty('is_hidden');
|
||||
});
|
||||
|
||||
it('picks a cover face from a photo the guest may open', async () => {
|
||||
const eventId = await seedEvent('cover-scoping');
|
||||
// The BEST face (highest score) is in a hidden photo — a naive
|
||||
// implementation would hand its crop to the guest.
|
||||
const hidden = await addPhotoWithFace(eventId, makeEmbedding(3, 0), {
|
||||
visibility: 'hidden', score: 0.99,
|
||||
});
|
||||
const visible = await addPhotoWithFace(eventId, makeEmbedding(3, 1), {
|
||||
visibility: 'visible', score: 0.80,
|
||||
});
|
||||
await clustering.assignFaces(eventId, [hidden.face, visible.face]);
|
||||
|
||||
const [guestPerson] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestPerson.cover.photo_id).toBe(visible.photoId);
|
||||
expect(guestPerson.cover.photo_id).not.toBe(hidden.photoId);
|
||||
});
|
||||
|
||||
it('prefers the cover the photographer chose (#1096)', async () => {
|
||||
const eventId = await seedEvent('chosen-cover');
|
||||
// The auto-pick would take the 0.99 face. The photographer picked the
|
||||
// other one — without this the PATCH saved, the toast said so, and the
|
||||
// avatar reverted on the very next read.
|
||||
const best = await addPhotoWithFace(eventId, makeEmbedding(9, 0), { score: 0.99 });
|
||||
const chosen = await addPhotoWithFace(eventId, makeEmbedding(9, 1), { score: 0.70 });
|
||||
await clustering.assignFaces(eventId, [best.face, chosen.face]);
|
||||
|
||||
const [before] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
expect(before.cover.photo_id).toBe(best.photoId);
|
||||
// Clustering must not have written one: an automatic seed here would be
|
||||
// indistinguishable from a real choice the moment listPeople honours it.
|
||||
const seeded = await db('event_people').where({ id: before.id }).first();
|
||||
expect(seeded.cover_face_id).toBeFalsy();
|
||||
|
||||
await db('event_people').where({ id: before.id }).update({ cover_face_id: chosen.face.id });
|
||||
|
||||
const [after] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
expect(after.cover.photo_id).toBe(chosen.photoId);
|
||||
});
|
||||
|
||||
it('carries a chosen cover through a merge', async () => {
|
||||
const eventId = await seedEvent('cover-merge');
|
||||
const a = await addPhotoWithFace(eventId, makeEmbedding(20, 0), { score: 0.90 });
|
||||
const b = await addPhotoWithFace(eventId, makeEmbedding(60, 0), { score: 0.95 });
|
||||
await clustering.assignFaces(eventId, [a.face]);
|
||||
await clustering.assignFaces(eventId, [b.face]);
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
expect(people.length).toBeGreaterThan(1);
|
||||
|
||||
// The SOURCE carries the choice; the target has none.
|
||||
await db('event_people').where({ id: people[1].id }).update({ cover_face_id: b.face.id });
|
||||
await clustering.mergePeople(eventId, [people[1].id], people[0].id);
|
||||
|
||||
const target = await db('event_people').where({ id: people[0].id }).first();
|
||||
expect(target.cover_face_id).toBe(b.face.id);
|
||||
});
|
||||
|
||||
it('carries a chosen cover through a recluster', async () => {
|
||||
const eventId = await seedEvent('cover-recluster');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(21, v), { score: 0.9 - v * 0.1 })).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
const [person] = await db('event_people').where({ event_id: eventId });
|
||||
// Pick the WORST-scoring face, so an automatic re-pick would differ.
|
||||
const chosen = faces[2].id;
|
||||
await db('event_people').where({ id: person.id }).update({ cover_face_id: chosen });
|
||||
|
||||
await clustering.recluster(eventId);
|
||||
|
||||
const after = await db('event_people').where({ event_id: eventId }).whereNotNull('cover_face_id');
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0].cover_face_id).toBe(chosen);
|
||||
});
|
||||
|
||||
it('falls back to a visible face when the chosen cover is hidden from this audience', async () => {
|
||||
const eventId = await seedEvent('chosen-cover-hidden');
|
||||
// Choosing a cover must never override the visibility scoping — that
|
||||
// would hand a guest a crop of a photo they cannot open.
|
||||
const hidden = await addPhotoWithFace(eventId, makeEmbedding(10, 0), {
|
||||
visibility: 'hidden', score: 0.99,
|
||||
});
|
||||
const visible = await addPhotoWithFace(eventId, makeEmbedding(10, 1), { score: 0.70 });
|
||||
await clustering.assignFaces(eventId, [hidden.face, visible.face]);
|
||||
|
||||
const [person] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
await db('event_people').where({ id: person.id }).update({ cover_face_id: hidden.face.id });
|
||||
|
||||
const [guestView] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestView.cover.photo_id).toBe(visible.photoId);
|
||||
expect(guestView.cover.photo_id).not.toBe(hidden.photoId);
|
||||
});
|
||||
|
||||
it('drops a person entirely when all their photos are hidden', async () => {
|
||||
const eventId = await seedEvent('all-hidden');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(4, v), { visibility: 'hidden' })).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestView).toHaveLength(0);
|
||||
const clientView = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
expect(clientView).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('omits hidden and ignored people from the guest response', async () => {
|
||||
const eventId = await seedEvent('hidden-people');
|
||||
const a = (await addPhotoWithFace(eventId, makeEmbedding(5))).face;
|
||||
const b = (await addPhotoWithFace(eventId, makeEmbedding(6))).face;
|
||||
await clustering.assignFaces(eventId, [a, b]);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
await db('event_people').where({ id: people[0].id }).update({ is_hidden: true });
|
||||
await db('event_people').where({ id: people[1].id }).update({ is_ignored: true });
|
||||
|
||||
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestView).toHaveLength(0);
|
||||
const adminView = await peopleService.listPeople(eventId, { isClient: true, forAdmin: true });
|
||||
expect(adminView).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not attach a hidden person to a photo a guest can see', async () => {
|
||||
const eventId = await seedEvent('person-ids-hidden');
|
||||
const { photoId, face } = await addPhotoWithFace(eventId, makeEmbedding(7));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
await db('event_people').where({ id: person.id }).update({ is_hidden: true });
|
||||
|
||||
const guestMap = await peopleService.getPersonIdsByPhoto(eventId, [photoId], { forAdmin: false });
|
||||
expect(guestMap.get(photoId)).toBeUndefined();
|
||||
|
||||
const adminMap = await peopleService.getPersonIdsByPhoto(eventId, [photoId], { forAdmin: true });
|
||||
expect(adminMap.get(photoId)).toEqual([person.id]);
|
||||
});
|
||||
|
||||
it('respects the minimum cluster size so one-off bystanders stay out', async () => {
|
||||
const eventId = await seedEvent('min-cluster');
|
||||
const solo = (await addPhotoWithFace(eventId, makeEmbedding(8))).face;
|
||||
const crowd = [];
|
||||
for (let v = 0; v < 4; v++) {
|
||||
crowd.push((await addPhotoWithFace(eventId, makeEmbedding(9, v))).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, [solo, ...crowd]);
|
||||
|
||||
const people = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 3 });
|
||||
expect(people).toHaveLength(1);
|
||||
expect(people[0].face_count).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('erasure', () => {
|
||||
it('purgeEvent removes every face row and resets the photos', async () => {
|
||||
const eventId = await seedEvent('purge');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(10, v))).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
await db('photos').where({ event_id: eventId }).update({ face_status: 'done', face_count: 1 });
|
||||
|
||||
expect(await db('photo_faces').where({ event_id: eventId })).not.toHaveLength(0);
|
||||
expect(await db('event_people').where({ event_id: eventId })).not.toHaveLength(0);
|
||||
|
||||
await faceProcessor.purgeEvent(eventId);
|
||||
|
||||
expect(await db('photo_faces').where({ event_id: eventId })).toHaveLength(0);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.every((p) => p.face_status === null && p.face_count === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('purgePhotoFaces removes face rows WITHOUT relying on the FK cascade', async () => {
|
||||
// The regression this guards: PicPeak does not enable
|
||||
// `PRAGMA foreign_keys` on SQLite, so ON DELETE CASCADE never fires
|
||||
// there and biometric embeddings outlived the photo. The pragma is
|
||||
// explicitly OFF here so the assertion can only pass if the deletion
|
||||
// path purges the rows itself.
|
||||
await db.raw('PRAGMA foreign_keys = OFF');
|
||||
|
||||
const eventId = await seedEvent('purge-no-cascade');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(20, v))).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(person.face_count_total).toBe(3);
|
||||
|
||||
const victim = faces[0];
|
||||
await faceProcessor.purgePhotoFaces(victim.photo_id);
|
||||
|
||||
expect(await db('photo_faces').where({ photo_id: victim.photo_id })).toHaveLength(0);
|
||||
// …and the person it belonged to was rebuilt, not left with a stale count.
|
||||
const after = await db('event_people').where({ id: person.id }).first();
|
||||
expect(after.face_count_total).toBe(2);
|
||||
});
|
||||
|
||||
it('purging the last face of a person removes the person too', async () => {
|
||||
await db.raw('PRAGMA foreign_keys = OFF');
|
||||
const eventId = await seedEvent('purge-last-face');
|
||||
const { face, photoId } = await addPhotoWithFace(eventId, makeEmbedding(21));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
|
||||
|
||||
await faceProcessor.purgePhotoFaces(photoId);
|
||||
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deleting an event removes its people and faces', async () => {
|
||||
await db.raw('PRAGMA foreign_keys = ON');
|
||||
const eventId = await seedEvent('event-delete');
|
||||
const { face } = await addPhotoWithFace(eventId, makeEmbedding(11));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
|
||||
await db('photos').where({ event_id: eventId }).del();
|
||||
await db('events').where({ id: eventId }).del();
|
||||
|
||||
expect(await db('photo_faces').where({ event_id: eventId })).toHaveLength(0);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all-in-one image block (#1042 / PR #1068)', () => {
|
||||
// Blocked for performance: the AIO image runs backend, frontend, SQLite
|
||||
// and every worker in one container, with no ML sidecar to talk to. The
|
||||
// failure there would not be loud — just a slow install that looks
|
||||
// broken — so the gate is asserted rather than assumed.
|
||||
const faceSettings = require('../../src/services/faceSettings');
|
||||
|
||||
afterEach(() => { delete process.env.PICPEAK_SINGLE_CONTAINER; });
|
||||
|
||||
it('reports the feature off regardless of the flag row', async () => {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(true);
|
||||
// Even with the flag ON in the database.
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => {
|
||||
await db('feature_flags').where({ key: 'faces' }).update({ value: true });
|
||||
});
|
||||
expect(await faceSettings.isFeatureEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses per-event detection too', async () => {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
|
||||
const eventId = await seedEvent('aio-block');
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
expect(event.face_recognition_enabled).toBeTruthy();
|
||||
expect(await faceSettings.isEnabledForEvent(event)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts only explicit truthy markers', () => {
|
||||
for (const v of ['true', '1', 'yes', 'TRUE']) {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = v;
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(true);
|
||||
}
|
||||
for (const v of ['false', '0', '', 'no']) {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = v;
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(false);
|
||||
}
|
||||
delete process.env.PICPEAK_SINGLE_CONTAINER;
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('export and backup exclusion', () => {
|
||||
it('excludes both face tables from .picpeak exports', () => {
|
||||
const { EXCLUDED_TABLES } = require('../../src/services/picpeakExportService');
|
||||
expect(EXCLUDED_TABLES.has('photo_faces')).toBe(true);
|
||||
expect(EXCLUDED_TABLES.has('event_people')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes both face tables from the database backup table list', async () => {
|
||||
const databaseBackup = require('../../src/services/databaseBackup');
|
||||
const service = databaseBackup.DatabaseBackupService
|
||||
? new databaseBackup.DatabaseBackupService()
|
||||
: databaseBackup;
|
||||
if (typeof service.getTables !== 'function') return; // shape differs; covered by the export test
|
||||
|
||||
const tables = await service.getTables();
|
||||
expect(tables).not.toContain('photo_faces');
|
||||
expect(tables).not.toContain('event_people');
|
||||
// Sanity: the filter didn't eat everything.
|
||||
expect(tables).toContain('events');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Bounding-box coordinate space (#1074).
|
||||
*
|
||||
* The sidecar reports boxes in the pixel space of the image it was HANDED —
|
||||
* the ≤1920px preview — while every consumer (the strip's avatar crop, the
|
||||
* admin manager, the auto-category portrait rule) compares them against
|
||||
* photos.width/height, the ORIGINAL dimensions. faceProcessor scales once so
|
||||
* everything downstream can assume original-image coordinates.
|
||||
*
|
||||
* This is the defect that survived longest in review, and it is invisible on
|
||||
* any photo already under 1920px — the entire demo gallery was 750px, so the
|
||||
* scale factor was always exactly 1.0 and the correction never ran. Verified
|
||||
* by hand afterwards on a real 4000x3000 upload (stored box moved from
|
||||
* 1493,204 to 3110,426 — a factor of 2.083, exactly 4000/1920). This test
|
||||
* exists so that verification does not have to be repeated by hand.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facescale-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facescale-test-secret';
|
||||
|
||||
// A 1920x1440 JPEG standing in for the preview rendition. faceProcessor reads
|
||||
// its dimensions with sharp to derive the scale, so it must be a real image.
|
||||
const sharp = require('sharp');
|
||||
|
||||
let mockPreviewBuffer;
|
||||
const mockSidecarBox = [1493, 204, 131, 161]; // what the sidecar sees on the preview
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => ({
|
||||
...jest.requireActual('../../src/services/imageProcessor'),
|
||||
ensurePreviewImage: jest.fn(async () => 'previews/preview_test.jpg'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({
|
||||
getStorage: () => ({ get: async () => mockPreviewBuffer }),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/faceClient', () => ({
|
||||
detectFaces: jest.fn(async () => ({
|
||||
model_version: 'test-v1',
|
||||
faces: [{
|
||||
bbox: mockSidecarBox,
|
||||
score: 0.99,
|
||||
landmarks: [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
|
||||
yaw: 0, pitch: 0, blur: 500,
|
||||
embedding: Array.from({ length: 64 }, (_, i) => (i === 0 ? 1 : 0)),
|
||||
}],
|
||||
})),
|
||||
SidecarUnavailableError: class extends Error {},
|
||||
}));
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceProcessor;
|
||||
|
||||
async function seedPhoto(width, height) {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `scale-${width}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'scale',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `scale-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'big.jpg',
|
||||
path: '/tmp/big.jpg',
|
||||
type: 'individual',
|
||||
width,
|
||||
height,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
describe('face bbox coordinate space (#1074)', () => {
|
||||
beforeAll(async () => {
|
||||
mockPreviewBuffer = await sharp({
|
||||
create: { width: 1920, height: 1440, channels: 3, background: { r: 20, g: 40, b: 80 } },
|
||||
}).jpeg().toBuffer();
|
||||
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// The faces flag gates everything; turn it on for this suite.
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('scales preview-space boxes into ORIGINAL image coordinates', async () => {
|
||||
// 4000px original, 1920px preview -> every coordinate must grow by 4000/1920.
|
||||
const { photoId } = await seedPhoto(4000, 3000);
|
||||
await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
const face = await db('photo_faces').where({ photo_id: photoId }).first();
|
||||
const scale = 4000 / 1920;
|
||||
|
||||
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0] * scale, 1);
|
||||
expect(face.bbox_y).toBeCloseTo(mockSidecarBox[1] * scale, 1);
|
||||
expect(face.bbox_w).toBeCloseTo(mockSidecarBox[2] * scale, 1);
|
||||
expect(face.bbox_h).toBeCloseTo(mockSidecarBox[3] * scale, 1);
|
||||
|
||||
// The regression this guards: the raw preview-space value being stored.
|
||||
expect(face.bbox_x).not.toBeCloseTo(mockSidecarBox[0], 1);
|
||||
// And a sanity check that it lands inside the original frame.
|
||||
expect(face.bbox_x + face.bbox_w).toBeLessThanOrEqual(4000);
|
||||
});
|
||||
|
||||
it('leaves boxes untouched when the photo is already preview-sized', async () => {
|
||||
// The case that hid the bug: no downscale, so scale is exactly 1 and the
|
||||
// stored box equals what the sidecar reported.
|
||||
const { photoId } = await seedPhoto(1920, 1440);
|
||||
await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
const face = await db('photo_faces').where({ photo_id: photoId }).first();
|
||||
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0], 1);
|
||||
expect(face.bbox_w).toBeCloseTo(mockSidecarBox[2], 1);
|
||||
});
|
||||
|
||||
it('falls back to unscaled rather than corrupting when width is unknown', async () => {
|
||||
// Pre-dimension-migration rows have no width. Storing a box scaled by
|
||||
// NaN/0 would be worse than storing an unscaled one.
|
||||
const { photoId } = await seedPhoto(null, null);
|
||||
await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
const face = await db('photo_faces').where({ photo_id: photoId }).first();
|
||||
expect(Number.isFinite(face.bbox_x)).toBe(true);
|
||||
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0], 1);
|
||||
});
|
||||
});
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* A deferred photo must not stall the queue.
|
||||
*
|
||||
* claimNextPhoto orders by id ascending, and the queue defaults to a single
|
||||
* worker. So returning an unreachable photo to 'pending' — the obvious way to
|
||||
* say "try again later" — makes that same row the oldest pending one forever:
|
||||
* the worker reclaims it after every backoff and never reaches a higher id.
|
||||
* One dead mount would stall face scanning for the entire install, including
|
||||
* unrelated events and fresh uploads.
|
||||
*
|
||||
* The row is instead left parked in 'processing' with its face_started_at
|
||||
* intact. It is not claimable, so the worker advances; the existing janitor
|
||||
* returns it to 'pending' after STUCK_TIMEOUT_MS, which is the retry.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-defer-'));
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'defer-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceQueue; let faceProcessor;
|
||||
|
||||
describe('deferred photos do not block the queue', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
faceQueue = require('../../src/services/faceQueue');
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('exports TransientSourceError for the queue to branch on', () => {
|
||||
// The queue imports this from faceProcessor; if the export is dropped the
|
||||
// instanceof check silently becomes false and every deferral turns back
|
||||
// into a permanent failure.
|
||||
expect(typeof faceProcessor.TransientSourceError).toBe('function');
|
||||
expect(new faceProcessor.TransientSourceError(1, 'x'))
|
||||
.toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('does NOT return a deferred row to pending', () => {
|
||||
// Source inspection, deliberately. workerLoop is an unexported infinite
|
||||
// loop, so the branch cannot be driven directly, and asserting on database
|
||||
// state alone does not distinguish the fix from the bug — a version that
|
||||
// re-queues the row passes every state assertion in this file. What
|
||||
// actually matters is that this one branch does not call releaseToPending,
|
||||
// so that is what is pinned. Same approach as the contract tests added for
|
||||
// #596.
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'services', 'faceQueue.js'), 'utf8'
|
||||
);
|
||||
|
||||
const marker = 'if (err instanceof TransientSourceError) {';
|
||||
const start = src.indexOf(marker);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
|
||||
// The branch body, up to its closing brace.
|
||||
const body = src.slice(start, src.indexOf('\n }', start));
|
||||
expect(body).not.toMatch(/releaseToPending/);
|
||||
expect(body).toMatch(/continue/);
|
||||
|
||||
// And the sidecar branch, which SHOULD still release, so this test fails
|
||||
// if the two branches are ever collapsed back together.
|
||||
const sideStart = src.indexOf('if (err instanceof SidecarUnavailableError) {');
|
||||
expect(sideStart).toBeGreaterThan(-1);
|
||||
const sideBody = src.slice(sideStart, src.indexOf('\n }', sideStart));
|
||||
expect(sideBody).toMatch(/releaseToPending/);
|
||||
});
|
||||
|
||||
it('leaves a deferred row claimable-later, not claimable-now', async () => {
|
||||
// A row parked in 'processing' is invisible to claimNextPhoto, which only
|
||||
// ever selects face_status='pending' — that is what lets the worker move
|
||||
// past it instead of spinning on it.
|
||||
const [e] = await db('events').insert({
|
||||
slug: `defer-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'defer',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `defer-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [stuck] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'stuck.jpg',
|
||||
path: 'd/stuck.jpg',
|
||||
type: 'individual',
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
face_started_at: new Date().toISOString(),
|
||||
source_origin: 'external',
|
||||
}).returning('id');
|
||||
const stuckId = typeof stuck === 'object' ? stuck.id : stuck;
|
||||
|
||||
const parked = await db('photos')
|
||||
.where({ id: stuckId, face_status: 'pending' })
|
||||
.first();
|
||||
expect(parked).toBeUndefined(); // not claimable while parked
|
||||
|
||||
// The janitor's contract is what turns the park into a retry: it resets
|
||||
// 'processing' rows whose face_started_at is older than the stuck timeout.
|
||||
// Backdate past it and the row becomes claimable again.
|
||||
const longAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
await db('photos').where({ id: stuckId }).update({ face_started_at: longAgo });
|
||||
|
||||
const cutoff = new Date(Date.now() - 600000).toISOString();
|
||||
const reset = await db('photos')
|
||||
.where('face_status', 'processing')
|
||||
.where('face_started_at', '<', cutoff)
|
||||
.update({ face_status: 'pending', face_started_at: null });
|
||||
|
||||
expect(reset).toBeGreaterThan(0);
|
||||
const after = await db('photos').where({ id: stuckId }).first();
|
||||
expect(after.face_status).toBe('pending');
|
||||
});
|
||||
|
||||
it('claimNextPhoto skips events inside their backoff window', async () => {
|
||||
// The per-event cooldown is what stops the janitor handing a whole dead
|
||||
// gallery back every sweep. Without the exclusion the worker walks all of
|
||||
// it again — one slow stat per photo against a possibly hard-mounted
|
||||
// share — before reaching any healthy event.
|
||||
const mk = async (name) => {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `cd-${name}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: name,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `cd-${name}-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
const [p2] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${name}.jpg`,
|
||||
path: `cd/${name}.jpg`,
|
||||
type: 'individual',
|
||||
processing_status: 'complete',
|
||||
face_status: 'pending',
|
||||
source_origin: 'external',
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p2 === 'object' ? p2.id : p2 };
|
||||
};
|
||||
|
||||
await db('photos').del();
|
||||
const dead = await mk('dead'); // lower id -> would win the FIFO
|
||||
const healthy = await mk('healthy');
|
||||
|
||||
// Without exclusion the dead event's row is claimed first...
|
||||
const first = await faceQueue.claimNextPhoto([]);
|
||||
expect(first.id).toBe(dead.photoId);
|
||||
await db('photos').where({ id: dead.photoId }).update({ face_status: 'pending' });
|
||||
|
||||
// ...and with it, the worker reaches the healthy event instead.
|
||||
const second = await faceQueue.claimNextPhoto([dead.eventId]);
|
||||
expect(second.id).toBe(healthy.photoId);
|
||||
});
|
||||
|
||||
it('backoff spares managed rows in a mixed-source event', async () => {
|
||||
// A reference event can hold managed uploads alongside imported external
|
||||
// ones. Excluding the whole event id would leave those unscanned for as
|
||||
// long as external rows keep renewing the cooldown — indefinitely, during
|
||||
// a real outage — even though their local source is fine.
|
||||
await db('photos').del();
|
||||
const [e] = await db('events').insert({
|
||||
slug: `mix-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'mix',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `mix-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const add = async (origin, name) => {
|
||||
const [p2] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: name,
|
||||
path: `mix/${name}`,
|
||||
type: 'individual',
|
||||
processing_status: 'complete',
|
||||
face_status: 'pending',
|
||||
source_origin: origin,
|
||||
}).returning('id');
|
||||
return typeof p2 === 'object' ? p2.id : p2;
|
||||
};
|
||||
await add('external', 'ext.jpg'); // lower id, would win the FIFO
|
||||
const managedId = await add('managed', 'man.jpg');
|
||||
|
||||
// Event is in backoff: the external row is skipped, the managed one is not.
|
||||
const claimed = await faceQueue.claimNextPhoto([eventId]);
|
||||
expect(claimed).toBeTruthy();
|
||||
expect(claimed.id).toBe(managedId);
|
||||
});
|
||||
|
||||
it('startQueue is exported and does not throw on import', () => {
|
||||
// faceQueue requires faceProcessor for TransientSourceError while
|
||||
// faceProcessor is itself required by the routes — a circular require here
|
||||
// would surface as an undefined export rather than a crash, so assert the
|
||||
// module actually loaded something usable.
|
||||
expect(faceQueue).toBeTruthy();
|
||||
expect(Object.keys(faceQueue).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* "The scan finished" is not a thing this queue is told (#1107).
|
||||
*
|
||||
* It claims photos one at a time, so a backfill is just a lot of independent
|
||||
* claims and the only available signal is a worker finding nothing left. That
|
||||
* signal is NOT sufficient on its own — with concurrency above one the other
|
||||
* workers may still be busy, and a photo released back to `pending` by a down
|
||||
* sidecar is still owed — so the drain is tested against the queue directly.
|
||||
*
|
||||
* These are the cases that decide whether consolidation runs too early (a
|
||||
* wasted pass over half-formed clusters) or never (the feature silently does
|
||||
* nothing, which is the state #1107 was filed about).
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facedrain-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facedrain-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceQueue; let clustering;
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
// The drain rechecks this before consolidating, so the fixture has to be
|
||||
// a gallery that actually has detection on.
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** Both halves of the "two deliberate actions" rule have to be on. */
|
||||
async function enableFacesGlobally() {
|
||||
const existing = await db('feature_flags').where({ key: 'faces' }).first();
|
||||
if (existing) await db('feature_flags').where({ key: 'faces' }).update({ value: true });
|
||||
else await db('feature_flags').insert({ key: 'faces', value: true });
|
||||
}
|
||||
|
||||
async function insertPhoto(eventId, faceStatus) {
|
||||
const [row] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
face_status: faceStatus,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
describe('faceQueue drain consolidation (#1107)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
faceQueue = require('../../src/services/faceQueue');
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
await enableFacesGlobally();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(() => {
|
||||
faceQueue.touchedEvents.clear();
|
||||
faceQueue.consolidationRetryAt.clear();
|
||||
faceQueue.inFlightByEvent.clear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does nothing at all when no photo has been scanned', async () => {
|
||||
const spy = jest.spyOn(clustering, 'consolidate');
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits while the event still has photos queued', async () => {
|
||||
const eventId = await seedEvent('drain-pending');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await insertPhoto(eventId, 'pending');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate');
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
// Still owed, so it must keep its place for the next idle tick — dropping
|
||||
// it here would mean the gallery never consolidates at all.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it('waits while a photo is still being processed by another worker', async () => {
|
||||
const eventId = await seedEvent('drain-processing');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await insertPhoto(eventId, 'processing');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate');
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it('consolidates once the queue is empty, and does not repeat itself', async () => {
|
||||
const eventId = await seedEvent('drain-empty');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await insertPhoto(eventId, 'failed');
|
||||
await insertPhoto(eventId, 'skipped');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith(eventId);
|
||||
// Drained and handled, so a second idle tick must not pay for it again.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
|
||||
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a failing consolidation never propagates into the worker loop, and is retried', async () => {
|
||||
const eventId = await seedEvent('drain-throws');
|
||||
await insertPhoto(eventId, 'done');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(faceQueue.drainConsolidation()).resolves.toBeUndefined();
|
||||
|
||||
// A transient database error must not cost the gallery its consolidation
|
||||
// outright — the event keeps its place so a later tick retries.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
|
||||
// ...but not on the very next tick. The worker idles every couple of
|
||||
// seconds, so an immediate retry would hot-loop a permanently broken event
|
||||
// and warn every time.
|
||||
expect(faceQueue.consolidationRetryAt.get(eventId)).toBeGreaterThan(Date.now());
|
||||
const callsBefore = spy.mock.calls.length;
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).toHaveBeenCalledTimes(callsBefore);
|
||||
|
||||
// Once the backoff elapses it really does try again, and succeeds.
|
||||
faceQueue.consolidationRetryAt.set(eventId, Date.now() - 1);
|
||||
spy.mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
|
||||
expect(faceQueue.consolidationRetryAt.has(eventId)).toBe(false);
|
||||
});
|
||||
|
||||
it('waits while another worker is still inside processPhotoFaces', async () => {
|
||||
const eventId = await seedEvent('drain-inflight');
|
||||
// Every row already reads as drained: the last photo is committed 'done'
|
||||
// inside the transaction, and auto-categorisation runs afterwards. Only
|
||||
// the in-flight count knows a worker is still there.
|
||||
await insertPhoto(eventId, 'done');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
faceQueue.inFlightByEvent.set(eventId, 1);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
// Consolidating here would record its count, and the busy worker would
|
||||
// then re-mark the event — the next pass merges nothing and overwrites the
|
||||
// real number with zero.
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
|
||||
faceQueue.inFlightByEvent.delete(eventId);
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not consolidate an event whose detection was switched off mid-drain', async () => {
|
||||
const eventId = await seedEvent('drain-disabled');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await db('events').where({ id: eventId }).update({ face_recognition_enabled: false });
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
// An earlier photo legitimately marked the event before the toggle went
|
||||
// off. Merging someone's clusters just after they disabled the feature is
|
||||
// not a thing to do quietly.
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
// Dropped rather than retried — it is not coming back on its own.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats events independently — a busy gallery does not hold up a finished one', async () => {
|
||||
const busy = await seedEvent('drain-busy');
|
||||
const done = await seedEvent('drain-done');
|
||||
await insertPhoto(busy, 'pending');
|
||||
await insertPhoto(done, 'done');
|
||||
faceQueue.touchedEvents.add(busy);
|
||||
faceQueue.touchedEvents.add(done);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith(done);
|
||||
expect(faceQueue.touchedEvents.has(busy)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,567 +0,0 @@
|
||||
/**
|
||||
* "Not the same person" has to outlive re-derivation (#1132).
|
||||
*
|
||||
* The decision used to be stored as a pair of event_people.id, and neither
|
||||
* person ids nor face ids survive:
|
||||
*
|
||||
* - recluster() deletes every person and re-assigns, so person ids die but
|
||||
* photo_faces.id survives
|
||||
* - a full re-scan replaces a photo's faces outright, so FACE ids die too
|
||||
*
|
||||
* The embedding is the only stable handle, so that is what the separation is
|
||||
* keyed on. These tests simulate both kinds of re-derivation by destroying the
|
||||
* ids and rebuilding from the same vectors — which is exactly what the real
|
||||
* paths do — and assert the constraint still binds.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sep-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sep-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering;
|
||||
|
||||
const THRESHOLDS = { face_match_threshold: 0.6, face_quality_min_score: 0.7, face_quality_min_px: 40 };
|
||||
const DIM = 64;
|
||||
|
||||
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
|
||||
function pairAtSimilarity(target, basis) {
|
||||
const a = new Float32Array(DIM);
|
||||
const b = new Float32Array(DIM);
|
||||
a[basis] = 1;
|
||||
b[basis] = target;
|
||||
b[basis + 1] = Math.sqrt(1 - target * target);
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug, event_type: 'wedding', event_name: slug, event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: `${slug}-share`, expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function insertPerson(eventId, centroid, overrides = {}) {
|
||||
const [row] = await db('event_people').insert({
|
||||
event_id: eventId,
|
||||
centroid: clustering.packEmbedding(centroid),
|
||||
face_count_total: 1,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** The mirror of pairAtSimilarity's second vector: same similarity, other side. */
|
||||
function mirrorAtSimilarity(target, basis) {
|
||||
const b = new Float32Array(DIM);
|
||||
b[basis] = target;
|
||||
b[basis + 1] = -Math.sqrt(1 - target * target);
|
||||
return b;
|
||||
}
|
||||
|
||||
async function insertFaceWithPhoto(eventId, personId, centroid) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/x.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId, event_id: eventId, person_id: personId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(centroid),
|
||||
model_version: 'test-v1', created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return { faceId: typeof f === 'object' ? f.id : f, photoId };
|
||||
}
|
||||
|
||||
async function insertFace(eventId, personId, centroid) {
|
||||
const { faceId } = await insertFaceWithPhoto(eventId, personId, centroid);
|
||||
return faceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a re-scan does to identity: the people are gone and the faces come back
|
||||
* with brand-new ids. Same vectors, nothing else preserved.
|
||||
*/
|
||||
async function simulateRescan(eventId, vectors) {
|
||||
await db('photo_faces').where({ event_id: eventId }).del();
|
||||
await db('event_people').where({ event_id: eventId }).del();
|
||||
const ids = [];
|
||||
for (const vec of vectors) {
|
||||
const personId = await insertPerson(eventId, vec);
|
||||
await insertFace(eventId, personId, vec);
|
||||
ids.push(personId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe('separations survive re-derivation (#1132)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('the matcher', () => {
|
||||
it('binds a pair that still looks like the one that was separated', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
|
||||
});
|
||||
|
||||
it('binds regardless of which way round the candidates arrive', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
// Neither the stored pair nor the candidate pair has a meaningful order.
|
||||
expect(clustering.separationForbids(b, a, [{ a, b }])).toBe(true);
|
||||
});
|
||||
|
||||
it('lapses once a side has drifted past recognition', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
// A cluster reshaped far enough is no longer the cluster the
|
||||
// photographer pointed at, so the constraint should stop applying rather
|
||||
// than bind something they never saw.
|
||||
const drifted = new Float32Array(DIM);
|
||||
drifted[10] = 1;
|
||||
expect(clustering.separationForbids(drifted, b, [{ a, b }])).toBe(false);
|
||||
});
|
||||
|
||||
it('does not bind two clusters that are both the SAME side', () => {
|
||||
// A split leaves two halves of one cluster, so the pair it records is
|
||||
// often similar to itself — here 0.95. Two candidates that are plainly
|
||||
// both side A (0.97 to each other) each clear the bar against BOTH
|
||||
// stored sides, so a test that only asks "does each side match
|
||||
// something" says yes and refuses to let that person cluster with
|
||||
// itself. It fragments into singletons — the person the split was not
|
||||
// even about.
|
||||
const [a, b] = pairAtSimilarity(0.95, 0);
|
||||
const x = new Float32Array(DIM); x[0] = 1;
|
||||
const y = mirrorAtSimilarity(0.97, 0);
|
||||
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
|
||||
// The pair it was actually about still binds.
|
||||
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a separation recorded under a different embedding model', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
// Vectors from another model are meaningless here, not merely stale —
|
||||
// the same rule assignment and consolidation apply to person centroids.
|
||||
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v2' }],
|
||||
{ modelVersion: 'test-v1' })).toBe(false);
|
||||
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v1' }],
|
||||
{ modelVersion: 'test-v1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores an unrelated pair entirely', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const [x, y] = pairAtSimilarity(0.64, 20);
|
||||
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('across a re-scan', () => {
|
||||
it('still refuses to merge the pair after every id has changed', async () => {
|
||||
const eventId = await seedEvent('sep-rescan');
|
||||
// Well above the auto-merge threshold: only the separation keeps them apart.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await insertFace(eventId, idB, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
const newIds = await simulateRescan(eventId, [a, b]);
|
||||
// The premise: nothing the old row named still exists.
|
||||
expect(newIds).not.toContain(idA);
|
||||
expect(newIds).not.toContain(idB);
|
||||
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
expect(merged).toEqual([]);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the pair out of the suggestion list too', async () => {
|
||||
const eventId = await seedEvent('sep-rescan-suggest');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0); // inside the suggestion band
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await simulateRescan(eventId, [a, b]);
|
||||
|
||||
expect(await clustering.suggestMerges(eventId, { thresholds: THRESHOLDS })).toEqual([]);
|
||||
});
|
||||
|
||||
it('a split still binds after the ids it recorded are gone', async () => {
|
||||
const eventId = await seedEvent('sep-split-rescan');
|
||||
// Two faces that look alike enough to have been clustered together, but
|
||||
// are not the same vector — which is what a split is FOR, and the only
|
||||
// case it can survive re-derivation in. Two byte-identical embeddings
|
||||
// carry no information about which side is which, so a separation
|
||||
// between them has nothing to key on once the ids are gone.
|
||||
const [base, other] = pairAtSimilarity(0.96, 0);
|
||||
const personId = await insertPerson(eventId, base);
|
||||
await insertFace(eventId, personId, base);
|
||||
const extra = await insertFace(eventId, personId, other);
|
||||
|
||||
const newPersonId = await clustering.splitPerson(eventId, personId, [extra]);
|
||||
expect(newPersonId).toBeTruthy();
|
||||
|
||||
// The snapshot must have been taken AFTER recomputeCentroid — before it,
|
||||
// the new person has no centroid at all.
|
||||
const row = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
||||
expect(row.centroid_a).toBeTruthy();
|
||||
expect(row.centroid_b).toBeTruthy();
|
||||
|
||||
await simulateRescan(eventId, [base, other]);
|
||||
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a photo is hard-deleted', () => {
|
||||
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
||||
|
||||
it('drops the separation when one side has no photos left', async () => {
|
||||
const eventId = await seedEvent('sep-purge-gone');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
// Person B is gone with its only photo. The row held a COPY of its
|
||||
// centroid, so leaving it standing would keep a vector derived from a
|
||||
// deleted photo alive in a table nothing else touches.
|
||||
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the constraint when a side still has another cluster on it', async () => {
|
||||
const eventId = await seedEvent('sep-purge-descendant');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// Re-derivation can leave one stored side represented by more than one
|
||||
// current person. Deleting the photo behind ONE of them must not throw
|
||||
// the whole decision away — the other still stands for that side, and the
|
||||
// pair would be free to merge again.
|
||||
const twin = new Float32Array(DIM);
|
||||
for (let i = 0; i < DIM; i++) twin[i] = 0.98 * b[i];
|
||||
twin[6] = Math.sqrt(1 - 0.98 ** 2);
|
||||
const survivor = await insertPerson(eventId, twin);
|
||||
await insertFace(eventId, survivor, twin);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
|
||||
|
||||
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
|
||||
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
|
||||
expect(rows).toHaveLength(1);
|
||||
// Re-anchored onto the survivor, so it still binds.
|
||||
expect(clustering.separationForbids(a, twin, [{
|
||||
a: clustering.unpackEmbedding(rows[0].centroid_a),
|
||||
b: clustering.unpackEmbedding(rows[0].centroid_b),
|
||||
}])).toBe(true);
|
||||
});
|
||||
|
||||
it('re-takes the snapshot from what is left when the person survives', async () => {
|
||||
const eventId = await seedEvent('sep-purge-survives');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await insertFace(eventId, idB, b);
|
||||
// A second face on B, close enough that B stays recognisably B — so
|
||||
// purging it moves B's centroid rather than deleting the person, and the
|
||||
// side still resolves to B afterwards.
|
||||
const other = new Float32Array(DIM);
|
||||
for (let i = 0; i < DIM; i++) other[i] = 0.95 * b[i];
|
||||
other[5] = Math.sqrt(1 - 0.95 ** 2);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, idB, other);
|
||||
await clustering.recomputeCentroid(idB);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
const before = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
||||
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
const after = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
||||
expect(after).toBeTruthy();
|
||||
expect(Buffer.from(after.centroid_b).equals(Buffer.from(before.centroid_b))).toBe(false);
|
||||
// It now equals the recomputed centroid — nothing of the deleted face left.
|
||||
const person = await db('event_people').where({ id: idB }).first();
|
||||
expect(Buffer.from(after.centroid_b).equals(Buffer.from(person.centroid))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the photographer changes their mind', () => {
|
||||
it('a manual merge clears the separation between the merged people', async () => {
|
||||
const eventId = await seedEvent('sep-merge-overrules');
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await insertFace(eventId, idB, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
// ...and then decides they ARE the same person after all.
|
||||
await clustering.mergePeople(eventId, [idB], idA);
|
||||
|
||||
// The row is keyed on the centroids as well as the ids, so leaving it
|
||||
// would survive the ids it names: the next recluster would recognise
|
||||
// those two sides and pull the merge apart again.
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
|
||||
await simulateRescan(eventId, [a, b]);
|
||||
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup after the ids have already died', () => {
|
||||
// The rows these paths must find are exactly the ones whose person ids no
|
||||
// longer resolve — that is the state this whole feature creates. Matching
|
||||
// on ids alone walks past them, which is worse than not cleaning up at
|
||||
// all: the surviving row still enforces its vectors.
|
||||
|
||||
it('a merge clears a separation that had already outlived its ids', async () => {
|
||||
const eventId = await seedEvent('sep-merge-stale');
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// A recluster: same vectors, brand-new people. The row now names nobody.
|
||||
const [newA, newB] = await simulateRescan(eventId, [a, b]);
|
||||
expect([newA, newB]).not.toContain(idA);
|
||||
|
||||
await clustering.mergePeople(eventId, [newB], newA);
|
||||
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
// And it stays merged through the next re-derivation.
|
||||
await simulateRescan(eventId, [a, b]);
|
||||
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a purge clears a separation that had already outlived its ids', async () => {
|
||||
const eventId = await seedEvent('sep-purge-stale');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// Same recluster, then hard-delete the photo behind the B side.
|
||||
await db('photo_faces').where({ event_id: eventId }).del();
|
||||
await db('event_people').where({ event_id: eventId }).del();
|
||||
const newA = await insertPerson(eventId, a);
|
||||
await insertFace(eventId, newA, a);
|
||||
const newB = await insertPerson(eventId, b);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, newB, b);
|
||||
|
||||
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
expect(await db('event_people').where({ id: newB }).first()).toBeUndefined();
|
||||
// The row named idA/idB, neither of which exists — but its centroid_b is
|
||||
// a copy of a vector derived from the photo that was just destroyed.
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the whole gallery is deleted', () => {
|
||||
it('deleteEventCascade clears the separations too', () => {
|
||||
// Source inspection, deliberately. deleteEventCascade takes an admin
|
||||
// context and does filesystem cleanup, so driving it here would test the
|
||||
// scaffolding rather than the contract. The contract is narrow and
|
||||
// absolute: this table now holds centroid BLOBs, it has no event FK by
|
||||
// design, and nothing else in the codebase would ever reach it — so the
|
||||
// one delete has to be in the cascade or the embeddings outlive the
|
||||
// gallery. Same approach as the contract tests added for #596.
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'routes', 'adminEvents', 'helpers.js'), 'utf8'
|
||||
);
|
||||
const body = src.slice(src.indexOf('async function deleteEventCascade'));
|
||||
expect(body).toContain('event_people_merge_dismissals\').where(\'event_id\', eventId).del()');
|
||||
// Guarded, not caught: a failed statement aborts the transaction on PG.
|
||||
expect(body).toContain('hasTable(\'event_people_merge_dismissals\')');
|
||||
});
|
||||
|
||||
it('permanent archive deletion clears the face data too', () => {
|
||||
// Same contract, second door. This route deletes the event row directly
|
||||
// and leans on the FK cascade, which is inert on SQLite — and no FK
|
||||
// reaches the dismissals table on either engine. archiveEvent's purge is
|
||||
// nonfatal, so an event really can arrive here still holding embeddings.
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'routes', 'adminArchives.js'), 'utf8'
|
||||
);
|
||||
expect(src).toContain('event_people_merge_dismissals');
|
||||
expect(src).toContain('db(\'photo_faces\').where(\'event_id\', req.params.id).del()');
|
||||
expect(src).toContain('db(\'event_people\').where(\'event_id\', req.params.id).del()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('during assignment', () => {
|
||||
it('will not put a new face into a cluster it was separated from', async () => {
|
||||
const eventId = await seedEvent('sep-assign');
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// A face that looks like side B arrives. Its nearest centroid is A (0.97,
|
||||
// far above the 0.6 match threshold), and before #1132 it would simply
|
||||
// have joined — reforming the pair the photographer pulled apart, because
|
||||
// assignment consulted no separations at all.
|
||||
await db('event_people').where({ id: idB }).del();
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'new.jpg', path: '/tmp/n.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(b), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const faceId = typeof f === 'object' ? f.id : f;
|
||||
|
||||
const assignments = await clustering.assignFaces(
|
||||
eventId, [{ id: faceId, embedding: clustering.packEmbedding(b), model_version: 'test-v1',
|
||||
det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
||||
{ thresholds: THRESHOLDS },
|
||||
);
|
||||
|
||||
expect(assignments).toHaveLength(1);
|
||||
expect(assignments[0].personId).not.toBe(idA);
|
||||
// It opened its own person rather than being forced into the wrong one.
|
||||
expect(assignments[0].personId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('holds back a face that is only loosely like the side it belongs to', async () => {
|
||||
const eventId = await seedEvent('sep-assign-loose');
|
||||
// The separated sides are CENTROIDS; an individual face sits well below
|
||||
// its own centroid — that is why faces join at 0.6 and not at 0.92. A
|
||||
// face 0.85-like its own side would clear no strict bar against it, and
|
||||
// before this it walked straight into the other person during a
|
||||
// recluster, which is the exact merge the photographer undid.
|
||||
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
|
||||
const idA = await insertPerson(eventId, sideA);
|
||||
const idB = await insertPerson(eventId, sideB);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await db('event_people').where({ id: idB }).del();
|
||||
|
||||
// 0.65 to side A — above the 0.6 match threshold, so it would join A —
|
||||
// and 0.85 to side B, which is where it actually belongs.
|
||||
const face = new Float32Array(DIM);
|
||||
face[0] = 0.65; face[1] = 0.553; face[2] = Math.sqrt(1 - 0.65 ** 2 - 0.553 ** 2);
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'loose.jpg', path: '/tmp/l.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(face), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
|
||||
const assignments = await clustering.assignFaces(
|
||||
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(face),
|
||||
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
||||
{ thresholds: THRESHOLDS },
|
||||
);
|
||||
|
||||
expect(assignments[0].personId).not.toBe(idA);
|
||||
expect(assignments[0].personId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('binds while the clusters are still being rebuilt one face at a time', async () => {
|
||||
const eventId = await seedEvent('sep-assign-rebuild');
|
||||
// recluster() empties event_people and re-assigns from scratch, so for
|
||||
// the first faces of a batch the "person" on the other side of the
|
||||
// comparison is a cluster of ONE. A settled centroid it is not, and
|
||||
// holding it to the strict threshold meant the pair was already merged
|
||||
// by the time the constraint could bind — with nothing left to split it.
|
||||
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
|
||||
const idA = await insertPerson(eventId, sideA);
|
||||
const idB = await insertPerson(eventId, sideB);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await db('event_people').where({ event_id: eventId }).del();
|
||||
|
||||
// Two faces, one per side, each a little off its own side's centroid —
|
||||
// 0.91, just under the strict bar — and 0.66 to each other, over the
|
||||
// match threshold. Exactly the pair that must not re-form.
|
||||
const off = Math.sqrt(1 - 0.91 ** 2);
|
||||
const faceA = new Float32Array(DIM);
|
||||
faceA[0] = 0.91; faceA[3] = off;
|
||||
const faceB = new Float32Array(DIM);
|
||||
faceB[0] = 0.91 * 0.7; faceB[1] = 0.91 * Math.sqrt(1 - 0.7 ** 2); faceB[3] = off;
|
||||
|
||||
const rows = [];
|
||||
for (const vec of [faceA, faceB]) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/r.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(vec), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
rows.push({ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(vec),
|
||||
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 });
|
||||
}
|
||||
|
||||
// The premise: they are close enough to each other to cluster together.
|
||||
expect(clustering.dot(faceA, faceB)).toBeGreaterThan(THRESHOLDS.face_match_threshold);
|
||||
|
||||
const assignments = await clustering.assignFaces(eventId, rows, { thresholds: THRESHOLDS });
|
||||
expect(assignments[0].personId).not.toBe(assignments[1].personId);
|
||||
});
|
||||
|
||||
it('leaves ordinary assignment alone when no separation applies', async () => {
|
||||
const eventId = await seedEvent('sep-assign-clean');
|
||||
const base = new Float32Array(DIM); base[0] = 1;
|
||||
const personId = await insertPerson(eventId, base);
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'x.jpg', path: '/tmp/x.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(base), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
|
||||
const assignments = await clustering.assignFaces(
|
||||
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(base),
|
||||
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
||||
{ thresholds: THRESHOLDS },
|
||||
);
|
||||
|
||||
// The whole point of the strict threshold: a constraint that fires when
|
||||
// it should not would quietly wreck ordinary clustering.
|
||||
expect(assignments[0].personId).toBe(personId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* A dropped mount defers a scan; a dead photo fails it.
|
||||
*
|
||||
* ensurePreviewImage returns null for both "this JPEG is corrupt" and "the
|
||||
* NFS share is gone", and #1090 made that distinction matter: external
|
||||
* libraries now reach this path, and network mounts drop far more often than
|
||||
* local disks. Failing on an outage strands the photo — faceQueue only ever
|
||||
* claims 'pending', and nothing re-queues a failure automatically, so a mount
|
||||
* that blinked mid-scan would cost an entire gallery a manual Re-scan.
|
||||
*
|
||||
* The probe checks the containing DIRECTORY rather than the file, because that
|
||||
* is what separates the two cases: a missing file inside a healthy directory
|
||||
* is a broken photo, an unreachable directory is broken storage.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-transient-'));
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'transient-test-secret';
|
||||
// Created BEFORE anything requires externalMediaService: getExternalMediaRoot
|
||||
// only honours the env var if the directory already exists, and caches the
|
||||
// result on first call — set it later and every path silently resolves
|
||||
// against a fallback root instead.
|
||||
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpRoot, 'media');
|
||||
fs.mkdirSync(process.env.EXTERNAL_MEDIA_ROOT, { recursive: true });
|
||||
|
||||
let previewKeyResult = null;
|
||||
const mockEnsurePreviewImage = jest.fn(async () => previewKeyResult);
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => ({
|
||||
...jest.requireActual('../../src/services/imageProcessor'),
|
||||
ensurePreviewImage: (...args) => mockEnsurePreviewImage(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/faceClient', () => ({
|
||||
detectFaces: jest.fn(async () => ({ model_version: 'test-v1', faces: [] })),
|
||||
SidecarUnavailableError: class extends Error {},
|
||||
}));
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceProcessor;
|
||||
|
||||
async function seedExternalPhoto({ externalPath, relpath = 'individual/a.jpg' }) {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `tr-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'tr',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `tr-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
source_mode: 'reference',
|
||||
external_path: externalPath,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'a.jpg',
|
||||
path: 'tr/a.jpg',
|
||||
type: 'individual',
|
||||
width: 4000,
|
||||
height: 3000,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
source_origin: 'external',
|
||||
// Stored relative to EXTERNAL_MEDIA_ROOT (#1163), which is what the import
|
||||
// route writes — `relpath` above is expressed relative to the event's
|
||||
// folder only because that reads better at the call sites.
|
||||
external_relpath: path.join(externalPath, relpath),
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
describe('transient source vs dead photo', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
previewKeyResult = null; // i.e. ensurePreviewImage could not build one
|
||||
mockEnsurePreviewImage.mockClear();
|
||||
});
|
||||
|
||||
it('defers, not fails, when the source directory is unreachable', async () => {
|
||||
// Nothing was ever created under EXTERNAL_MEDIA_ROOT for this path, so the
|
||||
// directory does not resolve — the shape a dropped mount presents.
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'vanished-share' });
|
||||
|
||||
await expect(faceProcessor.processPhotoFaces(photoId))
|
||||
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
|
||||
|
||||
// Critically: still claimable. A 'failed' here is what stranded the photo.
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).not.toBe('failed');
|
||||
});
|
||||
|
||||
it('defers when the event root survives an unmount but is empty', async () => {
|
||||
// The common NFS/SMB shape: unmounting leaves the mountpoint behind as an
|
||||
// ordinary empty directory, so fs.access succeeds on storage that is
|
||||
// entirely gone. The EVENT ROOT is the thing that goes empty — the photo's
|
||||
// own subdirectory vanishes with it.
|
||||
const emptyRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'unmounted');
|
||||
await fs.promises.mkdir(emptyRoot, { recursive: true });
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'unmounted' });
|
||||
|
||||
await expect(faceProcessor.processPhotoFaces(photoId))
|
||||
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
|
||||
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).not.toBe('failed');
|
||||
});
|
||||
|
||||
it('fails when the directory is healthy but the file is gone', async () => {
|
||||
// Directory exists, file does not — a genuinely broken photo, which should
|
||||
// surface as a failure the admin can see rather than retry forever.
|
||||
const live = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'live-share', 'individual');
|
||||
await fs.promises.mkdir(live, { recursive: true });
|
||||
// Non-empty: an empty directory is now read as an unmounted share, so the
|
||||
// "healthy storage, dead photo" case needs a sibling file present.
|
||||
await fs.promises.writeFile(path.join(live, 'sibling.jpg'), 'x');
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'live-share' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).toBe('failed');
|
||||
expect(photo.face_error).toMatch(/preview/i);
|
||||
});
|
||||
|
||||
it('fails a missing subdirectory rather than deferring the whole event', async () => {
|
||||
// individual/ deleted while collages/ is fine. Probing only the photo's own
|
||||
// directory reports ENOENT and would read as a mount-wide outage, deferring
|
||||
// the event and starving every healthy sibling folder. The root is
|
||||
// populated, so the mount is up and this is a broken path.
|
||||
const root = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'partial');
|
||||
await fs.promises.mkdir(path.join(root, 'collages'), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(root, 'collages', 'kept.jpg'), 'x');
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'partial' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('defers a file that exists but cannot be read', async () => {
|
||||
// EACCES / EIO / ESTALE on the file itself, with the mount up: a transient
|
||||
// condition wearing a per-file disguise. Only ENOENT means genuinely gone.
|
||||
const root = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'locked');
|
||||
const dir = path.join(root, 'individual');
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const file = path.join(dir, 'a.jpg');
|
||||
await fs.promises.writeFile(file, 'x');
|
||||
await fs.promises.chmod(file, 0o000);
|
||||
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'locked' });
|
||||
try {
|
||||
await expect(faceProcessor.processPhotoFaces(photoId))
|
||||
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
|
||||
} finally {
|
||||
await fs.promises.chmod(file, 0o644).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('still fails managed photos without probing the mount', async () => {
|
||||
// The probe is scoped to external/reference rows: a managed photo with no
|
||||
// preview is broken, and there is no mount to blame.
|
||||
const [e] = await db('events').insert({
|
||||
slug: `tr-m-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'trm',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `tr-m-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: typeof e === 'object' ? e.id : e,
|
||||
filename: 'm.jpg',
|
||||
path: 'trm/m.jpg',
|
||||
type: 'individual',
|
||||
width: 100,
|
||||
height: 100,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
source_origin: 'managed',
|
||||
}).returning('id');
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(typeof p === 'object' ? p.id : p);
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
@@ -1,300 +0,0 @@
|
||||
/**
|
||||
* Guest filters must respect show_feedback_to_guests (#1044 follow-up).
|
||||
*
|
||||
* Every filter token on /photos is an OR of two halves: what THIS viewer
|
||||
* marked, and what ANYONE marked. The response fields built from the second
|
||||
* half — like_count, comment_count, color_label_count — are all gated on
|
||||
* show_feedback_to_guests. The FILTER was not.
|
||||
*
|
||||
* So with the setting off, the numbers were hidden but `?filter=liked` still
|
||||
* returned exactly the photos other people had liked: the same information as
|
||||
* a set instead of a count, one token at a time. These tests pin the gate on
|
||||
* every token, and pin that the viewer's own half is never gated — filtering
|
||||
* by what you yourself marked is yours to do regardless.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'filter-visibility-secret';
|
||||
|
||||
const SLUG = 'filter-visibility-event';
|
||||
const ME = 'guest-me-identifier';
|
||||
const SOMEONE_ELSE = 'guest-other-identifier';
|
||||
|
||||
describe('guest filters and show_feedback_to_guests (#1044)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let mine;
|
||||
let theirs;
|
||||
let myGuestRowId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const setVisibility = (visible) => db('event_feedback_settings')
|
||||
.where({ event_id: eventId })
|
||||
.update({ show_feedback_to_guests: visible });
|
||||
|
||||
// A real verified guest, which is how the viewer's own feedback is actually
|
||||
// identified — NOT the `guest_id` query parameter the frontend invents.
|
||||
const guestToken = () => jwt.sign(
|
||||
{ type: 'guest', guestId: myGuestRowId, eventId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
// The photo payload itself, not just the filtered id list — `is_liked` and
|
||||
// the aggregate counts live here (#1286).
|
||||
const payload = async ({ as = 'me' } = {}) => {
|
||||
const req = request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
if (as === 'me') req.set('x-guest-token', guestToken());
|
||||
const res = await req;
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return Object.fromEntries((photos || []).map((p) => [p.id, p]));
|
||||
};
|
||||
|
||||
const filter = async (token, { as = 'me', claimGuestId } = {}) => {
|
||||
const req = request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.query({ filter: token, ...(claimGuestId ? { guest_id: claimGuestId } : {}) })
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
if (as === 'me') req.set('x-guest-token', guestToken());
|
||||
const res = await req;
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Filter Visibility',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'filter-visibility-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const addPhoto = async (name) => {
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: name,
|
||||
path: `events/filter/${name}`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return p[0]?.id ?? p[0];
|
||||
};
|
||||
mine = await addPhoto('mine.jpg');
|
||||
theirs = await addPhoto('theirs.jpg');
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
feedback_enabled: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_ratings: true,
|
||||
allow_favorites: true,
|
||||
allow_color_labels: true,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
const guestRow = await db('gallery_guests').insert({
|
||||
event_id: eventId,
|
||||
name: 'Me',
|
||||
identifier: ME,
|
||||
created_at: new Date().toISOString(),
|
||||
last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
}).returning('id');
|
||||
myGuestRowId = guestRow[0]?.id ?? guestRow[0];
|
||||
|
||||
const feedback = (photoId, who, type, extra = {}) => db('photo_feedback').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
guest_identifier: who,
|
||||
// Submission links to the per-person guest row when one is present, and
|
||||
// that is the column the viewer's own half resolves through.
|
||||
guest_id: who === ME ? myGuestRowId : null,
|
||||
feedback_type: type,
|
||||
is_approved: true,
|
||||
is_hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
// Everything on `theirs` belongs to somebody else; `mine` is this viewer's.
|
||||
await feedback(mine, ME, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'favorite');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'comment', { comment_text: 'lovely' });
|
||||
await feedback(theirs, SOMEONE_ELSE, 'rating', { rating: 5 });
|
||||
await feedback(theirs, SOMEONE_ELSE, 'color_label', { color_label: 'green' });
|
||||
|
||||
// The denormalized counters the aggregate half of the filter reads.
|
||||
await db('photos').where('id', theirs).update({
|
||||
like_count: 1, favorite_count: 1, comment_count: 1, average_rating: 5, color_label_count: 1,
|
||||
});
|
||||
await db('photos').where('id', mine).update({ like_count: 1 });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('with feedback visible to guests', () => {
|
||||
beforeAll(() => setVisibility(true));
|
||||
|
||||
it('shows other people\'s marks through every token, as before', async () => {
|
||||
expect(await filter('liked')).toEqual([mine, theirs].sort((a, b) => a - b));
|
||||
expect(await filter('favorited')).toEqual([theirs]);
|
||||
expect(await filter('rated')).toEqual([theirs]);
|
||||
expect(await filter('commented')).toEqual([theirs]);
|
||||
expect(await filter('color:green')).toEqual([theirs]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with feedback hidden from guests', () => {
|
||||
beforeAll(() => setVisibility(false));
|
||||
|
||||
it('stops every token from selecting on other people\'s marks', async () => {
|
||||
// `theirs` is the photo only other guests marked. It must not come back
|
||||
// through any token — a filter that selects on hidden feedback reports
|
||||
// that feedback just as surely as a count would.
|
||||
expect(await filter('favorited')).toEqual([]);
|
||||
expect(await filter('rated')).toEqual([]);
|
||||
expect(await filter('commented')).toEqual([]);
|
||||
expect(await filter('color:green')).toEqual([]);
|
||||
});
|
||||
|
||||
it('still filters by what the viewer marked themselves', async () => {
|
||||
// The viewer's own half is never gated: this is their own action, and
|
||||
// hiding it would break "show me the ones I liked" for no privacy gain.
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('drops the viewer\'s own feedback once an admin hides it', async () => {
|
||||
// Moderation has to reach the filter too. getPhotoFeedback excludes
|
||||
// hidden rows for the guest's OWN feedback, so a photo matching here
|
||||
// would come back with nothing visible on it to explain why.
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
expect(await filter('liked')).toEqual([]);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: false });
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('ignores a guest_id supplied by the caller', async () => {
|
||||
// The own-half is resolved from the request identity. If it honoured the
|
||||
// query string instead, anyone holding another guest's identifier could
|
||||
// read that guest's hidden memberships one token at a time — straight
|
||||
// back through the gate this file exists to pin.
|
||||
expect(await filter('favorited', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
|
||||
expect(await filter('color:green', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
|
||||
// And an anonymous caller claiming to be me gets nothing of mine.
|
||||
expect(await filter('liked', { as: 'anon', claimGuestId: ME })).toEqual([]);
|
||||
});
|
||||
});
|
||||
// #1286 — the viewer's OWN like is not other people's feedback.
|
||||
describe("a guest's own likes with feedback hidden (#1286)", () => {
|
||||
beforeAll(() => setVisibility(false));
|
||||
|
||||
it('still reports is_liked on the photo the viewer liked', async () => {
|
||||
// The regression: every heart came back empty on a gallery with
|
||||
// sharing off, so the grid looked like it had discarded the guest's
|
||||
// choices on every reload.
|
||||
const photos = await payload();
|
||||
expect(photos[mine].is_liked).toBe(true);
|
||||
});
|
||||
|
||||
it("does not report is_liked for someone else's like", async () => {
|
||||
const photos = await payload();
|
||||
expect(photos[theirs].is_liked).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the aggregate like_count hidden', async () => {
|
||||
// The count IS other people's feedback and must stay gated — the fix
|
||||
// must not leak it back through the same payload.
|
||||
const photos = await payload();
|
||||
expect(photos[mine].like_count).toBe(0);
|
||||
expect(photos[theirs].like_count).toBe(0);
|
||||
expect(photos[theirs].has_feedback).toBe(false);
|
||||
});
|
||||
|
||||
it('reports nothing as liked for a viewer who liked nothing', async () => {
|
||||
const photos = await payload({ as: 'anon' });
|
||||
expect(photos[mine].is_liked).toBe(false);
|
||||
expect(photos[theirs].is_liked).toBe(false);
|
||||
});
|
||||
|
||||
it("still respects an admin hiding the viewer's own like (#1150)", async () => {
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
const photos = await payload();
|
||||
expect(photos[mine].is_liked).toBe(false);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: false });
|
||||
});
|
||||
|
||||
it('matches what the liked filter already returned', async () => {
|
||||
// The filter half was never gated; the payload flag was. After the fix
|
||||
// the two agree, which is what makes the grid and the Likes chip show
|
||||
// the same set.
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
const photos = await payload();
|
||||
const flagged = Object.values(photos).filter((p) => p.is_liked).map((p) => p.id);
|
||||
expect(flagged).toEqual([mine]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with feedback visible again (#1286 regression guard)', () => {
|
||||
beforeAll(() => setVisibility(true));
|
||||
|
||||
it('is_liked and the counts both come back', async () => {
|
||||
const photos = await payload();
|
||||
expect(photos[mine].is_liked).toBe(true);
|
||||
expect(photos[theirs].is_liked).toBe(false);
|
||||
expect(photos[theirs].like_count).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Gallery password invisible-Unicode fallback (#654).
|
||||
*
|
||||
* Passwords relayed through chat apps (Instagram DMs especially) pick up
|
||||
* invisible characters on copy-paste — zero-width space/joiners, word
|
||||
* joiner, BOM, soft hyphen — which fail the byte-exact bcrypt compare and
|
||||
* surface as "incorrect password" for a correct password. The verify route
|
||||
* retries the compare with those characters stripped, in the SAME request,
|
||||
* so the fallback costs no reCAPTCHA token and no failed-attempt quota.
|
||||
*
|
||||
* Pins the contract:
|
||||
* - exact submitted bytes always win first, so stored passwords that
|
||||
* legitimately contain these characters (e.g. ZWJ emoji sequences)
|
||||
* keep working
|
||||
* - paste artifacts (mid-string ZWSP, leading BOM, trailing space) are
|
||||
* rescued by the sanitized fallback compare
|
||||
* - the fallback never invents a match (missing ZWJ still 401s), and a
|
||||
* rescued login records no failed attempt
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sanitize-test-secret';
|
||||
|
||||
const PLAIN_SLUG = 'sanitize-plain-event';
|
||||
const ZWJ_SLUG = 'sanitize-zwj-event';
|
||||
const PLAIN_PASSWORD = 'wedding2026';
|
||||
// Stored password legitimately containing a ZWJ emoji sequence.
|
||||
const ZWJ_PASSWORD = 'Family\u{1F468}\u200D\u{1F469}Aa1';
|
||||
|
||||
describe('gallery/verify invisible-Unicode fallback (#654)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
|
||||
const makeEvent = async (slug, password) => {
|
||||
const inserted = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: `Sanitize ${slug}`,
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: await bcrypt.hash(password, 4),
|
||||
share_link: `/gallery/${slug}/share`,
|
||||
share_token: `${slug}-share`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
};
|
||||
let plainEventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
plainEventId = await makeEvent(PLAIN_SLUG, PLAIN_PASSWORD);
|
||||
await makeEvent(ZWJ_SLUG, ZWJ_PASSWORD);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/auth', require('../../src/routes/auth'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const verify = (slug, password) =>
|
||||
request(app).post('/api/auth/gallery/verify').send({ slug, password });
|
||||
|
||||
it('accepts the exact password', async () => {
|
||||
const res = await verify(PLAIN_SLUG, PLAIN_PASSWORD);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rescues a mid-string zero-width space from chat-app copy-paste', async () => {
|
||||
const res = await verify(PLAIN_SLUG, 'wedding\u200B2026');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rescues leading BOM + trailing space paste artifacts', async () => {
|
||||
const res = await verify(PLAIN_SLUG, `\uFEFF${PLAIN_PASSWORD} `);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('records no login_fail for a rescued login (single-request fallback)', async () => {
|
||||
await verify(PLAIN_SLUG, 'wedding\u200B2026').expect(200);
|
||||
const failed = await db('access_logs')
|
||||
.where({ event_id: plainEventId, action: 'login_fail' });
|
||||
expect(failed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still accepts a stored password that legitimately contains a ZWJ', async () => {
|
||||
const res = await verify(ZWJ_SLUG, ZWJ_PASSWORD);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not invent a match when the ZWJ is missing from the input', async () => {
|
||||
const res = await verify(ZWJ_SLUG, 'Family\u{1F468}\u{1F469}Aa1');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a plain wrong password', async () => {
|
||||
const res = await verify(PLAIN_SLUG, 'not-the-password');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ const request = require('supertest');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db; let cleanup; let service; let app;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db; let cleanup; let service; let adminId;
|
||||
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* Spotting one person registered twice (#1210).
|
||||
*
|
||||
* Guest registration always inserts. A client whose token expired, or who
|
||||
* opens the gallery on a second device, becomes a new gallery_guests row and
|
||||
* their likes and favourites split across the copies — so the photographer's
|
||||
* "final selection" is only trustworthy after someone notices two Tinas with
|
||||
* half the picks each and merges them.
|
||||
*
|
||||
* Merging already worked. This is the half that was missing: saying which rows
|
||||
* are the same person, so the admin does not have to find them by eye.
|
||||
*
|
||||
* Detection only — the registration path is deliberately untouched. Reusing a
|
||||
* row because someone typed a matching email would let anyone who knows that
|
||||
* email inherit the identity and its selections, and answering differently for
|
||||
* a known email would leak which addresses are in the gallery, which is
|
||||
* exactly what /guest/recover goes out of its way to avoid.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
describe('duplicate guest detection (#1210)', () => {
|
||||
let db; let cleanup; let app; let eventId;
|
||||
|
||||
const listGuests = async () => {
|
||||
const res = await request(app).get(`/api/admin/events/${eventId}/guests`);
|
||||
expect(res.status).toBe(200);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
const addGuest = async (name, email, extra = {}) => {
|
||||
const [g] = await db('gallery_guests').insert({
|
||||
event_id: eventId, name, email,
|
||||
identifier: `id-${name}-${Math.random()}`,
|
||||
created_at: new Date().toISOString(),
|
||||
last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
...extra,
|
||||
}).returning('id');
|
||||
return typeof g === 'object' ? g.id : g;
|
||||
};
|
||||
|
||||
const byId = (body, id) => body.guests.find((g) => g.id === id);
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: 'dupe-guests', event_type: 'wedding', event_name: 'Dupe Guests',
|
||||
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'x', share_link: '/gallery/dupe-guests/share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin', require('../../src/routes/adminGuests'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('gallery_guests').where({ event_id: eventId }).del();
|
||||
});
|
||||
|
||||
it('groups duplicates under a shared key', async () => {
|
||||
const first = await addGuest('Tina', 'tina@example.com');
|
||||
const second = await addGuest('Tina', 'tina@example.com');
|
||||
const other = await addGuest('Marc', 'marc@example.com');
|
||||
|
||||
const body = await listGuests();
|
||||
|
||||
// A shared group key rather than a list of sibling ids: the payload stays
|
||||
// linear in the number of guests, and the case/whitespace folding lives in
|
||||
// one place instead of being reimplemented on the client.
|
||||
expect(byId(body, first).duplicate_group).toBe('tina@example.com');
|
||||
expect(byId(body, second).duplicate_group).toBe('tina@example.com');
|
||||
expect(byId(body, other).duplicate_group).toBeNull();
|
||||
});
|
||||
|
||||
it('counts the groups and the rows in them', async () => {
|
||||
await addGuest('Tina', 'tina@example.com');
|
||||
await addGuest('Tina', 'tina@example.com');
|
||||
await addGuest('Ben', 'ben@example.com');
|
||||
await addGuest('Ben', 'ben@example.com');
|
||||
await addGuest('Ben', 'ben@example.com');
|
||||
await addGuest('Marc', 'marc@example.com');
|
||||
|
||||
expect((await listGuests()).duplicates).toEqual({ groups: 2, guests: 5 });
|
||||
});
|
||||
|
||||
it('matches the same address typed with different capitals or a stray space', async () => {
|
||||
// The same person on a different day. Both read as distinct rows in the
|
||||
// admin list, which is precisely why they need catching here.
|
||||
const a = await addGuest('Tina', 'tina@example.com');
|
||||
const b = await addGuest('Tina', 'Tina@Example.com ');
|
||||
|
||||
const body = await listGuests();
|
||||
expect(byId(body, a).duplicate_group).toBe('tina@example.com');
|
||||
expect(byId(body, b).duplicate_group).toBe('tina@example.com');
|
||||
expect(body.duplicates).toEqual({ groups: 1, guests: 2 });
|
||||
});
|
||||
|
||||
it('does not treat two guests without an email as the same person', async () => {
|
||||
// require_name_email is off by default, so a shared gallery link produces
|
||||
// plenty of these. Grouping them would merge strangers.
|
||||
const a = await addGuest('Anon', null);
|
||||
const b = await addGuest('Anon', null);
|
||||
|
||||
const body = await listGuests();
|
||||
expect(byId(body, a).duplicate_group).toBeNull();
|
||||
expect(byId(body, b).duplicate_group).toBeNull();
|
||||
expect(body.duplicates).toEqual({ groups: 0, guests: 0 });
|
||||
});
|
||||
|
||||
it('does not treat a shared name as evidence of anything', async () => {
|
||||
const a = await addGuest('Anna', 'anna.k@example.com');
|
||||
const b = await addGuest('Anna', 'anna.m@example.com');
|
||||
|
||||
const body = await listGuests();
|
||||
expect(byId(body, a).duplicate_group).toBeNull();
|
||||
expect(byId(body, b).duplicate_group).toBeNull();
|
||||
});
|
||||
|
||||
it('moves a pending invite to the survivor instead of stranding it', async () => {
|
||||
// Creating an invite inserts a real gallery_guests row, so an admin who
|
||||
// pre-mints one and then sees the guest self-register has two rows — and
|
||||
// this feature now points that pair out and offers the merge. Redemption
|
||||
// resolves guest_invites.guest_id with `is_deleted: false`, so merging
|
||||
// without moving the invite leaves the emailed link returning 404
|
||||
// guest_missing while the invite dialog still shows it as Pending.
|
||||
const placeholder = await addGuest('Tina', 'tina@example.com');
|
||||
const selfRegistered = await addGuest('Tina', 'tina@example.com');
|
||||
const [inv] = await db('guest_invites').insert({
|
||||
event_id: eventId, guest_id: placeholder, token: 'invite-token-1',
|
||||
created_by_admin_id: 1, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const inviteId = typeof inv === 'object' ? inv.id : inv;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${eventId}/guests/${selfRegistered}/merge`)
|
||||
.send({ mergeIds: [placeholder] });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const invite = await db('guest_invites').where({ id: inviteId }).first();
|
||||
expect(invite.guest_id).toBe(selfRegistered);
|
||||
// And it still resolves: the survivor is not soft-deleted.
|
||||
const target = await db('gallery_guests').where({ id: invite.guest_id }).first();
|
||||
expect(Boolean(target.is_deleted)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a spent invite pointing at what it actually redeemed', async () => {
|
||||
// A redeemed invite is a record of who redeemed what. Retargeting it would
|
||||
// rewrite that history to name a guest who was never on the other end.
|
||||
const old = await addGuest('Tina', 'tina@example.com');
|
||||
const kept = await addGuest('Tina', 'tina@example.com');
|
||||
const [inv] = await db('guest_invites').insert({
|
||||
event_id: eventId, guest_id: old, token: 'invite-token-2',
|
||||
created_by_admin_id: 1, redeemed_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const inviteId = typeof inv === 'object' ? inv.id : inv;
|
||||
|
||||
await request(app)
|
||||
.post(`/api/admin/events/${eventId}/guests/${kept}/merge`)
|
||||
.send({ mergeIds: [old] });
|
||||
|
||||
expect((await db('guest_invites').where({ id: inviteId }).first()).guest_id).toBe(old);
|
||||
});
|
||||
|
||||
it('canonicalises the survivor\'s address so recovery can still find them', async () => {
|
||||
// Grouping folds case and whitespace, so a merge can be proposed between a
|
||||
// clean address and a legacy one that is not. /guest/recover lowercases
|
||||
// what the guest types and matches on equality, so a survivor left holding
|
||||
// the raw value becomes permanently unrecoverable by email.
|
||||
const legacy = await addGuest('Tina', 'Tina@Example.com ');
|
||||
const other = await addGuest('Tina', 'tina@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${eventId}/guests/${legacy}/merge`)
|
||||
.send({ mergeIds: [other] });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect((await db('gallery_guests').where({ id: legacy }).first()).email).toBe('tina@example.com');
|
||||
});
|
||||
|
||||
it('leaves an already-canonical survivor untouched', async () => {
|
||||
const kept = await addGuest('Tina', 'tina@example.com');
|
||||
const dupe = await addGuest('Tina', 'Tina@Example.com');
|
||||
|
||||
await request(app)
|
||||
.post(`/api/admin/events/${eventId}/guests/${kept}/merge`)
|
||||
.send({ mergeIds: [dupe] });
|
||||
|
||||
expect((await db('gallery_guests').where({ id: kept }).first()).email).toBe('tina@example.com');
|
||||
});
|
||||
|
||||
it('ignores a removed guest', async () => {
|
||||
const kept = await addGuest('Tina', 'tina@example.com');
|
||||
await addGuest('Tina', 'tina@example.com', { is_deleted: true });
|
||||
|
||||
const body = await listGuests();
|
||||
// The deleted row is not listed at all, so the survivor is not a duplicate
|
||||
// of something the admin cannot see or merge.
|
||||
expect(body.guests.map((g) => g.id)).toEqual([kept]);
|
||||
expect(byId(body, kept).duplicate_group).toBeNull();
|
||||
expect(body.duplicates).toEqual({ groups: 0, guests: 0 });
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* Minimal in-process OIDC provider for integration tests (#798).
|
||||
*
|
||||
* Serves just enough of the spec for openid-client's full validation to
|
||||
* pass: discovery, JWKS (RS256), authorization endpoint (immediate redirect,
|
||||
* no login UI), and token endpoint (authorization_code + PKCE). Claims for
|
||||
* the next login are scripted per test via `setNextUser()`.
|
||||
*
|
||||
* Runs on an ephemeral localhost port over plain http — the service allows
|
||||
* that in NODE_ENV=test only.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const { URL } = require('url');
|
||||
|
||||
function b64url(input) {
|
||||
return Buffer.from(input).toString('base64url');
|
||||
}
|
||||
|
||||
class MockOidcProvider {
|
||||
constructor() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
this.privateKey = privateKey;
|
||||
this.publicJwk = publicKey.export({ format: 'jwk' });
|
||||
this.publicJwk.kid = 'test-key-1';
|
||||
this.publicJwk.alg = 'RS256';
|
||||
this.publicJwk.use = 'sig';
|
||||
|
||||
this.clientId = 'picpeak-test';
|
||||
this.clientSecret = 'test-client-secret';
|
||||
this.codes = new Map(); // code -> { nonce, redirectUri, codeChallenge, user }
|
||||
this.nextUser = { sub: 'user-1', email: 'sso@example.com', email_verified: true };
|
||||
// Test hooks:
|
||||
this.tamperNonce = false; // sign the ID token with a WRONG nonce
|
||||
this.emailViaUserinfoOnly = false; // omit email from the ID token; serve it on /userinfo
|
||||
this.advertiseEndSession = true; // include end_session_endpoint in discovery (#798 phase 3)
|
||||
this.accessTokens = new Map(); // access_token -> user (for /userinfo)
|
||||
this.server = null;
|
||||
this.issuer = null;
|
||||
}
|
||||
|
||||
setNextUser(user) {
|
||||
this.nextUser = user;
|
||||
}
|
||||
|
||||
signIdToken({ sub, nonce, extraClaims = {} }) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = { alg: 'RS256', kid: this.publicJwk.kid, typ: 'JWT' };
|
||||
const payload = {
|
||||
iss: this.issuer,
|
||||
aud: this.clientId,
|
||||
sub,
|
||||
iat: now,
|
||||
exp: now + 300,
|
||||
nonce,
|
||||
...extraClaims,
|
||||
};
|
||||
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
|
||||
const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), this.privateKey);
|
||||
return `${signingInput}.${signature.toString('base64url')}`;
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.server = http.createServer((req, res) => this.handle(req, res));
|
||||
await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve));
|
||||
this.issuer = `http://127.0.0.1:${this.server.address().port}`;
|
||||
return this.issuer;
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.server) await new Promise((resolve) => this.server.close(resolve));
|
||||
}
|
||||
|
||||
handle(req, res) {
|
||||
const url = new URL(req.url, this.issuer);
|
||||
const json = (status, body) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
if (url.pathname === '/.well-known/openid-configuration') {
|
||||
return json(200, {
|
||||
issuer: this.issuer,
|
||||
authorization_endpoint: `${this.issuer}/authorize`,
|
||||
token_endpoint: `${this.issuer}/token`,
|
||||
userinfo_endpoint: `${this.issuer}/userinfo`,
|
||||
jwks_uri: `${this.issuer}/jwks`,
|
||||
...(this.advertiseEndSession ? { end_session_endpoint: `${this.issuer}/logout` } : {}),
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/jwks') {
|
||||
return json(200, { keys: [this.publicJwk] });
|
||||
}
|
||||
|
||||
if (url.pathname === '/authorize') {
|
||||
// "Log in" instantly: mint a code bound to this request's params and
|
||||
// bounce back to the redirect_uri like a real IdP would.
|
||||
const code = crypto.randomBytes(16).toString('base64url');
|
||||
this.codes.set(code, {
|
||||
nonce: url.searchParams.get('nonce'),
|
||||
redirectUri: url.searchParams.get('redirect_uri'),
|
||||
codeChallenge: url.searchParams.get('code_challenge'),
|
||||
user: this.nextUser,
|
||||
});
|
||||
const back = new URL(url.searchParams.get('redirect_uri'));
|
||||
back.searchParams.set('code', code);
|
||||
back.searchParams.set('state', url.searchParams.get('state'));
|
||||
res.writeHead(302, { location: back.href });
|
||||
return res.end();
|
||||
}
|
||||
|
||||
if (url.pathname === '/token' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
const params = new URLSearchParams(body);
|
||||
const stored = this.codes.get(params.get('code'));
|
||||
if (!stored) return json(400, { error: 'invalid_grant' });
|
||||
this.codes.delete(params.get('code'));
|
||||
|
||||
// PKCE check — S256(code_verifier) must match the challenge.
|
||||
const verifier = params.get('code_verifier') || '';
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
if (challenge !== stored.codeChallenge) {
|
||||
return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
|
||||
}
|
||||
|
||||
const { sub, ...extraClaims } = stored.user;
|
||||
// Spec-compliant providers may keep profile/email claims OFF the ID
|
||||
// token and serve them from /userinfo only — this hook simulates that.
|
||||
const idTokenClaims = this.emailViaUserinfoOnly ? {} : extraClaims;
|
||||
const idToken = this.signIdToken({
|
||||
sub,
|
||||
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
|
||||
extraClaims: idTokenClaims,
|
||||
});
|
||||
const accessToken = crypto.randomBytes(16).toString('base64url');
|
||||
this.accessTokens.set(accessToken, stored.user);
|
||||
return json(200, {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 300,
|
||||
id_token: idToken,
|
||||
});
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (url.pathname === '/userinfo') {
|
||||
const auth = req.headers.authorization || '';
|
||||
const user = this.accessTokens.get(auth.replace(/^Bearer\s+/i, ''));
|
||||
if (!user) return json(401, { error: 'invalid_token' });
|
||||
return json(200, { ...user });
|
||||
}
|
||||
|
||||
return json(404, { error: 'not_found' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MockOidcProvider };
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* Hidden feedback, seen from the guest who left it (#1150).
|
||||
*
|
||||
* Everything in the system treats a hidden row as absent: getPhotoFeedback
|
||||
* drops it even for the guest's own feedback, the /photos filters drop it, and
|
||||
* updatePhotoFeedbackStats does not count it. Two places disagreed — the
|
||||
* per-viewer `is_liked` heart and the `my_color_label` badge — so a like the
|
||||
* photographer had hidden still showed as liked on a photo whose like_count
|
||||
* was zero.
|
||||
*
|
||||
* Making those two agree exposes the second half: the duplicate check that
|
||||
* powers like/favorite toggling did NOT skip hidden rows, so the now-empty
|
||||
* heart, when clicked, found the hidden row and toggled it OFF. The click
|
||||
* appeared to do nothing and it took two more to get back to a filled heart.
|
||||
*
|
||||
* Hiding a non-comment is deliberate, not an accident of the raw route: #839
|
||||
* and #1044 both ship it, with tests asserting that a hidden reaction or
|
||||
* colour label stops counting. So the fix is to make hidden mean absent
|
||||
* consistently — not to stop admins hiding these.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-feedback-secret';
|
||||
|
||||
const SLUG = 'hidden-own-feedback';
|
||||
const ME = 'guest-me-identifier';
|
||||
|
||||
describe('a guest\'s own hidden feedback (#1150)', () => {
|
||||
let db; let cleanup; let app; let feedbackService;
|
||||
let eventId; let photoId; let myGuestRowId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
const guestToken = () => jwt.sign(
|
||||
{ type: 'guest', guestId: myGuestRowId, eventId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const getPhoto = async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.set('x-guest-token', guestToken());
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).find((p) => p.id === photoId);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
feedbackService = require('../../src/services/feedbackService');
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Hidden Own Feedback',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'hidden-own-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'shot.jpg', path: 'events/hidden/shot.jpg',
|
||||
type: 'individual', uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const [g] = await db('gallery_guests').insert({
|
||||
event_id: eventId, name: 'Me', identifier: ME,
|
||||
created_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
}).returning('id');
|
||||
myGuestRowId = typeof g === 'object' ? g.id : g;
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId, feedback_enabled: true, allow_likes: true,
|
||||
allow_color_labels: true, moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const like = () => db('photo_feedback').insert({
|
||||
photo_id: photoId, event_id: eventId, guest_identifier: ME,
|
||||
guest_id: myGuestRowId, feedback_type: 'like',
|
||||
is_approved: true, is_hidden: false, created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photo_feedback').where({ photo_id: photoId }).del();
|
||||
await db('photos').where('id', photoId).update({ like_count: 0, color_label_count: 0 });
|
||||
});
|
||||
|
||||
describe('the read surfaces agree with each other', () => {
|
||||
it('un-fills the heart once the like is hidden', async () => {
|
||||
await like();
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
expect((await getPhoto()).is_liked).toBe(true);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
|
||||
const photo = await getPhoto();
|
||||
// like_count already ignored hidden rows, so the heart was the only
|
||||
// thing still claiming this photo was liked.
|
||||
expect(photo.like_count).toBe(0);
|
||||
expect(photo.is_liked).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a hidden colour label from the badge', async () => {
|
||||
await db('photo_feedback').insert({
|
||||
photo_id: photoId, event_id: eventId, guest_identifier: ME,
|
||||
guest_id: myGuestRowId, feedback_type: 'color_label', color_label: 'green',
|
||||
is_approved: true, is_hidden: true, created_at: new Date().toISOString(),
|
||||
});
|
||||
expect((await getPhoto()).my_color_label).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and every other surface agrees', () => {
|
||||
it('keeps a hidden like out of /my-feedback', async () => {
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/my-feedback`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.set('x-guest-token', guestToken());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// In guest identity mode the Liked/Favorited/Rated chips and their
|
||||
// filters are built from THIS array, not from is_liked — so a hidden
|
||||
// like left an empty heart while the chip still counted it.
|
||||
expect(res.body.filter((f) => f.feedback_type === 'like')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not count a hidden row against the guest cap', async () => {
|
||||
await db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ max_likes_per_guest: 1 });
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
// The hidden row is room, not an occupant: the guest sees an empty
|
||||
// heart, and meeting that click with limit_reached leaves the control
|
||||
// dead until they un-like something they can still see.
|
||||
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
||||
});
|
||||
expect(result.limit_reached).toBeUndefined();
|
||||
|
||||
await db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ max_likes_per_guest: null });
|
||||
});
|
||||
|
||||
it('keeps the hidden record when the guest changes their replacement', async () => {
|
||||
// A hidden colour label and a visible replacement now coexist. The
|
||||
// toggle/switch and rating-clear paths DELETE over the guest-scoped set,
|
||||
// so an unfiltered scope took the admin's record with it — leaving
|
||||
// nothing to review or unhide.
|
||||
const [orig] = await db('photo_feedback').insert({
|
||||
photo_id: photoId, event_id: eventId, guest_identifier: ME,
|
||||
guest_id: myGuestRowId, feedback_type: 'color_label', color_label: 'red',
|
||||
is_approved: true, is_hidden: true, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const hiddenId = typeof orig === 'object' ? orig.id : orig;
|
||||
|
||||
// The guest, seeing no label, picks green, then switches to blue, then
|
||||
// toggles blue off — every mutation the single-value path offers.
|
||||
const opts = { feedback_type: 'color_label', guest_identifier: ME, guest_id: myGuestRowId };
|
||||
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'green' });
|
||||
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'blue' });
|
||||
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'blue' });
|
||||
|
||||
const survivor = await db('photo_feedback').where('id', hiddenId).first();
|
||||
expect(survivor).toBeTruthy();
|
||||
expect(survivor.is_hidden).toBeTruthy();
|
||||
expect(survivor.color_label).toBe('red');
|
||||
});
|
||||
|
||||
it('leaves other anonymous rows alone when there is no identity to scope by', async () => {
|
||||
// With neither guest_id nor guest_identifier the collapse scope degrades
|
||||
// to `guest_identifier IS NULL` — every identifier-less row on the
|
||||
// photo, i.e. other people's. Verified: knex renders that as `is null`.
|
||||
const anon = (extra) => ({
|
||||
photo_id: photoId, event_id: eventId, feedback_type: 'like',
|
||||
is_approved: true, created_at: new Date().toISOString(), ...extra,
|
||||
});
|
||||
const [h] = await db('photo_feedback').insert(anon({ is_hidden: true })).returning('id');
|
||||
const hiddenId = typeof h === 'object' ? h.id : h;
|
||||
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
||||
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
||||
|
||||
await feedbackService.moderateFeedback(hiddenId, 'approve', 1);
|
||||
|
||||
// All three survive: two unrelated visitors plus the unhidden one.
|
||||
expect(await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false }))
|
||||
.toHaveLength(3);
|
||||
});
|
||||
|
||||
it('collapses the replacement when an admin unhides the original', async () => {
|
||||
await like();
|
||||
const original = await db('photo_feedback').where({ photo_id: photoId }).first();
|
||||
await db('photo_feedback').where('id', original.id).update({ is_hidden: true });
|
||||
|
||||
// The guest, seeing an empty heart, likes again — a second row.
|
||||
await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
||||
});
|
||||
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(2);
|
||||
|
||||
await feedbackService.moderateFeedback(original.id, 'approve', 1);
|
||||
|
||||
// Two visible rows for one guest would double-count in the tallies and
|
||||
// need two toggles to clear, since each deletes a single row.
|
||||
const visible = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
||||
expect(visible).toHaveLength(1);
|
||||
expect(visible[0].id).toBe(original.id);
|
||||
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
expect((await db('photos').where('id', photoId).first()).like_count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and clicking still works afterwards', () => {
|
||||
it('re-liking creates a fresh row instead of toggling the hidden one off', async () => {
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
// What the guest sees is an empty heart, so this is an ADD.
|
||||
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like',
|
||||
guest_identifier: ME,
|
||||
guest_id: myGuestRowId,
|
||||
});
|
||||
|
||||
// Before this, the duplicate check found the hidden row and deleted it —
|
||||
// `removed: true` — so the click did nothing visible and the moderation
|
||||
// was silently undone.
|
||||
expect(result.removed).toBeUndefined();
|
||||
|
||||
const visible = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
||||
expect(visible).toHaveLength(1);
|
||||
expect((await getPhoto()).is_liked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
/**
|
||||
* Image-security settings applied as creation defaults (#1296).
|
||||
*
|
||||
* Four controls in Settings → Image security were written, reloaded and
|
||||
* rendered as toggles, and read by nothing:
|
||||
*
|
||||
* default_protection_level, default_image_quality,
|
||||
* enable_canvas_rendering
|
||||
*
|
||||
* Each maps onto an `events` column migration 038 already created, and each
|
||||
* is labelled "… by default". `enable_devtools_protection` was the only one
|
||||
* of the five ever wired.
|
||||
*
|
||||
* The load-bearing constraint is that this is CREATION-time only. Applying
|
||||
* these to existing events would silently change live galleries on upgrade —
|
||||
* an install with enable_canvas_rendering already on would flip every grid to
|
||||
* canvas rendering, which is the memory profile under investigation in #1287.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('image-security creation defaults', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let getImageSecurityDefaults;
|
||||
let resolveImageSecurityColumns;
|
||||
let readBooleanSetting;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ getImageSecurityDefaults, resolveImageSecurityColumns, readBooleanSetting } =
|
||||
require('../../src/routes/adminEvents/helpers'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const setSetting = async (key, value) => {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'security' })
|
||||
.onConflict('setting_key')
|
||||
.merge();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').whereIn('setting_key', [
|
||||
'default_protection_level', 'default_image_quality',
|
||||
'enable_canvas_rendering',
|
||||
]).del();
|
||||
});
|
||||
|
||||
it('returns nothing when no settings are configured', async () => {
|
||||
// Every key absent must fall through to the column defaults, which is
|
||||
// exactly the behaviour before this existed.
|
||||
expect(await getImageSecurityDefaults()).toEqual({});
|
||||
});
|
||||
|
||||
it('maps each setting onto its events column', async () => {
|
||||
await setSetting('default_protection_level', 'enhanced');
|
||||
await setSetting('default_image_quality', 72);
|
||||
await setSetting('enable_canvas_rendering', true);
|
||||
|
||||
expect(await getImageSecurityDefaults()).toEqual({
|
||||
protection_level: 'enhanced',
|
||||
image_quality: 72,
|
||||
use_canvas_rendering: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries a false canvas setting through, rather than dropping it', async () => {
|
||||
// `false` is a real choice — dropping it as falsy would leave the column
|
||||
// default in place and make "off" unreachable.
|
||||
await setSetting('enable_canvas_rendering', false);
|
||||
expect(await getImageSecurityDefaults()).toEqual({ use_canvas_rendering: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unknown protection level', 'default_protection_level', 'paranoid'],
|
||||
['a non-enum protection level', 'default_protection_level', 42],
|
||||
['image quality above 100', 'default_image_quality', 250],
|
||||
['image quality of zero', 'default_image_quality', 0],
|
||||
['a non-numeric quality', 'default_image_quality', 'high'],
|
||||
['a non-boolean canvas value', 'enable_canvas_rendering', 'yes'],
|
||||
// parseInt would have rescued each of these into a valid-looking
|
||||
// integer. The settings PUT stores values without validating them, so
|
||||
// they can genuinely be in the table.
|
||||
['a numeric prefix with trailing junk', 'default_image_quality', '72oops'],
|
||||
['a fractional quality', 'default_image_quality', 72.5],
|
||||
['a single-element array', 'default_image_quality', [72]],
|
||||
])('ignores %s and falls through to the column default', async (_label, key, value) => {
|
||||
await setSetting(key, value);
|
||||
expect(await getImageSecurityDefaults()).toEqual({});
|
||||
});
|
||||
|
||||
it('applies only the keys that are configured', async () => {
|
||||
await setSetting('default_protection_level', 'maximum');
|
||||
expect(await getImageSecurityDefaults()).toEqual({ protection_level: 'maximum' });
|
||||
});
|
||||
|
||||
it('never throws, so a settings failure cannot block event creation', async () => {
|
||||
await setSetting('default_image_quality', { nonsense: true });
|
||||
await expect(getImageSecurityDefaults()).resolves.toEqual({});
|
||||
});
|
||||
|
||||
describe('double-encoded settings (the settings tab round trip)', () => {
|
||||
// GET returns setting_value undecoded and the tab PUTs the whole object
|
||||
// back through JSON.stringify, so on SQLite one visit to the tab turns
|
||||
// every value it read into a doubly-encoded string. A single parse left
|
||||
// a string behind, the type checks rejected it, and the defaults went
|
||||
// silently dead again.
|
||||
const setRaw = async (key, raw) => {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: key, setting_value: raw, setting_type: 'security' })
|
||||
.onConflict('setting_key').merge();
|
||||
};
|
||||
|
||||
it('reads a double-encoded boolean', async () => {
|
||||
await setRaw('enable_canvas_rendering', JSON.stringify(JSON.stringify(true)));
|
||||
expect(await getImageSecurityDefaults()).toEqual({ use_canvas_rendering: true });
|
||||
});
|
||||
|
||||
it('reads a double-encoded protection level', async () => {
|
||||
await setRaw('default_protection_level', JSON.stringify(JSON.stringify('enhanced')));
|
||||
expect(await getImageSecurityDefaults()).toEqual({ protection_level: 'enhanced' });
|
||||
});
|
||||
|
||||
it('reads a double-encoded integer', async () => {
|
||||
await setRaw('default_image_quality', JSON.stringify(JSON.stringify(72)));
|
||||
expect(await getImageSecurityDefaults()).toEqual({ image_quality: 72 });
|
||||
});
|
||||
|
||||
it('reads a value buried under many saves, not just one', async () => {
|
||||
// Each visit to the settings tab used to add a layer, so the depth is
|
||||
// however many times someone opened it — not a number to cap.
|
||||
let raw = JSON.stringify('maximum');
|
||||
for (let i = 0; i < 8; i += 1) raw = JSON.stringify(raw);
|
||||
await setRaw('default_protection_level', raw);
|
||||
expect(await getImageSecurityDefaults()).toEqual({ protection_level: 'maximum' });
|
||||
});
|
||||
|
||||
it('still rejects a malformed value however many times it was encoded', async () => {
|
||||
await setRaw('default_image_quality', JSON.stringify(JSON.stringify('72oops')));
|
||||
expect(await getImageSecurityDefaults()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBooleanSetting shares the same decoder', () => {
|
||||
// Every reader of app_settings has to agree, or the settings tab shows
|
||||
// protection disabled while newly created galleries turn it on.
|
||||
const setRaw2 = async (key, raw) => {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: key, setting_value: raw, setting_type: 'security' })
|
||||
.onConflict('setting_key').merge();
|
||||
};
|
||||
|
||||
it('reads a double-encoded false as false, not as absent', async () => {
|
||||
await setRaw2('enable_devtools_protection', JSON.stringify(JSON.stringify(false)));
|
||||
expect(await readBooleanSetting('enable_devtools_protection')).toBe(false);
|
||||
});
|
||||
|
||||
it('still reads a singly-encoded value', async () => {
|
||||
await setRaw2('enable_devtools_protection', JSON.stringify(true));
|
||||
expect(await readBooleanSetting('enable_devtools_protection')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns undefined for a non-boolean, so the caller keeps its default', async () => {
|
||||
await setRaw2('enable_devtools_protection', JSON.stringify('sometimes'));
|
||||
expect(await readBooleanSetting('enable_devtools_protection')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveImageSecurityColumns', () => {
|
||||
it('omits every column when neither the request nor the settings supply one', () => {
|
||||
expect(resolveImageSecurityColumns({}, {})).toEqual({});
|
||||
});
|
||||
|
||||
it('uses the global default when the request says nothing', () => {
|
||||
expect(resolveImageSecurityColumns({}, { protection_level: 'maximum' }))
|
||||
.toEqual({ protection_level: 'maximum' });
|
||||
});
|
||||
|
||||
it('lets an explicit request value win over the global default', () => {
|
||||
expect(resolveImageSecurityColumns(
|
||||
{ protection_level: 'basic' },
|
||||
{ protection_level: 'maximum' },
|
||||
)).toEqual({ protection_level: 'basic' });
|
||||
});
|
||||
|
||||
it('keeps an explicit false canvas value instead of reading it as absent', () => {
|
||||
const columns = resolveImageSecurityColumns(
|
||||
{ use_canvas_rendering: false },
|
||||
{ use_canvas_rendering: true },
|
||||
);
|
||||
expect(columns.use_canvas_rendering).toBeFalsy();
|
||||
});
|
||||
|
||||
it('keeps a zero-ish explicit value rather than falling through', () => {
|
||||
// 0 is out of range for the column, but the guard is `!== undefined`,
|
||||
// not truthiness — the validator is what rejects out-of-range input.
|
||||
expect(resolveImageSecurityColumns({ image_quality: 0 }, { image_quality: 85 }))
|
||||
.toEqual({ image_quality: 0 });
|
||||
});
|
||||
|
||||
it('resolves each column independently', () => {
|
||||
expect(resolveImageSecurityColumns(
|
||||
{ image_quality: 60 },
|
||||
{ protection_level: 'enhanced' },
|
||||
)).toEqual({
|
||||
protection_level: 'enhanced',
|
||||
image_quality: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates a missing body, which is what an empty API request looks like', () => {
|
||||
expect(resolveImageSecurityColumns(undefined, { image_quality: 90 }))
|
||||
.toEqual({ image_quality: 90 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
|
||||
// on first use; bump the budget for this file.
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(60000);
|
||||
|
||||
describe('incoming-invoice categorise / re-bill chain', () => {
|
||||
let db;
|
||||
|
||||
@@ -32,7 +32,7 @@ jest.mock('../../src/services/restoreService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('installFromBackupBoot', () => {
|
||||
let db;
|
||||
|
||||
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
|
||||
// parallel load on a small CI runner that can exceed the 5s default. Match the
|
||||
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* Shared run state for the maintenance sweeps (#1181).
|
||||
*
|
||||
* The behaviour that matters here cannot be observed from one process holding
|
||||
* a module-level flag, which is exactly why the flag moved into the database.
|
||||
* A second replica is simulated the only way that is honest in a single-process
|
||||
* test: by asserting on the shared row itself, and by driving claim() twice —
|
||||
* a second caller getting null is precisely what a second replica gets.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('maintenance job state (#1181)', () => {
|
||||
let tmpDir; let db; let app; let jobs;
|
||||
|
||||
const dimStatus = () => request(app).get('/api/admin/photos/repair-dimensions/status');
|
||||
const capStatus = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mjs-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mjs-secret';
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
jobs = require('../../src/services/maintenanceJobState');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('maintenance_jobs').update({
|
||||
is_running: false, started_at: null, heartbeat_at: null, finished_at: null, last_result: null, owner: null, claim_token: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('the lease table is kept out of .picpeak archives', () => {
|
||||
// It is live state, not data. An archive taken mid-sweep would otherwise
|
||||
// carry is_running = true and a claim token owned by a process on the
|
||||
// SOURCE install; restored inside the staleness window, the target reports
|
||||
// the job as running and refuses new POSTs with no runner to release it.
|
||||
// The importer filters on this same set, so archives written before the
|
||||
// exclusion are skipped on restore too.
|
||||
const { EXCLUDED_TABLES } = require('../../src/services/picpeakExportService');
|
||||
expect(EXCLUDED_TABLES.has('maintenance_jobs')).toBe(true);
|
||||
});
|
||||
|
||||
test('the migration seeds a row for each job', async () => {
|
||||
const names = await db('maintenance_jobs').pluck('job_name');
|
||||
// 190 seeds the orientation backfill alongside 189's two (#1198).
|
||||
expect(names.sort()).toEqual([
|
||||
'photo_capture_date_backfill', 'photo_dimension_repair', 'photo_orientation_backfill',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a second claim is refused while the first is alive', async () => {
|
||||
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
|
||||
// What a second replica's POST does. Nothing about the first claim lives in
|
||||
// this process, so this is the same question the other replica asks.
|
||||
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
|
||||
});
|
||||
|
||||
test('the two jobs claim independently', async () => {
|
||||
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
|
||||
expect(await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL)).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test('each claim gets a distinct token', async () => {
|
||||
const first = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
||||
await jobs.release(jobs.JOB_DIMENSION_REPAIR, first);
|
||||
const second = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
||||
// Same process, same pid — so an owner string would have collided here and
|
||||
// the fencing below would be worthless.
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
test('a claim whose heartbeat has gone quiet can be taken over', async () => {
|
||||
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
|
||||
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
|
||||
|
||||
// The replica holding it was killed: no release, no further heartbeats.
|
||||
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
|
||||
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
|
||||
|
||||
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test('a superseded runner cannot renew its lease', async () => {
|
||||
const oldToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
||||
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
|
||||
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
|
||||
const newToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
||||
expect(newToken).toEqual(expect.any(String));
|
||||
|
||||
// The old runner is still alive and mid-loop. Its renewal must tell it so,
|
||||
// which is what makes the route loop stop instead of running alongside the
|
||||
// new owner.
|
||||
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, oldToken)).toBe(false);
|
||||
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, newToken)).toBe(true);
|
||||
});
|
||||
|
||||
test('a superseded runner cannot release the new owner\'s claim', async () => {
|
||||
const oldToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
||||
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
|
||||
await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).update({ heartbeat_at: longAgo });
|
||||
const newToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
||||
|
||||
// The old runner finishes late and tries to write its result. Unfenced,
|
||||
// this cleared is_running under the new owner and let a THIRD sweep start.
|
||||
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, oldToken, { success: 999, noExif: 0, failed: 0 })).toBe(false);
|
||||
|
||||
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
||||
expect(state.isRunning).toBe(true);
|
||||
expect(state.lastResult).toBeNull();
|
||||
// And the row is still the new owner's to release.
|
||||
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, newToken, { success: 1, noExif: 0, failed: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
test('a stale run reads as not running, so the button comes back', async () => {
|
||||
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
||||
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
|
||||
|
||||
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
|
||||
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
|
||||
|
||||
// is_running is still true in the row — nothing released it — but a status
|
||||
// poll must not leave the operator staring at a job that cannot finish.
|
||||
expect((await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).first()).is_running).toBeTruthy();
|
||||
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(false);
|
||||
});
|
||||
|
||||
test('a heartbeat keeps a long run claimed', async () => {
|
||||
const token = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
||||
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
|
||||
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
|
||||
|
||||
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, token)).toBe(true);
|
||||
|
||||
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
|
||||
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
|
||||
});
|
||||
|
||||
test('release stores the result and read gives it back parsed', async () => {
|
||||
const token = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
||||
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, token, { success: 3, noExif: 2, failed: 1 });
|
||||
|
||||
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
||||
expect(state.isRunning).toBe(false);
|
||||
expect(state.lastResult).toEqual({ success: 3, noExif: 2, failed: 1 });
|
||||
});
|
||||
|
||||
test('releasing without a result keeps the previous run visible', async () => {
|
||||
const first = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
||||
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, first, { success: 7, noExif: 0, failed: 0 });
|
||||
|
||||
// The "nothing to do" path: claimed, found no candidates, released. It must
|
||||
// not blank the numbers the last real run reported.
|
||||
const second = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
||||
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, second);
|
||||
|
||||
expect((await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL)).lastResult).toEqual({ success: 7, noExif: 0, failed: 0 });
|
||||
});
|
||||
|
||||
test('a malformed result does not take the status endpoint down', async () => {
|
||||
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ last_result: 'not json' });
|
||||
const state = await jobs.read(jobs.JOB_DIMENSION_REPAIR);
|
||||
expect(state.lastResult).toBeNull();
|
||||
expect(state.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
test('both status endpoints report the shared row, not process memory', async () => {
|
||||
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
||||
const capToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
||||
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, capToken, { success: 1, noExif: 0, failed: 0 });
|
||||
|
||||
// Written straight to the row, exactly as another replica would have.
|
||||
const dim = await dimStatus();
|
||||
expect(dim.status).toBe(200);
|
||||
expect(dim.body.isRunning).toBe(true);
|
||||
|
||||
const cap = await capStatus();
|
||||
expect(cap.status).toBe(200);
|
||||
expect(cap.body.isRunning).toBe(false);
|
||||
expect(cap.body.lastResult).toEqual({ success: 1, noExif: 0, failed: 0 });
|
||||
});
|
||||
|
||||
test('a POST is refused while another replica holds the claim', async () => {
|
||||
// The claim was taken by "another replica" — this process knows nothing
|
||||
// about it beyond the row.
|
||||
await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
expect(res.status).toBe(409);
|
||||
|
||||
const dimRes = await request(app).post('/api/admin/photos/repair-dimensions');
|
||||
// The other job is untouched by that claim, so it is free to start.
|
||||
expect(dimRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test('the no-op path releases the claim it took', async () => {
|
||||
// No photos at all, so both endpoints take their "nothing to do" exit.
|
||||
await db('photos').del();
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
expect(res.body.count).toBe(0);
|
||||
|
||||
const row = await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).first();
|
||||
expect(row.is_running).toBeFalsy();
|
||||
// ...and a second POST is therefore accepted rather than 409ing forever.
|
||||
expect((await request(app).post('/api/admin/photos/repair-capture-dates')).status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* PostgreSQL checks for the shared maintenance-job state (#1181).
|
||||
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway database, e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_mjs_test" \
|
||||
* npx jest __tests__/integration/maintenanceJobStatePg.test.js
|
||||
*
|
||||
* What SQLite cannot answer: the claim leans on comparing a `timestamp` column
|
||||
* against an ISO-8601 string, and on an UPDATE ... WHERE guard being atomic
|
||||
* under real concurrent connections. SQLite compares those strings
|
||||
* lexicographically and serialises writes anyway, so it would pass either way —
|
||||
* exactly the shape of divergence that has bitten this repo before.
|
||||
*/
|
||||
|
||||
const knex = require('knex');
|
||||
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('maintenance job state on Postgres', () => {
|
||||
let pgDb;
|
||||
let jobs;
|
||||
const JOB = 'photo_dimension_repair';
|
||||
|
||||
beforeAll(async () => {
|
||||
pgDb = knex({ client: 'pg', connection: PG_URL, pool: { min: 0, max: 10 } });
|
||||
await pgDb.raw('DROP TABLE IF EXISTS maintenance_jobs');
|
||||
await require('../../migrations/core/189_maintenance_job_state').up(pgDb);
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
jobs = require('../../src/services/maintenanceJobState');
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
jest.dontMock('../../src/database/db');
|
||||
if (pgDb) await pgDb.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await pgDb('maintenance_jobs').update({
|
||||
is_running: false, started_at: null, heartbeat_at: null, finished_at: null, last_result: null, owner: null, claim_token: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('the ISO-string cutoff really compares as a timestamp, not as text', async () => {
|
||||
expect(await jobs.claim(JOB)).toEqual(expect.any(String));
|
||||
expect(await jobs.claim(JOB)).toBeNull();
|
||||
|
||||
await pgDb('maintenance_jobs').where({ job_name: JOB })
|
||||
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString() });
|
||||
|
||||
// If Postgres had rejected or mis-cast the ISO string this would either
|
||||
// throw or never match.
|
||||
expect(await jobs.claim(JOB)).toEqual(expect.any(String));
|
||||
|
||||
const row = await pgDb('maintenance_jobs').where({ job_name: JOB }).first();
|
||||
expect(row.heartbeat_at).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test('concurrent claims on real connections produce exactly one winner', async () => {
|
||||
// The whole point of the conditional UPDATE. Ten connections race; nine
|
||||
// must lose. SQLite cannot demonstrate this — it serialises writers.
|
||||
const results = await Promise.all(Array.from({ length: 10 }, () => jobs.claim(JOB)));
|
||||
expect(results.filter(Boolean)).toHaveLength(1);
|
||||
// ...and the winner holds a token nobody else can forge.
|
||||
expect(results.find(Boolean)).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test('a released job can be re-claimed exactly once again', async () => {
|
||||
const token = await jobs.claim(JOB);
|
||||
await jobs.release(JOB, token, { success: 2, failed: 0 });
|
||||
|
||||
const results = await Promise.all(Array.from({ length: 5 }, () => jobs.claim(JOB)));
|
||||
expect(results.filter(Boolean)).toHaveLength(1);
|
||||
expect((await jobs.read(JOB)).lastResult).toEqual({ success: 2, failed: 0 });
|
||||
});
|
||||
|
||||
test('a superseded runner is fenced out on real Postgres', async () => {
|
||||
const oldToken = await jobs.claim(JOB);
|
||||
await pgDb('maintenance_jobs').where({ job_name: JOB })
|
||||
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString() });
|
||||
const newToken = await jobs.claim(JOB);
|
||||
|
||||
expect(await jobs.heartbeat(JOB, oldToken)).toBe(false);
|
||||
expect(await jobs.release(JOB, oldToken, { success: 999, failed: 0 })).toBe(false);
|
||||
// The new owner still holds it, with its result unwritten.
|
||||
expect((await jobs.read(JOB)).isRunning).toBe(true);
|
||||
expect(await jobs.release(JOB, newToken, { success: 4, failed: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
test('read() reports a live claim as running and a stale one as not', async () => {
|
||||
await jobs.claim(JOB);
|
||||
expect((await jobs.read(JOB)).isRunning).toBe(true);
|
||||
|
||||
await pgDb('maintenance_jobs').where({ job_name: JOB })
|
||||
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 1000).toISOString() });
|
||||
expect((await jobs.read(JOB)).isRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,658 +0,0 @@
|
||||
/**
|
||||
* Newsletter campaigns — render, recipients, queue, processor hook (#1264).
|
||||
*
|
||||
* These run against a real (temp SQLite) DB because the interesting parts of
|
||||
* this feature are all row-level: which customers are selected, what
|
||||
* `scheduled_at` values get written, whether the transaction rolls back, and
|
||||
* whether an opt-out that lands AFTER queueing still stops the send.
|
||||
*/
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
describe('newsletter campaigns', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminId;
|
||||
let newsletterService;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'newsletter-test-secret';
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
newsletterService = require('../../src/services/newsletterService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('email_campaign_recipients').del();
|
||||
await db('email_queue').del();
|
||||
await db('email_campaigns').del();
|
||||
await db('customer_accounts').del();
|
||||
});
|
||||
|
||||
async function seedCustomer(overrides = {}) {
|
||||
const row = {
|
||||
email: `c${Math.random().toString(36).slice(2, 10)}@example.com`,
|
||||
first_name: 'Alex',
|
||||
last_name: 'Sample',
|
||||
display_name: 'Alex Sample',
|
||||
company_name: 'Sample & Co',
|
||||
preferred_language: 'en',
|
||||
is_active: 1,
|
||||
marketing_opt_out: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
const [id] = await db('customer_accounts').insert(row).returning('id');
|
||||
return { id: typeof id === 'object' ? id.id : id, ...row };
|
||||
}
|
||||
|
||||
async function seedCampaign(overrides = {}) {
|
||||
return await newsletterService.createCampaign({
|
||||
name: 'Spring news',
|
||||
subject: 'Our spring offers',
|
||||
bodyHtml: '<p>Hi {{first_name}}, welcome!</p>',
|
||||
...overrides,
|
||||
}, adminId);
|
||||
}
|
||||
|
||||
// ---- recipients --------------------------------------------------------
|
||||
|
||||
describe('resolveRecipients', () => {
|
||||
it('selects active, opted-in customers with an email', async () => {
|
||||
await seedCustomer({ email: 'in@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
|
||||
|
||||
expect(recipients.map((r) => r.email)).toEqual(['in@example.com']);
|
||||
expect(skippedOptOut).toBe(0);
|
||||
});
|
||||
|
||||
it('skips opted-out customers and counts them', async () => {
|
||||
await seedCustomer({ email: 'in@example.com' });
|
||||
await seedCustomer({ email: 'out@example.com', marketing_opt_out: 1 });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
|
||||
|
||||
expect(recipients.map((r) => r.email)).toEqual(['in@example.com']);
|
||||
expect(skippedOptOut).toBe(1);
|
||||
});
|
||||
|
||||
it('skips inactive customers', async () => {
|
||||
await seedCustomer({ email: 'in@example.com' });
|
||||
await seedCustomer({ email: 'gone@example.com', is_active: 0 });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { recipients } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients.map((r) => r.email)).toEqual(['in@example.com']);
|
||||
});
|
||||
|
||||
it('collapses duplicate addresses so one person is mailed once', async () => {
|
||||
await seedCustomer({ email: 'same@example.com' });
|
||||
await seedCustomer({ email: 'SAME@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { recipients } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('scopes a manual campaign to the named ids only', async () => {
|
||||
const a = await seedCustomer({ email: 'a@example.com' });
|
||||
await seedCustomer({ email: 'b@example.com' });
|
||||
const campaign = await seedCampaign({
|
||||
recipientMode: 'manual', customerIds: [a.id],
|
||||
});
|
||||
|
||||
const { recipients } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients.map((r) => r.email)).toEqual(['a@example.com']);
|
||||
});
|
||||
|
||||
it('still honours opt-out inside a manual id list', async () => {
|
||||
const a = await seedCustomer({ email: 'a@example.com', marketing_opt_out: 1 });
|
||||
const campaign = await seedCampaign({ recipientMode: 'manual', customerIds: [a.id] });
|
||||
|
||||
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients).toHaveLength(0);
|
||||
expect(skippedOptOut).toBe(1);
|
||||
});
|
||||
|
||||
it('skips every row sharing an opted-out address', async () => {
|
||||
// #1285 review: unsubscribing flips only the row whose token was in the
|
||||
// mail. Filtering row-by-row skipped that one and still delivered to
|
||||
// the same inbox through the duplicate — so the link appeared to do
|
||||
// nothing.
|
||||
await seedCustomer({ email: 'shared@example.com', marketing_opt_out: 1 });
|
||||
await seedCustomer({ email: 'SHARED@example.com', marketing_opt_out: 0 });
|
||||
await seedCustomer({ email: 'other@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
|
||||
|
||||
expect(recipients.map((r) => r.email)).toEqual(['other@example.com']);
|
||||
// Counted once for the address, not once per row.
|
||||
expect(skippedOptOut).toBe(1);
|
||||
});
|
||||
|
||||
it('honours an opted-out twin that is NOT in the manual selection', async () => {
|
||||
// The opted-out set is queried across every active customer, not just
|
||||
// the selected ids — otherwise picking the opted-in twin of an
|
||||
// unsubscribed account mails the address that opted out.
|
||||
await seedCustomer({ email: 'twin@example.com', marketing_opt_out: 1 });
|
||||
const selected = await seedCustomer({ email: 'TWIN@example.com', marketing_opt_out: 0 });
|
||||
const campaign = await seedCampaign({
|
||||
recipientMode: 'manual', customerIds: [selected.id],
|
||||
});
|
||||
|
||||
const { recipients } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns nobody for a manual campaign with no ids', async () => {
|
||||
await seedCustomer();
|
||||
const campaign = await seedCampaign({ recipientMode: 'manual', customerIds: [] });
|
||||
|
||||
const { recipients } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- rendering ---------------------------------------------------------
|
||||
|
||||
describe('renderForRecipient', () => {
|
||||
it('substitutes the customer variables', async () => {
|
||||
const customer = await seedCustomer({ first_name: 'Jamie', email: 'j@example.com' });
|
||||
const campaign = await seedCampaign({ bodyHtml: '<p>Hi {{first_name}} at {{company_name}}</p>' });
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
|
||||
expect(html).toContain('Hi Jamie at Sample & 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('<script>');
|
||||
});
|
||||
|
||||
it('resolves {{#if}} blocks', async () => {
|
||||
const withCompany = await seedCustomer({ company_name: 'Acme' });
|
||||
const without = await seedCustomer({ company_name: null });
|
||||
const campaign = await seedCampaign({
|
||||
bodyHtml: '<p>Hi{{#if company_name}} from {{company_name}}{{/if}}!</p>',
|
||||
});
|
||||
|
||||
expect((await newsletterService.renderForRecipient(campaign, withCompany)).html)
|
||||
.toContain('Hi from Acme!');
|
||||
expect((await newsletterService.renderForRecipient(campaign, without)).html)
|
||||
.toContain('Hi!');
|
||||
});
|
||||
|
||||
it('includes a working unsubscribe URL for the recipient', async () => {
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign({ bodyHtml: '<p><a href="{{unsubscribe_url}}">Stop</a></p>' });
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
|
||||
const match = html.match(/\/api\/public\/newsletter\/unsubscribe\/([A-Za-z0-9_-]+)/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(newsletterService.verifyUnsubscribeToken(match[1])).toBe(customer.id);
|
||||
});
|
||||
|
||||
it('appends an unsubscribe link when the body omits the placeholder', async () => {
|
||||
// The opt-out design rests on every campaign carrying the link; a body
|
||||
// that simply leaves out {{unsubscribe_url}} must not break it.
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign({ bodyHtml: '<p>No link in here</p>' });
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
|
||||
const match = html.match(/\/api\/public\/newsletter\/unsubscribe\/([A-Za-z0-9_-]+)/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(newsletterService.verifyUnsubscribeToken(match[1])).toBe(customer.id);
|
||||
});
|
||||
|
||||
it('does not double up when the body places the link itself', async () => {
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign({
|
||||
bodyHtml: '<p><a href="{{unsubscribe_url}}">Stop</a></p>',
|
||||
});
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
|
||||
const links = html.match(/\/api\/public\/newsletter\/unsubscribe\//g) || [];
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prefers the customer language over the campaign language', async () => {
|
||||
const german = await seedCustomer({ preferred_language: 'de' });
|
||||
const campaign = await seedCampaign({ language: 'en' });
|
||||
|
||||
expect((await newsletterService.renderForRecipient(campaign, german)).language).toBe('de');
|
||||
});
|
||||
|
||||
it('falls back to the campaign language when the customer has none', async () => {
|
||||
const customer = await seedCustomer({ preferred_language: null });
|
||||
const campaign = await seedCampaign({ language: 'de' });
|
||||
|
||||
expect((await newsletterService.renderForRecipient(campaign, customer)).language).toBe('de');
|
||||
});
|
||||
|
||||
it('inlines the sanitized CSS into the body', async () => {
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign({ bodyCss: '.cta { color: #fff; }' });
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
expect(html).toContain('.cta { color: #fff; }');
|
||||
});
|
||||
|
||||
it('re-sanitizes a body that was stored unsanitized', async () => {
|
||||
// Simulates a row written by an older/buggier version: the render path
|
||||
// must not trust what is in the column.
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign();
|
||||
await db('email_campaigns').where({ id: campaign.id })
|
||||
.update({ body_html: '<p>hi</p><script>alert(1)</script>' });
|
||||
const tainted = await newsletterService.getCampaign(campaign.id);
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(tainted, customer);
|
||||
expect(html).not.toContain('alert(1)');
|
||||
});
|
||||
|
||||
it('wraps the body in the standard email chrome', async () => {
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('email-footer');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- unsubscribe tokens ------------------------------------------------
|
||||
|
||||
describe('unsubscribe tokens', () => {
|
||||
it('round-trips a customer id', () => {
|
||||
const token = newsletterService.unsubscribeToken(4242);
|
||||
expect(newsletterService.verifyUnsubscribeToken(token)).toBe(4242);
|
||||
});
|
||||
|
||||
it('rejects a tampered signature', () => {
|
||||
const token = newsletterService.unsubscribeToken(1);
|
||||
const decoded = Buffer.from(token, 'base64url').toString('utf8');
|
||||
const tampered = Buffer.from(decoded.replace(/.$/, 'f'), 'utf8').toString('base64url');
|
||||
expect(newsletterService.verifyUnsubscribeToken(tampered)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects another customer's id spliced onto a valid signature", () => {
|
||||
const token = newsletterService.unsubscribeToken(1);
|
||||
const sig = Buffer.from(token, 'base64url').toString('utf8').split('.')[1];
|
||||
const forged = Buffer.from(`2.${sig}`, 'utf8').toString('base64url');
|
||||
expect(newsletterService.verifyUnsubscribeToken(forged)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([['', 'empty'], ['not-a-token', 'garbage'], ['!!!', 'non-base64']])
|
||||
('rejects %s (%s)', (token) => {
|
||||
expect(newsletterService.verifyUnsubscribeToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects null and non-strings', () => {
|
||||
expect(newsletterService.verifyUnsubscribeToken(null)).toBeNull();
|
||||
expect(newsletterService.verifyUnsubscribeToken(undefined)).toBeNull();
|
||||
expect(newsletterService.verifyUnsubscribeToken(123)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- queueing ----------------------------------------------------------
|
||||
|
||||
describe('queueCampaign', () => {
|
||||
it('writes one queue row and one recipient row per recipient', async () => {
|
||||
await seedCustomer({ email: 'a@example.com' });
|
||||
await seedCustomer({ email: 'b@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const result = await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
expect(result.queued).toBe(2);
|
||||
const queue = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
expect(queue).toHaveLength(2);
|
||||
expect(queue.every((r) => r.email_type === 'newsletter')).toBe(true);
|
||||
expect(queue.every((r) => r.origin === 'campaign')).toBe(true);
|
||||
expect(queue.every((r) => r.status === 'pending')).toBe(true);
|
||||
expect(await db('email_campaign_recipients').where({ campaign_id: campaign.id }))
|
||||
.toHaveLength(2);
|
||||
});
|
||||
|
||||
it('staggers scheduled_at by the send rate', async () => {
|
||||
for (let i = 0; i < 5; i += 1) await seedCustomer({ email: `r${i}@example.com` });
|
||||
const campaign = await seedCampaign({ sendRatePerMinute: 2 });
|
||||
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
const rows = await db('email_queue')
|
||||
.where({ campaign_id: campaign.id }).orderBy('id', 'asc');
|
||||
const minutes = rows.map((r) => Math.round(
|
||||
(new Date(r.scheduled_at).getTime() - new Date(rows[0].scheduled_at).getTime()) / 60000
|
||||
));
|
||||
// 2 per minute → minute 0, 0, 1, 1, 2.
|
||||
expect(minutes).toEqual([0, 0, 1, 1, 2]);
|
||||
});
|
||||
|
||||
it('writes queue timestamps in the engine shape the processor compares', async () => {
|
||||
// #1285 review: storing ISO TEXT in a column the processor compares
|
||||
// against a Date-bound value meant SQLite never matched the row — every
|
||||
// INTEGER sorts below every TEXT — so campaigns silently sent nothing
|
||||
// on SQLite installs.
|
||||
//
|
||||
// The due-predicate itself CANNOT be exercised here: under jest a
|
||||
// sandbox-created Date binds as a string (the landmine documented in
|
||||
// CLAUDE.md), so `INTEGER <= TEXT` is trivially true and every row
|
||||
// reads as due whatever the fix does. So assert the stored SHAPE
|
||||
// against the production shape utils/queueTimestamps documents for
|
||||
// this engine instead.
|
||||
await seedCustomer({ email: 'a@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
|
||||
// SQLite: epoch ms, exactly what queueEmail's Date becomes through the
|
||||
// native binding. Never an ISO string, which is what regressed.
|
||||
expect(typeof row.scheduled_at).toBe('number');
|
||||
expect(typeof row.created_at).toBe('number');
|
||||
expect(Number.isFinite(row.scheduled_at)).toBe(true);
|
||||
// Still a sane instant, not a truncated or NaN value.
|
||||
expect(Math.abs(row.scheduled_at - Date.now())).toBeLessThan(120000);
|
||||
});
|
||||
|
||||
it('clamps an absurd send rate', async () => {
|
||||
await seedCustomer();
|
||||
const campaign = await seedCampaign({ sendRatePerMinute: 100000 });
|
||||
|
||||
const result = await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
expect(result.sendRatePerMinute).toBe(newsletterService.MAX_RATE_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('moves the campaign to queued and records the recipient count', async () => {
|
||||
await seedCustomer();
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
const after = await newsletterService.getCampaign(campaign.id);
|
||||
expect(after.status).toBe('queued');
|
||||
expect(after.recipient_count).toBe(1);
|
||||
expect(after.queued_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('refuses to queue a campaign twice', async () => {
|
||||
await seedCustomer();
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
await expect(newsletterService.queueCampaign(campaign.id, adminId))
|
||||
.rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
it('refuses to queue with no recipients', async () => {
|
||||
const campaign = await seedCampaign();
|
||||
await expect(newsletterService.queueCampaign(campaign.id, adminId))
|
||||
.rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('refuses to queue an empty body', async () => {
|
||||
await seedCustomer();
|
||||
const campaign = await seedCampaign({ bodyHtml: '' });
|
||||
await expect(newsletterService.queueCampaign(campaign.id, adminId))
|
||||
.rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('leaves nothing behind when the transaction fails', async () => {
|
||||
await seedCustomer({ email: 'a@example.com' });
|
||||
await seedCustomer({ email: 'b@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
// Force the second insert to fail: a unique (campaign_id,
|
||||
// customer_account_id) row already exists for one of them.
|
||||
const [{ id: firstId }] = await db('customer_accounts').select('id').orderBy('id').limit(1);
|
||||
await db('email_campaign_recipients').insert({
|
||||
campaign_id: campaign.id, customer_account_id: firstId,
|
||||
email: 'a@example.com', status: 'queued', created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await expect(newsletterService.queueCampaign(campaign.id, adminId)).rejects.toThrow();
|
||||
|
||||
// A partial queue — half a customer list mailed — is the outcome the
|
||||
// transaction exists to prevent.
|
||||
expect(await db('email_queue').where({ campaign_id: campaign.id })).toHaveLength(0);
|
||||
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('draft');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- cancel ------------------------------------------------------------
|
||||
|
||||
describe('cancel', () => {
|
||||
it('removes pending rows and leaves sent ones alone', async () => {
|
||||
await seedCustomer({ email: 'a@example.com' });
|
||||
await seedCustomer({ email: 'b@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
// Pretend the first one already went out.
|
||||
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
|
||||
await db('email_queue').where({ id: rows[0].id })
|
||||
.update({ status: 'sent', sent_at: new Date().toISOString() });
|
||||
await db('email_campaign_recipients')
|
||||
.where({ campaign_id: campaign.id, email_queue_id: rows[0].id })
|
||||
.update({ status: 'sent' });
|
||||
|
||||
const result = await newsletterService.cancel(campaign.id, adminId);
|
||||
|
||||
expect(result.cancelled).toBe(1);
|
||||
const remaining = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].status).toBe('sent');
|
||||
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('refuses to delete a cancelled campaign that already reached someone', async () => {
|
||||
// The recipient rows cascade, and they are the only durable record of
|
||||
// who received the mail once queue rows are pruned (#1285 review).
|
||||
await seedCustomer({ email: 'a@example.com' });
|
||||
await seedCustomer({ email: 'b@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
|
||||
await db('email_queue').where({ id: rows[0].id })
|
||||
.update({ status: 'sent', sent_at: new Date().toISOString() });
|
||||
await db('email_campaign_recipients')
|
||||
.where({ campaign_id: campaign.id, email_queue_id: rows[0].id })
|
||||
.update({ status: 'sent' });
|
||||
await newsletterService.cancel(campaign.id, adminId);
|
||||
|
||||
await expect(newsletterService.deleteCampaign(campaign.id, adminId))
|
||||
.rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
it('still deletes a cancelled campaign that reached nobody', async () => {
|
||||
await seedCustomer({ email: 'a@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
await newsletterService.cancel(campaign.id, adminId);
|
||||
|
||||
await expect(newsletterService.deleteCampaign(campaign.id, adminId))
|
||||
.resolves.toEqual({ deleted: true });
|
||||
});
|
||||
|
||||
it('refuses to cancel a draft', async () => {
|
||||
const campaign = await seedCampaign();
|
||||
await expect(newsletterService.cancel(campaign.id, adminId))
|
||||
.rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- processor bookkeeping --------------------------------------------
|
||||
|
||||
describe('recordRecipientResult / recomputeCounts', () => {
|
||||
it('moves the campaign to sending on the first result, then to sent', async () => {
|
||||
await seedCustomer({ email: 'a@example.com' });
|
||||
await seedCustomer({ email: 'b@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
|
||||
|
||||
await newsletterService.recordRecipientResult(rows[0], { status: 'sent' });
|
||||
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('sending');
|
||||
|
||||
await newsletterService.recordRecipientResult(rows[1], { status: 'sent' });
|
||||
const done = await newsletterService.getCampaign(campaign.id);
|
||||
expect(done.status).toBe('sent');
|
||||
expect(done.sent_count).toBe(2);
|
||||
expect(done.completed_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('counts a partial failure as a sent campaign, not a failed one', async () => {
|
||||
await seedCustomer({ email: 'a@example.com' });
|
||||
await seedCustomer({ email: 'b@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
|
||||
|
||||
await newsletterService.recordRecipientResult(rows[0], { status: 'sent' });
|
||||
await newsletterService.recordRecipientResult(rows[1], {
|
||||
status: 'failed', errorMessage: 'mailbox full',
|
||||
});
|
||||
|
||||
const done = await newsletterService.getCampaign(campaign.id);
|
||||
expect(done.status).toBe('sent');
|
||||
expect(done.sent_count).toBe(1);
|
||||
expect(done.failed_count).toBe(1);
|
||||
});
|
||||
|
||||
it('marks the campaign failed only when nothing got through', async () => {
|
||||
await seedCustomer({ email: 'a@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
|
||||
await newsletterService.recordRecipientResult(row, { status: 'failed', errorMessage: 'nope' });
|
||||
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('failed');
|
||||
});
|
||||
|
||||
it('does not double-count a repeated result', async () => {
|
||||
await seedCustomer({ email: 'a@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
|
||||
await newsletterService.recordRecipientResult(row, { status: 'sent' });
|
||||
await newsletterService.recordRecipientResult(row, { status: 'sent' });
|
||||
|
||||
expect((await newsletterService.getCampaign(campaign.id)).sent_count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- send-time opt-out re-check ---------------------------------------
|
||||
|
||||
describe('send-time opt-out', () => {
|
||||
it('skips a customer who unsubscribed after the campaign was queued', async () => {
|
||||
const customer = await seedCustomer({ email: 'a@example.com' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
|
||||
// The gap this closes: consent withdrawn between queue and send.
|
||||
await newsletterService.setMarketingOptOut(customer.id, true, 'link');
|
||||
expect(await newsletterService.shouldSkipForOptOut(customer.id)).toBe(true);
|
||||
|
||||
await newsletterService.markSkippedOptOut(row);
|
||||
|
||||
const recipient = await db('email_campaign_recipients')
|
||||
.where({ campaign_id: campaign.id }).first();
|
||||
expect(recipient.status).toBe('skipped_opt_out');
|
||||
expect((await db('email_queue').where({ id: row.id }).first()).status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('skips a customer deactivated after queueing', async () => {
|
||||
const customer = await seedCustomer();
|
||||
await db('customer_accounts').where({ id: customer.id }).update({ is_active: 0 });
|
||||
expect(await newsletterService.shouldSkipForOptOut(customer.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips when another account on the same address opted out', async () => {
|
||||
// Consent belongs to the address: a click by the twin has to stop this
|
||||
// mail too, or the person who unsubscribed still receives it.
|
||||
const queued = await seedCustomer({ email: 'shared@example.com', marketing_opt_out: 0 });
|
||||
await seedCustomer({ email: 'SHARED@example.com', marketing_opt_out: 1 });
|
||||
|
||||
expect(await newsletterService.shouldSkipForOptOut(queued.id, 'shared@example.com'))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it('does not skip an ordinary opted-in customer', async () => {
|
||||
const customer = await seedCustomer();
|
||||
expect(await newsletterService.shouldSkipForOptOut(customer.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- opt-out column ----------------------------------------------------
|
||||
|
||||
describe('setMarketingOptOut', () => {
|
||||
it('stamps a timestamp when opting out and clears it when opting back in', async () => {
|
||||
const customer = await seedCustomer();
|
||||
|
||||
await newsletterService.setMarketingOptOut(customer.id, true, 'link');
|
||||
let row = await db('customer_accounts').where({ id: customer.id }).first();
|
||||
expect(row.marketing_opt_out).toBeTruthy();
|
||||
expect(row.marketing_opt_out_at).toBeTruthy();
|
||||
|
||||
await newsletterService.setMarketingOptOut(customer.id, false, 'admin');
|
||||
row = await db('customer_accounts').where({ id: customer.id }).first();
|
||||
expect(row.marketing_opt_out).toBeFalsy();
|
||||
expect(row.marketing_opt_out_at).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a repeated opt-out and preserves the original timestamp', async () => {
|
||||
// #1285 review: link scanners, prefetchers and refreshes all re-hit an
|
||||
// unsubscribe URL. Rewriting the timestamp each time buries the moment
|
||||
// consent was actually withdrawn, and files a duplicate activity row.
|
||||
const customer = await seedCustomer();
|
||||
await newsletterService.setMarketingOptOut(customer.id, true, 'link');
|
||||
const first = await db('customer_accounts').where({ id: customer.id }).first();
|
||||
|
||||
const second = await newsletterService.setMarketingOptOut(customer.id, true, 'link');
|
||||
|
||||
expect(second).toBe(false);
|
||||
const after = await db('customer_accounts').where({ id: customer.id }).first();
|
||||
expect(after.marketing_opt_out_at).toBe(first.marketing_opt_out_at);
|
||||
|
||||
const logs = (await db('activity_logs')
|
||||
.where({ activity_type: 'customer_marketing_opt_out' }))
|
||||
.filter((row) => JSON.parse(row.metadata).customerId === customer.id);
|
||||
expect(logs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports no-op for an unknown customer', async () => {
|
||||
expect(await newsletterService.setMarketingOptOut(999999, true, 'link')).toBe(false);
|
||||
});
|
||||
|
||||
it('writes an activity log entry naming the source', async () => {
|
||||
const customer = await seedCustomer();
|
||||
await newsletterService.setMarketingOptOut(customer.id, true, 'portal');
|
||||
|
||||
const log = await db('activity_logs')
|
||||
.where({ activity_type: 'customer_marketing_opt_out' }).orderBy('id', 'desc').first();
|
||||
expect(JSON.parse(log.metadata).source).toBe('portal');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,284 +0,0 @@
|
||||
/**
|
||||
* OIDC logout-to-IdP integration tests (#798 phase 3).
|
||||
*
|
||||
* Same full-stack shape as oidcSso.test.js: real routes over a mock
|
||||
* in-process IdP, genuine discovery/JWKS/PKCE via openid-client. Pins:
|
||||
*
|
||||
* - the SSO callback stores the raw ID token in the oidc_id_token cookie
|
||||
* - /logout with that cookie + oidc_logout_from_idp=true returns the
|
||||
* IdP end-session URL (id_token_hint, post_logout_redirect_uri,
|
||||
* client_id) and clears the cookie
|
||||
* - feature off → no ssoLogoutUrl even for an SSO session
|
||||
* - no oidc_id_token cookie (local-password session) → no ssoLogoutUrl
|
||||
* even with the feature on — local sessions never bounce to the IdP
|
||||
* - IdP without an end_session_endpoint → no ssoLogoutUrl, logout still 200
|
||||
* - settings surface: GET exposes the flag + post_logout_redirect_uri,
|
||||
* PUT persists the flag
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
const { MockOidcProvider } = require('./helpers/mockOidcProvider');
|
||||
|
||||
describe('OIDC logout-to-IdP (#798 phase 3)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let idp;
|
||||
let oidcService;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-logout-test-secret';
|
||||
process.env.FRONTEND_URL = 'http://localhost:5199';
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
idp = new MockOidcProvider();
|
||||
const issuer = await idp.start();
|
||||
|
||||
oidcService = require('../../src/services/oidcService');
|
||||
await oidcService.saveOidcSettings({
|
||||
oidc_enabled: true,
|
||||
oidc_issuer_url: issuer,
|
||||
oidc_client_id: idp.clientId,
|
||||
oidc_client_secret: idp.clientSecret,
|
||||
oidc_autoprovision: true,
|
||||
oidc_default_role: 'viewer',
|
||||
oidc_logout_from_idp: true,
|
||||
});
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/auth', authRouter);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (idp) await idp.stop();
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
/** Drive login → IdP → callback like a browser; returns the callback response. */
|
||||
async function ssoRoundTrip() {
|
||||
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
|
||||
const stateCookie = (loginRes.headers['set-cookie'] || [])
|
||||
.find((c) => c.startsWith('oidc_state='))
|
||||
.split(';')[0];
|
||||
|
||||
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
|
||||
expect(idpRes.status).toBe(302);
|
||||
const back = new URL(idpRes.headers.get('location'));
|
||||
|
||||
return request(app)
|
||||
.get(`${back.pathname}?${back.searchParams.toString()}`)
|
||||
.set('Cookie', stateCookie)
|
||||
.expect(302);
|
||||
}
|
||||
|
||||
/**
|
||||
* The oidc_id_token cookie pair ("oidc_id_token=<jwt>") from a callback
|
||||
* response. The callback carries TWO Set-Cookie headers for this name —
|
||||
* establishAdminSession clears any stale marker, then the callback sets
|
||||
* the fresh one — and browsers apply them in order, so the LAST wins.
|
||||
*/
|
||||
function idTokenCookie(res) {
|
||||
const cookies = (res.headers['set-cookie'] || []).filter((c) => c.startsWith('oidc_id_token='));
|
||||
const last = cookies[cookies.length - 1];
|
||||
return last ? last.split(';')[0] : null;
|
||||
}
|
||||
|
||||
it('stores the raw ID token in the oidc_id_token cookie on SSO login', async () => {
|
||||
idp.setNextUser({ sub: 'logout-sub-1', email: 'logout@example.com', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
|
||||
const cookie = idTokenCookie(res);
|
||||
expect(cookie).toBeTruthy();
|
||||
// Raw JWT, HttpOnly, scoped to /api/auth.
|
||||
const raw = decodeURIComponent(cookie.replace('oidc_id_token=', ''));
|
||||
expect(raw.split('.')).toHaveLength(3);
|
||||
const setCookies = (res.headers['set-cookie'] || []).filter((c) => c.startsWith('oidc_id_token='));
|
||||
const full = setCookies[setCookies.length - 1];
|
||||
expect(full).toMatch(/HttpOnly/i);
|
||||
expect(full).toMatch(/Path=\/api\/auth/i);
|
||||
});
|
||||
|
||||
it('returns the IdP end-session URL on logout and clears the cookie', async () => {
|
||||
idp.setNextUser({ sub: 'logout-sub-2', email: 'logout2@example.com', email_verified: true });
|
||||
const cbRes = await ssoRoundTrip();
|
||||
const cookie = idTokenCookie(cbRes);
|
||||
const rawIdToken = decodeURIComponent(cookie.replace('oidc_id_token=', ''));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.ssoLogoutUrl).toBeTruthy();
|
||||
const url = new URL(res.body.ssoLogoutUrl);
|
||||
expect(url.href.startsWith(`${idp.issuer}/logout`)).toBe(true);
|
||||
expect(url.searchParams.get('id_token_hint')).toBe(rawIdToken);
|
||||
expect(url.searchParams.get('post_logout_redirect_uri')).toBe('http://localhost:5199/admin/login');
|
||||
expect(url.searchParams.get('client_id')).toBe(idp.clientId);
|
||||
|
||||
// Cookie must be cleared so a later local-password logout in the same
|
||||
// browser doesn't bounce to the IdP again.
|
||||
const cleared = (res.headers['set-cookie'] || []).find((c) => c.startsWith('oidc_id_token='));
|
||||
expect(cleared).toBeTruthy();
|
||||
expect(cleared).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i);
|
||||
});
|
||||
|
||||
it('omits ssoLogoutUrl when the feature is disabled', async () => {
|
||||
idp.setNextUser({ sub: 'logout-sub-3', email: 'logout3@example.com', email_verified: true });
|
||||
const cbRes = await ssoRoundTrip();
|
||||
const cookie = idTokenCookie(cbRes);
|
||||
|
||||
await oidcService.saveOidcSettings({ oidc_logout_from_idp: false });
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
expect(res.body.ssoLogoutUrl).toBeUndefined();
|
||||
} finally {
|
||||
await oidcService.saveOidcSettings({ oidc_logout_from_idp: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('omits ssoLogoutUrl without an oidc_id_token cookie (local-password session)', async () => {
|
||||
const res = await request(app).post('/api/auth/logout').expect(200);
|
||||
expect(res.body.ssoLogoutUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits ssoLogoutUrl when the IdP advertises no end_session_endpoint', async () => {
|
||||
// Separate provider whose discovery document lacks end_session_endpoint;
|
||||
// repointing the settings invalidates the discovery cache.
|
||||
const bareIdp = new MockOidcProvider();
|
||||
bareIdp.advertiseEndSession = false;
|
||||
const bareIssuer = await bareIdp.start();
|
||||
try {
|
||||
await oidcService.saveOidcSettings({
|
||||
oidc_issuer_url: bareIssuer,
|
||||
oidc_client_id: bareIdp.clientId,
|
||||
oidc_client_secret: bareIdp.clientSecret,
|
||||
});
|
||||
|
||||
bareIdp.setNextUser({ sub: 'logout-sub-4', email: 'logout4@example.com', email_verified: true });
|
||||
const cbRes = await ssoRoundTrip();
|
||||
const cookie = idTokenCookie(cbRes);
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
expect(res.body.ssoLogoutUrl).toBeUndefined();
|
||||
} finally {
|
||||
await bareIdp.stop();
|
||||
await oidcService.saveOidcSettings({
|
||||
oidc_issuer_url: idp.issuer,
|
||||
oidc_client_id: idp.clientId,
|
||||
oidc_client_secret: idp.clientSecret,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('stores an issuer-tagged marker for oversized ID tokens; logout still round-trips, without a hint', async () => {
|
||||
idp.setNextUser({
|
||||
sub: 'logout-sub-5',
|
||||
email: 'logout5@example.com',
|
||||
email_verified: true,
|
||||
// ~9KB of group claims — far past the 4KB cookie limit.
|
||||
groups: Array.from({ length: 300 }, (_, i) => `group-${String(i).padStart(4, '0')}-xxxxxxxxxxxxxxxx`),
|
||||
});
|
||||
const cbRes = await ssoRoundTrip();
|
||||
const cookie = idTokenCookie(cbRes);
|
||||
expect(cookie).toBeTruthy();
|
||||
// Issuer-tagged marker, not the (oversized) token itself.
|
||||
const marker = decodeURIComponent(cookie.replace('oidc_id_token=', ''));
|
||||
expect(marker.startsWith('sso.')).toBe(true);
|
||||
expect(Buffer.from(marker.split('.')[1], 'base64url').toString('utf8')).toBe(idp.issuer);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
expect(res.body.ssoLogoutUrl).toBeTruthy();
|
||||
const url = new URL(res.body.ssoLogoutUrl);
|
||||
expect(url.searchParams.get('id_token_hint')).toBeNull();
|
||||
expect(url.searchParams.get('client_id')).toBe(idp.clientId);
|
||||
});
|
||||
|
||||
it('skips the round-trip for an oversized-token marker from a DIFFERENT issuer', async () => {
|
||||
const foreignMarker = `sso.${Buffer.from('http://other-idp.example').toString('base64url')}`;
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', `oidc_id_token=${foreignMarker}`)
|
||||
.expect(200);
|
||||
expect(res.body.ssoLogoutUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a fresh local-password login clears a stale SSO marker', async () => {
|
||||
const role = await db('roles').where({ name: 'admin' }).first();
|
||||
await db('admin_users').insert({
|
||||
username: 'stale-marker-admin',
|
||||
email: 'stale-marker@example.com',
|
||||
password_hash: await bcrypt.hash('StaleMarker123!', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
must_change_password: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Stale marker from a dead SSO session rides along on the login request.
|
||||
const res = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.set('Cookie', 'oidc_id_token=stale.jwt.value')
|
||||
.send({ username: 'stale-marker-admin', password: 'StaleMarker123!' })
|
||||
.expect(200);
|
||||
|
||||
const cleared = (res.headers['set-cookie'] || []).find((c) => c.startsWith('oidc_id_token='));
|
||||
expect(cleared).toBeTruthy();
|
||||
expect(cleared).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i);
|
||||
});
|
||||
|
||||
it('skips the round-trip when the stored hint was issued by a DIFFERENT issuer (config changed)', async () => {
|
||||
// Fake-but-well-formed JWT from another IdP — payload is all that matters,
|
||||
// buildEndSessionUrl decodes without verification for routing only.
|
||||
const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
const foreignToken = `${b64({ alg: 'none' })}.${b64({ iss: 'http://other-idp.example', aud: idp.clientId })}.sig`;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', `oidc_id_token=${foreignToken}`)
|
||||
.expect(200);
|
||||
expect(res.body.ssoLogoutUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops only the hint when the issuer matches but the client changed', async () => {
|
||||
const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
const oldClientToken = `${b64({ alg: 'none' })}.${b64({ iss: idp.issuer, aud: 'previous-client-id' })}.sig`;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', `oidc_id_token=${oldClientToken}`)
|
||||
.expect(200);
|
||||
expect(res.body.ssoLogoutUrl).toBeTruthy();
|
||||
const url = new URL(res.body.ssoLogoutUrl);
|
||||
expect(url.searchParams.get('id_token_hint')).toBeNull();
|
||||
expect(url.searchParams.get('client_id')).toBe(idp.clientId);
|
||||
});
|
||||
|
||||
it('exposes the flag and post_logout_redirect_uri via getOidcConfig/getPostLogoutRedirectUri', async () => {
|
||||
// Settings-route auth chains are covered in oidcSso.test.js; here the
|
||||
// service surface the routes read from is pinned directly.
|
||||
const cfg = await oidcService.getOidcConfig();
|
||||
expect(cfg.logoutFromIdp).toBe(true);
|
||||
expect(await oidcService.getPostLogoutRedirectUri()).toBe('http://localhost:5199/admin/login');
|
||||
});
|
||||
});
|
||||
@@ -1,416 +0,0 @@
|
||||
/**
|
||||
* OIDC role mapping + login policy integration tests (#798, phase 2).
|
||||
*
|
||||
* Same harness as oidcSso.test.js: supertest over the real routes, mock
|
||||
* in-process IdP with genuine RS256/PKCE validation, fresh-SQLite DB. Pins:
|
||||
*
|
||||
* - JIT provisioning takes the MAPPED role from a nested dot-path claim
|
||||
* (Keycloak's realm_access.roles), not the static default
|
||||
* - roles are re-evaluated on every SSO login (upgrade AND downgrade)
|
||||
* - several mapped roles → the highest-priority one wins
|
||||
* - non-strict: unmapped login keeps the current role / default at JIT
|
||||
* - strict (require_mapped_role): unmapped login → sso_error=no_role
|
||||
* - the last active super_admin is never demoted by mapping
|
||||
* - space-separated string claim values work (flat `roles` claim)
|
||||
* - disable_local_login: password login → 403; OIDC_BREAK_GLASS=true
|
||||
* re-opens it; flag is inert while SSO is disabled
|
||||
* - PUT /sso validation: unknown mapping target and
|
||||
* disable-local-login-without-SSO are rejected
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
const { MockOidcProvider } = require('./helpers/mockOidcProvider');
|
||||
|
||||
describe('OIDC role mapping + login policy (#798 phase 2)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let idp;
|
||||
let oidcService;
|
||||
let superAdminToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-test-secret';
|
||||
process.env.FRONTEND_URL = 'http://localhost:5199';
|
||||
delete process.env.OIDC_BREAK_GLASS;
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
idp = new MockOidcProvider();
|
||||
const issuer = await idp.start();
|
||||
|
||||
oidcService = require('../../src/services/oidcService');
|
||||
await oidcService.saveOidcSettings({
|
||||
oidc_enabled: true,
|
||||
oidc_issuer_url: issuer,
|
||||
oidc_client_id: idp.clientId,
|
||||
oidc_client_secret: idp.clientSecret,
|
||||
oidc_autoprovision: true,
|
||||
oidc_default_role: 'viewer',
|
||||
oidc_role_mapping_enabled: true,
|
||||
oidc_roles_claim: 'realm_access.roles',
|
||||
oidc_role_mappings: {
|
||||
'pp-super': 'super_admin',
|
||||
'pp-admins': 'admin',
|
||||
'pp-view': 'viewer',
|
||||
},
|
||||
});
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
const adminSettingsRouter = require('../../src/routes/adminSettings');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/admin/settings', adminSettingsRouter);
|
||||
|
||||
// A real super_admin row + token for the settings-validation tests.
|
||||
const superRole = await db('roles').where({ name: 'super_admin' }).first();
|
||||
const [rootId] = await db('admin_users').insert({
|
||||
username: 'root-admin',
|
||||
email: 'root@example.com',
|
||||
password_hash: await bcrypt.hash('RootPass123', 4),
|
||||
role_id: superRole.id,
|
||||
is_active: 1,
|
||||
auth_provider: 'local',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
||||
superAdminToken = jwt.sign(
|
||||
{ id: rootId, username: 'root-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.OIDC_BREAK_GLASS;
|
||||
if (idp) await idp.stop();
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
/** Drive login → IdP → callback like a browser; returns the callback response. */
|
||||
async function ssoRoundTrip() {
|
||||
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
|
||||
const stateCookie = (loginRes.headers['set-cookie'] || [])
|
||||
.find((c) => c.startsWith('oidc_state=')).split(';')[0];
|
||||
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
|
||||
expect(idpRes.status).toBe(302);
|
||||
const back = new URL(idpRes.headers.get('location'));
|
||||
return request(app)
|
||||
.get(`${back.pathname}?${back.searchParams.toString()}`)
|
||||
.set('Cookie', stateCookie)
|
||||
.expect(302);
|
||||
}
|
||||
|
||||
async function roleOf(email) {
|
||||
const row = await db('admin_users').where({ email }).first();
|
||||
const role = await db('roles').where({ id: row.role_id }).first();
|
||||
return role.name;
|
||||
}
|
||||
|
||||
it('JIT-provisions with the role mapped from the nested dot-path claim', async () => {
|
||||
idp.setNextUser({
|
||||
sub: 'sub-map-1',
|
||||
email: 'mapped@example.com',
|
||||
email_verified: true,
|
||||
realm_access: { roles: ['irrelevant', 'pp-admins'] },
|
||||
});
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
expect(await roleOf('mapped@example.com')).toBe('admin');
|
||||
});
|
||||
|
||||
it('re-evaluates the role on every login — downgrade lands', async () => {
|
||||
idp.setNextUser({
|
||||
sub: 'sub-map-1',
|
||||
email: 'mapped@example.com',
|
||||
email_verified: true,
|
||||
realm_access: { roles: ['pp-view'] },
|
||||
});
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
expect(await roleOf('mapped@example.com')).toBe('viewer');
|
||||
});
|
||||
|
||||
it('re-evaluates the role on every login — upgrade lands and the session JWT carries it', async () => {
|
||||
idp.setNextUser({
|
||||
sub: 'sub-map-1',
|
||||
email: 'mapped@example.com',
|
||||
email_verified: true,
|
||||
realm_access: { roles: ['pp-admins'] },
|
||||
});
|
||||
const res = await ssoRoundTrip();
|
||||
expect(await roleOf('mapped@example.com')).toBe('admin');
|
||||
|
||||
// The freshly-minted session token must already carry the NEW role —
|
||||
// the sync happens before session establishment.
|
||||
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
|
||||
const token = decodeURIComponent(adminCookie.split(';')[0].replace('admin_token=', ''));
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
expect(decoded.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('picks the highest-priority role when several IdP values map', async () => {
|
||||
idp.setNextUser({
|
||||
sub: 'sub-multi',
|
||||
email: 'multi@example.com',
|
||||
email_verified: true,
|
||||
realm_access: { roles: ['pp-view', 'pp-admins'] },
|
||||
});
|
||||
await ssoRoundTrip();
|
||||
expect(await roleOf('multi@example.com')).toBe('admin');
|
||||
});
|
||||
|
||||
it('non-strict: an unmapped login keeps the current role / gets the default at JIT', async () => {
|
||||
// Existing admin keeps its role.
|
||||
idp.setNextUser({
|
||||
sub: 'sub-map-1',
|
||||
email: 'mapped@example.com',
|
||||
email_verified: true,
|
||||
realm_access: { roles: ['nothing-mapped'] },
|
||||
});
|
||||
let res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
expect(await roleOf('mapped@example.com')).toBe('admin');
|
||||
|
||||
// JIT falls back to the configured default role.
|
||||
idp.setNextUser({
|
||||
sub: 'sub-unmapped-jit',
|
||||
email: 'unmapped@example.com',
|
||||
email_verified: true,
|
||||
realm_access: { roles: ['nothing-mapped'] },
|
||||
});
|
||||
res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
expect(await roleOf('unmapped@example.com')).toBe('viewer');
|
||||
});
|
||||
|
||||
it('strict mode refuses unmapped logins with sso_error=no_role and no session', async () => {
|
||||
await oidcService.saveOidcSettings({ oidc_require_mapped_role: true });
|
||||
idp.setNextUser({
|
||||
sub: 'sub-map-1',
|
||||
email: 'mapped@example.com',
|
||||
email_verified: true,
|
||||
realm_access: { roles: ['nothing-mapped'] },
|
||||
});
|
||||
const res = await ssoRoundTrip();
|
||||
await oidcService.saveOidcSettings({ oidc_require_mapped_role: false });
|
||||
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=no_role');
|
||||
expect((res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='))).toBeFalsy();
|
||||
// Role untouched by the refused attempt.
|
||||
expect(await roleOf('mapped@example.com')).toBe('admin');
|
||||
});
|
||||
|
||||
it('never demotes the last active super_admin', async () => {
|
||||
// Make the SSO admin the ONLY active super_admin.
|
||||
const superRole = await db('roles').where({ name: 'super_admin' }).first();
|
||||
const ssoAdmin = await db('admin_users').where({ email: 'mapped@example.com' }).first();
|
||||
await db('admin_users').where({ role_id: superRole.id }).update({ is_active: 0 });
|
||||
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id, is_active: 1 });
|
||||
|
||||
idp.setNextUser({
|
||||
sub: 'sub-map-1',
|
||||
email: 'mapped@example.com',
|
||||
email_verified: true,
|
||||
realm_access: { roles: ['pp-view'] },
|
||||
});
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
// Still super_admin — the demotion was refused, the login was not.
|
||||
expect(await roleOf('mapped@example.com')).toBe('super_admin');
|
||||
|
||||
// Restore: root admin back to active super_admin, SSO admin back to admin.
|
||||
const adminRole = await db('roles').where({ name: 'admin' }).first();
|
||||
await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 1 });
|
||||
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: adminRole.id });
|
||||
|
||||
// With ANOTHER active super_admin present the same downgrade goes through.
|
||||
idp.setNextUser({
|
||||
sub: 'sub-map-1',
|
||||
email: 'mapped@example.com',
|
||||
email_verified: true,
|
||||
realm_access: { roles: ['pp-view'] },
|
||||
});
|
||||
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id });
|
||||
await ssoRoundTrip();
|
||||
expect(await roleOf('mapped@example.com')).toBe('viewer');
|
||||
});
|
||||
|
||||
it('never demotes the last LOCAL-password super_admin even when an OIDC-owned super exists', async () => {
|
||||
const superRole = await db('roles').where({ name: 'super_admin' }).first();
|
||||
const viewerRole = await db('roles').where({ name: 'viewer' }).first();
|
||||
|
||||
// A local-password super admin, SSO-linked via verified email so role
|
||||
// sync applies to it.
|
||||
const [localId] = await db('admin_users').insert({
|
||||
username: 'local-super',
|
||||
email: 'local-super@example.com',
|
||||
password_hash: await bcrypt.hash('LocalSuper123', 4),
|
||||
role_id: superRole.id,
|
||||
is_active: 1,
|
||||
auth_provider: 'local',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
||||
|
||||
// The only OTHER active super is OIDC-owned (root goes inactive) — the
|
||||
// plain last-super guard would allow the demotion, the break-glass
|
||||
// guard must not.
|
||||
const ssoAdmin = await db('admin_users').where({ email: 'mapped@example.com' }).first();
|
||||
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id });
|
||||
await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 0 });
|
||||
|
||||
idp.setNextUser({
|
||||
sub: 'sub-local-super',
|
||||
email: 'local-super@example.com',
|
||||
email_verified: true,
|
||||
realm_access: { roles: ['pp-view'] },
|
||||
});
|
||||
const res = await ssoRoundTrip();
|
||||
|
||||
const row = await db('admin_users').where({ id: localId }).first();
|
||||
// Restore the fixture state before asserting.
|
||||
await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 1 });
|
||||
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: viewerRole.id });
|
||||
await db('admin_users').where({ id: localId }).update({ is_active: 0 });
|
||||
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
expect(row.role_id).toBe(superRole.id); // kept — it is the break-glass account
|
||||
});
|
||||
|
||||
it('treats prototype-property IdP values (constructor/toString) as unmapped, not as an error', async () => {
|
||||
idp.setNextUser({
|
||||
sub: 'sub-proto',
|
||||
email: 'proto@example.com',
|
||||
email_verified: true,
|
||||
realm_access: { roles: ['constructor', 'toString', '__proto__'] },
|
||||
});
|
||||
const res = await ssoRoundTrip();
|
||||
// Non-strict: unmapped → JIT with the default role, login succeeds.
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
expect(await roleOf('proto@example.com')).toBe('viewer');
|
||||
});
|
||||
|
||||
it('accepts a space-separated string value on a flat claim', async () => {
|
||||
await oidcService.saveOidcSettings({ oidc_roles_claim: 'roles' });
|
||||
idp.setNextUser({
|
||||
sub: 'sub-flat',
|
||||
email: 'flat@example.com',
|
||||
email_verified: true,
|
||||
roles: 'other pp-admins',
|
||||
});
|
||||
const res = await ssoRoundTrip();
|
||||
await oidcService.saveOidcSettings({ oidc_roles_claim: 'realm_access.roles' });
|
||||
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
expect(await roleOf('flat@example.com')).toBe('admin');
|
||||
});
|
||||
|
||||
it('refuses local password login while disable_local_login is effective', async () => {
|
||||
await oidcService.saveOidcSettings({ oidc_disable_local_login: true });
|
||||
const res = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: 'root@example.com', password: 'RootPass123' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('LOCAL_LOGIN_DISABLED');
|
||||
});
|
||||
|
||||
it('OIDC_BREAK_GLASS=true re-opens local login despite the policy', async () => {
|
||||
process.env.OIDC_BREAK_GLASS = 'true';
|
||||
const res = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: 'root@example.com', password: 'RootPass123' });
|
||||
delete process.env.OIDC_BREAK_GLASS;
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeTruthy();
|
||||
});
|
||||
|
||||
it('the stored flag is inert while SSO is disabled', async () => {
|
||||
// Simulate a torn-down SSO config with the stale flag still set — the
|
||||
// runtime check must ignore it (no lockout).
|
||||
await db('app_settings').where({ setting_key: 'oidc_enabled' })
|
||||
.update({ setting_value: JSON.stringify(false) });
|
||||
expect(await oidcService.isLocalLoginDisabled()).toBe(false);
|
||||
await db('app_settings').where({ setting_key: 'oidc_enabled' })
|
||||
.update({ setting_value: JSON.stringify(true) });
|
||||
expect(await oidcService.isLocalLoginDisabled()).toBe(true);
|
||||
await oidcService.saveOidcSettings({ oidc_disable_local_login: false });
|
||||
});
|
||||
|
||||
it('the policy disarms itself when no active local-password super admin remains', async () => {
|
||||
await oidcService.saveOidcSettings({ oidc_disable_local_login: true });
|
||||
expect(await oidcService.isLocalLoginDisabled()).toBe(true);
|
||||
// The break-glass account disappears (e.g. manual demotion/deactivation
|
||||
// while the policy is on) → local login must re-open by itself.
|
||||
await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'oidc' });
|
||||
expect(await oidcService.isLocalLoginDisabled()).toBe(false);
|
||||
await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'local' });
|
||||
await oidcService.saveOidcSettings({ oidc_disable_local_login: false });
|
||||
});
|
||||
|
||||
it('PUT /sso rejects a mapping onto an unknown role', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/settings/sso')
|
||||
.set('Authorization', `Bearer ${superAdminToken}`)
|
||||
.send({ oidc_role_mappings: { 'pp-admins': 'does_not_exist' } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/does_not_exist/);
|
||||
// Stored mapping unchanged.
|
||||
const cfg = await oidcService.getOidcConfig();
|
||||
expect(cfg.roleMappings['pp-admins']).toBe('admin');
|
||||
});
|
||||
|
||||
it('PUT /sso rejects disabling local login while SSO is (being turned) off', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/settings/sso')
|
||||
.set('Authorization', `Bearer ${superAdminToken}`)
|
||||
.send({ oidc_enabled: false, oidc_disable_local_login: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/while SSO is enabled/);
|
||||
});
|
||||
|
||||
it('PUT /sso refuses SSO-only mode without an active local-password super admin', async () => {
|
||||
// Make every active super_admin OIDC-owned — break-glass would then
|
||||
// re-open a password route that no account can use.
|
||||
const superRole = await db('roles').where({ name: 'super_admin' }).first();
|
||||
await db('admin_users').where({ role_id: superRole.id }).update({ auth_provider: 'oidc' });
|
||||
const denied = await request(app)
|
||||
.put('/api/admin/settings/sso')
|
||||
.set('Authorization', `Bearer ${superAdminToken}`)
|
||||
.send({ oidc_disable_local_login: true });
|
||||
// Restore the local break-glass account, then the same request passes.
|
||||
await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'local' });
|
||||
expect(denied.status).toBe(400);
|
||||
expect(denied.body.error).toMatch(/break-glass/);
|
||||
|
||||
const allowed = await request(app)
|
||||
.put('/api/admin/settings/sso')
|
||||
.set('Authorization', `Bearer ${superAdminToken}`)
|
||||
.send({ oidc_disable_local_login: true });
|
||||
expect(allowed.status).toBe(200);
|
||||
await oidcService.saveOidcSettings({ oidc_disable_local_login: false });
|
||||
});
|
||||
|
||||
it('GET /sso returns the phase-2 fields', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/settings/sso')
|
||||
.set('Authorization', `Bearer ${superAdminToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.oidc_role_mapping_enabled).toBe(true);
|
||||
expect(res.body.oidc_roles_claim).toBe('realm_access.roles');
|
||||
expect(res.body.oidc_role_mappings).toEqual({
|
||||
'pp-super': 'super_admin',
|
||||
'pp-admins': 'admin',
|
||||
'pp-view': 'viewer',
|
||||
});
|
||||
expect(res.body.oidc_require_mapped_role).toBe(false);
|
||||
expect(res.body.oidc_disable_local_login).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* OIDC SSO integration tests (#798, phase 1).
|
||||
*
|
||||
* Full-stack over a mock in-process IdP (mockOidcProvider): supertest drives
|
||||
* the real /admin/sso/login and /admin/sso/callback routes on a fresh-SQLite
|
||||
* database, openid-client does genuine discovery/JWKS/PKCE/ID-token
|
||||
* validation against the mock issuer. Pins:
|
||||
*
|
||||
* - happy path: JIT provisioning creates an admin and sets the session cookie
|
||||
* - JIT off → not_provisioned redirect, no row created
|
||||
* - repeat login matches by sub, not email (email change ≠ new account)
|
||||
* - verified-email one-time link onto an existing local admin
|
||||
* - unverified email must NOT link (falls through to JIT/or error)
|
||||
* - deactivated admin → inactive redirect
|
||||
* - missing/forged state cookie → state redirect
|
||||
* - nonce tamper from the IdP → idp redirect
|
||||
* - settings endpoints: secret write-only, generic /general upsert cannot
|
||||
* clobber oidc_client_secret
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
const { MockOidcProvider } = require('./helpers/mockOidcProvider');
|
||||
|
||||
describe('OIDC SSO (#798)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let idp;
|
||||
let oidcService;
|
||||
|
||||
const agentCookies = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-test-secret';
|
||||
// The redirect_uri derives from the public base URL — pin it explicitly:
|
||||
// CI has no backend/.env, and getFrontendBaseUrl() returning '' makes
|
||||
// buildAuthorizationRequest fail (by design) with OIDC_BAD_CONFIG.
|
||||
process.env.FRONTEND_URL = 'http://localhost:5199';
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
idp = new MockOidcProvider();
|
||||
const issuer = await idp.start();
|
||||
|
||||
// Require AFTER bootCrmDb so services share this db instance.
|
||||
oidcService = require('../../src/services/oidcService');
|
||||
await oidcService.saveOidcSettings({
|
||||
oidc_enabled: true,
|
||||
oidc_issuer_url: issuer,
|
||||
oidc_client_id: idp.clientId,
|
||||
oidc_client_secret: idp.clientSecret,
|
||||
oidc_autoprovision: true,
|
||||
oidc_default_role: 'viewer',
|
||||
});
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/auth', authRouter);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (idp) await idp.stop();
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
/** Drive login → IdP → callback like a browser; returns the callback response. */
|
||||
async function ssoRoundTrip({ mutateState } = {}) {
|
||||
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
|
||||
const idpUrl = loginRes.headers.location;
|
||||
expect(idpUrl.startsWith(idp.issuer)).toBe(true);
|
||||
|
||||
let stateCookie = (loginRes.headers['set-cookie'] || [])
|
||||
.find((c) => c.startsWith('oidc_state='));
|
||||
expect(stateCookie).toBeTruthy();
|
||||
stateCookie = stateCookie.split(';')[0];
|
||||
if (mutateState === 'drop') stateCookie = null;
|
||||
if (mutateState === 'forge') {
|
||||
stateCookie = `oidc_state=${jwt.sign({ type: 'oidc_state', s: 'x', n: 'y', cv: 'z' }, 'wrong-secret', { issuer: 'picpeak-auth' })}`;
|
||||
}
|
||||
|
||||
// "Browser" follows the redirect to the IdP, which instantly bounces back.
|
||||
const idpRes = await fetch(idpUrl, { redirect: 'manual' });
|
||||
expect(idpRes.status).toBe(302);
|
||||
const back = new URL(idpRes.headers.get('location'));
|
||||
|
||||
let cb = request(app).get(`${back.pathname}?${back.searchParams.toString()}`);
|
||||
if (stateCookie) cb = cb.set('Cookie', stateCookie);
|
||||
return cb.expect(302);
|
||||
}
|
||||
|
||||
it('JIT-provisions an unknown user and establishes an admin session', async () => {
|
||||
idp.setNextUser({ sub: 'sub-jit-1', email: 'jit@example.com', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
|
||||
expect(adminCookie).toBeTruthy();
|
||||
|
||||
const row = await db('admin_users').where({ email: 'jit@example.com' }).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.auth_provider).toBe('oidc');
|
||||
expect(row.external_subject).toBe('sub-jit-1');
|
||||
|
||||
const role = await db('roles').where('id', row.role_id).first();
|
||||
expect(role.name).toBe('viewer');
|
||||
|
||||
// The session JWT must be a normal admin token.
|
||||
const token = adminCookie.split(';')[0].replace('admin_token=', '');
|
||||
const decoded = jwt.verify(decodeURIComponent(token), process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
expect(decoded.type).toBe('admin');
|
||||
expect(decoded.id).toBe(row.id);
|
||||
agentCookies.jitAdminId = row.id;
|
||||
});
|
||||
|
||||
it('matches repeat logins by sub even when the email changed at the IdP', async () => {
|
||||
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
|
||||
// No second row — resolved via external_subject.
|
||||
expect(await db('admin_users').where({ email: 'renamed@example.com' }).first()).toBeFalsy();
|
||||
const byId = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
|
||||
expect(byId.external_subject).toBe('sub-jit-1');
|
||||
});
|
||||
|
||||
it('links an existing local admin one-time via VERIFIED email and stamps the sub', async () => {
|
||||
const role = await db('roles').where({ name: 'admin' }).first();
|
||||
const [localId] = await db('admin_users').insert({
|
||||
username: 'local-admin',
|
||||
email: 'local@example.com',
|
||||
password_hash: await bcrypt.hash('LocalPass123', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
auth_provider: 'local',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
||||
|
||||
idp.setNextUser({ sub: 'sub-local-1', email: 'local@example.com', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
|
||||
const row = await db('admin_users').where({ id: localId }).first();
|
||||
expect(row.external_subject).toBe('sub-local-1');
|
||||
expect(row.auth_provider).toBe('local'); // password keeps working
|
||||
});
|
||||
|
||||
it('does NOT link by unverified email — provisions a separate account instead', async () => {
|
||||
const role = await db('roles').where({ name: 'admin' }).first();
|
||||
await db('admin_users').insert({
|
||||
username: 'victim-admin',
|
||||
email: 'victim@example.com',
|
||||
password_hash: await bcrypt.hash('VictimPass123', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
auth_provider: 'local',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
idp.setNextUser({ sub: 'sub-attacker', email: 'victim@example.com', email_verified: false });
|
||||
// JIT would need this email but the victim row owns it (unique) — the
|
||||
// insert fails and the flow must land on an error, never on the
|
||||
// victim's session.
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toMatch(/sso_error=/);
|
||||
|
||||
const victim = await db('admin_users').where({ email: 'victim@example.com' }).first();
|
||||
expect(victim.external_subject).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a deactivated admin with sso_error=inactive', async () => {
|
||||
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 0 });
|
||||
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=inactive');
|
||||
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 1 });
|
||||
});
|
||||
|
||||
it('rejects a callback without the state cookie', async () => {
|
||||
const res = await ssoRoundTrip({ mutateState: 'drop' });
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
|
||||
});
|
||||
|
||||
it('rejects a forged state cookie (wrong signing key)', async () => {
|
||||
const res = await ssoRoundTrip({ mutateState: 'forge' });
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
|
||||
});
|
||||
|
||||
it('rejects an ID token whose nonce does not match', async () => {
|
||||
idp.tamperNonce = true;
|
||||
idp.setNextUser({ sub: 'sub-nonce', email: 'nonce@example.com', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
idp.tamperNonce = false;
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=idp');
|
||||
expect(await db('admin_users').where({ email: 'nonce@example.com' }).first()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('blocks JIT with sso_error=not_provisioned when autoprovision is off', async () => {
|
||||
await oidcService.saveOidcSettings({ oidc_autoprovision: false });
|
||||
idp.setNextUser({ sub: 'sub-new-user', email: 'new@example.com', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=not_provisioned');
|
||||
expect(await db('admin_users').where({ email: 'new@example.com' }).first()).toBeFalsy();
|
||||
await oidcService.saveOidcSettings({ oidc_autoprovision: true });
|
||||
});
|
||||
|
||||
it('stores the client secret encrypted and survives a config round-trip', async () => {
|
||||
const row = await db('app_settings').where({ setting_key: 'oidc_client_secret' }).first();
|
||||
const stored = JSON.parse(row.setting_value);
|
||||
expect(stored).not.toContain(idp.clientSecret);
|
||||
expect(oidcService.decryptSecret(stored)).toBe(idp.clientSecret);
|
||||
|
||||
const cfg = await oidcService.getOidcConfig();
|
||||
expect(cfg.clientSecret).toBe(idp.clientSecret);
|
||||
});
|
||||
|
||||
it('refuses local password login for OIDC-owned accounts', async () => {
|
||||
// Give the JIT admin a KNOWN password hash directly in the DB — the
|
||||
// auth_provider check must reject the login even with valid credentials
|
||||
// (otherwise a password reset would mint an IdP-bypassing local login).
|
||||
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({
|
||||
password_hash: await bcrypt.hash('KnownPass123', 4),
|
||||
});
|
||||
const row = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: row.email, password: 'KnownPass123' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 404 from /sso/login when SSO is disabled', async () => {
|
||||
await oidcService.saveOidcSettings({ oidc_enabled: false });
|
||||
await request(app).get('/api/auth/admin/sso/login').expect(404);
|
||||
await oidcService.saveOidcSettings({ oidc_enabled: true });
|
||||
});
|
||||
|
||||
it('merges email from the UserInfo endpoint when the ID token omits it', async () => {
|
||||
idp.emailViaUserinfoOnly = true;
|
||||
idp.setNextUser({ sub: 'sub-userinfo', email: 'userinfo@example.com', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
idp.emailViaUserinfoOnly = false;
|
||||
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
const row = await db('admin_users').where({ email: 'userinfo@example.com' }).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.external_subject).toBe('sub-userinfo');
|
||||
});
|
||||
|
||||
it('binds identities per ISSUER — a sub collision on a new IdP must not inherit the old account', async () => {
|
||||
// The JIT admin from the first test is bound to (issuer A, 'sub-jit-1').
|
||||
const boundAdmin = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
|
||||
expect(boundAdmin.external_issuer).toBe(idp.issuer);
|
||||
|
||||
// Same sub, DIFFERENT issuer: a second IdP the instance switches to.
|
||||
const idp2 = new MockOidcProvider();
|
||||
await idp2.start();
|
||||
try {
|
||||
await oidcService.saveOidcSettings({
|
||||
oidc_issuer_url: idp2.issuer,
|
||||
oidc_client_id: idp2.clientId,
|
||||
oidc_client_secret: idp2.clientSecret,
|
||||
});
|
||||
idp2.setNextUser({ sub: 'sub-jit-1', email: 'colliding@example.com', email_verified: true });
|
||||
|
||||
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
|
||||
const stateCookie = (loginRes.headers['set-cookie'] || [])
|
||||
.find((c) => c.startsWith('oidc_state=')).split(';')[0];
|
||||
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
|
||||
const back = new URL(idpRes.headers.get('location'));
|
||||
const res = await request(app)
|
||||
.get(`${back.pathname}?${back.searchParams.toString()}`)
|
||||
.set('Cookie', stateCookie)
|
||||
.expect(302);
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
|
||||
// A NEW row bound to issuer B — the issuer-A admin is untouched and
|
||||
// its role was not inherited.
|
||||
const collider = await db('admin_users').where({ email: 'colliding@example.com' }).first();
|
||||
expect(collider).toBeTruthy();
|
||||
expect(collider.id).not.toBe(agentCookies.jitAdminId);
|
||||
expect(collider.external_issuer).toBe(idp2.issuer);
|
||||
const original = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
|
||||
expect(original.external_issuer).toBe(idp.issuer);
|
||||
} finally {
|
||||
await idp2.stop();
|
||||
await oidcService.saveOidcSettings({
|
||||
oidc_issuer_url: idp.issuer,
|
||||
oidc_client_id: idp.clientId,
|
||||
oidc_client_secret: idp.clientSecret,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,361 +0,0 @@
|
||||
/**
|
||||
* Backfilling orientation for a library that predates #1185 (#1198).
|
||||
*
|
||||
* The orientation fix corrected the generators and every ingest path, but left
|
||||
* existing rows describing the raw sensor order. Those rows end up worse than
|
||||
* untouched ones: before the fix a rotated photo was consistently wrong — a
|
||||
* sideways image in a matching tile — and afterwards the thumbnail is right
|
||||
* while the stored aspect ratio is not.
|
||||
*
|
||||
* A first attempt at this was reverted from #1194 after review. These tests
|
||||
* pin the five things that went wrong with it:
|
||||
*
|
||||
* 1. requeueing faces without clearing the cached preview, so the rescan
|
||||
* re-read unrotated pixels;
|
||||
* 2. reading originals in a way that cannot see S3 or RAW;
|
||||
* 3. deciding "did this change" from a dimension delta, which never fires
|
||||
* for orientations 2, 3 and 4;
|
||||
* 4. walking archived events whose originals no longer exist;
|
||||
* 5. writing dimensions and invalidation non-atomically.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const sharp = require('sharp');
|
||||
|
||||
describe('orientation backfill (#1198)', () => {
|
||||
let tmpDir; let db; let app; let storageRoot;
|
||||
|
||||
const status = () => request(app).get('/api/admin/photos/repair-orientation/status');
|
||||
const run = () => request(app).post('/api/admin/photos/repair-orientation');
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const s = await status();
|
||||
if (!s.body.isRunning) return s;
|
||||
}
|
||||
throw new Error('backfill did not settle');
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-orientbf-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'orientbf-secret';
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
// bootCrmDb owns STORAGE_PATH; fixtures must live where the app resolves.
|
||||
storageRoot = process.env.STORAGE_PATH;
|
||||
await fs.promises.mkdir(path.join(storageRoot, 'events/active/orientbf'), { recursive: true });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seed({
|
||||
orientation, storedWidth, storedHeight, faceStatus = null,
|
||||
previewPath = 'previews/prev_orientbf.jpg', archived = false, filename = 'p.jpg',
|
||||
thumbnailPath = 'thumbnails/thumb_orientbf.jpg', heroPath = 'heroes/hero_orientbf.jpg',
|
||||
watermarkPath = 'watermarks/wm_orientbf.jpg', checkedAt = null,
|
||||
}) {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
const [e] = await db('events').insert({
|
||||
slug: 'orientbf', event_type: 'wedding', event_name: 'orientbf', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: `orientbf-${Math.random()}`, expires_at: new Date().toISOString(),
|
||||
is_archived: archived,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const img = sharp({ create: { width: 400, height: 200, channels: 3, background: { r: 7, g: 7, b: 7 } } });
|
||||
await (orientation ? img.withMetadata({ orientation }) : img)
|
||||
.jpeg().toFile(path.join(storageRoot, 'events/active/orientbf', filename));
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename, path: `orientbf/${filename}`, type: 'individual',
|
||||
width: storedWidth, height: storedHeight, face_status: faceStatus,
|
||||
preview_path: previewPath, thumbnail_path: thumbnailPath, hero_path: heroPath,
|
||||
watermark_path: watermarkPath, orientation_checked_at: checkedAt,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
it('corrects a row whose dimensions are transposed', async () => {
|
||||
// The case the dimension repair can never reach: both values present,
|
||||
// just in the raw sensor order.
|
||||
const { photoId } = await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
|
||||
|
||||
expect((await run()).body.count).toBe(1);
|
||||
const done = await settle();
|
||||
|
||||
const row = await db('photos').where({ id: photoId }).first();
|
||||
expect(row.width).toBe(200);
|
||||
expect(row.height).toBe(400);
|
||||
expect(done.body.lastResult.corrected).toBe(1);
|
||||
});
|
||||
|
||||
it('clears the cached preview before requeueing, not after', async () => {
|
||||
// The reverted attempt's own-goal: ensurePreviewImage hands back a cached
|
||||
// preview whenever it is still a valid image, so a rescan against the
|
||||
// pre-fix preview produced boxes in the old coordinate system and scaled
|
||||
// them by the corrected dimensions.
|
||||
const { photoId } = await seed({
|
||||
orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
|
||||
});
|
||||
|
||||
await run();
|
||||
const done = await settle();
|
||||
|
||||
const row = await db('photos').where({ id: photoId }).first();
|
||||
expect(row.preview_path).toBeNull();
|
||||
expect(row.face_status).toBe('pending');
|
||||
expect(done.body.lastResult.requeuedFaces).toBe(1);
|
||||
});
|
||||
|
||||
it('clears the thumbnail and hero too, not just the preview', async () => {
|
||||
// The miss that mattered most: ensureThumbnail and ensureHeroImage return
|
||||
// their cached file whenever it is merely VALID, and a pre-fix sideways
|
||||
// thumbnail is perfectly valid. Clearing only the preview fixed the face
|
||||
// data and left the gallery rendering the old sideways image inside a
|
||||
// newly-corrected portrait tile.
|
||||
const { photoId } = await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
|
||||
|
||||
await run();
|
||||
await settle();
|
||||
|
||||
const row = await db('photos').where({ id: photoId }).first();
|
||||
expect(row.preview_path).toBeNull();
|
||||
expect(row.thumbnail_path).toBeNull();
|
||||
expect(row.hero_path).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the renditions of an untransformed photo alone', async () => {
|
||||
// Nothing moved, so nothing cached is stale — clearing them would make a
|
||||
// routine run regenerate the whole library for no reason.
|
||||
const { photoId } = await seed({ orientation: null, storedWidth: 400, storedHeight: 200 });
|
||||
|
||||
await run();
|
||||
await settle();
|
||||
|
||||
const row = await db('photos').where({ id: photoId }).first();
|
||||
expect(row.thumbnail_path).toBe('thumbnails/thumb_orientbf.jpg');
|
||||
expect(row.hero_path).toBe('heroes/hero_orientbf.jpg');
|
||||
expect(row.preview_path).toBe('previews/prev_orientbf.jpg');
|
||||
});
|
||||
|
||||
it('does not touch a row whose file was replaced while it was reading', async () => {
|
||||
// replacePhoto swaps a new file under an existing row and rewrites
|
||||
// path/filename (reachable from replace_by_name). The writes are fenced on
|
||||
// the identity that was measured, so a replacement that lands mid-run is
|
||||
// left entirely alone rather than being given the previous file's
|
||||
// dimensions and having its fresh renditions cleared.
|
||||
const { photoId } = await seed({
|
||||
orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
|
||||
});
|
||||
|
||||
const res = await run();
|
||||
expect(res.body.count).toBe(1);
|
||||
// Simulate the replacement landing before the loop writes.
|
||||
await db('photos').where({ id: photoId })
|
||||
.update({ path: 'orientbf/replaced.jpg', filename: 'replaced.jpg' });
|
||||
await settle();
|
||||
|
||||
const row = await db('photos').where({ id: photoId }).first();
|
||||
expect(row.width).toBe(400); // untouched
|
||||
expect(row.face_status).toBe('done'); // not requeued
|
||||
expect(row.thumbnail_path).toBe('thumbnails/thumb_orientbf.jpg');
|
||||
});
|
||||
|
||||
it('is idempotent — a second run finds nothing left to do', async () => {
|
||||
// The trigger is the EXIF tag on the ORIGINAL, which correcting a photo
|
||||
// never changes. Without a marker every re-run would throw away the
|
||||
// renditions it had just regenerated and requeue every completed face
|
||||
// scan — on a face-enabled install, re-detecting the whole library.
|
||||
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: 'done' });
|
||||
|
||||
expect((await run()).body.count).toBe(1);
|
||||
const first = await settle();
|
||||
expect(first.body.lastResult.corrected).toBe(1);
|
||||
|
||||
expect((await run()).body.count).toBe(0);
|
||||
});
|
||||
|
||||
it('force revisits rows it has already checked', async () => {
|
||||
await seed({
|
||||
orientation: 6, storedWidth: 200, storedHeight: 400,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect((await run()).body.count).toBe(0);
|
||||
const forced = await request(app).post('/api/admin/photos/repair-orientation').send({ force: true });
|
||||
expect(forced.body.count).toBe(1);
|
||||
await settle();
|
||||
});
|
||||
|
||||
it('clears the watermarked rendition, which is what a guest actually sees', async () => {
|
||||
// gallery.js serves watermark_path ahead of the original when branding
|
||||
// watermarking is on.
|
||||
const { photoId } = await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
|
||||
|
||||
await run();
|
||||
await settle();
|
||||
|
||||
expect((await db('photos').where({ id: photoId }).first()).watermark_path).toBeNull();
|
||||
});
|
||||
|
||||
it('does not report stale tiers after a clean run', async () => {
|
||||
// storage.stat() RESOLVES with null for a missing key rather than
|
||||
// rejecting, so counting "the promise settled" marked every deleted and
|
||||
// never-created tier as a survivor and told the operator to re-run.
|
||||
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
|
||||
|
||||
await run();
|
||||
const done = await settle();
|
||||
|
||||
expect(done.body.lastResult.staleTiers).toBe(0);
|
||||
});
|
||||
|
||||
it('requeues faces when only the dimensions were wrong', async () => {
|
||||
// No rotation involved: boxes are scaled by photo.width at read time, so
|
||||
// any change to the stored dimensions invalidates them.
|
||||
const { photoId } = await seed({
|
||||
orientation: null, storedWidth: 999, storedHeight: 111, faceStatus: 'done',
|
||||
});
|
||||
|
||||
await run();
|
||||
const done = await settle();
|
||||
|
||||
const row = await db('photos').where({ id: photoId }).first();
|
||||
expect(row.width).toBe(400);
|
||||
expect(row.face_status).toBe('pending');
|
||||
expect(done.body.lastResult.requeuedFaces).toBe(1);
|
||||
});
|
||||
|
||||
it('requeues an orientation that moves pixels without moving dimensions', async () => {
|
||||
// Orientation 3 is a 180° turn: every pixel moves, width and height do
|
||||
// not. A dimension-delta check sees nothing and skips exactly this row.
|
||||
const { photoId } = await seed({
|
||||
orientation: 3, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
|
||||
});
|
||||
|
||||
await run();
|
||||
const done = await settle();
|
||||
|
||||
const row = await db('photos').where({ id: photoId }).first();
|
||||
expect(row.width).toBe(400); // unchanged, correctly
|
||||
expect(row.face_status).toBe('pending');
|
||||
expect(row.preview_path).toBeNull();
|
||||
expect(done.body.lastResult.requeuedFaces).toBe(1);
|
||||
expect(done.body.lastResult.corrected).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves a post-fix import alone, renditions and all', async () => {
|
||||
// A 5-8 rotation changes the dimensions, so a tagged photo whose stored
|
||||
// dimensions are already oriented must have been ingested after #1185.
|
||||
// Re-clearing its renditions would delete valid files and rescan a
|
||||
// completed face detection for nothing.
|
||||
const { photoId } = await seed({
|
||||
orientation: 6, storedWidth: 200, storedHeight: 400, faceStatus: 'done',
|
||||
});
|
||||
|
||||
await run();
|
||||
const done = await settle();
|
||||
|
||||
const row = await db('photos').where({ id: photoId }).first();
|
||||
expect(row.thumbnail_path).toBe('thumbnails/thumb_orientbf.jpg');
|
||||
expect(row.face_status).toBe('done');
|
||||
expect(done.body.lastResult).toMatchObject({ corrected: 0, requeuedFaces: 0 });
|
||||
// ...and it is marked, so it is not re-read next time either.
|
||||
expect(row.orientation_checked_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('still invalidates a 180-degree rotation, which carries no such evidence', async () => {
|
||||
// Orientation 3 leaves the dimensions identical whether or not it has been
|
||||
// processed, so there is nothing to infer from and it must be done once.
|
||||
const { photoId } = await seed({
|
||||
orientation: 3, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
|
||||
});
|
||||
|
||||
await run();
|
||||
await settle();
|
||||
|
||||
const row = await db('photos').where({ id: photoId }).first();
|
||||
expect(row.thumbnail_path).toBeNull();
|
||||
expect(row.face_status).toBe('pending');
|
||||
});
|
||||
|
||||
it('leaves an untagged photo completely alone', async () => {
|
||||
const { photoId } = await seed({
|
||||
orientation: null, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
|
||||
});
|
||||
|
||||
await run();
|
||||
const done = await settle();
|
||||
|
||||
const row = await db('photos').where({ id: photoId }).first();
|
||||
expect(row.width).toBe(400);
|
||||
expect(row.face_status).toBe('done');
|
||||
expect(row.preview_path).toBe('previews/prev_orientbf.jpg');
|
||||
expect(done.body.lastResult).toMatchObject({ corrected: 0, requeuedFaces: 0, failed: 0 });
|
||||
});
|
||||
|
||||
it('does not start face scanning on an install that never enabled it', async () => {
|
||||
const { photoId } = await seed({
|
||||
orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: null,
|
||||
});
|
||||
|
||||
await run();
|
||||
const done = await settle();
|
||||
|
||||
expect((await db('photos').where({ id: photoId }).first()).face_status).toBeNull();
|
||||
expect(done.body.lastResult.requeuedFaces).toBe(0);
|
||||
// ...but the dimensions are still corrected.
|
||||
expect(done.body.lastResult.corrected).toBe(1);
|
||||
});
|
||||
|
||||
it('skips archived events, whose originals were deleted on archive', async () => {
|
||||
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200, archived: true });
|
||||
|
||||
const res = await run();
|
||||
expect(res.body.count).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a second run while one is in flight', async () => {
|
||||
// Shares the maintenance-lease plumbing, on its own job row so it neither
|
||||
// blocks nor is blocked by the dimension repair.
|
||||
const jobs = require('../../src/services/maintenanceJobState');
|
||||
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
|
||||
|
||||
const claim = await jobs.claim(jobs.JOB_ORIENTATION_BACKFILL);
|
||||
expect(claim).toEqual(expect.any(String));
|
||||
|
||||
expect((await run()).status).toBe(409);
|
||||
|
||||
// The dimension repair is a different job and is unaffected.
|
||||
expect((await request(app).post('/api/admin/photos/repair-dimensions')).status).toBe(200);
|
||||
await jobs.release(jobs.JOB_ORIENTATION_BACKFILL, claim);
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Cross-engine .picpeak restore policy (#1041): a SQLite archive restored onto
|
||||
* a PostgreSQL instance — the official small-install → full-stack upgrade
|
||||
* path — now allowed by validateManifest's direction rule instead of the
|
||||
* former CLI-only allowEngineSwitch flag. The coercion engine itself
|
||||
* (typedColumnsFor / epochToIso / coerceForTargetEngine) landed with #1039;
|
||||
* these tests pin the direction policy and the coercion's cross-engine
|
||||
* value-correctness.
|
||||
*
|
||||
* Ungated: validateManifest direction rules and the pure coercion units.
|
||||
* The reverse direction (pg backup onto a sqlite instance) staying blocked is
|
||||
* pinned by picpeakRoundtrip.test.js, which runs on the real sqlite harness.
|
||||
*
|
||||
* Gated on PICPEAK_PG_TEST_URL (same contract as picpeakRestorePg.test.js):
|
||||
* sqlite-shaped NDJSON rows land in real Postgres with correct stored VALUES,
|
||||
* not just row counts, e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:pw@127.0.0.1:7102/picpeak_xengine_test" \
|
||||
* npx jest __tests__/integration/picpeakCrossEngine.test.js
|
||||
*/
|
||||
const knexLib = require('knex');
|
||||
|
||||
describe('validateManifest cross-engine direction (pg target)', () => {
|
||||
let validateManifest;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetModules();
|
||||
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
|
||||
// validateManifest wraps its knex_migrations lookup in try/catch — a
|
||||
// throwing stub simply skips the forward-only check, which is not under
|
||||
// test here.
|
||||
jest.doMock('../../src/database/db', () => ({ db: () => { throw new Error('stub'); } }));
|
||||
({ validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.dontMock('../../src/database/db');
|
||||
jest.dontMock('../../knexfile');
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('allows a sqlite backup onto a pg instance (upgrade direction)', async () => {
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'sqlite' }, tables: {},
|
||||
});
|
||||
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still allows same-engine pg → pg', async () => {
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
|
||||
});
|
||||
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('epochToIso (landed with #1039)', () => {
|
||||
let epochToIso;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetModules();
|
||||
({ epochToIso } = require('../../src/services/picpeakImportService'));
|
||||
});
|
||||
|
||||
it('converts epoch milliseconds', () => {
|
||||
expect(epochToIso(1723400000000)).toBe('2024-08-11T18:13:20.000Z');
|
||||
});
|
||||
|
||||
it('converts epoch SECONDS to the same instant, not January 1970', () => {
|
||||
expect(epochToIso(1723400000)).toBe('2024-08-11T18:13:20.000Z');
|
||||
});
|
||||
|
||||
it('converts numeric strings', () => {
|
||||
expect(epochToIso('1723400000000')).toBe('2024-08-11T18:13:20.000Z');
|
||||
});
|
||||
|
||||
it('passes non-numeric values through untouched', () => {
|
||||
expect(epochToIso('2026-08-12 10:00:00')).toBe('2026-08-12 10:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerceForTargetEngine on sqlite-shaped rows', () => {
|
||||
let coerceForTargetEngine;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetModules();
|
||||
({ coerceForTargetEngine } = require('../../src/services/picpeakImportService'));
|
||||
});
|
||||
|
||||
const types = { timestamps: ['created_at', 'expires_at'], booleans: ['is_active'] };
|
||||
|
||||
it('coerces 0/1 booleans and epoch timestamps, leaves date strings alone', () => {
|
||||
const [row] = coerceForTargetEngine(
|
||||
[{ id: 1, is_active: 1, created_at: 1723400000000, expires_at: '2026-09-01 12:00:00' }],
|
||||
types
|
||||
);
|
||||
expect(row.is_active).toBe(true);
|
||||
expect(row.created_at).toBe('2024-08-11T18:13:20.000Z');
|
||||
expect(row.expires_at).toBe('2026-09-01 12:00:00'); // pg parses this natively
|
||||
});
|
||||
|
||||
it('coerces falsy variants and passes null/empty through', () => {
|
||||
const [row] = coerceForTargetEngine(
|
||||
[{ is_active: 0, created_at: null, expires_at: '' }],
|
||||
types
|
||||
);
|
||||
expect(row.is_active).toBe(false);
|
||||
expect(row.created_at).toBeNull();
|
||||
expect(row.expires_at).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Real-Postgres integration (gated) ────────────────────────────────────────
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('sqlite-shaped rows land correctly in real Postgres', () => {
|
||||
let pgDb;
|
||||
let svc;
|
||||
|
||||
beforeAll(async () => {
|
||||
pgDb = knexLib({ client: 'pg', connection: PG_URL });
|
||||
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
|
||||
await pgDb.schema.createTable('xengine_events', (t) => {
|
||||
t.increments('id');
|
||||
t.string('slug');
|
||||
t.boolean('is_active').defaultTo(true);
|
||||
t.boolean('allow_downloads').defaultTo(true);
|
||||
t.timestamp('created_at');
|
||||
t.timestamp('expires_at');
|
||||
});
|
||||
await pgDb.schema.createTable('xengine_settings', (t) => {
|
||||
t.increments('id');
|
||||
t.string('setting_key').notNullable().unique();
|
||||
t.jsonb('setting_value');
|
||||
});
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
|
||||
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
|
||||
svc = require('../../src/services/picpeakImportService');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.dontMock('../../src/database/db');
|
||||
jest.dontMock('../../knexfile');
|
||||
if (pgDb) {
|
||||
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
|
||||
await pgDb.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('typedColumnsFor classifies boolean and timestamp columns via columnInfo()', async () => {
|
||||
const types = await svc.typedColumnsFor(pgDb, 'xengine_events');
|
||||
expect(types.booleans.sort()).toEqual(['allow_downloads', 'is_active']);
|
||||
expect(types.timestamps.sort()).toEqual(['created_at', 'expires_at']);
|
||||
});
|
||||
|
||||
it('inserts a sqlite archive row (0/1 booleans, epoch dates, json text) with correct stored values', async () => {
|
||||
// Exactly what a sqlite-created .picpeak carries: integers for booleans,
|
||||
// epoch numbers for #485-shape timestamps (ms here, seconds covered by the
|
||||
// epochToIso unit), a "YYYY-MM-DD HH:MM:SS" string for clean ones, and
|
||||
// json columns as TEXT (the crossEngine path skips serialiseJsonColumns —
|
||||
// the text is already what pg wants).
|
||||
const epoch = 1723400000000;
|
||||
const eventRows = [
|
||||
{ id: 1, slug: 'wedding', is_active: 1, allow_downloads: 0, created_at: epoch, expires_at: '2026-09-01 12:00:00' },
|
||||
];
|
||||
const settingRows = [{ id: 1, setting_key: 'brand', setting_value: '{"name":"PicPeak","dark":true}' }];
|
||||
|
||||
await pgDb.transaction(async (trx) => {
|
||||
const evTypes = await svc.typedColumnsFor(trx, 'xengine_events');
|
||||
await trx.batchInsert('xengine_events', svc.coerceForTargetEngine(eventRows, evTypes), 100);
|
||||
const stTypes = await svc.typedColumnsFor(trx, 'xengine_settings');
|
||||
await trx.batchInsert('xengine_settings', svc.coerceForTargetEngine(settingRows, stTypes), 100);
|
||||
});
|
||||
|
||||
const ev = await pgDb('xengine_events').where({ id: 1 }).first();
|
||||
expect(ev.is_active).toBe(true); // 1 → true, not backwards (#1028 class)
|
||||
expect(ev.allow_downloads).toBe(false); // 0 → false
|
||||
expect(new Date(ev.created_at).getTime()).toBe(epoch);
|
||||
expect(new Date(ev.expires_at).toISOString().slice(0, 10)).toBe('2026-09-01');
|
||||
|
||||
const st = await pgDb('xengine_settings').where({ id: 1 }).first();
|
||||
// jsonb parsed back by the driver — value intact, no double encoding.
|
||||
expect(st.setting_value).toEqual({ name: 'PicPeak', dark: true });
|
||||
});
|
||||
|
||||
it('id sequence works after explicit-id insert + resync (next natural insert)', async () => {
|
||||
await svc.resyncSequences(['xengine_events']);
|
||||
const [next] = await pgDb('xengine_events')
|
||||
.insert({ slug: 'fresh', is_active: true })
|
||||
.returning('id');
|
||||
expect(Number(next.id || next)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
}, 120000);
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* PostgreSQL integration tests for the .picpeak restore robustness fixes.
|
||||
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway Postgres DB,
|
||||
* e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_restore_test" \
|
||||
* npx jest __tests__/integration/picpeakRestorePg.test.js
|
||||
*
|
||||
* Validates the Postgres-specific paths that SQLite can't exercise: identity
|
||||
* sequences left stale by explicit-id inserts, pg_get_serial_sequence raising on
|
||||
* id-less tables, reinject/role-recreate explicit-id inserts, and FK integrity.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('picpeak restore on Postgres', () => {
|
||||
let pgDb;
|
||||
let svc;
|
||||
|
||||
beforeAll(async () => {
|
||||
pgDb = knex({ client: 'pg', connection: PG_URL });
|
||||
|
||||
await pgDb.raw('DROP TABLE IF EXISTS role_permissions, events, admin_users, roles, permissions, app_settings CASCADE');
|
||||
await pgDb.schema.createTable('roles', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name', 50).notNullable().unique();
|
||||
t.string('display_name', 100);
|
||||
t.integer('priority').defaultTo(0);
|
||||
t.boolean('is_system').defaultTo(false);
|
||||
});
|
||||
await pgDb.schema.createTable('permissions', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name', 100).notNullable().unique();
|
||||
t.string('display_name', 150);
|
||||
t.string('category', 50);
|
||||
});
|
||||
await pgDb.schema.createTable('role_permissions', (t) => {
|
||||
t.integer('role_id').notNullable().references('id').inTable('roles').onDelete('CASCADE');
|
||||
t.integer('permission_id').notNullable().references('id').inTable('permissions').onDelete('CASCADE');
|
||||
t.primary(['role_id', 'permission_id']);
|
||||
});
|
||||
await pgDb.schema.createTable('admin_users', (t) => {
|
||||
t.increments('id');
|
||||
t.string('username').notNullable().unique();
|
||||
t.string('email').notNullable().unique();
|
||||
t.string('password_hash');
|
||||
t.boolean('is_active').defaultTo(true);
|
||||
t.boolean('must_change_password').defaultTo(false);
|
||||
t.integer('role_id').references('id').inTable('roles').onDelete('SET NULL');
|
||||
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
t.boolean('two_factor_enabled').defaultTo(false);
|
||||
t.string('two_factor_secret');
|
||||
t.text('two_factor_recovery_codes');
|
||||
});
|
||||
await pgDb.schema.createTable('events', (t) => {
|
||||
t.increments('id');
|
||||
t.string('slug');
|
||||
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
});
|
||||
await pgDb.schema.createTable('app_settings', (t) => {
|
||||
t.increments('id');
|
||||
t.string('setting_key').notNullable().unique();
|
||||
t.json('setting_value');
|
||||
t.string('setting_type');
|
||||
t.timestamp('updated_at').defaultTo(pgDb.fn.now());
|
||||
});
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
|
||||
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
|
||||
svc = require('../../src/services/picpeakImportService');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.dontMock('../../src/database/db');
|
||||
jest.dontMock('../../knexfile');
|
||||
if (pgDb) await pgDb.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await pgDb('role_permissions').del();
|
||||
await pgDb('events').del();
|
||||
await pgDb('admin_users').del();
|
||||
await pgDb('roles').del();
|
||||
await pgDb('permissions').del();
|
||||
});
|
||||
|
||||
test('resyncSequences fast-forwards stale sequences and skips id-less tables', async () => {
|
||||
// Simulate a restore: explicit-id inserts leave the sequence at 1.
|
||||
await pgDb('roles').insert([{ id: 5, name: 'super_admin', display_name: 'SA' }]);
|
||||
await pgDb('admin_users').insert([{ id: 9, username: 'a', email: 'a@x.io', password_hash: 'h' }]);
|
||||
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
|
||||
await pgDb('role_permissions').insert([{ role_id: 5, permission_id: 3 }]); // id-less table
|
||||
|
||||
// Must not throw on role_permissions (no `id` column → pg_get_serial_sequence raises unguarded).
|
||||
await expect(svc.resyncSequences(['roles', 'admin_users', 'permissions', 'role_permissions'])).resolves.toBeUndefined();
|
||||
|
||||
// Natural inserts (no explicit id) now avoid the restored ids.
|
||||
const [adminId] = await pgDb('admin_users').insert({ username: 'b', email: 'b@x.io', password_hash: 'h' }).returning('id');
|
||||
expect(Number(adminId.id || adminId)).toBe(10); // max(9)+1, no duplicate-key error
|
||||
const [roleId] = await pgDb('roles').insert({ name: 'editor', display_name: 'Ed' }).returning('id');
|
||||
expect(Number(roleId.id || roleId)).toBe(6);
|
||||
});
|
||||
|
||||
test('reinjectCurrentAdmin insert branch works with a stale sequence (explicit max+1)', async () => {
|
||||
await pgDb('admin_users').insert({ id: 9, username: 'backup', email: 'backup@x.io', password_hash: 'h' });
|
||||
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, created_by: 42 };
|
||||
|
||||
await pgDb.transaction((trx) => svc.reinjectCurrentAdmin(trx, operator));
|
||||
|
||||
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
|
||||
expect(op.id).toBe(10); // max(9)+1
|
||||
expect(op.password_hash).toBe('OP');
|
||||
expect(op.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
|
||||
});
|
||||
|
||||
test('preserveOperatorRole re-creates a missing role on Postgres and keeps FK integrity', async () => {
|
||||
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
|
||||
await pgDb('roles').insert([{ id: 2, name: 'viewer', display_name: 'V' }]);
|
||||
await pgDb('admin_users').insert({ id: 1, username: 'admin', email: 'op@x.io', password_hash: 'h', role_id: null });
|
||||
const snapshot = { role: { name: 'super_admin', display_name: 'SA', priority: 100, is_system: true }, permissions: ['events.create', 'missing.perm'] };
|
||||
|
||||
await pgDb.transaction((trx) => svc.preserveOperatorRole(trx, 1, snapshot));
|
||||
await svc.resyncSequences(['roles']); // post-commit, mirrors importFromPicpeak
|
||||
|
||||
const role = await pgDb('roles').where({ name: 'super_admin' }).first();
|
||||
expect(role).toBeTruthy();
|
||||
const op = await pgDb('admin_users').where({ id: 1 }).first();
|
||||
expect(op.role_id).toBe(role.id); // FK valid, operator not downgraded
|
||||
const grants = await pgDb('role_permissions').where({ role_id: role.id }).pluck('permission_id');
|
||||
expect(grants).toEqual([3]); // existing perm granted, missing.perm skipped
|
||||
});
|
||||
|
||||
test('full replaceAllTables: cross-instance backup preserves the operator, role, FKs, and sequences', async () => {
|
||||
// A backup from ANOTHER instance: omits the operator's email AND their
|
||||
// super_admin role; uses explicit ids that leave sequences stale.
|
||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pgtest-'));
|
||||
const dataDir = path.join(staging, 'data');
|
||||
fs.mkdirSync(dataDir);
|
||||
const write = (t, rows) => fs.writeFileSync(path.join(dataDir, `${t}.ndjson`), rows.map((r) => JSON.stringify(r)).join('\n'));
|
||||
write('roles', [{ id: 5, name: 'admin', display_name: 'Admin', priority: 50, is_system: true }]);
|
||||
write('permissions', [{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
|
||||
write('role_permissions', [{ role_id: 5, permission_id: 3 }]);
|
||||
write('admin_users', [{ id: 9, username: 'backupadmin', email: 'backup@x.io', password_hash: 'h', role_id: 5, is_active: true }]);
|
||||
write('events', [{ id: 2, slug: 'restored-ev', created_by: 9 }]);
|
||||
|
||||
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, role_id: 999, created_by: null };
|
||||
const roleSnapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
|
||||
const tables = ['roles', 'permissions', 'role_permissions', 'admin_users', 'events'];
|
||||
|
||||
// replaceAllTables isn't exported, so drive its exact transaction sequence
|
||||
// (suspend FKs, wipe, batchInsert, reinject, preserve role) through the
|
||||
// exported units against real Postgres.
|
||||
const importSvc = svc;
|
||||
await pgDb.transaction(async (trx) => {
|
||||
await trx.raw('SET session_replication_role = \'replica\'');
|
||||
for (const t of tables) await trx(t).del();
|
||||
for (const t of tables) {
|
||||
const rows = fs.readFileSync(path.join(dataDir, `${t}.ndjson`), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
||||
if (rows.length) await trx.batchInsert(t, rows, 100);
|
||||
}
|
||||
const opId = await importSvc.reinjectCurrentAdmin(trx, operator);
|
||||
await importSvc.preserveOperatorRole(trx, opId, roleSnapshot);
|
||||
await trx.raw('SET session_replication_role = \'origin\'');
|
||||
});
|
||||
await importSvc.resyncSequences(tables);
|
||||
|
||||
// Operator preserved (inserted, since email absent from backup).
|
||||
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
|
||||
expect(op).toBeTruthy();
|
||||
expect(op.password_hash).toBe('OP');
|
||||
// super_admin role re-created and the operator bound to it.
|
||||
const sa = await pgDb('roles').where({ name: 'super_admin' }).first();
|
||||
expect(sa).toBeTruthy();
|
||||
expect(op.role_id).toBe(sa.id);
|
||||
expect(await pgDb('role_permissions').where({ role_id: sa.id }).pluck('permission_id')).toEqual([3]);
|
||||
// Restored event's created_by FK to the backup admin still valid.
|
||||
const ev = await pgDb('events').where({ slug: 'restored-ev' }).first();
|
||||
expect(ev.created_by).toBe(9);
|
||||
// Sequences resynced → natural inserts don't collide.
|
||||
const [newAdmin] = await pgDb('admin_users').insert({ username: 'fresh', email: 'fresh@x.io', password_hash: 'h' }).returning('id');
|
||||
expect(Number(newAdmin.id || newAdmin)).toBeGreaterThan(op.id);
|
||||
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ beforeAll(async () => {
|
||||
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
superAdminRoleId = role.id;
|
||||
}, 120000);
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
/**
|
||||
* Responsive preview tiers (#1095).
|
||||
*
|
||||
* A phone can display ~1170px at most, so the single 1920px preview ships
|
||||
* roughly twice the bytes it can use on every lightbox swipe — and the
|
||||
* lightbox prefetches neighbours, so a guest flicking through a wedding
|
||||
* gallery on cellular pays that repeatedly.
|
||||
*
|
||||
* The width is whitelisted rather than free-form: every distinct value is a
|
||||
* permanent cache entry on disk, so an open ?w= is an invitation to fill the
|
||||
* volume with renditions nobody asked for.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-tiers-'));
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'tiers-test-secret';
|
||||
process.env.STORAGE_PATH = path.join(tmpRoot, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
|
||||
const sharp = require('sharp');
|
||||
const imageProcessor = require('../../src/services/imageProcessor');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup;
|
||||
|
||||
describe('preview tiers (#1095)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('normalizeTierWidth', () => {
|
||||
const { normalizeTierWidth, PREVIEW_WIDTHS, THUMBNAIL_WIDTHS } = imageProcessor;
|
||||
|
||||
it('accepts every advertised width', () => {
|
||||
for (const w of PREVIEW_WIDTHS) {
|
||||
expect(normalizeTierWidth(String(w), PREVIEW_WIDTHS)).toBe(w);
|
||||
}
|
||||
for (const w of THUMBNAIL_WIDTHS) {
|
||||
expect(normalizeTierWidth(String(w), THUMBNAIL_WIDTHS)).toBe(w);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects anything not on the list', () => {
|
||||
// The disk-filling cases: arbitrary sizes, and a caller walking a range.
|
||||
for (const bad of ['999', '1921', '0', '-100', '99999']) {
|
||||
expect(normalizeTierWidth(bad, PREVIEW_WIDTHS)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects junk without throwing', () => {
|
||||
// Straight off a query string, so it is whatever the client sent.
|
||||
for (const bad of [undefined, null, '', 'abc', '12abc', {}, [], '1e3', 'NaN']) {
|
||||
expect(normalizeTierWidth(bad, PREVIEW_WIDTHS)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a thumbnail width through the preview list', () => {
|
||||
// The two lists are separate on purpose; 600 is a thumb tier, not a
|
||||
// preview tier, and vice versa for 1280.
|
||||
expect(normalizeTierWidth('600', PREVIEW_WIDTHS)).toBeNull();
|
||||
expect(normalizeTierWidth('1280', THUMBNAIL_WIDTHS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensurePreviewImageAtWidth', () => {
|
||||
async function seedPhoto() {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `tier-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'tier',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `tier-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
// A real image on disk under STORAGE_PATH, since the managed branch
|
||||
// resolves through storage rather than a mount.
|
||||
const rel = `events/active/tier/${Math.random().toString(36).slice(2, 8)}.jpg`;
|
||||
const abs = path.join(process.env.STORAGE_PATH, rel);
|
||||
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
|
||||
await sharp({ create: { width: 3000, height: 2000, channels: 3, background: { r: 10, g: 90, b: 160 } } })
|
||||
.jpeg().toFile(abs);
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: path.basename(rel),
|
||||
path: rel.replace(/^events\/active\//, ''),
|
||||
type: 'individual',
|
||||
width: 3000,
|
||||
height: 2000,
|
||||
processing_status: 'complete',
|
||||
source_origin: 'managed',
|
||||
}).returning('id');
|
||||
return db('photos').where({ id: typeof p === 'object' ? p.id : p }).first();
|
||||
}
|
||||
|
||||
it('scopes keys by photo id so two galleries cannot collide', async () => {
|
||||
// The leak: managed auto-imports keep camera basenames, so two events can
|
||||
// each hold an IMG_0001.jpg. A tier is served straight from a cache hit
|
||||
// without re-reading the source, so a shared key hands one gallery's
|
||||
// photo to another.
|
||||
const a = await seedPhoto();
|
||||
const b = await seedPhoto();
|
||||
await db('photos').where({ id: a.id }).update({ path: 'wedding-a/IMG_0001.jpg' });
|
||||
await db('photos').where({ id: b.id }).update({ path: 'wedding-b/IMG_0001.jpg' });
|
||||
|
||||
const keyA = imageProcessor.previewTierKeys(await db('photos').where({ id: a.id }).first())[0];
|
||||
const keyB = imageProcessor.previewTierKeys(await db('photos').where({ id: b.id }).first())[0];
|
||||
|
||||
expect(keyA).not.toBe(keyB);
|
||||
expect(keyA).toContain(`p${a.id}_`);
|
||||
expect(keyB).toContain(`p${b.id}_`);
|
||||
});
|
||||
|
||||
it('derives every non-default tier key for cleanup', () => {
|
||||
// Tiers live outside preview_path, so delete/archive/regenerate have no
|
||||
// other way to find them. 1920 is excluded because that IS preview_path.
|
||||
// Two candidates per width — the encoder picks `.jpg` or `.webp` and the
|
||||
// cleanup list cannot know which without probing the source.
|
||||
const keys = imageProcessor.previewTierKeys({ id: 5, path: 'e/a.jpg', source_origin: 'managed' });
|
||||
const widths = imageProcessor.PREVIEW_WIDTHS.filter((w) => w !== 1920);
|
||||
expect(keys).toHaveLength(widths.length * 2);
|
||||
for (const w of widths) {
|
||||
expect(keys).toContain(`previews/preview_w${w}_p5_a.jpg`);
|
||||
expect(keys).toContain(`previews/preview_w${w}_p5_a.webp`);
|
||||
}
|
||||
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
|
||||
expect(keys.every((k) => k.includes('p5_'))).toBe(true);
|
||||
});
|
||||
|
||||
it('deletePreviewTiers removes generated tiers from storage', async () => {
|
||||
const photo = await seedPhoto();
|
||||
const key = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
||||
const abs = path.join(process.env.STORAGE_PATH, key);
|
||||
expect(fs.existsSync(abs)).toBe(true);
|
||||
|
||||
await imageProcessor.deletePreviewTiers(await db('photos').where({ id: photo.id }).first());
|
||||
expect(fs.existsSync(abs)).toBe(false);
|
||||
});
|
||||
|
||||
it('produces a distinct key per width and never touches preview_path', async () => {
|
||||
const photo = await seedPhoto();
|
||||
|
||||
const small = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
||||
expect(small).toContain('preview_w640_');
|
||||
|
||||
// The extra tiers are cache, not state. Writing them to the row would
|
||||
// mean the last size requested silently becomes "the" preview.
|
||||
const row = await db('photos').where({ id: photo.id }).first();
|
||||
expect(row.preview_path == null || !String(row.preview_path).includes('w640')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves the default width to the canonical preview, not a w1920 copy', async () => {
|
||||
// Otherwise every existing install grows a duplicate of every preview it
|
||||
// already has, for no benefit.
|
||||
const photo = await seedPhoto();
|
||||
const def = await imageProcessor.ensurePreviewImageAtWidth(photo, 1920);
|
||||
expect(def).not.toContain('preview_w1920_');
|
||||
});
|
||||
|
||||
it('reuses the cached tier instead of regenerating', async () => {
|
||||
const photo = await seedPhoto();
|
||||
const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
|
||||
expect(first).toBeTruthy();
|
||||
|
||||
const abs = path.join(process.env.STORAGE_PATH, first);
|
||||
const before = (await fs.promises.stat(abs)).mtimeMs;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const second = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
|
||||
expect(second).toBe(first);
|
||||
expect((await fs.promises.stat(abs)).mtimeMs).toBe(before);
|
||||
});
|
||||
|
||||
it('actually resizes to the requested tier', async () => {
|
||||
const photo = await seedPhoto();
|
||||
const key = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
||||
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
|
||||
// 3000x2000 constrained to a 640 long edge.
|
||||
expect(Math.max(meta.width, meta.height)).toBe(640);
|
||||
expect(meta.height).toBe(Math.round(640 * (2000 / 3000)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbnail tiers', () => {
|
||||
async function seedThumbPhoto(w = 3000, h = 2000) {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `tt-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding', event_name: 'tt', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'x', share_link: `tt-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
const rel = `events/active/tt/${Math.random().toString(36).slice(2, 8)}.jpg`;
|
||||
const abs = path.join(process.env.STORAGE_PATH, rel);
|
||||
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
|
||||
await sharp({ create: { width: w, height: h, channels: 3, background: { r: 5, g: 5, b: 5 } } })
|
||||
.jpeg().toFile(abs);
|
||||
const [p2] = await db('photos').insert({
|
||||
event_id: eventId, filename: path.basename(rel),
|
||||
path: rel.replace(/^events\/active\//, ''), type: 'individual',
|
||||
width: w, height: h, processing_status: 'complete', source_origin: 'managed',
|
||||
}).returning('id');
|
||||
return db('photos').where({ id: typeof p2 === 'object' ? p2.id : p2 }).first();
|
||||
}
|
||||
|
||||
it('scopes thumbnail tier keys by photo id', async () => {
|
||||
// Same cross-gallery hazard the preview tiers had: a cache hit serves
|
||||
// without re-reading the source, so a shared basename leaks across events.
|
||||
const keys = imageProcessor.thumbnailTierKeys({ id: 42, path: 'a/IMG_0001.jpg', source_origin: 'managed' });
|
||||
expect(keys.every((k) => k.includes('p42_'))).toBe(true);
|
||||
// Every width, canonical included: which one is canonical depends on the
|
||||
// thumbnail_width setting, so on a 600-configured install w300 is the
|
||||
// tier file. Deleting a key that was never written is a no-op; missing
|
||||
// one strands it forever.
|
||||
expect(keys).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('tags the tier against the configured width, not the 300 default', async () => {
|
||||
// Regression: with thumbnail_width=600 a w=300 request wrote
|
||||
// `thumb_<name>` while the caller probed `thumb_w300_<name>`. The cache
|
||||
// never hit, so every request re-downloaded the original and ran Sharp,
|
||||
// and the file it left behind was in no cleanup list.
|
||||
await db('app_settings').where('setting_key', 'thumbnail_width')
|
||||
.update({ setting_value: 600 });
|
||||
try {
|
||||
const photo = await seedThumbPhoto();
|
||||
|
||||
const first = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
|
||||
expect(first).toContain('thumb_w300_');
|
||||
|
||||
// The second call must be a cache hit on the key the first one wrote.
|
||||
const before = fs.statSync(path.join(process.env.STORAGE_PATH, first)).mtimeMs;
|
||||
const second = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
|
||||
expect(second).toBe(first);
|
||||
expect(fs.statSync(path.join(process.env.STORAGE_PATH, second)).mtimeMs).toBe(before);
|
||||
|
||||
// ...and 600 is now the canonical, so it resolves to the plain thumbnail.
|
||||
const canonical = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
|
||||
expect(canonical).not.toContain('thumb_w600_');
|
||||
|
||||
// Cleanup still reaches the w300 tier this install actually generated.
|
||||
expect(imageProcessor.thumbnailTierKeys(photo)).toContain(first);
|
||||
} finally {
|
||||
await db('app_settings').where('setting_key', 'thumbnail_width')
|
||||
.update({ setting_value: 300 });
|
||||
}
|
||||
});
|
||||
|
||||
it('generates a tier at the requested size', async () => {
|
||||
const photo = await seedThumbPhoto();
|
||||
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
|
||||
expect(key).toContain('thumb_w600_');
|
||||
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
|
||||
expect(Math.max(meta.width, meta.height)).toBe(600);
|
||||
});
|
||||
|
||||
it('does not upscale past the source, which is why the tier is clamped', async () => {
|
||||
// The reason tileThumbnailWidth checks the short edge: ask a 400px
|
||||
// source for 900 and withoutEnlargement caps it, so the request buys a
|
||||
// Sharp run and a second cache entry for a file identical to the 300.
|
||||
const small = await seedThumbPhoto(500, 400);
|
||||
const key = await imageProcessor.ensureThumbnailAtWidth(small, 900);
|
||||
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
|
||||
expect(Math.max(meta.width, meta.height)).toBeLessThan(900);
|
||||
});
|
||||
|
||||
it('resolves the canonical width to the normal thumbnail', async () => {
|
||||
const photo = await seedThumbPhoto();
|
||||
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
|
||||
expect(key).not.toContain('thumb_w300_');
|
||||
});
|
||||
|
||||
it('keeps the configured aspect ratio instead of forcing a square', async () => {
|
||||
// Thumbnails are square by default, but the settings API takes any
|
||||
// width/height in 50..1000. With fit:'cover' a 300x200 canonical and a
|
||||
// 600x600 tier are two different crops, so the photo would visibly
|
||||
// reframe as the tile size changed.
|
||||
await db('app_settings').where('setting_key', 'thumbnail_height')
|
||||
.update({ setting_value: 200 });
|
||||
try {
|
||||
const photo = await seedThumbPhoto();
|
||||
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
|
||||
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
|
||||
expect(meta.width).toBe(600);
|
||||
expect(meta.height).toBe(400); // 600 * (200/300), not 600
|
||||
} finally {
|
||||
await db('app_settings').where('setting_key', 'thumbnail_height')
|
||||
.update({ setting_value: 300 });
|
||||
}
|
||||
});
|
||||
|
||||
it('never hands a video to Sharp', async () => {
|
||||
// A video's thumbnail is a poster frame from videoProcessor, not a
|
||||
// resize of the stored file. Without the short-circuit the tier path
|
||||
// would download the whole video (withLocalCopy, in full on S3) and
|
||||
// then fail to decode it — every request, since nothing caches a miss.
|
||||
const photo = await seedThumbPhoto();
|
||||
await db('photos').where({ id: photo.id })
|
||||
.update({ media_type: 'video', mime_type: 'video/mp4' });
|
||||
const video = await db('photos').where({ id: photo.id }).first();
|
||||
|
||||
const key = await imageProcessor.ensureThumbnailAtWidth(video, 900);
|
||||
expect(key).not.toContain('thumb_w900_');
|
||||
});
|
||||
|
||||
it('drops tiers when a rename moves the basename they are keyed on', async () => {
|
||||
// The key embeds the basename, so the DB update in renamePhotoFiles is
|
||||
// the point past which the old keys cannot be derived at all — a later
|
||||
// delete or archive computes the new ones and leaves these behind.
|
||||
const renameService = require('../../src/services/eventRenameService');
|
||||
const photo = await seedThumbPhoto();
|
||||
const event = await db('events').where({ id: photo.event_id }).first();
|
||||
|
||||
// Give it a filename the rename will actually rewrite.
|
||||
const dir = path.join(process.env.STORAGE_PATH, 'events/active', event.slug, 'individual');
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await sharp({ create: { width: 1200, height: 900, channels: 3, background: { r: 7, g: 7, b: 7 } } })
|
||||
.jpeg().toFile(path.join(dir, 'Old_Name_001.jpg'));
|
||||
await db('photos').where({ id: photo.id }).update({
|
||||
filename: 'Old_Name_001.jpg',
|
||||
path: `${event.slug}/individual/Old_Name_001.jpg`,
|
||||
});
|
||||
const renamable = await db('photos').where({ id: photo.id }).first();
|
||||
|
||||
const key = await imageProcessor.ensureThumbnailAtWidth(renamable, 600);
|
||||
const abs = path.join(process.env.STORAGE_PATH, key);
|
||||
expect(fs.existsSync(abs)).toBe(true);
|
||||
|
||||
await renameService.renamePhotoFiles(
|
||||
event.id, 'Old Name', 'New Name', event.slug, event.slug
|
||||
);
|
||||
|
||||
expect(await db('photos').where({ id: photo.id }).first())
|
||||
.toMatchObject({ filename: 'New_Name_001.jpg' });
|
||||
expect(fs.existsSync(abs)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves tiers alone when a rename does not move the basename', async () => {
|
||||
// Four storage deletes per photo is 20k calls against S3 for a
|
||||
// 5,000-photo event whose slug merely changed, so the sweep is gated on
|
||||
// the filename actually moving.
|
||||
const renameService = require('../../src/services/eventRenameService');
|
||||
const photo = await seedThumbPhoto();
|
||||
const event = await db('events').where({ id: photo.event_id }).first();
|
||||
|
||||
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
|
||||
const abs = path.join(process.env.STORAGE_PATH, key);
|
||||
|
||||
// The photo's filename carries no event-name prefix, so nothing moves.
|
||||
await renameService.renamePhotoFiles(
|
||||
event.id, 'Old Name', 'New Name', event.slug, event.slug
|
||||
);
|
||||
|
||||
expect(fs.existsSync(abs)).toBe(true);
|
||||
});
|
||||
|
||||
it('deleteThumbnailTiers removes them', async () => {
|
||||
const photo = await seedThumbPhoto();
|
||||
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
|
||||
const abs = path.join(process.env.STORAGE_PATH, key);
|
||||
expect(fs.existsSync(abs)).toBe(true);
|
||||
await imageProcessor.deleteThumbnailTiers(await db('photos').where({ id: photo.id }).first());
|
||||
expect(fs.existsSync(abs)).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The crash in #1128 needed two things: a tier that disappears, and a
|
||||
* reader that dies on it. The reader is fixed in streamResponse; this is
|
||||
* the half that stops the file disappearing in the first place.
|
||||
*/
|
||||
describe('concurrent generation (#1128)', () => {
|
||||
it('never leaves the tier absent once it has been published', async () => {
|
||||
const photo = await seedThumbPhoto();
|
||||
const key = imageProcessor.thumbnailTierKeys(photo).find((k) => k.includes('_w600_'));
|
||||
const abs = path.join(process.env.STORAGE_PATH, key);
|
||||
|
||||
// A grid fires one request per tile at once, and on a cold gallery
|
||||
// every one of them misses the cache. Previously each carried
|
||||
// `regenerate: true`, whose first act is to DELETE the target — so a
|
||||
// later arrival unlinked the file an earlier one had already published
|
||||
// and handed to a reader.
|
||||
const watcher = [];
|
||||
const poll = setInterval(() => watcher.push(fs.existsSync(abs)), 1);
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 12 }, () => imageProcessor.ensureThumbnailAtWidth(photo, 600))
|
||||
);
|
||||
clearInterval(poll);
|
||||
|
||||
expect(results.every((r) => r === key)).toBe(true);
|
||||
expect(fs.existsSync(abs)).toBe(true);
|
||||
|
||||
// Once true, never false again: no window where a validated file is gone.
|
||||
const firstSeen = watcher.indexOf(true);
|
||||
if (firstSeen !== -1) {
|
||||
expect(watcher.slice(firstSeen).every(Boolean)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('runs one generation for a burst of requests, not one per request', async () => {
|
||||
const photo = await seedThumbPhoto();
|
||||
const thumbDir = path.join(process.env.STORAGE_PATH, 'thumbnails');
|
||||
await fs.promises.mkdir(thumbDir, { recursive: true });
|
||||
|
||||
// Counted through the staging files LocalFsStorage writes:
|
||||
// `<key>.tmp.<pid>.<hex>`, one per put, each a distinct random suffix.
|
||||
// So distinct temp names == distinct generations, which is the thing
|
||||
// the dedupe is supposed to collapse. (Spying on generateThumbnail
|
||||
// would not work — ensureThumbnailAtWidth calls it through the
|
||||
// module-local binding, so an export spy never sees it.)
|
||||
const seen = new Set();
|
||||
const poll = setInterval(() => {
|
||||
for (const f of fs.readdirSync(thumbDir)) {
|
||||
if (f.includes('_w900_') && f.includes('.tmp.')) seen.add(f);
|
||||
}
|
||||
}, 1);
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 8 }, () => imageProcessor.ensureThumbnailAtWidth(photo, 900))
|
||||
);
|
||||
clearInterval(poll);
|
||||
|
||||
const abs = path.join(
|
||||
process.env.STORAGE_PATH,
|
||||
imageProcessor.thumbnailTierKeys(photo).find((k) => k.includes('_w900_'))
|
||||
);
|
||||
expect(fs.existsSync(abs)).toBe(true);
|
||||
expect(new Set(results).size).toBe(1);
|
||||
// 8 requests, at most one Sharp pass. Before the dedupe this was 8 —
|
||||
// and on an external photo, 8 full reads of the original.
|
||||
expect(seen.size).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('does not cache a failure — a later request retries', async () => {
|
||||
const photo = await seedThumbPhoto();
|
||||
// Source removed underneath: generation fails and must not poison the
|
||||
// key for the lifetime of the process.
|
||||
const src = path.join(process.env.STORAGE_PATH, 'events/active', photo.path);
|
||||
const saved = await fs.promises.readFile(src);
|
||||
await fs.promises.unlink(src);
|
||||
|
||||
expect(await imageProcessor.ensureThumbnailAtWidth(photo, 600)).toBeNull();
|
||||
|
||||
await fs.promises.writeFile(src, saved);
|
||||
expect(await imageProcessor.ensureThumbnailAtWidth(photo, 600)).toContain('_w600_');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* PostgreSQL checks for product usage (#1110).
|
||||
*
|
||||
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway database, e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_usage_pg_test" \
|
||||
* npx jest __tests__/integration/productUsagePg.test.js
|
||||
*
|
||||
* What SQLite cannot answer:
|
||||
* - `cancel_seq` and `sequence` are bigint, and node-postgres returns bigint
|
||||
* as a STRING. The withdrawal guard compares that value, so a `'1' !== 1`
|
||||
* slip would let an activation complete after an opt-out — and SQLite,
|
||||
* which hands back a number, would never show it.
|
||||
* - booleans are real booleans here, not 0/1, which is what every
|
||||
* `configured` signal in a report is built from.
|
||||
* - markUsed takes SELECT ... FOR UPDATE on this engine only.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
|
||||
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('product usage on Postgres', () => {
|
||||
let db;
|
||||
let UsageService;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Its own schema, not `public`. CI hands every gated suite the same
|
||||
// PICPEAK_PG_TEST_URL and runs jest with parallel workers, and both
|
||||
// picpeakRestorePg and externalRelpathFoldPg drop and recreate `events`
|
||||
// and `app_settings` there. Sharing that would have made all three
|
||||
// intermittently destroy each other's fixtures. The service queries
|
||||
// unqualified table names, so a searchPath keeps it entirely in here.
|
||||
const bootstrap = knex({
|
||||
client: 'pg', connection: PG_URL, pool: { min: 0, max: 2 }
|
||||
});
|
||||
await bootstrap.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE');
|
||||
await bootstrap.raw('CREATE SCHEMA usage_pg_test');
|
||||
await bootstrap.destroy();
|
||||
|
||||
db = knex({
|
||||
client: 'pg',
|
||||
connection: PG_URL,
|
||||
searchPath: ['usage_pg_test'],
|
||||
pool: { min: 0, max: 10 }
|
||||
});
|
||||
// The real migrations, on the real engine.
|
||||
await require('../../migrations/core/201_product_usage').up(db);
|
||||
await require('../../migrations/core/202_product_usage_cancel_requested').up(db);
|
||||
await require('../../migrations/core/203_product_usage_cancel_seq').up(db);
|
||||
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
|
||||
await require('../../migrations/core/205_product_usage_consent_version').up(db);
|
||||
await require('../../migrations/core/206_product_usage_delivery_backoff').up(db);
|
||||
await require('../../migrations/core/212_product_usage_prompt_shown').up(db);
|
||||
|
||||
await db.schema.createTable('app_settings', (t) => {
|
||||
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
|
||||
});
|
||||
await db.schema.createTable('feature_flags', (t) => {
|
||||
t.string('key').primary(); t.boolean('value');
|
||||
});
|
||||
await db.schema.createTable('events', (t) => {
|
||||
t.increments('id'); t.text('color_theme'); t.string('external_path'); t.integer('css_template_id');
|
||||
});
|
||||
await db.schema.createTable('css_templates', (t) => {
|
||||
t.increments('id'); t.boolean('is_enabled'); t.text('css_content');
|
||||
});
|
||||
for (const table of ['email_configs', 'mail_accounts']) {
|
||||
await db.schema.createTable(table, (t) => { t.increments('id'); t.string('smtp_host'); });
|
||||
}
|
||||
await db.schema.createTable('whatsapp_configs', (t) => {
|
||||
t.increments('id'); t.boolean('enabled'); t.string('phone_number_id'); t.string('access_token');
|
||||
});
|
||||
|
||||
({ UsageService } = require('../../src/usage/UsageService'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE');
|
||||
await db.destroy();
|
||||
}
|
||||
fs.rmSync(bindingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('product_usage_markers').delete();
|
||||
await db('product_usage_state').delete();
|
||||
await db('product_usage_state').insert({ id: 1 });
|
||||
await db('events').delete();
|
||||
await db('css_templates').delete();
|
||||
await db('feature_flags').delete();
|
||||
await db('app_settings').delete();
|
||||
});
|
||||
|
||||
// The instance-binding file defaults to STORAGE_PATH, which is '/storage'
|
||||
// in a bare test process. Point it at a temp dir so the real binding code
|
||||
// runs rather than being stubbed out.
|
||||
const bindingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-usage-pg-'));
|
||||
|
||||
const service = (over = {}) =>
|
||||
new UsageService(db, {
|
||||
secret: 'p'.repeat(48),
|
||||
endpoint: 'http://127.0.0.1:9/',
|
||||
bindingPath: path.join(bindingDir, 'usage-instance.key'),
|
||||
fetch: async () => { throw new Error('collector unreachable in tests'); },
|
||||
...over,
|
||||
});
|
||||
|
||||
it('creates the columns with the types the code expects', async () => {
|
||||
const cols = await db('product_usage_state').columnInfo();
|
||||
expect(cols.cancel_seq).toBeDefined();
|
||||
expect(cols.cancel_requested).toBeUndefined(); // dropped by 203
|
||||
expect(cols.sequence).toBeDefined();
|
||||
expect(cols.privacy_receipts).toBeDefined();
|
||||
expect(cols.consent_version).toBeDefined();
|
||||
// next_attempt_at is a bigint like sequence and cancel_seq, so pg hands it
|
||||
// back as a STRING — the tick() gate compares it against a number.
|
||||
expect(cols.attempts).toBeDefined();
|
||||
expect(cols.next_attempt_at).toBeDefined();
|
||||
expect(cols.prompt_shown).toBeDefined();
|
||||
});
|
||||
|
||||
it('backfills the prompt for existing participation using PostgreSQL booleans', async () => {
|
||||
const migration = require('../../migrations/core/212_product_usage_prompt_shown');
|
||||
await migration.down(db);
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
expect(await service().status()).toMatchObject({ status: 'active', prompt_shown: true, consent_version: 'usage-consent.v2' });
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'disabled' });
|
||||
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true });
|
||||
});
|
||||
|
||||
it('persists a fresh installation declining without opting in on PostgreSQL', async () => {
|
||||
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: false });
|
||||
await service().markPromptShown();
|
||||
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
|
||||
});
|
||||
|
||||
it('reruns the backoff migration safely', async () => {
|
||||
const migration = require('../../migrations/core/206_product_usage_delivery_backoff');
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(Number(row.attempts)).toBe(0);
|
||||
expect(Number(row.next_attempt_at)).toBe(0);
|
||||
});
|
||||
|
||||
it('honours the retry gate even though pg returns next_attempt_at as a string', async () => {
|
||||
let clock = 5_000_000;
|
||||
let calls = 0;
|
||||
const identity = generateIdentity();
|
||||
const client = service({
|
||||
now: () => clock,
|
||||
fetch: async () => { calls += 1; throw new Error('collector unreachable'); },
|
||||
});
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'active',
|
||||
consent_version: 'usage-consent.v2',
|
||||
installation_id: identity.installation_id,
|
||||
public_key: identity.public_key,
|
||||
private_key_encrypted: client.encrypt(identity.private_key),
|
||||
sequence: 1,
|
||||
attempts: 0,
|
||||
next_attempt_at: 0,
|
||||
pending_packet: JSON.stringify(makePacket(identity, 'session', 2, {}, 'usage.v2')),
|
||||
});
|
||||
|
||||
await client.tick();
|
||||
expect(calls).toBe(1);
|
||||
const paced = await db('product_usage_state').where({ id: 1 }).first();
|
||||
// A '5000120000' > 5000000 string comparison would be a different answer.
|
||||
expect(typeof paced.next_attempt_at).toBe('string');
|
||||
await client.tick();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
clock = Number(paced.next_attempt_at) + 1;
|
||||
await client.tick();
|
||||
expect(calls).toBe(2);
|
||||
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'disabled', pending_packet: null, attempts: 0, next_attempt_at: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => {
|
||||
const migration = require('../../migrations/core/204_product_usage_privacy_receipts');
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
last_receipt: JSON.stringify({ status: 'accepted', session_token: 'synthetic-old-token' })
|
||||
});
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(JSON.parse(row.last_receipt)).toEqual({ status: 'accepted' });
|
||||
});
|
||||
|
||||
it('migration preserves v1 consent and v2 snapshot works with PostgreSQL booleans and optional modules', async () => {
|
||||
const migration = require('../../migrations/core/205_product_usage_consent_version');
|
||||
await migration.up(db); await migration.up(db);
|
||||
const svc = service();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
await svc.markUsed(['video_uploads']);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
|
||||
expect((await svc.status()).schema_version).toBe('usage.v1');
|
||||
await db('product_usage_state').where({ id: 1 }).update({ consent_version: 'usage-consent.v2' });
|
||||
await db('feature_flags').insert({ key: 'quotes', value: true });
|
||||
await db('app_settings').insert({ setting_key: 'general_allowed_file_types', setting_value: '"dng,mp4"' });
|
||||
await svc.markUsed(['video_uploads', 'gallery_downloads']);
|
||||
const report = await svc.snapshot();
|
||||
expect(Object.keys(report.features)).toHaveLength(73);
|
||||
expect(report.features.video_uploads).toEqual({ configured: true, used: true });
|
||||
expect(report.features.camera_raw_uploads).toEqual({ configured: true, used: false });
|
||||
expect(report.features.gallery_downloads).toEqual({ configured: false });
|
||||
expect(report.features.crm.configured).toBe(true);
|
||||
expect(report.features.api_integration.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('reads bigint cancel_seq correctly even though pg returns it as a string', async () => {
|
||||
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 5 });
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
// The thing SQLite hides: this is a string here.
|
||||
expect(typeof row.cancel_seq).toBe('string');
|
||||
expect(Number(row.cancel_seq)).toBe(5);
|
||||
});
|
||||
|
||||
it('honours a withdrawal that lands while an activation is starting', async () => {
|
||||
const svc = service();
|
||||
const realBinding = svc.binding.bind(svc);
|
||||
svc.binding = async (create = false) => {
|
||||
// The withdrawal lands inside the window where the row still reads
|
||||
// `disabled`, with the real binding write still happening.
|
||||
if (create) await svc.disable();
|
||||
return realBinding(create);
|
||||
};
|
||||
await svc.enable('usage-consent.v1');
|
||||
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(row.status).toBe('disabled');
|
||||
expect(row.installation_id).toBeNull();
|
||||
});
|
||||
|
||||
it('activates when no withdrawal arrives', async () => {
|
||||
await service().enable('usage-consent.v1');
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(row.status).toBe('activation_pending');
|
||||
expect(row.installation_id).not.toBeNull();
|
||||
});
|
||||
|
||||
it('records markers only while active, using SELECT ... FOR UPDATE', async () => {
|
||||
const svc = service();
|
||||
await svc.markUsed(['crm']);
|
||||
expect(await db('product_usage_markers').count('* as c').first()).toMatchObject({ c: '0' });
|
||||
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
await svc.markUsed(['crm', 'newsletters']);
|
||||
const rows = await db('product_usage_markers').pluck('feature');
|
||||
expect(rows.sort()).toEqual(['crm', 'newsletters']);
|
||||
|
||||
// onConflict().ignore() must not throw on a repeat.
|
||||
await svc.markUsed(['crm']);
|
||||
expect((await db('product_usage_markers').pluck('feature')).length).toBe(2);
|
||||
});
|
||||
|
||||
it('builds a report from real booleans, not 0/1', async () => {
|
||||
await db('feature_flags').insert([
|
||||
{ key: 'clients', value: true },
|
||||
{ key: 'newsletters', value: false },
|
||||
]);
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
await service().markUsed(['crm']);
|
||||
|
||||
const report = await service().snapshot();
|
||||
expect(report.features.crm.configured).toBe(true);
|
||||
expect(report.features.crm.used).toBe(true);
|
||||
expect(report.features.newsletters.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves preset layouts and template CSS on this engine too', async () => {
|
||||
const [tpl] = await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' }).returning('id');
|
||||
const templateId = typeof tpl === 'object' ? tpl.id : tpl;
|
||||
await db('events').insert([
|
||||
{ color_theme: 'modernMasonry' },
|
||||
{ color_theme: null, css_template_id: templateId },
|
||||
]);
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'theme_config',
|
||||
setting_value: JSON.stringify({ galleryLayout: 'carousel' }),
|
||||
});
|
||||
|
||||
const report = await service().snapshot();
|
||||
expect(report.gallery_layouts.sort()).toEqual(['carousel', 'masonry']);
|
||||
expect(report.features.custom_css.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,396 +0,0 @@
|
||||
/**
|
||||
* Publish without notifying, and send the gallery email later (#1235).
|
||||
*
|
||||
* Publishing queued the gallery_created email whenever any customer email
|
||||
* existed, with no opt-out — so a photographer with no address yet had to type
|
||||
* their OWN into the required field, publish, receive the client-facing email
|
||||
* themselves, and hand the link over by DM. That is the workaround this
|
||||
* removes.
|
||||
*
|
||||
* The send-later half is the part that makes it a workflow rather than a dead
|
||||
* end: publishing quietly is only useful if the real email can go out once the
|
||||
* address arrives.
|
||||
*
|
||||
* The default must not move. Every existing caller — the v1 API, an older
|
||||
* frontend, a script — omits the flag entirely and must keep notifying.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-publish-quiet-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'publish-quiet-test-secret';
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.mock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin/events', require('../../src/routes/adminEvents'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('email_queue').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
async function seedDraft({ slug, customerEmail = 'client@example.com', isDraft = true } = {}) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: `Event ${slug}`,
|
||||
event_date: '2026-09-01',
|
||||
host_email: customerEmail,
|
||||
admin_email: 'admin@example.com',
|
||||
customer_email: customerEmail,
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${slug}/share`,
|
||||
share_token: `${slug}-token`,
|
||||
require_password: 0,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: isDraft ? 1 : 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
const queuedFor = (eventId) =>
|
||||
db('email_queue').where({ event_id: eventId, email_type: 'gallery_created' });
|
||||
|
||||
describe('publish quietly (#1235)', () => {
|
||||
it('queues the gallery email by default — the flag being absent must not change anything', async () => {
|
||||
const id = await seedDraft({ slug: 'default-publish' });
|
||||
|
||||
const res = await request(app).post(`/admin/events/${id}/publish`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notified_customer).toBe(true);
|
||||
|
||||
expect(await queuedFor(id)).toHaveLength(1);
|
||||
const event = await db('events').where({ id }).first();
|
||||
expect(Number(event.is_draft)).toBe(0);
|
||||
});
|
||||
|
||||
it('publishes without queuing anything when notify_customer is false', async () => {
|
||||
const id = await seedDraft({ slug: 'quiet-publish' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/admin/events/${id}/publish`)
|
||||
.send({ notify_customer: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notified_customer).toBe(false);
|
||||
|
||||
// The whole point: live gallery, no email.
|
||||
expect(await queuedFor(id)).toHaveLength(0);
|
||||
const event = await db('events').where({ id }).first();
|
||||
expect(Number(event.is_draft)).toBe(0);
|
||||
});
|
||||
|
||||
it('sends the gallery email later, on demand', async () => {
|
||||
const id = await seedDraft({ slug: 'send-later' });
|
||||
await request(app).post(`/admin/events/${id}/publish`).send({ notify_customer: false });
|
||||
expect(await queuedFor(id)).toHaveLength(0);
|
||||
|
||||
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.recipient).toBe('client@example.com');
|
||||
|
||||
const queued = await queuedFor(id);
|
||||
expect(queued).toHaveLength(1);
|
||||
const data = JSON.parse(queued[0].email_data);
|
||||
expect(data.event_name).toBe('Event send-later');
|
||||
expect(data.gallery_link).toContain('send-later');
|
||||
});
|
||||
|
||||
it('refuses to send the gallery email for a draft — the link would not work yet', async () => {
|
||||
const id = await seedDraft({ slug: 'still-draft' });
|
||||
|
||||
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/draft/i);
|
||||
expect(await queuedFor(id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses to send when there is no recipient', async () => {
|
||||
const [row] = await db('events').insert({
|
||||
slug: 'no-email',
|
||||
event_type: 'wedding',
|
||||
event_name: 'No Email',
|
||||
event_date: '2026-09-01',
|
||||
host_email: '',
|
||||
admin_email: 'admin@example.com',
|
||||
customer_email: null,
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/no-email/share',
|
||||
share_token: 'no-email-token',
|
||||
require_password: 0,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const id = typeof row === 'object' ? row.id : row;
|
||||
|
||||
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no customer email/i);
|
||||
});
|
||||
|
||||
it('still publishes a gallery that has no recipient at all', async () => {
|
||||
const [row] = await db('events').insert({
|
||||
slug: 'quiet-no-email',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Quiet No Email',
|
||||
event_date: '2026-09-01',
|
||||
host_email: '',
|
||||
admin_email: 'admin@example.com',
|
||||
customer_email: null,
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/quiet-no-email/share',
|
||||
share_token: 'quiet-no-email-token',
|
||||
require_password: 0,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const id = typeof row === 'object' ? row.id : row;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/admin/events/${id}/publish`)
|
||||
.send({ notify_customer: false });
|
||||
expect(res.status).toBe(200);
|
||||
const event = await db('events').where({ id }).first();
|
||||
expect(Number(event.is_draft)).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses to send for an archived, inactive or expired gallery', async () => {
|
||||
// The link in the email would be rejected by the gallery middleware, so
|
||||
// sending it hands the customer a dead link with no explanation.
|
||||
const cases = [
|
||||
{ slug: 'arch-ev', patch: { is_archived: 1 }, match: /archived/i },
|
||||
{ slug: 'inactive-ev', patch: { is_active: 0 }, match: /inactive/i },
|
||||
{
|
||||
slug: 'expired-ev',
|
||||
patch: { expires_at: new Date(Date.now() - 3600 * 1000).toISOString() },
|
||||
match: /expired/i,
|
||||
},
|
||||
];
|
||||
for (const c of cases) {
|
||||
const id = await seedDraft({ slug: c.slug, isDraft: false });
|
||||
await db('events').where({ id }).update(c.patch);
|
||||
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(c.match);
|
||||
expect(await queuedFor(id)).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the password the admin supplies, instead of the sentinel', async () => {
|
||||
// password_hash is a hash, so the plaintext only exists in this request.
|
||||
// Without it the email says "(set at creation)", which cannot get anyone
|
||||
// into the gallery — and the send-later action is most useful right after
|
||||
// a quiet publish, the path that never collected a password.
|
||||
const id = await seedDraft({ slug: 'with-password', isDraft: false });
|
||||
await db('events').where({ id }).update({ require_password: 1 });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/admin/events/${id}/send-gallery-email`)
|
||||
.send({ password: 'Sup3r-Secret' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [queued] = await queuedFor(id);
|
||||
expect(JSON.parse(queued.email_data).gallery_password).toBe('Sup3r-Secret');
|
||||
});
|
||||
|
||||
it('persists a changed password so the emailed one actually works', async () => {
|
||||
// The dialog invites "or pick a new one". Queueing that plaintext without
|
||||
// rehashing would email a password the gallery rejects — worse than the
|
||||
// sentinel, because it looks usable.
|
||||
const id = await seedDraft({ slug: 'rehash', isDraft: false });
|
||||
await db('events').where({ id }).update({ require_password: 1, password_hash: 'stale-hash' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/admin/events/${id}/send-gallery-email`)
|
||||
.send({ password: 'Brand-New-Pass1' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.password_hash).not.toBe('stale-hash');
|
||||
expect(await bcrypt.compare('Brand-New-Pass1', row.password_hash)).toBe(true);
|
||||
|
||||
const [queued] = await queuedFor(id);
|
||||
expect(JSON.parse(queued.email_data).gallery_password).toBe('Brand-New-Pass1');
|
||||
});
|
||||
|
||||
it('does NOT touch the gallery password when only an account notice goes out', async () => {
|
||||
// customer_gallery_assigned links to the customer portal and never carries
|
||||
// a password. Rehashing for it would silently change the live gallery
|
||||
// password and lock out everyone holding the old one, for nothing.
|
||||
const [row] = await db('events').insert({
|
||||
slug: 'account-only',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Account Only',
|
||||
event_date: '2026-09-01',
|
||||
host_email: '',
|
||||
admin_email: 'admin@example.com',
|
||||
customer_email: null,
|
||||
password_hash: 'original-hash',
|
||||
require_password: 1,
|
||||
share_link: '/gallery/account-only/share',
|
||||
share_token: 'account-only-token',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const id = typeof row === 'object' ? row.id : row;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/admin/events/${id}/send-gallery-email`)
|
||||
.send({ password: 'should-not-be-applied' });
|
||||
|
||||
// No inline recipient and no assigned accounts in this fixture, so the
|
||||
// route refuses — but the password must be untouched either way.
|
||||
expect(res.status).toBe(400);
|
||||
const after = await db('events').where({ id }).first();
|
||||
expect(after.password_hash).toBe('original-hash');
|
||||
});
|
||||
|
||||
it('refuses to send when the only assigned account is passive', async () => {
|
||||
// A passive customer (created directly, never invited) is active and has
|
||||
// an address, but password_hash IS NULL — customerAuth rejects the login,
|
||||
// so the customer_gallery_assigned portal link goes to a door that will
|
||||
// not open. Reporting success here would leave the admin believing the
|
||||
// customer was told.
|
||||
const [evRow] = await db('events').insert({
|
||||
slug: 'passive-only',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Passive Only',
|
||||
event_date: '2026-09-01',
|
||||
host_email: '',
|
||||
admin_email: 'admin@example.com',
|
||||
customer_email: null,
|
||||
password_hash: 'original-hash',
|
||||
require_password: 1,
|
||||
share_link: '/gallery/passive-only/share',
|
||||
share_token: 'passive-only-token',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const eventId = typeof evRow === 'object' ? evRow.id : evRow;
|
||||
|
||||
const [custRow] = await db('customer_accounts').insert({
|
||||
email: 'passive@example.com',
|
||||
display_name: 'Passive Person',
|
||||
password_hash: null, // never invited
|
||||
is_active: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const customerId = typeof custRow === 'object' ? custRow.id : custRow;
|
||||
|
||||
await db('event_customer_assignments').insert({
|
||||
event_id: eventId,
|
||||
customer_account_id: customerId,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/admin/events/${eventId}/send-gallery-email`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no customer email/i);
|
||||
});
|
||||
|
||||
it('applies the configured gallery policy before rehashing, on both doors', async () => {
|
||||
// Both endpoints re-hash a plaintext the admin re-types, and both used to
|
||||
// validate it with nothing but isLength({min:6}) — so the configured
|
||||
// complexity governed creation and reset while these two accepted
|
||||
// 'aaaaaa' and made it the live gallery password.
|
||||
const draftId = await seedDraft({ slug: 'weak-publish' });
|
||||
await db('events').where({ id: draftId }).update({ require_password: 1 });
|
||||
|
||||
const publishRes = await request(app)
|
||||
.post(`/admin/events/${draftId}/publish`)
|
||||
.send({ password: 'aaaaaa' });
|
||||
|
||||
expect(publishRes.status).toBe(400);
|
||||
expect(publishRes.body.error).toMatch(/security requirements/i);
|
||||
|
||||
// And the same password must not sneak in through send-later, or a
|
||||
// gallery published quietly could still be weakened afterwards.
|
||||
const [row] = await db('events').insert({
|
||||
slug: 'weak-send',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Weak Send',
|
||||
event_date: '2026-09-01',
|
||||
host_email: '',
|
||||
admin_email: 'admin@example.com',
|
||||
customer_email: 'client@example.com',
|
||||
password_hash: 'original-hash',
|
||||
require_password: 1,
|
||||
share_link: '/gallery/weak-send/share',
|
||||
share_token: 'weak-send-token',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const sendId = typeof row === 'object' ? row.id : row;
|
||||
|
||||
const sendRes = await request(app)
|
||||
.post(`/admin/events/${sendId}/send-gallery-email`)
|
||||
.send({ password: 'aaaaaa' });
|
||||
|
||||
expect(sendRes.status).toBe(400);
|
||||
expect(sendRes.body.error).toMatch(/security requirements/i);
|
||||
// Rejected means untouched — not rejected after the write.
|
||||
const after = await db('events').where({ id: sendId }).first();
|
||||
expect(after.password_hash).toBe('original-hash');
|
||||
});
|
||||
|
||||
it('re-sending is allowed — a lost email should not need an unpublish/republish', async () => {
|
||||
const id = await seedDraft({ slug: 'resend' });
|
||||
await request(app).post(`/admin/events/${id}/publish`).send({});
|
||||
expect(await queuedFor(id)).toHaveLength(1);
|
||||
|
||||
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await queuedFor(id)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* Issue #866 — the createInvoice-free halves of the re-bill proof + CRM panel
|
||||
* feature, against a real SQLite schema:
|
||||
*
|
||||
* • listCustomerRebills — status DERIVED from the linked invoice lifecycle
|
||||
* (open / sent / paid; a cancelled/Storno'd cover drops back to open) plus
|
||||
* cost-vs-rebilled math and mode.
|
||||
* • collectRebillProofAttachments — the Send-dialog per-file selection, the
|
||||
* all-or-none default resolution (per-customer override else global), the
|
||||
* Beleg-<inv#> filename (suffix only when >1), and the missing-file marker.
|
||||
*
|
||||
* The invoice-MINTING paths (billCombinedForCustomer / billPendingRebills) call
|
||||
* createInvoice inside a db.transaction, which deadlocks on the SQLite harness
|
||||
* (global-db sequence write vs. held write lock) — same limitation the sibling
|
||||
* incomingInvoiceRebill.test.js documents. They're covered by the existing
|
||||
* billPendingRebills / billUnbilledEntries suites; here we hand-craft billed
|
||||
* state instead.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('#866 re-bill proof attachment + CRM panel', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminId;
|
||||
let expenseService;
|
||||
let rebillProofs;
|
||||
let flagCache;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const dbModule = require('../../src/database/db');
|
||||
dbModule.logActivity = async () => {};
|
||||
({ adminId } = await seedMinimal(db));
|
||||
expenseService = require('../../src/services/expenseService');
|
||||
rebillProofs = require('../../src/services/invoice/rebillProofs');
|
||||
flagCache = require('../../src/middleware/requireFeatureFlag');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
|
||||
let seq = 0;
|
||||
|
||||
async function makeCustomer(overrides = {}) {
|
||||
seq += 1;
|
||||
const ins = await db('customer_accounts').insert({
|
||||
email: `c866-${seq}@example.com`,
|
||||
display_name: `C866 ${seq}`,
|
||||
password_hash: 'x',
|
||||
preferred_language: 'de',
|
||||
is_active: 1,
|
||||
billing_cadence: 'per_event',
|
||||
created_at: new Date(),
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return unwrapId(ins);
|
||||
}
|
||||
|
||||
async function makeDoc(customerId, overrides = {}) {
|
||||
const ins = await db('inbound_documents').insert({
|
||||
source: 'upload', status: 'categorized', parse_status: 'parsed', parse_method: 'none',
|
||||
supplier_name: 'ACME AG', currency: 'CHF', total_amount_minor: 10000,
|
||||
invoice_date: '2026-06-01', disposition: 'rebill', customer_account_id: customerId,
|
||||
created_at: new Date(), updated_at: new Date(),
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return unwrapId(ins);
|
||||
}
|
||||
|
||||
async function makeInvoice(customerId, status, number) {
|
||||
const ins = await db('invoices').insert({
|
||||
invoice_number: number,
|
||||
customer_account_id: customerId,
|
||||
status,
|
||||
currency: 'CHF',
|
||||
issue_date: '2026-06-01', due_date: '2026-07-01',
|
||||
vat_rate: 0, net_amount_minor: 10000, vat_amount_minor: 0, total_amount_minor: 10000,
|
||||
created_at: new Date(), updated_at: new Date(),
|
||||
}).returning('id');
|
||||
return unwrapId(ins);
|
||||
}
|
||||
|
||||
describe('listCustomerRebills', () => {
|
||||
it('derives open / sent / paid and open→cost==rebilled for passthrough, +markup for rebill', async () => {
|
||||
const customerId = await makeCustomer();
|
||||
|
||||
// Open re-bill (10% markup): rebilled = 11000.
|
||||
await makeDoc(customerId, { total_amount_minor: 10000, markup_type: 'percent', markup_percent: 10 });
|
||||
// Open passthrough: no markup, rebilled == cost.
|
||||
await makeDoc(customerId, { disposition: 'durchlaufend', total_amount_minor: 5000, markup_type: 'none' });
|
||||
// Sent (on a 'sent' invoice).
|
||||
const sentInv = await makeInvoice(customerId, 'sent', 'R-2026-0001');
|
||||
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: sentInv });
|
||||
// Paid.
|
||||
const paidInv = await makeInvoice(customerId, 'paid', 'R-2026-0002');
|
||||
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: paidInv });
|
||||
// Cancelled cover → drops back to 'open', no invoice link surfaced.
|
||||
const cancInv = await makeInvoice(customerId, 'cancelled', 'R-2026-0003');
|
||||
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: cancInv });
|
||||
|
||||
const items = await expenseService.listCustomerRebills(customerId);
|
||||
const byStatus = (s) => items.filter((r) => r.status === s);
|
||||
|
||||
expect(items).toHaveLength(5);
|
||||
expect(byStatus('open')).toHaveLength(3); // 2 genuinely-open + 1 cancelled-cover
|
||||
expect(byStatus('sent')).toHaveLength(1);
|
||||
expect(byStatus('paid')).toHaveLength(1);
|
||||
|
||||
const rebill = items.find((r) => r.mode === 'rebill' && r.costMinor === 10000);
|
||||
expect(rebill.rebilledMinor).toBe(11000);
|
||||
const passthrough = items.find((r) => r.mode === 'passthrough');
|
||||
expect(passthrough.rebilledMinor).toBe(passthrough.costMinor);
|
||||
|
||||
const sent = byStatus('sent')[0];
|
||||
expect(sent.invoiceNumber).toBe('R-2026-0001');
|
||||
expect(sent.invoiceId).toBe(sentInv);
|
||||
|
||||
const cancelledCover = items.find((r) => r.status === 'open' && r.invoiceNumber === null && r.costMinor === 8000);
|
||||
expect(cancelledCover).toBeDefined(); // cancelled cover isn't shown as a live invoice link
|
||||
});
|
||||
});
|
||||
|
||||
describe('storno releases the re-bill linkage (#866 review)', () => {
|
||||
it("clears billed_invoice_id so a Storno'd cover returns to the billable pool", async () => {
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
const customerId = await makeCustomer();
|
||||
const invId = await makeInvoice(customerId, 'sent', 'R-2026-9000');
|
||||
const lineIns = await db('invoice_line_items').insert({
|
||||
invoice_id: invId, position: 1, quantity: 1, description: 'Rebill',
|
||||
unit_price_minor: 8000, discount_percent: 0, line_total_minor: 8000,
|
||||
}).returning('id');
|
||||
const lineId = unwrapId(lineIns);
|
||||
const docId = await makeDoc(customerId, {
|
||||
total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: invId, billed_invoice_line_item_id: lineId,
|
||||
});
|
||||
|
||||
// Storno claims a fresh number from document_sequences; the other tests
|
||||
// seed explicit R-2026-000x numbers without advancing it, so push the
|
||||
// counter past them to avoid a number collision (a test artifact — real
|
||||
// invoices always claim through the sequence).
|
||||
await db('document_sequences').insert({ kind: 'invoice', year: 2026, current_value: 9000, created_at: new Date(), updated_at: new Date() })
|
||||
.onConflict(['kind', 'year']).ignore();
|
||||
await db('document_sequences').where({ kind: 'invoice', year: 2026 }).update({ current_value: 9000 });
|
||||
|
||||
// Storno the covering invoice (the issued-cancel path).
|
||||
await db.transaction(async (trx) => invoiceService.createStorno(invId, adminId, trx));
|
||||
|
||||
const doc = await db('inbound_documents').where({ id: docId }).first();
|
||||
expect(doc.billed_invoice_id).toBeNull();
|
||||
expect(doc.billed_invoice_line_item_id).toBeNull();
|
||||
|
||||
// It now surfaces as a genuinely-open item AND the pending pool picks it up.
|
||||
const items = await expenseService.listCustomerRebills(customerId);
|
||||
const row = items.find((r) => r.id === docId);
|
||||
expect(row.status).toBe('open');
|
||||
expect(row.invoiceId).toBeNull();
|
||||
const pending = await db('inbound_documents')
|
||||
.where({ customer_account_id: customerId }).whereNull('billed_invoice_id')
|
||||
.whereIn('disposition', ['rebill', 'durchlaufend']).where('status', 'categorized');
|
||||
expect(pending.map((p) => p.id)).toContain(docId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectRebillProofAttachments', () => {
|
||||
const businessDocs = () => path.join(process.env.STORAGE_PATH, 'business-docs', 'inbound', '2026');
|
||||
|
||||
async function enableIncoming() {
|
||||
const existing = await db('feature_flags').where({ key: 'incomingInvoices' }).first();
|
||||
if (existing) await db('feature_flags').where({ key: 'incomingInvoices' }).update({ value: 1 });
|
||||
else await db('feature_flags').insert({ key: 'incomingInvoices', value: 1 });
|
||||
flagCache.invalidateFeatureFlagCache();
|
||||
}
|
||||
|
||||
function writeProof(name) {
|
||||
fs.mkdirSync(businessDocs(), { recursive: true });
|
||||
const p = path.join(businessDocs(), name);
|
||||
fs.writeFileSync(p, '%PDF-1.4\n% test proof\n');
|
||||
return p;
|
||||
}
|
||||
|
||||
it('honours explicit selection, names Beleg-<inv#>, and marks a missing file', async () => {
|
||||
await enableIncoming();
|
||||
const customerId = await makeCustomer();
|
||||
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-1000');
|
||||
const invoice = await db('invoices').where({ id: invId }).first();
|
||||
|
||||
const good1 = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('p1.pdf') });
|
||||
const good2 = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('p2.pdf') });
|
||||
const missing = await makeDoc(customerId, { billed_invoice_id: invId, file_path: path.join(businessDocs(), 'nope.pdf') });
|
||||
|
||||
// Select the two good proofs → two attachments, suffixed because >1.
|
||||
const both = await rebillProofs.collectRebillProofAttachments(invoice, null, [good1, good2]);
|
||||
expect(both.map((a) => a.filename).sort()).toEqual(['Beleg-R-2026-1000-1.pdf', 'Beleg-R-2026-1000-2.pdf']);
|
||||
|
||||
// Select exactly one → single, unsuffixed.
|
||||
const one = await rebillProofs.collectRebillProofAttachments(invoice, null, [good1]);
|
||||
expect(one).toHaveLength(1);
|
||||
expect(one[0].filename).toBe('Beleg-R-2026-1000.pdf');
|
||||
|
||||
// Select the missing-file doc → no attachment, but a marker is persisted.
|
||||
const none = await rebillProofs.collectRebillProofAttachments(invoice, null, [missing]);
|
||||
expect(none).toHaveLength(0);
|
||||
const markerRow = await db('inbound_documents').where({ id: missing }).first('proof_attach_error');
|
||||
expect(markerRow.proof_attach_error).toBeTruthy();
|
||||
// A successful attach clears any prior marker.
|
||||
await rebillProofs.collectRebillProofAttachments(invoice, null, [good1]);
|
||||
const cleared = await db('inbound_documents').where({ id: good1 }).first('proof_attach_error');
|
||||
expect(cleared.proof_attach_error).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the all-or-none default from the per-customer override then global', async () => {
|
||||
await enableIncoming();
|
||||
const customerId = await makeCustomer();
|
||||
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-2000');
|
||||
const invoice = await db('invoices').where({ id: invId }).first();
|
||||
await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('d1.pdf') });
|
||||
|
||||
// Global default off, no override → none.
|
||||
const off = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: null }, undefined);
|
||||
expect(off).toHaveLength(0);
|
||||
|
||||
// Per-customer override ON → all, regardless of the (off) global.
|
||||
const on = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: true }, undefined);
|
||||
expect(on).toHaveLength(1);
|
||||
|
||||
// Global ON (no override) → all.
|
||||
await db('app_settings').insert({ setting_key: 'accounting_rebill_attach_proof', setting_value: JSON.stringify(true), setting_type: 'accounting' });
|
||||
const globalOn = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: null }, undefined);
|
||||
expect(globalOn).toHaveLength(1);
|
||||
// Override OFF beats global ON.
|
||||
const overrideOff = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: false }, undefined);
|
||||
expect(overrideOff).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('attaches nothing when the incoming-invoices flag is off', async () => {
|
||||
const existing = await db('feature_flags').where({ key: 'incomingInvoices' }).first();
|
||||
if (existing) await db('feature_flags').where({ key: 'incomingInvoices' }).update({ value: 0 });
|
||||
else await db('feature_flags').insert({ key: 'incomingInvoices', value: 0 });
|
||||
flagCache.invalidateFeatureFlagCache();
|
||||
|
||||
const customerId = await makeCustomer();
|
||||
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-3000');
|
||||
const invoice = await db('invoices').where({ id: invId }).first();
|
||||
const doc = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('f1.pdf') });
|
||||
|
||||
const res = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: true }, [doc]);
|
||||
expect(res).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* scripts/regenerate-thumbnails.js against external photos (#1148).
|
||||
*
|
||||
* The same defect #1129 fixed in the admin route, still standing in the CLI
|
||||
* fallback: the script resolved every source as
|
||||
* `storage/events/active/<photo.path>` and fs.access'd it. External and
|
||||
* reference rows do not live there — their originals sit under
|
||||
* `events.external_path` — so every one failed the check and was counted as an
|
||||
* error. On an install where all photos are external the script did nothing at
|
||||
* all, while reporting one error per photo.
|
||||
*
|
||||
* Driven against a REAL file on a REAL external mount with the real
|
||||
* imageProcessor, not a mock: the whole point is that the source resolves off
|
||||
* the mount, and a mocked ensureThumbnail would assert nothing about that.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
describe('regenerate-thumbnails script (#1148)', () => {
|
||||
let tmpDir; let db; let cleanup; let regenerateThumbnails;
|
||||
let eventId; let externalPhotoId; let videoPhotoId; let watcherVideoId; let repairPhotoId;
|
||||
let vanishingPhotoId;
|
||||
let externalRoot;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-script-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
// External sources are sandboxed under EXTERNAL_MEDIA_ROOT. Rows carry a
|
||||
// path relative to that root (#1163), so the 'wedding/' prefix on each
|
||||
// external_relpath below is the event folder, not decoration.
|
||||
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpDir, 'media');
|
||||
externalRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'wedding');
|
||||
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
await fs.promises.mkdir(externalRoot, { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
// A real image on the external mount — never under events/active.
|
||||
await sharp({
|
||||
create: { width: 1200, height: 800, channels: 3, background: { r: 10, g: 90, b: 160 } },
|
||||
}).jpeg().toFile(path.join(externalRoot, 'shot.jpg'));
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: 'regen-script-event',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Regen Script',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/regen-script-event/share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
source_mode: 'reference',
|
||||
external_path: 'wedding',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'shot.jpg',
|
||||
// `path` is what the old script joined onto events/active. Left
|
||||
// populated on purpose: the fix must ignore it for an external row.
|
||||
path: 'regen-script-event/shot.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'wedding/shot.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
externalPhotoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const [v] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'clip.mp4',
|
||||
path: 'regen-script-event/clip.mp4',
|
||||
type: 'individual',
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'wedding/clip.mp4',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
videoPhotoId = typeof v === 'object' ? v.id : v;
|
||||
|
||||
// How fileWatcher.processNewPhoto actually writes a video: `type` and
|
||||
// `mime_type` set, media_type left to its 'image' default. A media_type-only
|
||||
// filter lets this through and hands the container to Sharp.
|
||||
//
|
||||
// The file has to EXIST, otherwise the row fails resolution and looks
|
||||
// skipped for the wrong reason — the bug is Sharp being handed a video, not
|
||||
// a missing source. Real MP4 header bytes, no image in sight.
|
||||
await fs.promises.writeFile(
|
||||
path.join(externalRoot, 'watched.mp4'),
|
||||
Buffer.from('00000018667479706d70343200000000', 'hex')
|
||||
);
|
||||
const [wv] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'watched.mp4',
|
||||
path: 'regen-script-event/watched.mp4',
|
||||
type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'wedding/watched.mp4',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
watcherVideoId = typeof wv === 'object' ? wv.id : wv;
|
||||
expect((await db('photos').where('id', watcherVideoId).first()).media_type).not.toBe('video');
|
||||
|
||||
// A photo whose thumbnail_path points at something that is no longer there.
|
||||
await sharp({
|
||||
create: { width: 900, height: 600, channels: 3, background: { r: 200, g: 40, b: 40 } },
|
||||
}).jpeg().toFile(path.join(externalRoot, 'repair.jpg'));
|
||||
const [rp] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'repair.jpg',
|
||||
path: 'regen-script-event/repair.jpg',
|
||||
type: 'individual',
|
||||
thumbnail_path: 'thumbnails/thumb_ext_missing_repair.jpg',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'wedding/repair.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
repairPhotoId = typeof rp === 'object' ? rp.id : rp;
|
||||
|
||||
// A photo whose source will be removed after its canonical thumbnail is
|
||||
// cached — the "mount went away" case, where the canonical rendition is
|
||||
// served from cache but a tier still needs to read the original.
|
||||
await sharp({
|
||||
create: { width: 1000, height: 700, channels: 3, background: { r: 30, g: 140, b: 60 } },
|
||||
}).jpeg().toFile(path.join(externalRoot, 'vanishing.jpg'));
|
||||
const [vp] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'vanishing.jpg',
|
||||
path: 'regen-script-event/vanishing.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'wedding/vanishing.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
vanishingPhotoId = typeof vp === 'object' ? vp.id : vp;
|
||||
|
||||
({ regenerateThumbnails } = require('../../scripts/regenerate-thumbnails'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('builds a thumbnail for an external photo instead of erroring on events/active', async () => {
|
||||
// The location the old script computed and fs.access'd. Nothing is there,
|
||||
// which is the whole defect — it is not where an external original lives.
|
||||
// (The old script cannot be driven from a test directly: it had no export
|
||||
// and ran on require, calling process.exit. Making it importable is part
|
||||
// of this fix.)
|
||||
const legacyPath = path.join(process.env.STORAGE_PATH, 'events/active', 'regen-script-event/shot.jpg');
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
|
||||
const result = await regenerateThumbnails(eventId, { tiers: false });
|
||||
|
||||
// The old script reported an error for this photo and wrote nothing.
|
||||
expect(result.errorCount).toBe(0);
|
||||
// The external photo, the repair row and the vanishing one; no video.
|
||||
expect(result.successCount).toBe(3);
|
||||
|
||||
const row = await db('photos').where('id', externalPhotoId).first();
|
||||
expect(row.thumbnail_path).toBeTruthy();
|
||||
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
// Named per-photo so two events referencing one NAS basename cannot
|
||||
// clobber each other — the property ensureThumbnail owns and the reason
|
||||
// the script must not build this name itself.
|
||||
expect(path.basename(row.thumbnail_path)).toContain(`ext${externalPhotoId}_`);
|
||||
});
|
||||
|
||||
it('leaves videos alone', async () => {
|
||||
// A video thumbnail is a poster frame from videoProcessor; handing the
|
||||
// container to Sharp produced one error per video row.
|
||||
const row = await db('photos').where('id', videoPhotoId).first();
|
||||
expect(row.thumbnail_path).toBeFalsy();
|
||||
});
|
||||
|
||||
it('leaves a watcher-imported video alone, which carries no media_type', async () => {
|
||||
// fileWatcher writes type + mime_type and lets media_type default to
|
||||
// 'image', so filtering on media_type alone still fed these to Sharp. The
|
||||
// signal is errorCount: the images are already done by now, so the only
|
||||
// thing that can fail this run is a video reaching Sharp.
|
||||
const result = await regenerateThumbnails(eventId, { tiers: false });
|
||||
|
||||
expect(result.errorCount).toBe(0);
|
||||
const row = await db('photos').where('id', watcherVideoId).first();
|
||||
expect(row.thumbnail_path).toBeFalsy();
|
||||
});
|
||||
|
||||
it('is idempotent — a second run skips instead of rebuilding', async () => {
|
||||
const before = await db('photos').where('id', externalPhotoId).first();
|
||||
const result = await regenerateThumbnails(eventId, { tiers: false });
|
||||
|
||||
expect(result.errorCount).toBe(0);
|
||||
expect(result.successCount).toBe(0);
|
||||
expect(result.skipCount).toBe(3);
|
||||
|
||||
const after = await db('photos').where('id', externalPhotoId).first();
|
||||
expect(after.thumbnail_path).toBe(before.thumbnail_path);
|
||||
});
|
||||
|
||||
it('counts a repaired thumbnail as generated, not skipped', async () => {
|
||||
// Both images are valid at this point. Destroy ONE thumbnail object while
|
||||
// leaving thumbnail_path pointing at it — the corrupt/missing case.
|
||||
const row = await db('photos').where('id', repairPhotoId).first();
|
||||
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
|
||||
await fs.promises.rm(onDisk);
|
||||
|
||||
const result = await regenerateThumbnails(eventId, { tiers: false });
|
||||
|
||||
// On local and external storage the rebuilt key is identical, so inferring
|
||||
// "skipped" from an unchanged path reports this repair as already valid —
|
||||
// the one number an operator running this is actually reading.
|
||||
expect(result.successCount).toBe(1);
|
||||
expect(result.skipCount).toBe(2);
|
||||
expect(result.errorCount).toBe(0);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
});
|
||||
|
||||
it('backfills the responsive tiers, which is what a backfill is for', async () => {
|
||||
// The tiers (#1095/#1109) are cached separately from thumbnail_path, so a
|
||||
// gallery can hold every canonical rendition and still serve phones the
|
||||
// full-size image. The old script only ever produced `thumb_<filename>` at
|
||||
// a hard-coded 300px and could not backfill them at all.
|
||||
const { THUMBNAIL_WIDTHS } = require('../../src/services/imageProcessor');
|
||||
const imageRows = 3; // external, repaired and vanishing; videos excluded
|
||||
const result = await regenerateThumbnails(eventId, { tiers: true });
|
||||
|
||||
expect(result.errorCount).toBe(0);
|
||||
expect(result.tierCount).toBe(THUMBNAIL_WIDTHS.length * imageRows);
|
||||
expect(result.tierFailures).toBe(0);
|
||||
});
|
||||
|
||||
it('reports tiers it could not build instead of claiming success', async () => {
|
||||
// ensureThumbnailAtWidth handles the expected failures itself and returns
|
||||
// NULL rather than throwing — an unreachable mount, a storage write that
|
||||
// did not land. A try/catch alone never sees those, so the run counted
|
||||
// zero errors and printed a clean summary after backfilling nothing.
|
||||
//
|
||||
// Reproduced the honest way: cache the canonical rendition, then take the
|
||||
// source away. The canonical is served from cache; the tiers still need
|
||||
// the original.
|
||||
const row = await db('photos').where('id', vanishingPhotoId).first();
|
||||
expect(row.thumbnail_path).toBeTruthy();
|
||||
|
||||
const { deleteThumbnailTiers } = require('../../src/services/imageProcessor');
|
||||
await deleteThumbnailTiers(row).catch(() => {});
|
||||
await fs.promises.rm(path.join(externalRoot, 'vanishing.jpg'));
|
||||
|
||||
const result = await regenerateThumbnails(eventId, { tiers: true });
|
||||
|
||||
expect(result.tierFailures).toBeGreaterThan(0);
|
||||
// Still not an error against the photo: the canonical rendition is intact
|
||||
// and the gallery falls back to it.
|
||||
expect(result.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
/** Run the CLI the way cron does, and hand back its exit status. */
|
||||
const runCli = (args = []) => new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[path.join(__dirname, '..', '..', 'scripts', 'regenerate-thumbnails.js'), ...args],
|
||||
{ env: { ...process.env }, cwd: path.join(__dirname, '..', '..') },
|
||||
(error, stdout, stderr) => resolve({ code: error?.code ?? 0, stdout, stderr })
|
||||
);
|
||||
});
|
||||
|
||||
it('exits nonzero when work was left unfinished', async () => {
|
||||
// Exit status is the only thing a cron job reads. `vanishing.jpg` still
|
||||
// has no source, so its tiers cannot be built.
|
||||
const failed = await runCli([String(eventId)]);
|
||||
expect(failed.code).toBe(1);
|
||||
expect(failed.stderr).toContain('completed with failures');
|
||||
}, 120000);
|
||||
|
||||
it('exits zero when there is nothing left to do', async () => {
|
||||
// Same event with tiers switched off: every canonical rendition is already
|
||||
// valid, so a clean run must not cry wolf at automation.
|
||||
const ok = await runCli([String(eventId), '--no-tiers']);
|
||||
expect(ok.code).toBe(0);
|
||||
expect(ok.stdout).toContain('Script completed successfully');
|
||||
}, 120000);
|
||||
});
|
||||
@@ -13,14 +13,14 @@ const { execFileSync } = require('child_process');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -183,24 +183,22 @@ describe('restoreService — PG branch scope contract (PR #596 review)', () => {
|
||||
expect(window).toMatch(/was_successful:\s*true/);
|
||||
});
|
||||
|
||||
it('the safe migration runner is invoked after the replay in restore()', () => {
|
||||
it('npm run migrate:safe is invoked after the replay in restore()', () => {
|
||||
// Contract from PR #596 round 4: backups taken on older picpeak
|
||||
// versions must restore COMPLETELY on a newer image — even if new
|
||||
// migrations have been added since the backup was taken. The
|
||||
// restore() flow shells out to the safe migration runner AFTER the
|
||||
// restore() flow shells out to `npm run migrate:safe` AFTER the
|
||||
// operator-meta replay so the schema catches up to the running
|
||||
// code WITHIN the restore boundary (not on the next container
|
||||
// restart). Invoked as `node migrations/run-migrations-safe.js` —
|
||||
// the runtime image ships no npm, so the former `npm run
|
||||
// migrate:safe` would ENOENT into the non-fatal catch.
|
||||
// restart).
|
||||
//
|
||||
// Contract:
|
||||
// 1. A run-migrations-safe shell-out exists somewhere in restoreService
|
||||
// 1. A `migrate:safe` shell-out exists somewhere in restoreService
|
||||
// 2. It sits AFTER the replay drain — verification → replay →
|
||||
// migrations is the documented order
|
||||
// 3. It does NOT sit inside performDatabaseRestore (must run
|
||||
// against the reinit'd pool from the parent restore())
|
||||
const migrateLine = findFirst(/run-migrations-safe\.js/);
|
||||
const migrateLine = findFirst(/['"]migrate:safe['"]/);
|
||||
expect(migrateLine).toBeGreaterThan(0);
|
||||
|
||||
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
/**
|
||||
* Reveal mode integration tests (#838).
|
||||
*
|
||||
* Pins the contract:
|
||||
* - effective visibility is computed at request time (isGalleryHidden):
|
||||
* reveal_at in the past opens the gate even before the scheduler stamps
|
||||
* - /photos returns the event shell with photos: [] + hidden_until_reveal
|
||||
* for plain guests; slideshow / client / admin-preview see everything
|
||||
* - image + download endpoints 403 with GALLERY_HIDDEN for plain guests
|
||||
* - the guest upload route is NOT gated (uploading while hidden is the point)
|
||||
* - the scheduler stamps revealed_at for due events, exactly once
|
||||
* - POST /events/:id/reveal stamps revealed_at (idempotent, 400 when the
|
||||
* mode is off); re-enabling reveal_mode clears revealed_at (re-hide)
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'reveal-test-secret';
|
||||
|
||||
const SLUG = 'reveal-test-event';
|
||||
|
||||
describe('Reveal mode (#838)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let photoIds;
|
||||
let adminToken;
|
||||
const { isGalleryHidden } = require('../../src/utils/revealMode');
|
||||
|
||||
const galleryToken = (extra = {}) => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Reveal Test',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'reveal-test-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
allow_user_uploads: 1,
|
||||
reveal_mode: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
photoIds = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `photo-${i}.jpg`,
|
||||
path: `events/reveal/${i}.jpg`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoIds.push(p[0]?.id ?? p[0]);
|
||||
}
|
||||
|
||||
// Super admin for the admin routes.
|
||||
const superRole = await db('roles').where({ name: 'super_admin' }).first();
|
||||
const [rootId] = await db('admin_users').insert({
|
||||
username: 'reveal-admin',
|
||||
email: 'reveal-admin@example.com',
|
||||
password_hash: await bcrypt.hash('RevealAdmin123', 4),
|
||||
role_id: superRole.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
||||
adminToken = jwt.sign(
|
||||
{ id: rootId, username: 'reveal-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/secure-images', require('../../src/routes/secureImages'));
|
||||
app.use('/api/images', require('../../src/routes/protectedImages'));
|
||||
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('effective visibility math (isGalleryHidden)', () => {
|
||||
const base = { reveal_mode: true, revealed_at: null, reveal_at: null };
|
||||
it('is hidden while armed and unrevealed, visible otherwise', () => {
|
||||
expect(isGalleryHidden({ ...base })).toBe(true);
|
||||
expect(isGalleryHidden({ ...base, reveal_mode: false })).toBe(false);
|
||||
expect(isGalleryHidden({ ...base, revealed_at: new Date() })).toBe(false);
|
||||
// reveal_at in the past opens the gate WITHOUT any stamp — time-exact.
|
||||
expect(isGalleryHidden({ ...base, reveal_at: new Date(Date.now() - 60_000) })).toBe(false);
|
||||
expect(isGalleryHidden({ ...base, reveal_at: new Date(Date.now() + 60_000) })).toBe(true);
|
||||
// SQLite 0/1 booleans
|
||||
expect(isGalleryHidden({ reveal_mode: 1, revealed_at: null, reveal_at: null })).toBe(true);
|
||||
expect(isGalleryHidden({ reveal_mode: 0, revealed_at: null, reveal_at: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gallery routes while hidden', () => {
|
||||
it('/photos gives plain guests the shell with no photos and the flag', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hidden_until_reveal).toBe(true);
|
||||
expect(res.body.photos).toEqual([]);
|
||||
expect(res.body.categories).toEqual([]);
|
||||
expect(res.body.event.event_name).toBe('Reveal Test');
|
||||
});
|
||||
|
||||
it('/photos serves the slideshow token everything (surprise beamer)', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'slideshow' })}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hidden_until_reveal).toBe(false);
|
||||
expect(res.body.photos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('/photos serves client access everything (host review)', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'client' })}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hidden_until_reveal).toBe(false);
|
||||
expect(res.body.photos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('/photos serves the admin preview everything (new transport: ?admin_preview=1 + admin cookie, even with a coexisting gallery session)', async () => {
|
||||
// #868/#981: reveal-mode hiding is bypassed for an admin preview via the
|
||||
// new transport (explicit flag + httpOnly admin_token cookie), NOT the
|
||||
// retired ?preview=<jwt>. The coexisting gallery Bearer must not shadow it.
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos?admin_preview=1`)
|
||||
.set('Cookie', [`admin_token=${adminToken}`])
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hidden_until_reveal).toBe(false);
|
||||
expect(res.body.photos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('image and download endpoints 403 with GALLERY_HIDDEN for plain guests', async () => {
|
||||
for (const url of [
|
||||
`/api/gallery/${SLUG}/thumbnail/${photoIds[0]}`,
|
||||
`/api/gallery/${SLUG}/photo/${photoIds[0]}`,
|
||||
`/api/gallery/${SLUG}/download/${photoIds[0]}`,
|
||||
`/api/gallery/${SLUG}/download-all`,
|
||||
`/api/gallery/${SLUG}/stats`,
|
||||
`/api/gallery/${SLUG}/hero/${photoIds[0]}`,
|
||||
]) {
|
||||
const res = await request(app).get(url).set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(`${url}:${res.status}`).toBe(`${url}:403`);
|
||||
expect(res.body.code).toBe('GALLERY_HIDDEN');
|
||||
}
|
||||
});
|
||||
|
||||
it('image endpoints are NOT reveal-blocked for the slideshow token', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/thumbnail/${photoIds[0]}`)
|
||||
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'slideshow' })}`);
|
||||
// The seeded file doesn't exist on disk, so anything but the reveal
|
||||
// gate's 403 is fine here.
|
||||
expect(res.body.code).not.toBe('GALLERY_HIDDEN');
|
||||
});
|
||||
|
||||
it('/info exposes the effective hidden state without auth', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/info`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hidden_until_reveal).toBe(true);
|
||||
});
|
||||
|
||||
it('the guest upload route is not gated', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/gallery/${eventId}/upload`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.send({});
|
||||
// Fails later for other reasons (no multipart body) — but never on the
|
||||
// reveal gate.
|
||||
expect(res.body.code).not.toBe('GALLERY_HIDDEN');
|
||||
});
|
||||
|
||||
it('legacy protected-image routes are reveal-gated for plain guests', async () => {
|
||||
for (const [method, url] of [
|
||||
['get', `/api/images/${SLUG}/photo/${photoIds[0]}/view`],
|
||||
['post', `/api/images/${SLUG}/photo/${photoIds[0]}/generate-secure-token`],
|
||||
['post', `/api/images/${SLUG}/photo/${photoIds[0]}/generate-url`],
|
||||
]) {
|
||||
const res = await request(app)[method](url).set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(`${url}:${res.status}`).toBe(`${url}:403`);
|
||||
expect(res.body.code).toBe('GALLERY_HIDDEN');
|
||||
}
|
||||
});
|
||||
|
||||
it('feedback endpoints are reveal-gated; my-feedback degrades to empty', async () => {
|
||||
// Feedback must be enabled for the routes to get past their own gate.
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId, feedback_enabled: 1, allow_likes: 1,
|
||||
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
|
||||
});
|
||||
const getRes = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos/${photoIds[0]}/feedback`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(getRes.status).toBe(403);
|
||||
expect(getRes.body.code).toBe('GALLERY_HIDDEN');
|
||||
|
||||
const postRes = await request(app)
|
||||
.post(`/api/gallery/${SLUG}/photos/${photoIds[0]}/feedback`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.send({ feedback_type: 'like' });
|
||||
expect(postRes.status).toBe(403);
|
||||
expect(postRes.body.code).toBe('GALLERY_HIDDEN');
|
||||
|
||||
const mine = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/my-feedback`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(mine.status).toBe(200);
|
||||
expect(mine.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('secure-image token minting is reveal-gated for plain guests', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/secure-images/${SLUG}/generate-token`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.send({ photoId: photoIds[0] });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('GALLERY_HIDDEN');
|
||||
});
|
||||
|
||||
it('customer-portal tokens (via:customer, no accessLevel) bypass reveal mode', async () => {
|
||||
const acct = await db('customer_accounts').insert({
|
||||
email: 'portal-customer@example.com',
|
||||
password_hash: 'x',
|
||||
is_active: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const customerId = acct[0]?.id ?? acct[0];
|
||||
await db('event_customer_assignments').insert({
|
||||
event_id: eventId,
|
||||
customer_account_id: customerId,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken({ via: 'customer', customerId })}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hidden_until_reveal).toBe(false);
|
||||
expect(res.body.photos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('a reveal_at in the past opens the gate without any stamp', async () => {
|
||||
await db('events').where('id', eventId).update({ reveal_at: new Date(Date.now() - 60_000).toISOString() });
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(res.body.hidden_until_reveal).toBe(false);
|
||||
expect(res.body.photos).toHaveLength(2);
|
||||
await db('events').where('id', eventId).update({ reveal_at: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduler and admin reveal', () => {
|
||||
it('the scheduler stamps revealed_at for due events exactly once', async () => {
|
||||
const revealAt = new Date(Date.now() - 5 * 60_000);
|
||||
await db('events').where('id', eventId).update({ reveal_at: revealAt.toISOString(), revealed_at: null });
|
||||
|
||||
const { checkScheduledReveals } = require('../../src/services/revealScheduler');
|
||||
await checkScheduledReveals();
|
||||
|
||||
const asMs = (v) => new Date(v).getTime();
|
||||
const row = await db('events').where('id', eventId).first();
|
||||
expect(row.revealed_at).not.toBeNull();
|
||||
expect(asMs(row.revealed_at)).toBe(revealAt.getTime());
|
||||
expect(row.reveal_at).toBeNull(); // schedule consumed, like "Reveal now"
|
||||
|
||||
// Second pass no-ops (revealed_at already set).
|
||||
await checkScheduledReveals();
|
||||
const again = await db('events').where('id', eventId).first();
|
||||
expect(asMs(again.revealed_at)).toBe(revealAt.getTime());
|
||||
|
||||
await db('events').where('id', eventId).update({ reveal_at: null, revealed_at: null });
|
||||
});
|
||||
|
||||
it('POST /:id/reveal stamps revealed_at, clears the schedule, and is idempotent', async () => {
|
||||
await db('events').where('id', eventId).update({ reveal_at: new Date(Date.now() + 3600_000).toISOString() });
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${eventId}/reveal`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.revealed_at).toBeTruthy();
|
||||
// "Reveal now" consumes the pending schedule.
|
||||
const cleared = await db('events').where('id', eventId).first();
|
||||
expect(cleared.reveal_at).toBeNull();
|
||||
|
||||
const first = res.body.revealed_at;
|
||||
const res2 = await request(app)
|
||||
.post(`/api/admin/events/${eventId}/reveal`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.revealed_at).toBe(first);
|
||||
|
||||
// Guests see photos now.
|
||||
const gallery = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(gallery.body.hidden_until_reveal).toBe(false);
|
||||
expect(gallery.body.photos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('re-enabling reveal_mode clears revealed_at (re-hide)', async () => {
|
||||
await db('events').where('id', eventId).update({ reveal_mode: 0 });
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/events/${eventId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ reveal_mode: true });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('events').where('id', eventId).first();
|
||||
expect(row.revealed_at).toBeNull();
|
||||
|
||||
const gallery = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(gallery.body.hidden_until_reveal).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduling a FUTURE reveal on a revealed gallery re-arms hiding', async () => {
|
||||
// State: revealed (previous tests). Saving a future schedule re-hides.
|
||||
await db('events').where('id', eventId).update({ revealed_at: new Date().toISOString() });
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/events/${eventId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ reveal_mode: true, reveal_at: new Date(Date.now() + 3600_000).toISOString() });
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where('id', eventId).first();
|
||||
expect(row.revealed_at).toBeNull();
|
||||
|
||||
const gallery = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(gallery.body.hidden_until_reveal).toBe(true);
|
||||
await db('events').where('id', eventId).update({ reveal_at: null });
|
||||
});
|
||||
|
||||
it('re-arming without a schedule clears a stale PAST reveal_at', async () => {
|
||||
// Legacy/partial-API state: revealed with the old past schedule still
|
||||
// stored. {reveal_mode:false} then {reveal_mode:true} without
|
||||
// reveal_at must re-hide, not instantly re-open via the stale date.
|
||||
await db('events').where('id', eventId).update({
|
||||
reveal_mode: 0,
|
||||
revealed_at: new Date().toISOString(),
|
||||
reveal_at: new Date(Date.now() - 3600_000).toISOString(),
|
||||
});
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/events/${eventId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ reveal_mode: true });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('events').where('id', eventId).first();
|
||||
expect(row.revealed_at).toBeNull();
|
||||
expect(row.reveal_at).toBeNull();
|
||||
|
||||
const gallery = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(gallery.body.hidden_until_reveal).toBe(true);
|
||||
expect(gallery.body.photos).toEqual([]);
|
||||
});
|
||||
|
||||
it('POST /:id/reveal 400s while reveal mode is off', async () => {
|
||||
await db('events').where('id', eventId).update({ reveal_mode: 0, revealed_at: null });
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${eventId}/reveal`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(400);
|
||||
await db('events').where('id', eventId).update({ reveal_mode: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ beforeAll(async () => {
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
|
||||
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
|
||||
}, 120000);
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
@@ -105,133 +105,6 @@ describe('setupService (first-run bootstrap)', () => {
|
||||
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
|
||||
});
|
||||
|
||||
it('restores 0600 on a token file that already existed with looser permissions (#1218)', async () => {
|
||||
// fs.writeFileSync's `mode` applies only when the file is created, so
|
||||
// writing over a 0644 file left the first-admin credential group- and
|
||||
// world-readable while the code claimed otherwise. On a NAS the volume is
|
||||
// often a shared mount, which is exactly where that matters.
|
||||
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
|
||||
fs.writeFileSync(canonical, 'stale\n', { mode: 0o644 });
|
||||
fs.chmodSync(canonical, 0o644);
|
||||
expect(fs.statSync(canonical).mode & 0o777).toBe(0o644);
|
||||
|
||||
const token = await setupService.ensureSetupToken();
|
||||
|
||||
expect(fs.readFileSync(canonical, 'utf8').trim()).toBe(token);
|
||||
expect(fs.statSync(canonical).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('never publishes a token it cannot make private (#1218)', async () => {
|
||||
// The CIFS/SMB case this targets: the mount carries no Unix modes, so
|
||||
// chmod is a silent no-op. The check runs on the temporary file, before
|
||||
// the rename, so a credential that cannot be protected never reaches the
|
||||
// published path at all.
|
||||
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
|
||||
try { fs.unlinkSync(canonical); } catch (_) { /* start clean */ }
|
||||
const chmodSpy = jest.spyOn(fs, 'chmodSync').mockImplementation(() => {});
|
||||
const realLstat = fs.lstatSync;
|
||||
const lstatSpy = jest.spyOn(fs, 'lstatSync').mockImplementation((target, ...rest) => {
|
||||
const st = realLstat(target, ...rest);
|
||||
return String(target).includes('SETUP_TOKEN')
|
||||
? { ...st, mode: (st.mode & ~0o777) | 0o644 }
|
||||
: st;
|
||||
});
|
||||
|
||||
try {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
// Setup stays completable — server.js prints the token on stdout when no
|
||||
// file was written — but nothing readable was left on the volume.
|
||||
expect(token).toBeTruthy();
|
||||
expect(fs.existsSync(canonical)).toBe(false);
|
||||
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
|
||||
} finally {
|
||||
chmodSpy.mockRestore();
|
||||
lstatSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the token out of the log files when no private copy is possible (#1218)', async () => {
|
||||
// LOG_DIR is on the same mount as the token in the all-in-one image, so
|
||||
// logging the credential would put it in combined.log — as readable as the
|
||||
// file we just refused to leave, and it outlives setup. stdout is the
|
||||
// fallback instead, which server.js prints.
|
||||
const logger = require('../../src/utils/logger');
|
||||
try { fs.unlinkSync(path.join(tmpDir, 'SETUP_TOKEN')); } catch (_) { /* start clean */ }
|
||||
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
const chmodSpy = jest.spyOn(fs, 'chmodSync').mockImplementation(() => {});
|
||||
const realStat = fs.lstatSync;
|
||||
const statSpy = jest.spyOn(fs, 'lstatSync').mockImplementation((target, ...rest) => {
|
||||
const st = realStat(target, ...rest);
|
||||
// The mode check runs on the temporary file, so match the prefix.
|
||||
return String(target).includes('SETUP_TOKEN')
|
||||
? { ...st, mode: (st.mode & ~0o777) | 0o644 }
|
||||
: st;
|
||||
});
|
||||
|
||||
try {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const logged = warnSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(logged).toMatch(/could not write a private setup token file/i);
|
||||
expect(logged).not.toContain(token);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
chmodSpy.mockRestore();
|
||||
statSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('tells the startup banner where the token went, so it is never printed (#1218)', async () => {
|
||||
// server.js prints the token itself only when no file was written. If this
|
||||
// reports nothing after a successful write, the banner takes that failure
|
||||
// branch and puts the live credential into stdout and `docker logs` beside
|
||||
// a perfectly good 0600 file.
|
||||
const token = await setupService.ensureSetupToken();
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
expect(setupService.writtenSetupTokenFile()).toBe(path.join(tmpDir, 'SETUP_TOKEN'));
|
||||
});
|
||||
|
||||
it('lets a second worker publish without disturbing the first (#1218)', async () => {
|
||||
// The shipped PM2 cluster config runs several workers against one DATA_DIR.
|
||||
// Publishing through rename means they simply overwrite the same value in
|
||||
// turn — no shared inode to race, and neither worker can end up reporting
|
||||
// nothing written and printing the live token to its own log.
|
||||
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
|
||||
const first = await setupService.ensureSetupToken();
|
||||
expect(setupService.writtenSetupTokenFile()).toBe(canonical);
|
||||
|
||||
const second = await setupService.ensureSetupToken();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(setupService.writtenSetupTokenFile()).toBe(canonical);
|
||||
expect(fs.readFileSync(canonical, 'utf8').trim()).toBe(first);
|
||||
expect(fs.statSync(canonical).mode & 0o777).toBe(0o600);
|
||||
// No temporary files left lying about.
|
||||
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('revokes the token when it cannot replace an exposed file (#1218)', async () => {
|
||||
// A restart reuses the token from the database, so a file left at the
|
||||
// token path may hold the live value. If it cannot be replaced — an
|
||||
// ACL-backed or read-only directory — that credential is out of our
|
||||
// control, and /setup/admin would go on accepting it.
|
||||
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
|
||||
await setupService.ensureSetupToken();
|
||||
|
||||
const renameSpy = jest.spyOn(fs, 'renameSync').mockImplementation(() => {
|
||||
const err = new Error('EACCES'); err.code = 'EACCES'; throw err;
|
||||
});
|
||||
try {
|
||||
expect(await setupService.ensureSetupToken()).toBeNull();
|
||||
expect(await getAppSetting('setup_token')).toBeFalsy();
|
||||
// And the temporary file did not survive the failure.
|
||||
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
|
||||
} finally {
|
||||
renameSpy.mockRestore();
|
||||
try { fs.unlinkSync(canonical); } catch (_) { /* may be gone */ }
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to create a second admin (setup already complete)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
/**
|
||||
* The shared colour tag (#1197).
|
||||
*
|
||||
* A third identity model, requested in #1178: not "everyone shares a device's
|
||||
* state" but "everyone, on any device, shares the PHOTO's state". One
|
||||
* identity-less colour tag per photo, and whoever writes last wins.
|
||||
*
|
||||
* Stored as an ordinary photo_feedback row under a reserved identifier rather
|
||||
* than as a column on photos, which is what keeps the per-colour tallies, the
|
||||
* filters, the moderation queue and the XMP/CSV export working unchanged: the
|
||||
* tally simply has exactly one entry.
|
||||
*
|
||||
* The mode is scoped to the colour tag. Likes, ratings and the rest stay
|
||||
* per-guest, so the last test here is as important as the first.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
const { SHARED_COLOR_LABEL_IDENTITY } = require('../../src/constants/colorLabels');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'shared-tag-secret';
|
||||
|
||||
const SLUG = 'shared-color-tag';
|
||||
|
||||
describe('shared colour tag (#1197)', () => {
|
||||
let db; let cleanup; let app; let feedbackService;
|
||||
let eventId; let photoId; let otherPhotoId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
// Two different devices: distinct UA strings give distinct
|
||||
// generateGuestIdentifier hashes, which is exactly how `simple` mode tells
|
||||
// two anonymous guests apart.
|
||||
const asGuest = (ua) => ({ 'Authorization': `Bearer ${galleryToken()}`, 'User-Agent': ua });
|
||||
|
||||
const tag = (ua, color, id = photoId) => request(app)
|
||||
.post(`/api/gallery/${SLUG}/photos/${id}/feedback`)
|
||||
.set(asGuest(ua))
|
||||
.send({ feedback_type: 'color_label', color_label: color });
|
||||
|
||||
const photosFor = async (ua, query = '') => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos${query}`)
|
||||
.set(asGuest(ua));
|
||||
expect(res.status).toBe(200);
|
||||
return Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
};
|
||||
const photoFor = async (ua) => (await photosFor(ua)).find((p) => p.id === photoId);
|
||||
|
||||
const feedbackFor = async (ua) => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
|
||||
.set(asGuest(ua));
|
||||
expect(res.status).toBe(200);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
const sharedRows = () => db('photo_feedback').where({
|
||||
photo_id: photoId, feedback_type: 'color_label',
|
||||
guest_identifier: SHARED_COLOR_LABEL_IDENTITY,
|
||||
});
|
||||
|
||||
const setMode = (mode) => db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ identity_mode: mode });
|
||||
const setSharing = (on) => db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ show_feedback_to_guests: on });
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
feedbackService = require('../../src/services/feedbackService');
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Shared Colour Tag',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'shared-tag-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'a.jpg', path: 'events/shared/a.jpg',
|
||||
type: 'individual', uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const [p2] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'b.jpg', path: 'events/shared/b.jpg',
|
||||
type: 'individual', uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
otherPhotoId = typeof p2 === 'object' ? p2.id : p2;
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId, feedback_enabled: true, allow_likes: true,
|
||||
allow_color_labels: true, moderate_comments: false,
|
||||
show_feedback_to_guests: true, identity_mode: 'shared',
|
||||
});
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photo_feedback').where({ event_id: eventId }).del();
|
||||
await db('photos').where('event_id', eventId).update({ color_label_count: 0, like_count: 0 });
|
||||
await setMode('shared');
|
||||
await setSharing(true);
|
||||
});
|
||||
|
||||
describe('one tag per photo, last write wins', () => {
|
||||
it('lets a second guest overwrite the first guest\'s colour', async () => {
|
||||
// The request in the reporter's words: "if guest A marks a photo green
|
||||
// and guest B later marks the same photo orange, the shared tag simply
|
||||
// becomes orange".
|
||||
expect((await tag('device-A', 'green')).status).toBe(200);
|
||||
expect((await tag('device-B', 'blue')).status).toBe(200);
|
||||
|
||||
const rows = await sharedRows().select('color_label');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].color_label).toBe('blue');
|
||||
});
|
||||
|
||||
it('shows the same tag to a guest who never set one', async () => {
|
||||
await tag('device-A', 'green');
|
||||
// Different device, different identifier — in simple mode this would
|
||||
// read back null, which is the whole reason the mode exists.
|
||||
expect((await photoFor('device-B')).my_color_label).toBe('green');
|
||||
});
|
||||
|
||||
it('stores no attribution against the tag', async () => {
|
||||
await tag('device-A', 'green');
|
||||
const row = await sharedRows().first();
|
||||
expect(row.guest_id).toBeFalsy();
|
||||
expect(row.guest_name).toBeFalsy();
|
||||
expect(row.guest_email).toBeFalsy();
|
||||
});
|
||||
|
||||
it('clears the tag when any guest re-sends the colour already on it', async () => {
|
||||
await tag('device-A', 'green');
|
||||
// B, not A: clearing is not owned by whoever set it.
|
||||
expect((await tag('device-B', 'green')).status).toBe(200);
|
||||
|
||||
expect(await sharedRows().first()).toBeFalsy();
|
||||
expect((await photoFor('device-A')).my_color_label).toBeFalsy();
|
||||
});
|
||||
|
||||
it('leaves exactly one tag when two guests write at the same instant', async () => {
|
||||
// The race the transaction exists for: both writers read "no tag", both
|
||||
// insert, and the photo ends up carrying two shared tags — a per-guest
|
||||
// tally in the one mode that is supposed to have none.
|
||||
await Promise.all([
|
||||
tag('device-A', 'green'),
|
||||
tag('device-B', 'red'),
|
||||
tag('device-C', 'blue'),
|
||||
]);
|
||||
|
||||
const rows = await sharedRows().select('color_label');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(['green', 'red', 'blue']).toContain(rows[0].color_label);
|
||||
});
|
||||
|
||||
it('keeps the tag on the photo it was set on', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await tag('device-B', 'red', otherPhotoId);
|
||||
|
||||
const photos = await photosFor('device-C');
|
||||
expect(photos.find((p) => p.id === photoId).my_color_label).toBe('green');
|
||||
expect(photos.find((p) => p.id === otherPhotoId).my_color_label).toBe('red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the aggregates keep their existing shape', () => {
|
||||
it('reports the tally as a single colour with a count of one', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await tag('device-B', 'red');
|
||||
|
||||
// Unchanged consumers — grid badge, XMP export, admin filter — read
|
||||
// this map and dominantColorLabel() over it. One entry, so the dominant
|
||||
// colour is simply the tag.
|
||||
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({ red: 1 });
|
||||
const photo = await db('photos').where('id', photoId).first();
|
||||
expect(photo.color_label_count).toBe(1);
|
||||
});
|
||||
|
||||
it('does not render the tag a second time as another viewer\'s dot', async () => {
|
||||
await tag('device-A', 'green');
|
||||
// The shared row is not filed under device-B, so without the mode check
|
||||
// it would come back as "someone else's label" and the tile would show
|
||||
// the same green twice — once as the badge, once as a dot beside it.
|
||||
const photo = await photoFor('device-B');
|
||||
expect(photo.my_color_label).toBe('green');
|
||||
expect(photo.other_color_labels || []).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with show_feedback_to_guests off', () => {
|
||||
it('still shows the tag — it is the photo\'s state, not someone else\'s opinion', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setSharing(false);
|
||||
|
||||
expect((await photoFor('device-B')).my_color_label).toBe('green');
|
||||
expect((await feedbackFor('device-B')).my_feedback.color_label).toBe('green');
|
||||
});
|
||||
|
||||
it('still hides the per-colour tallies', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setSharing(false);
|
||||
expect((await feedbackFor('device-B')).color_labels).toEqual({});
|
||||
});
|
||||
|
||||
it('answers a colour filter from the shared tag', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setSharing(false);
|
||||
|
||||
// The aggregate half of this filter is gated on sharing; in shared mode
|
||||
// the tag counts as the viewer's own, so the filter still works.
|
||||
const filtered = await photosFor('device-B', '?filter=color:green');
|
||||
expect(filtered.map((p) => p.id)).toEqual([photoId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('switching modes is not destructive', () => {
|
||||
it('ignores per-guest labels while shared, and gives them back afterwards', async () => {
|
||||
await setMode('simple');
|
||||
await tag('device-A', 'green');
|
||||
await tag('device-B', 'red');
|
||||
const perGuestRows = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'color_label' }).count('* as c').first();
|
||||
expect(Number(perGuestRows.c)).toBe(2);
|
||||
|
||||
await setMode('shared');
|
||||
// Nothing collapsed, nothing guessed: the shared tag starts empty.
|
||||
expect((await photoFor('device-A')).my_color_label).toBeFalsy();
|
||||
expect(await sharedRows().first()).toBeFalsy();
|
||||
|
||||
await setMode('simple');
|
||||
// ...and every original mark is exactly where its owner left it.
|
||||
expect((await photoFor('device-A')).my_color_label).toBe('green');
|
||||
expect((await photoFor('device-B')).my_color_label).toBe('red');
|
||||
});
|
||||
|
||||
it('keeps the shared tag intact across a round trip through simple mode', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setMode('simple');
|
||||
await setMode('shared');
|
||||
expect((await photoFor('device-B')).my_color_label).toBe('green');
|
||||
});
|
||||
|
||||
// "Kept but not shown" has to mean every surface, not just the badge. The
|
||||
// dormant set is still sitting in photo_feedback, so a read that does not
|
||||
// say which set it means will happily count, tally, filter and export it.
|
||||
describe('the dormant set stays out of every reading of the live one', () => {
|
||||
const seedDormantPerGuestLabels = async () => {
|
||||
await setMode('simple');
|
||||
await tag('device-A', 'green');
|
||||
await tag('device-B', 'red', otherPhotoId);
|
||||
await setMode('shared');
|
||||
};
|
||||
|
||||
it('keeps dormant labels out of the per-colour tallies', async () => {
|
||||
await seedDormantPerGuestLabels();
|
||||
// The lightbox renders this map. Green belongs to a per-guest row the
|
||||
// mode does not use, so the photo reads as untagged.
|
||||
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({});
|
||||
|
||||
await tag('device-C', 'blue');
|
||||
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({ blue: 1 });
|
||||
});
|
||||
|
||||
it('keeps dormant labels out of color_label_count', async () => {
|
||||
await seedDormantPerGuestLabels();
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
|
||||
|
||||
await tag('device-C', 'blue');
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps dormant labels out of the admin grid and the XMP/CSV export', async () => {
|
||||
await seedDormantPerGuestLabels();
|
||||
// getEventColorLabelCounts feeds dominant_color_label, which is what
|
||||
// the admin badge shows and what xmp:Label round-trips into Lightroom.
|
||||
const map = await feedbackService.getEventColorLabelCounts(eventId, [photoId, otherPhotoId]);
|
||||
expect(map[photoId]).toBeUndefined();
|
||||
expect(map[otherPhotoId]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps dormant labels out of the raw feedback list too', async () => {
|
||||
// The per-colour tallies were scoped, but the endpoint also returns the
|
||||
// rows themselves. Those carried both sets, so a dormant per-guest
|
||||
// label was still visible to anyone reading the list — and with
|
||||
// sharing off it came back flagged as the caller's own.
|
||||
await seedDormantPerGuestLabels();
|
||||
const body = await feedbackFor('device-A');
|
||||
expect(body.feedback.filter((f) => f.feedback_type === 'color_label')).toHaveLength(0);
|
||||
expect(body.my_feedback.color_label).toBeFalsy();
|
||||
|
||||
await tag('device-A', 'blue');
|
||||
const after = await feedbackFor('device-B');
|
||||
const labels = after.feedback.filter((f) => f.feedback_type === 'color_label');
|
||||
expect(labels.map((f) => f.color_label)).toEqual(['blue']);
|
||||
});
|
||||
|
||||
it('does not answer a guest colour filter from a dormant label', async () => {
|
||||
await seedDormantPerGuestLabels();
|
||||
expect(await photosFor('device-A', '?filter=color:green')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not show the shared tag as another guest\'s dot after switching back', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setMode('simple');
|
||||
// The shared row belongs to nobody, so in a per-guest mode it is not
|
||||
// "another viewer's label" either — it is simply not in play.
|
||||
const photo = await photoFor('device-B');
|
||||
expect(photo.my_color_label).toBeFalsy();
|
||||
expect(photo.other_color_labels || []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('recounts the stored per-photo counter when the mode changes', async () => {
|
||||
// color_label_count is denormalized and recomputed on feedback writes.
|
||||
// A mode switch changes which rows are live without any write, so
|
||||
// without an explicit recount the tiles and the admin summary keep
|
||||
// reporting the old mode's totals until each photo is touched again —
|
||||
// on a finished gallery, never.
|
||||
await setMode('simple');
|
||||
await tag('device-A', 'green');
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
|
||||
|
||||
await feedbackService.updateEventFeedbackSettings(eventId, { identity_mode: 'shared' });
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
|
||||
|
||||
await feedbackService.updateEventFeedbackSettings(eventId, { identity_mode: 'simple' });
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
|
||||
});
|
||||
|
||||
it('does not count the shared tag as a participant', async () => {
|
||||
// feedback_count is COUNT(DISTINCT guest identity) and is exported as
|
||||
// rating_count. The shared tag has a reserved identifier rather than a
|
||||
// person's, so counting it made tagging a photo look like a second
|
||||
// guest had left feedback — and inflated the exported rating count.
|
||||
await request(app)
|
||||
.post(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
|
||||
.set(asGuest('device-A'))
|
||||
.send({ feedback_type: 'rating', rating: 5 });
|
||||
expect((await db('photos').where('id', photoId).first()).feedback_count).toBe(1);
|
||||
|
||||
await tag('device-B', 'green');
|
||||
expect((await db('photos').where('id', photoId).first()).feedback_count).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps dormant labels out of the event feedback summary', async () => {
|
||||
await seedDormantPerGuestLabels();
|
||||
// Feeds the admin analytics total_feedback and the guest
|
||||
// /feedback-summary response.
|
||||
const summary = await feedbackService.getEventFeedbackSummary(eventId);
|
||||
expect(Number(summary.stats.total_color_labels)).toBe(0);
|
||||
|
||||
await tag('device-C', 'blue');
|
||||
expect(Number((await feedbackService.getEventFeedbackSummary(eventId)).stats.total_color_labels)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not count the shared tag once the event is back on per-guest labels', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setMode('simple');
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
|
||||
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the mode is scoped to the colour tag', () => {
|
||||
it('keeps likes per-guest in shared mode', async () => {
|
||||
const like = (ua) => request(app)
|
||||
.post(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
|
||||
.set(asGuest(ua))
|
||||
.send({ feedback_type: 'like' });
|
||||
|
||||
expect((await like('device-A')).status).toBe(200);
|
||||
expect((await photoFor('device-A')).is_liked).toBe(true);
|
||||
// B has not liked it. If 'shared' leaked past colour labels, this would
|
||||
// come back true and B's click would un-like A's like.
|
||||
expect((await photoFor('device-B')).is_liked).toBe(false);
|
||||
|
||||
await like('device-B');
|
||||
const photo = await db('photos').where('id', photoId).first();
|
||||
expect(photo.like_count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the reserved identity cannot be claimed', () => {
|
||||
it('refuses a per-guest write that arrives under it', async () => {
|
||||
// Not reachable through the routes — a guest identifier is either a
|
||||
// sha256 hex or a server-minted UUID — but a future caller must not be
|
||||
// able to write the photo's shared tag as if it were their own.
|
||||
await expect(feedbackService.submitFeedback(
|
||||
photoId, eventId,
|
||||
{ feedback_type: 'color_label', color_label: 'green' },
|
||||
SHARED_COLOR_LABEL_IDENTITY,
|
||||
)).rejects.toThrow('Reserved guest identifier');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Slideshow photo source (#1015).
|
||||
*
|
||||
* The bug: with `lightbox_preview_enabled` off (the default), /photos emitted
|
||||
* `preview_url: null`, so the slideshow's `preview_url || hero_url || url`
|
||||
* chain fell through to `hero_url` — a 1920x1080 `fit: 'cover'` centre crop
|
||||
* meant for gallery header banners. With the "Black Bars (No crop)" fit the
|
||||
* show then letterboxed an already-cropped frame: portrait photos lost their
|
||||
* top and bottom and the setting looked broken.
|
||||
*
|
||||
* The contract pinned here: `slideshow_url` points at the aspect-preserved
|
||||
* preview tier and is emitted for image photos REGARDLESS of the lightbox
|
||||
* toggle, so the slideshow never has a reason to reach for `hero_url`.
|
||||
* `preview_url` itself must stay gated — the lightbox opt-in is unchanged.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-src-test-secret';
|
||||
|
||||
const SLUG = 'slideshow-source-event';
|
||||
|
||||
describe('Slideshow photo source (#1015)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let imagePhotoId;
|
||||
let videoPhotoId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const setLightboxPreview = async (on) => {
|
||||
await db('app_settings').where({ setting_key: 'lightbox_preview_enabled' }).del();
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'lightbox_preview_enabled',
|
||||
setting_value: JSON.stringify(on),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const fetchPhotos = async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.expect(200);
|
||||
return res.body.photos;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Slideshow Source Test',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'slideshow-source-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const img = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'portrait.jpg',
|
||||
path: 'events/slideshow-source/portrait.jpg',
|
||||
type: 'individual',
|
||||
mime_type: 'image/jpeg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
imagePhotoId = img[0]?.id ?? img[0];
|
||||
|
||||
const vid = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'clip.mp4',
|
||||
path: 'events/slideshow-source/clip.mp4',
|
||||
type: 'individual',
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
videoPhotoId = vid[0]?.id ?? vid[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('emits slideshow_url for image photos even when lightbox previews are OFF', async () => {
|
||||
await setLightboxPreview(false);
|
||||
const photos = await fetchPhotos();
|
||||
const image = photos.find((p) => p.id === imagePhotoId);
|
||||
|
||||
expect(image.slideshow_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
|
||||
// The regression: this is what used to be null, pushing the show to hero.
|
||||
expect(image.preview_url).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves preview_url gated so the lightbox opt-in is unchanged', async () => {
|
||||
await setLightboxPreview(true);
|
||||
const photos = await fetchPhotos();
|
||||
const image = photos.find((p) => p.id === imagePhotoId);
|
||||
|
||||
expect(image.preview_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
|
||||
expect(image.slideshow_url).toBe(image.preview_url);
|
||||
});
|
||||
|
||||
it('never points the slideshow at the cover-cropped hero tier', async () => {
|
||||
await setLightboxPreview(false);
|
||||
const photos = await fetchPhotos();
|
||||
const image = photos.find((p) => p.id === imagePhotoId);
|
||||
|
||||
// hero_url still ships (the gallery header uses it) — it just must not be
|
||||
// what the slideshow resolves to.
|
||||
expect(image.hero_url).toBe(`/api/gallery/${SLUG}/hero/${imagePhotoId}`);
|
||||
expect(image.slideshow_url).not.toBe(image.hero_url);
|
||||
});
|
||||
|
||||
it('emits slideshow_url: null for videos, which have no preview tier', async () => {
|
||||
await setLightboxPreview(false);
|
||||
const photos = await fetchPhotos();
|
||||
const video = photos.find((p) => p.id === videoPhotoId);
|
||||
|
||||
expect(video.slideshow_url).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* SQLite epoch-timestamp normalization (#485 follow-up).
|
||||
*
|
||||
* On SQLite, timestamp columns written with a raw `new Date()` through knex
|
||||
* hold epoch-millisecond numbers. Postgres returns ISO strings, so frontend
|
||||
* code written against Postgres calls parseISO() and crashes on native
|
||||
* (SQLite) installs — the exact class fixed for admin Users in #485, which
|
||||
* listed api tokens / photos / activity as an out-of-scope follow-up.
|
||||
*
|
||||
* Pins:
|
||||
* - gallery /photos serializes uploaded_at / captured_at as ISO strings
|
||||
* even when the row holds an epoch number (pre-fix archive restores)
|
||||
* - the api-tokens list serializes created_at / expires_at / last_used_at /
|
||||
* revoked_at as ISO strings for epoch-stored rows
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'epoch-test-secret';
|
||||
|
||||
const SLUG = 'epoch-test-event';
|
||||
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
||||
|
||||
describe('SQLite epoch timestamp normalization', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let adminToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Epoch Test',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'epoch-test-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
// The pre-fix corruption shape: epoch numbers in timestamp columns.
|
||||
await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'restored.jpg',
|
||||
path: 'events/epoch/restored.jpg',
|
||||
type: 'individual',
|
||||
uploaded_at: Date.now() - 3600_000,
|
||||
captured_at: Date.now() - 7200_000,
|
||||
});
|
||||
|
||||
const superRole = await db('roles').where({ name: 'super_admin' }).first();
|
||||
const [rootId] = await db('admin_users').insert({
|
||||
username: 'epoch-admin',
|
||||
email: 'epoch-admin@example.com',
|
||||
password_hash: await bcrypt.hash('EpochAdmin123', 4),
|
||||
role_id: superRole.id,
|
||||
is_active: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
||||
adminToken = jwt.sign(
|
||||
{ id: rootId, username: 'epoch-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
await db('api_tokens').insert({
|
||||
name: 'epoch-token',
|
||||
hashed_token: 'x'.repeat(64),
|
||||
preview: 'pk_test…abcd',
|
||||
scopes: JSON.stringify(['events:read']),
|
||||
created_by: rootId,
|
||||
created_at: Date.now() - 86400_000,
|
||||
last_used_at: Date.now() - 3600_000,
|
||||
revoked_at: Date.now() - 60_000,
|
||||
});
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/admin/api-tokens', require('../../src/routes/adminApiTokens'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('gallery /photos serializes epoch-stored uploaded_at/captured_at as ISO strings', async () => {
|
||||
const galleryToken = jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.photos).toHaveLength(1);
|
||||
const photo = res.body.photos[0];
|
||||
expect(typeof photo.uploaded_at).toBe('string');
|
||||
expect(photo.uploaded_at).toMatch(ISO_RE);
|
||||
expect(photo.captured_at).toMatch(ISO_RE);
|
||||
});
|
||||
|
||||
it('api-tokens list serializes epoch-stored timestamps as ISO strings', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/api-tokens')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
const token = res.body.find((t) => t.name === 'epoch-token');
|
||||
expect(token).toBeTruthy();
|
||||
for (const field of ['created_at', 'last_used_at', 'revoked_at']) {
|
||||
expect(`${field}:${typeof token[field]}`).toBe(`${field}:string`);
|
||||
expect(token[field]).toMatch(ISO_RE);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
const knex = require('knex');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { randomUUID } = require('crypto');
|
||||
const migration = require('../../migrations/core/211_revocations_without_expiry');
|
||||
|
||||
for (const client of ['sqlite3', 'pg']) {
|
||||
const enabled = client !== 'pg' || process.env.PICPEAK_PG_TEST_URL;
|
||||
(enabled ? describe : describe.skip)(`token revocation expiry (${client})`, () => {
|
||||
let db, owner, schema, revocation;
|
||||
const sign = claims => jwt.sign({ id: 1, type: 'admin', jti: randomUUID(), ...claims }, process.env.JWT_SECRET);
|
||||
|
||||
beforeAll(async () => {
|
||||
if (client === 'pg') {
|
||||
schema = `revocation_${randomUUID().replace(/-/g, '')}`;
|
||||
owner = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL });
|
||||
await owner.schema.createSchema(schema);
|
||||
db = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL, searchPath: [schema] });
|
||||
} else {
|
||||
db = knex({ client, connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
}
|
||||
// Exercise the upgrade from the real legacy NOT NULL schema as well as
|
||||
// repeated migration runs, without sharing another test's database.
|
||||
await require('../../migrations/legacy/017_add_token_revocation_tables').up(db);
|
||||
await db('revoked_tokens').insert({ token_id: 'existing', expires_at: '2099-01-01T00:00:00.000Z' });
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
revocation = require('../../src/utils/tokenRevocation');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.destroy();
|
||||
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
||||
jest.dontMock('../../src/database/db');
|
||||
});
|
||||
|
||||
it('preserves existing revocations and their unique key during upgrade', async () => {
|
||||
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
|
||||
await expect(db('revoked_tokens').insert({ token_id: 'existing', expires_at: null })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each([true, false])('permanently revokes a token without exp (jti: %s)', async withJti => {
|
||||
const token = sign(withJti ? {} : { jti: undefined });
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
expect(await revocation.isTokenRevoked(payload)).toBe(false);
|
||||
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(payload)).toBe(true);
|
||||
const rows = await db('revoked_tokens').where({ token_id: revocation.buildTokenId(payload) });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].expires_at).toBeNull();
|
||||
});
|
||||
|
||||
it('cleans up expired revocations and retains future ones', async () => {
|
||||
const expired = sign({ exp: Math.floor(Date.now() / 1000) - 60 });
|
||||
const future = sign({ exp: Math.floor(Date.now() / 1000) + 3600 });
|
||||
expect(await revocation.revokeToken(expired, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(future, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(expired))).toBe(false);
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(future))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([true, false])('upgrades an expiring entry with the same key permanently (jti: %s)', async withJti => {
|
||||
const claims = { id: 99, iat: Math.floor(Date.now() / 1000), jti: withJti ? randomUUID() : undefined };
|
||||
const expiring = sign({ ...claims, exp: claims.iat - 60 });
|
||||
const permanent = sign(claims);
|
||||
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(permanent, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(permanent))).toBe(true);
|
||||
});
|
||||
|
||||
it('retains a signed token whose numeric expiry cannot fit a database timestamp', async () => {
|
||||
const token = sign({ exp: 1e100 });
|
||||
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a rollback that would remove permanent revocations', async () => {
|
||||
await expect(migration.down(db)).rejects.toThrow('permanent token revocations');
|
||||
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(true);
|
||||
// A rollback with only expiring records remains supported and reversible.
|
||||
await db('revoked_tokens').whereNull('expires_at').delete();
|
||||
await migration.down(db);
|
||||
await migration.down(db);
|
||||
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(false);
|
||||
await migration.up(db);
|
||||
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user