Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69fee5faba |
+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
-91
@@ -10,14 +10,6 @@ NODE_ENV=production
|
||||
# Generate one with: openssl rand -base64 64
|
||||
#JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
|
||||
# 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)
|
||||
@@ -64,41 +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`.
|
||||
# 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
|
||||
|
||||
# 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
|
||||
@@ -111,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'.
|
||||
@@ -127,11 +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
|
||||
|
||||
# Release Channel
|
||||
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
|
||||
# 'stable' uses the :stable tag (same as :latest on main)
|
||||
@@ -235,56 +209,6 @@ 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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
buy_me_a_coffee: theluap
|
||||
@@ -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
@@ -25,14 +25,33 @@ jobs:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
target-branch: stable
|
||||
|
||||
# NOTE: stable release PRs are intentionally NOT auto-merged here
|
||||
# anymore. Fixes accumulate in the rolling release PR and are cut as
|
||||
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
|
||||
# or on demand via workflow_dispatch / a manual merge of the release
|
||||
# PR). Beta keeps instant releases — see release-please-beta.yml —
|
||||
# because same-day reporter verification depends on it.
|
||||
# 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
|
||||
@@ -17,9 +17,9 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, beta, stable]
|
||||
branches: [main, beta]
|
||||
pull_request:
|
||||
branches: [main, beta, stable]
|
||||
branches: [main, beta]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -28,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
|
||||
@@ -86,9 +52,6 @@ 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: |
|
||||
# Excluded suites — fail on upstream/beta too, tracked
|
||||
# separately as test-infra debt:
|
||||
@@ -124,33 +87,3 @@ jobs:
|
||||
- name: Run Vitest suite
|
||||
working-directory: ./frontend
|
||||
run: npm test -- --run
|
||||
|
||||
# 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
-15
@@ -130,18 +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
|
||||
# Generated CRM/accounting documents (runtime) — never commit
|
||||
backend/storage/business-docs/
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.115.3-beta.0"
|
||||
".": "3.80.0-beta.0"
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{".":"3.44.0"}
|
||||
{
|
||||
".": "2.6.1"
|
||||
}
|
||||
|
||||
-807
@@ -5,813 +5,6 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.115.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.115.2-beta.0...v3.115.3-beta.0) (2026-08-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** gate the dimension repair as system maintenance ([#1182](https://github.com/PicPeak/picpeak/issues/1182)) ([3991dc3](https://github.com/PicPeak/picpeak/commit/3991dc3ccb67e4e33a91b171dd9eb77c0c262502))
|
||||
* **admin:** make "Storage used" report storage used ([#1164](https://github.com/PicPeak/picpeak/issues/1164)) ([#1170](https://github.com/PicPeak/picpeak/issues/1170)) ([849a580](https://github.com/PicPeak/picpeak/commit/849a5807b7174d05e7f4769c8984843e0d0805e2))
|
||||
* **admin:** move the maintenance sweeps' run state into the database ([#1181](https://github.com/PicPeak/picpeak/issues/1181)) ([#1184](https://github.com/PicPeak/picpeak/issues/1184)) ([05e23ef](https://github.com/PicPeak/picpeak/commit/05e23ef1a1c78b426a21d3106ca5d732958c31a6))
|
||||
* **external-media:** record capture dates on import, and backfill existing libraries ([#1172](https://github.com/PicPeak/picpeak/issues/1172)) ([#1179](https://github.com/PicPeak/picpeak/issues/1179)) ([410b8f8](https://github.com/PicPeak/picpeak/commit/410b8f8f6f7258c285446b3454164c9a20f2280f))
|
||||
* **gallery:** show other guests' colour labels in the grid ([#1178](https://github.com/PicPeak/picpeak/issues/1178)) ([#1180](https://github.com/PicPeak/picpeak/issues/1180)) ([51d20c5](https://github.com/PicPeak/picpeak/commit/51d20c5920ec6cd5aa9bbe8504fd2aa59c180545))
|
||||
* **gallery:** stop the lightbox loading originals to display a photo ([#1166](https://github.com/PicPeak/picpeak/issues/1166)) ([#1169](https://github.com/PicPeak/picpeak/issues/1169)) ([77953c1](https://github.com/PicPeak/picpeak/commit/77953c15c12affd1987bd2e78bac815ffa64d0fc))
|
||||
* **previews:** preserve alpha and animation in the preview tier ([#1171](https://github.com/PicPeak/picpeak/issues/1171)) ([1366d6d](https://github.com/PicPeak/picpeak/commit/1366d6d14cd07e05ede043ff8cfb368cddc76362))
|
||||
|
||||
## [3.115.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.115.1-beta.0...v3.115.2-beta.0) (2026-08-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **external-media:** store external paths from the media root ([#1163](https://github.com/PicPeak/picpeak/issues/1163)) ([#1168](https://github.com/PicPeak/picpeak/issues/1168)) ([a7b74bc](https://github.com/PicPeak/picpeak/commit/a7b74bcd87fa9700351331e65a631e31c89d1354))
|
||||
|
||||
## [3.115.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.115.0-beta.0...v3.115.1-beta.0) (2026-08-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **external-media:** one row per external file per event ([#1162](https://github.com/PicPeak/picpeak/issues/1162)) ([#1167](https://github.com/PicPeak/picpeak/issues/1167)) ([06da1b9](https://github.com/PicPeak/picpeak/commit/06da1b9f7eefa5ff216a068f64fa648f49e7084a))
|
||||
|
||||
## [3.115.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.114.0-beta.0...v3.115.0-beta.0) (2026-08-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **faces:** make "not the same person" survive a re-scan ([#1132](https://github.com/PicPeak/picpeak/issues/1132)) ([#1145](https://github.com/PicPeak/picpeak/issues/1145)) ([c305ad4](https://github.com/PicPeak/picpeak/commit/c305ad41469bd04647e71ba768bfce26f20c4c96))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** a guest's own hidden feedback is hidden from them too ([#1150](https://github.com/PicPeak/picpeak/issues/1150)) ([#1153](https://github.com/PicPeak/picpeak/issues/1153)) ([2c81888](https://github.com/PicPeak/picpeak/commit/2c81888eafadc083a4654464ea6e955abc7e8704))
|
||||
* **gallery:** guest filters respect show_feedback_to_guests, and marks survive a mid-write clear ([#1147](https://github.com/PicPeak/picpeak/issues/1147)) ([00b20b2](https://github.com/PicPeak/picpeak/commit/00b20b2d72ffb9a8418cda4479d1e34df1b65ccf))
|
||||
* **gallery:** no Logout button on galleries that don't require a password ([#1149](https://github.com/PicPeak/picpeak/issues/1149)) ([#1152](https://github.com/PicPeak/picpeak/issues/1152)) ([e4a8be8](https://github.com/PicPeak/picpeak/commit/e4a8be8e7e8ede850f07a43c229c3e4e28e0e59f))
|
||||
* **scripts:** regenerate-thumbnails resolves external sources through ensureThumbnail ([#1148](https://github.com/PicPeak/picpeak/issues/1148)) ([#1151](https://github.com/PicPeak/picpeak/issues/1151)) ([b581267](https://github.com/PicPeak/picpeak/commit/b5812670318a852b4731d326f15f2623e0302395))
|
||||
|
||||
## [3.114.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.113.0-beta.0...v3.114.0-beta.0) (2026-08-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **gallery:** colour labels for client proofing, and one global default per feedback type ([#1044](https://github.com/PicPeak/picpeak/issues/1044)) ([#1137](https://github.com/PicPeak/picpeak/issues/1137)) ([e2844d1](https://github.com/PicPeak/picpeak/commit/e2844d190969269e53dfac9a74ebd8fe94e042dc))
|
||||
|
||||
## [3.113.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.112.0-beta.0...v3.113.0-beta.0) (2026-08-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **faces:** consolidate look-alike clusters after a scan, and suggest the rest ([#1107](https://github.com/PicPeak/picpeak/issues/1107)) ([3583c92](https://github.com/PicPeak/picpeak/commit/3583c924dae999e5edf6bf86c4611a035c9bd986))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** a missing thumbnail tier must not take the backend down ([#1128](https://github.com/PicPeak/picpeak/issues/1128)) ([f735d26](https://github.com/PicPeak/picpeak/commit/f735d26422ddd7e4f83cdbaf6fa10c2120dc82a4))
|
||||
* **gallery:** give masonry tiles their real shape back ([#1130](https://github.com/PicPeak/picpeak/issues/1130), [#1131](https://github.com/PicPeak/picpeak/issues/1131)) ([87115b2](https://github.com/PicPeak/picpeak/commit/87115b28e8aa4d955adc6534103d4cf1fb15485b))
|
||||
* **thumbnails:** regenerate external photos instead of dropping their tiers ([#1129](https://github.com/PicPeak/picpeak/issues/1129)) ([97d92f8](https://github.com/PicPeak/picpeak/commit/97d92f8428e28852011456ab5785e1f70dce5e8b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **faces:** link the face-recognition guidance from where people look ([#1125](https://github.com/PicPeak/picpeak/issues/1125)) ([25fbefc](https://github.com/PicPeak/picpeak/commit/25fbefc703c0531060203bcdb3910a590a6bfdb2))
|
||||
|
||||
## [3.112.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.111.1-beta.0...v3.112.0-beta.0) (2026-08-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deploy:** make the all-in-one image installable without a shell ([#1124](https://github.com/PicPeak/picpeak/issues/1124)) ([7223118](https://github.com/PicPeak/picpeak/commit/7223118b894ffa47bf8dedca5f77077983d29d52))
|
||||
|
||||
## [3.111.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.111.0-beta.0...v3.111.1-beta.0) (2026-08-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **faces:** dark-mode styling for the People surfaces ([#1106](https://github.com/PicPeak/picpeak/issues/1106)) ([#1126](https://github.com/PicPeak/picpeak/issues/1126)) ([24e11df](https://github.com/PicPeak/picpeak/commit/24e11df2991856753541aaaed380974a5eb267e4))
|
||||
|
||||
## [3.111.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.110.0-beta.0...v3.111.0-beta.0) (2026-08-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **faces:** show a detected face in its source photo, outlined ([#1120](https://github.com/PicPeak/picpeak/issues/1120)) ([38c27d0](https://github.com/PicPeak/picpeak/commit/38c27d097c593283c253208bb3bb547cb2512c32))
|
||||
|
||||
## [3.110.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.109.0-beta.0...v3.110.0-beta.0) (2026-08-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **faces:** let the photographer choose which photo represents a person ([#1119](https://github.com/PicPeak/picpeak/issues/1119)) ([bbce3cd](https://github.com/PicPeak/picpeak/commit/bbce3cd2a2822a3c53bbb9acdd60c0c3c41a5d7c))
|
||||
* **gallery:** responsive grid thumbnails ([#1095](https://github.com/PicPeak/picpeak/issues/1095)) ([#1109](https://github.com/PicPeak/picpeak/issues/1109)) ([887bdbe](https://github.com/PicPeak/picpeak/commit/887bdbe6e5cdc7db2f57674dd94b5308dfed0dff))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** let cors() own Access-Control-Allow-Origin on protected images ([#1118](https://github.com/PicPeak/picpeak/issues/1118)) ([0077623](https://github.com/PicPeak/picpeak/commit/00776234fd6683186c08ffcb510d1145586ad7e9))
|
||||
* **ui:** stop iOS Safari zooming in on 14px form fields ([#1113](https://github.com/PicPeak/picpeak/issues/1113)) ([d241919](https://github.com/PicPeak/picpeak/commit/d24191960476d042e9c99d852db782c25e8340f9))
|
||||
|
||||
## [3.109.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.108.1-beta.0...v3.109.0-beta.0) (2026-08-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **setup:** configure the public address and SMTP in the wizard, not .env ([#1104](https://github.com/PicPeak/picpeak/issues/1104)) ([9431b9f](https://github.com/PicPeak/picpeak/commit/9431b9f0949e8e51c486019224ca92f46443c50e))
|
||||
|
||||
## [3.108.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.108.0-beta.0...v3.108.1-beta.0) (2026-08-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **faces:** face avatars were cropped against a cropped rendition ([#1100](https://github.com/PicPeak/picpeak/issues/1100)) ([b3a7ab2](https://github.com/PicPeak/picpeak/commit/b3a7ab27ea6ecbae30f5b3eb5716c861b3660a73))
|
||||
|
||||
## [3.108.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.5-beta.0...v3.108.0-beta.0) (2026-08-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **gallery:** sized preview tiers so phones stop pulling 1920px ([#1095](https://github.com/PicPeak/picpeak/issues/1095)) ([#1099](https://github.com/PicPeak/picpeak/issues/1099)) ([011f6ae](https://github.com/PicPeak/picpeak/commit/011f6ae7eca48b41b98f4d4beab0aceaa9d34e6c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **faces:** defer on unreachable storage, and commit the import path first ([#1097](https://github.com/PicPeak/picpeak/issues/1097)) ([0b886ed](https://github.com/PicPeak/picpeak/commit/0b886ed9428b31831c86ad4ddafd0c0a98e4ac3a))
|
||||
|
||||
## [3.107.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.4-beta.0...v3.107.5-beta.0) (2026-08-20)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **readme:** point the single-container install at a tag that exists ([2a84efe](https://github.com/PicPeak/picpeak/commit/2a84efef719e15998a693947f80ed2aa9931ff85))
|
||||
* **readme:** point the single-container install at a tag that exists ([e47c103](https://github.com/PicPeak/picpeak/commit/e47c103c2a9011e45cd43c8476a0683e8896db2b))
|
||||
|
||||
## [3.107.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.3-beta.0...v3.107.4-beta.0) (2026-08-20)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **docker:** Hub pages for aio + ml, and the image table in the README ([899c9b3](https://github.com/PicPeak/picpeak/commit/899c9b34072ca73ddc4391b74ead43ef4157b235))
|
||||
|
||||
## [3.107.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.2-beta.0...v3.107.3-beta.0) (2026-08-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **faces:** scan external/reference photos instead of skipping them ([#1090](https://github.com/PicPeak/picpeak/issues/1090)) ([#1091](https://github.com/PicPeak/picpeak/issues/1091)) ([576924f](https://github.com/PicPeak/picpeak/commit/576924fa574c0fdbaf167a885216ad3ee6ebf66b))
|
||||
|
||||
## [3.107.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.1-beta.0...v3.107.2-beta.0) (2026-08-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **faces:** restore the :beta image tag and surface sidecar health ([#1087](https://github.com/PicPeak/picpeak/issues/1087)) ([37a15e3](https://github.com/PicPeak/picpeak/commit/37a15e3d49de5cad83b7e7b466153a732e64c45e))
|
||||
|
||||
## [3.107.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.0-beta.0...v3.107.1-beta.0) (2026-08-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **preview:** generate lightbox previews for external/reference photos ([#1078](https://github.com/PicPeak/picpeak/issues/1078)) ([#1079](https://github.com/PicPeak/picpeak/issues/1079)) ([af7970b](https://github.com/PicPeak/picpeak/commit/af7970b069d219e32d22c005dd5adf4204d0d514))
|
||||
|
||||
## [3.107.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.106.0-beta.0...v3.107.0-beta.0) (2026-08-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **faces:** People in this gallery — face recognition via an optional ML sidecar ([#1074](https://github.com/PicPeak/picpeak/issues/1074)) ([#1075](https://github.com/PicPeak/picpeak/issues/1075)) ([b69dd13](https://github.com/PicPeak/picpeak/commit/b69dd134d0f5b1570ace261af4541d36771e62cd))
|
||||
|
||||
## [3.106.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.105.1-beta.0...v3.106.0-beta.0) (2026-08-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **docker:** all-in-one image ([#1042](https://github.com/PicPeak/picpeak/issues/1042)) — my version of [#1067](https://github.com/PicPeak/picpeak/issues/1067) ([#1068](https://github.com/PicPeak/picpeak/issues/1068)) ([0874a30](https://github.com/PicPeak/picpeak/commit/0874a30ac94483e5a725bf2bb047dca11880129c))
|
||||
|
||||
## [3.105.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.105.0-beta.0...v3.105.1-beta.0) (2026-08-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** make per-event banner overrides actually work, both banners ([#440](https://github.com/PicPeak/picpeak/issues/440), [#932](https://github.com/PicPeak/picpeak/issues/932)) ([#1064](https://github.com/PicPeak/picpeak/issues/1064)) ([52db982](https://github.com/PicPeak/picpeak/commit/52db9826610a41f156b4878c142abfc71dea8b07))
|
||||
|
||||
## [3.105.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.104.1-beta.0...v3.105.0-beta.0) (2026-08-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **gallery:** info banner above the photo grid ([#932](https://github.com/PicPeak/picpeak/issues/932)) ([#1063](https://github.com/PicPeak/picpeak/issues/1063)) ([b48fa62](https://github.com/PicPeak/picpeak/commit/b48fa62eea5117c3e09bb6c8a7d3dc02931ee8f9))
|
||||
|
||||
## [3.104.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.104.0-beta.0...v3.104.1-beta.0) (2026-08-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **pdf:** RFC 6266-encode Content-Disposition on quote/invoice PDFs ([#1024](https://github.com/PicPeak/picpeak/issues/1024)) ([#1055](https://github.com/PicPeak/picpeak/issues/1055)) ([3a11e6e](https://github.com/PicPeak/picpeak/commit/3a11e6ebb5542a526a92c66f12e13d5a30aba7b2))
|
||||
|
||||
## [3.104.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.103.1-beta.0...v3.104.0-beta.0) (2026-08-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backup:** open sqlite → pg .picpeak restore as the supported upgrade direction ([#1041](https://github.com/PicPeak/picpeak/issues/1041)) ([#1043](https://github.com/PicPeak/picpeak/issues/1043)) ([8809564](https://github.com/PicPeak/picpeak/commit/8809564aadc1782484e0369ddbf03850530680a6))
|
||||
|
||||
## [3.103.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.103.0-beta.0...v3.103.1-beta.0) (2026-08-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **storage:** add S3 client timeouts so a dropped connection can't wedge uploads ([#1049](https://github.com/PicPeak/picpeak/issues/1049)) ([3600231](https://github.com/PicPeak/picpeak/commit/3600231d5f059cb7fd7250430a26bf0b0396a87f))
|
||||
|
||||
## [3.103.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.102.2-beta.0...v3.103.0-beta.0) (2026-08-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **permissions:** granular permission gating + role editor & presets ([#747](https://github.com/PicPeak/picpeak/issues/747), phase 1 of [#743](https://github.com/PicPeak/picpeak/issues/743)) ([#1045](https://github.com/PicPeak/picpeak/issues/1045)) ([b118695](https://github.com/PicPeak/picpeak/commit/b118695474b30f848e79d0ce8f52e051b5b8217b))
|
||||
|
||||
## [3.102.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.102.1-beta.0...v3.102.2-beta.0) (2026-08-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** default NODE_ENV=production so non-compose deploys don't fall back to SQLite ([#1038](https://github.com/PicPeak/picpeak/issues/1038)) ([#1039](https://github.com/PicPeak/picpeak/issues/1039)) ([6de30e5](https://github.com/PicPeak/picpeak/commit/6de30e5bf17b54601e64d1b0b6a31d8877aa642d))
|
||||
* **events:** make event_date/expires_at nullable on SQLite ([#1029](https://github.com/PicPeak/picpeak/issues/1029)) ([#1035](https://github.com/PicPeak/picpeak/issues/1035)) ([671c4db](https://github.com/PicPeak/picpeak/commit/671c4dbd56fde69d512c09af2e928f1899c5ad8a))
|
||||
* **feedback:** persist guest feedback settings, unshadow the guest route ([#1030](https://github.com/PicPeak/picpeak/issues/1030)) ([#1031](https://github.com/PicPeak/picpeak/issues/1031)) ([89dc962](https://github.com/PicPeak/picpeak/commit/89dc9623c154493eb50f94a25da3e774e8c215b1))
|
||||
* **gallery:** coerce SQLite 0/1 booleans in the guest surface ([#1028](https://github.com/PicPeak/picpeak/issues/1028)) ([#1034](https://github.com/PicPeak/picpeak/issues/1034)) ([34ee311](https://github.com/PicPeak/picpeak/commit/34ee31141b0e6b93c75ea91fd6d82ca35f72eacb))
|
||||
|
||||
## [3.102.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.102.0-beta.0...v3.102.1-beta.0) (2026-08-11)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* flip README links to docs.picpeak.app + delete docs/_to-migrate ([#1000](https://github.com/PicPeak/picpeak/issues/1000) phase 3) ([#1023](https://github.com/PicPeak/picpeak/issues/1023)) ([27dedb1](https://github.com/PicPeak/picpeak/commit/27dedb13f390f956690e667d41ed532bce697eb4))
|
||||
|
||||
## [3.102.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.5-beta.0...v3.102.0-beta.0) (2026-08-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **downloads:** per-gallery download resolutions ([#858](https://github.com/PicPeak/picpeak/issues/858)) ([#1022](https://github.com/PicPeak/picpeak/issues/1022)) ([8e35737](https://github.com/PicPeak/picpeak/commit/8e3573788b1bde8768d023e779ea3361c91f6223))
|
||||
|
||||
## [3.101.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.4-beta.0...v3.101.5-beta.0) (2026-08-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **slideshow:** stop "no crop" fit letterboxing a pre-cropped frame ([#1015](https://github.com/PicPeak/picpeak/issues/1015)) ([#1018](https://github.com/PicPeak/picpeak/issues/1018)) ([75bfad2](https://github.com/PicPeak/picpeak/commit/75bfad2b6ae2e7a9a622d0c648df30db83c69b14))
|
||||
|
||||
## [3.101.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.3-beta.0...v3.101.4-beta.0) (2026-08-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** bump nanoid and js-yaml out of two HIGH advisories ([#1013](https://github.com/PicPeak/picpeak/issues/1013)) ([e3830cd](https://github.com/PicPeak/picpeak/commit/e3830cd9219ad4e81b556c7682e0f48a806f1620))
|
||||
|
||||
## [3.101.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.2-beta.0...v3.101.3-beta.0) (2026-08-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** issuer-tag the oversize SSO logout marker ([#798](https://github.com/PicPeak/picpeak/issues/798)) ([#1010](https://github.com/PicPeak/picpeak/issues/1010)) ([a607cea](https://github.com/PicPeak/picpeak/commit/a607cea11018e68aea8797160dbde7f34b8eca44))
|
||||
|
||||
## [3.101.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.1-beta.0...v3.101.2-beta.0) (2026-08-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **branding:** route the gallery footer through <PoweredBy /> ([#1008](https://github.com/PicPeak/picpeak/issues/1008)) ([1bf19a7](https://github.com/PicPeak/picpeak/commit/1bf19a7caf45b7f800b3650b2cd2365ea89cb169))
|
||||
|
||||
## [3.101.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.0-beta.0...v3.101.1-beta.0) (2026-08-10)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* slim README to a lean router, stage deep content for docs-site migration ([#1001](https://github.com/PicPeak/picpeak/issues/1001)) ([ddebd50](https://github.com/PicPeak/picpeak/commit/ddebd50d3fd3750f97f13a07afb38447601e3889))
|
||||
|
||||
## [3.101.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.100.2-beta.0...v3.101.0-beta.0) (2026-08-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **transfers:** add PicTransfer — cross-event file transfers ([#998](https://github.com/PicPeak/picpeak/issues/998)) ([2e495d7](https://github.com/PicPeak/picpeak/commit/2e495d7c489c3195c6e1c042ec4ac35fe90cf4ba))
|
||||
|
||||
## [3.100.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.100.1-beta.0...v3.100.2-beta.0) (2026-08-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **branding:** hide "Powered by PicPeak" on every page, not only the gallery ([#999](https://github.com/PicPeak/picpeak/issues/999)) ([3bb4f1a](https://github.com/PicPeak/picpeak/commit/3bb4f1a1a894a6bbc4b1610c3585b73e38f1753d))
|
||||
|
||||
## [3.100.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.100.0-beta.0...v3.100.1-beta.0) (2026-08-04)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* the retired registry path freezes, it does not stop serving ([#995](https://github.com/PicPeak/picpeak/issues/995)) ([b9e4259](https://github.com/PicPeak/picpeak/commit/b9e42591f53d3e5dbee136f4ee53020461c3e2ba))
|
||||
|
||||
## [3.100.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.99.2-beta.0...v3.100.0-beta.0) (2026-08-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **admin:** surface the registry move through the update check ([#993](https://github.com/PicPeak/picpeak/issues/993)) ([137a42f](https://github.com/PicPeak/picpeak/commit/137a42f259692999fe88b75bbe6652d34893ef11))
|
||||
* **gallery:** admin preview skips the password on protected galleries ([#981](https://github.com/PicPeak/picpeak/issues/981)) ([f006615](https://github.com/PicPeak/picpeak/commit/f00661511c3f3b4fc338be860965244b0ee3b611))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** vet the destination project when linking a deal ([#991](https://github.com/PicPeak/picpeak/issues/991)) ([0c8ad6b](https://github.com/PicPeak/picpeak/commit/0c8ad6bbedb00ba443c20c7ab00b58925d6b9b5c))
|
||||
|
||||
## [3.99.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.99.1-beta.0...v3.99.2-beta.0) (2026-08-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** bump ip-address, brace-expansion and postcss for open CVEs ([#987](https://github.com/PicPeak/picpeak/issues/987)) ([6c03fea](https://github.com/PicPeak/picpeak/commit/6c03feaef5ef9be694445728f5d5a4ddabafd5c1))
|
||||
|
||||
## [3.99.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.99.0-beta.0...v3.99.1-beta.0) (2026-08-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **accounting:** gate cross-add counters on the permission their endpoint checks ([#984](https://github.com/PicPeak/picpeak/issues/984)) ([4b53b64](https://github.com/PicPeak/picpeak/commit/4b53b64277a6e3b1d3e94b8cc3bb705aa33629ec))
|
||||
|
||||
## [3.99.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.6-beta.0...v3.99.0-beta.0) (2026-08-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **accounting:** re-bill proof attachment, CRM panel & hours↔re-bills cross-add ([#979](https://github.com/PicPeak/picpeak/issues/979)) ([165cebd](https://github.com/PicPeak/picpeak/commit/165cebdb5c744cb3c3c26cc9cd4182cb5fa85143))
|
||||
|
||||
## [3.98.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.5-beta.0...v3.98.6-beta.0) (2026-08-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** fail closed when the adminAuth roles join errors ([#974](https://github.com/PicPeak/picpeak/issues/974)) ([6699855](https://github.com/PicPeak/picpeak/commit/6699855c931657c7af7860e9bdd097da303a3a26))
|
||||
* **projects:** stop the cockpit offering email controls the API rejects ([#976](https://github.com/PicPeak/picpeak/issues/976)) ([67592fc](https://github.com/PicPeak/picpeak/commit/67592fc56956b1ec4db696483efd2a285bffb6f4))
|
||||
|
||||
## [3.98.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.4-beta.0...v3.98.5-beta.0) (2026-08-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** enforce project ownership on project + project-email routes (GHSA-wrg5, GHSA-93x4) ([#960](https://github.com/PicPeak/picpeak/issues/960)) ([7c0c0a5](https://github.com/PicPeak/picpeak/commit/7c0c0a5b7ff5758ec07ac64ab5c0c81818cca96d))
|
||||
|
||||
## [3.98.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.3-beta.0...v3.98.4-beta.0) (2026-08-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** backup/restore hardening — public-dir DB dump, restore path allowlist, gunzip bound, manifest keying ([#956](https://github.com/PicPeak/picpeak/issues/956)) ([0d4c308](https://github.com/PicPeak/picpeak/commit/0d4c30884e21a43401f7e8acc0f31e5ab1f77bba))
|
||||
* **security:** bound inbound-mail resources, redact secrets from logs (GHSA-2qf9, pgmp, r794) ([#959](https://github.com/PicPeak/picpeak/issues/959)) ([1b4e5fe](https://github.com/PicPeak/picpeak/commit/1b4e5fee3efd1a7fb980476d45551971225df50c))
|
||||
* **security:** enforce event ownership on the v1 API surface (GHSA-9697) ([#957](https://github.com/PicPeak/picpeak/issues/957)) ([e2ce95e](https://github.com/PicPeak/picpeak/commit/e2ce95ee48105f6e04150334df77a866a1c60a83))
|
||||
* **security:** escape brand tokens, block tracker redirects, trim logo diagnostic (GHSA-j347, mw76, 29vm) ([#961](https://github.com/PicPeak/picpeak/issues/961)) ([164129b](https://github.com/PicPeak/picpeak/commit/164129b8f5bbf8a68d743930a72bdb95b88fdee3))
|
||||
* **security:** scope dashboard stats/analytics/activity to the caller's events (GHSA-c2jj, gqx7, jhcf) ([#958](https://github.com/PicPeak/picpeak/issues/958)) ([da855cf](https://github.com/PicPeak/picpeak/commit/da855cfef9e74b0b1e77d54c39008c998ab3e20b))
|
||||
|
||||
## [3.98.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.2-beta.0...v3.98.3-beta.0) (2026-08-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) ([#950](https://github.com/PicPeak/picpeak/issues/950)) ([c2ce12c](https://github.com/PicPeak/picpeak/commit/c2ce12c039d5564e4457fdbbd50a06bbcfec4d6a))
|
||||
* **security:** neutralize spreadsheet formulas in all CSV/export cell-writers (CSV injection cluster) ([#948](https://github.com/PicPeak/picpeak/issues/948)) ([8f91c2c](https://github.com/PicPeak/picpeak/commit/8f91c2ca99de09d64b32a292c5f7fe86e63f9787))
|
||||
* **security:** redact gallery share tokens from analytics tracking (GHSA-7m6c) ([#952](https://github.com/PicPeak/picpeak/issues/952)) ([1c8f7d5](https://github.com/PicPeak/picpeak/commit/1c8f7d58a88b867c07d8d9699c20866c334dfa73))
|
||||
* **security:** unauth share_token leak (HIGH) + restore path-traversal, logo file-read, branding path keys ([#946](https://github.com/PicPeak/picpeak/issues/946)) ([9050aff](https://github.com/PicPeak/picpeak/commit/9050affd8dd0d5dff0514a8a7cb677fc2d410fca))
|
||||
|
||||
## [3.98.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.1-beta.0...v3.98.2-beta.0) (2026-08-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** block guest access to hidden/client-only photos across bulk + secure routes ([#939](https://github.com/PicPeak/picpeak/issues/939)) ([8a87c92](https://github.com/PicPeak/picpeak/commit/8a87c9274b2950a500ed1d17bc30fa573fcbf0c0))
|
||||
* **security:** bump sanitize-html to 2.17.5 (CVE-2026-53606) ([#937](https://github.com/PicPeak/picpeak/issues/937)) ([fe615c8](https://github.com/PicPeak/picpeak/commit/fe615c82e48de42399d8be47f796878835b90c5d))
|
||||
* **security:** close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs) ([#943](https://github.com/PicPeak/picpeak/issues/943)) ([82d6871](https://github.com/PicPeak/picpeak/commit/82d68711cf7b74b9c81a0eed3fd0905d668d5df7))
|
||||
* **security:** resolve DNS before vetting external hostnames (SSRF cluster) ([#941](https://github.com/PicPeak/picpeak/issues/941)) ([b700569](https://github.com/PicPeak/picpeak/commit/b7005692b33595cf9df52892ce2f94342ca21fe5))
|
||||
|
||||
## [3.98.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.0-beta.0...v3.98.1-beta.0) (2026-08-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **uploads:** prevent cross-photo contamination from filename collisions and non-atomic writes ([#931](https://github.com/PicPeak/picpeak/issues/931)) ([#933](https://github.com/PicPeak/picpeak/issues/933)) ([defeae9](https://github.com/PicPeak/picpeak/commit/defeae96349e4b68a2db6d66ad68b255e98f9e3b))
|
||||
|
||||
## [3.98.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.6-beta.0...v3.98.0-beta.0) (2026-07-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **gallery:** mouse-wheel zoom at cursor in the lightbox ([#885](https://github.com/PicPeak/picpeak/issues/885)) ([#927](https://github.com/PicPeak/picpeak/issues/927)) ([926a4a5](https://github.com/PicPeak/picpeak/commit/926a4a540d6f6a1e134ca4e611f850edf6338378))
|
||||
* **gallery:** multi-select feedback filters + sort direction controls ([#889](https://github.com/PicPeak/picpeak/issues/889)) ([#929](https://github.com/PicPeak/picpeak/issues/929)) ([3bcded7](https://github.com/PicPeak/picpeak/commit/3bcded78a448f5b099e87a73c7f1e44e859882aa))
|
||||
* **gallery:** per-event toggle to hide the logo on the password page ([#894](https://github.com/PicPeak/picpeak/issues/894)) ([#928](https://github.com/PicPeak/picpeak/issues/928)) ([08ff9f2](https://github.com/PicPeak/picpeak/commit/08ff9f20e73a12bc89fad539781c4f48972f48e1))
|
||||
|
||||
## [3.97.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.5-beta.0...v3.97.6-beta.0) (2026-07-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) ([#924](https://github.com/PicPeak/picpeak/issues/924)) ([03087c7](https://github.com/PicPeak/picpeak/commit/03087c798c8414505fcd694df7cd53bc08126b32))
|
||||
|
||||
## [3.97.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.4-beta.0...v3.97.5-beta.0) (2026-07-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** code-review follow-ups on [#910](https://github.com/PicPeak/picpeak/issues/910)/[#916](https://github.com/PicPeak/picpeak/issues/916) (MIME resolver + expiry reactivity) ([#921](https://github.com/PicPeak/picpeak/issues/921)) ([252475f](https://github.com/PicPeak/picpeak/commit/252475fce2ce8d5e16915c4d3558576ad720189b))
|
||||
|
||||
## [3.97.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.3-beta.0...v3.97.4-beta.0) (2026-07-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** expose view/download counters in the admin photos list ([#895](https://github.com/PicPeak/picpeak/issues/895) follow-up) ([#914](https://github.com/PicPeak/picpeak/issues/914)) ([aca3c8e](https://github.com/PicPeak/picpeak/commit/aca3c8e4bc33e74c81c4d2f2a15baf490c967134))
|
||||
* **admin:** stop marking events expired up to 24h early ([#909](https://github.com/PicPeak/picpeak/issues/909)) ([#916](https://github.com/PicPeak/picpeak/issues/916)) ([487f55f](https://github.com/PicPeak/picpeak/commit/487f55f2d9463d85898555472cd66ae69d1d0f31))
|
||||
|
||||
## [3.97.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.2-beta.0...v3.97.3-beta.0) (2026-07-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** serve videos with their real MIME type in the admin photo view ([#908](https://github.com/PicPeak/picpeak/issues/908)) ([#910](https://github.com/PicPeak/picpeak/issues/910)) ([67c56c5](https://github.com/PicPeak/picpeak/commit/67c56c5b61fc9a25f5d0b7346fb042211bc1d1de))
|
||||
|
||||
## [3.97.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.1-beta.0...v3.97.2-beta.0) (2026-07-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** make per-photo view/download counters actually count ([#895](https://github.com/PicPeak/picpeak/issues/895)) ([#904](https://github.com/PicPeak/picpeak/issues/904)) ([78116e2](https://github.com/PicPeak/picpeak/commit/78116e2e8bf681c5483f06e9b1490dc8239e8576))
|
||||
|
||||
## [3.97.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.0-beta.0...v3.97.1-beta.0) (2026-07-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tests:** raise migration-boot hook timeout pins to the 120s default ([#900](https://github.com/PicPeak/picpeak/issues/900)) ([d9ad982](https://github.com/PicPeak/picpeak/commit/d9ad982373861cd05f23167f1e2e50eaebfb7bba))
|
||||
|
||||
## [3.97.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.96.1-beta.0...v3.97.0-beta.0) (2026-07-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **feedback:** let guests remove their star rating ([#884](https://github.com/PicPeak/picpeak/issues/884)) ([#893](https://github.com/PicPeak/picpeak/issues/893)) ([6a048d0](https://github.com/PicPeak/picpeak/commit/6a048d08bd5d1d16f5ec2d2e580831086a32c71b))
|
||||
|
||||
## [3.96.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.96.0-beta.0...v3.96.1-beta.0) (2026-07-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** keep the lightbox toolbar from masking the photo ([#888](https://github.com/PicPeak/picpeak/issues/888)) ([#892](https://github.com/PicPeak/picpeak/issues/892)) ([ec66cd2](https://github.com/PicPeak/picpeak/commit/ec66cd2684b5ee608f23304ec0da029f38a3eed4))
|
||||
|
||||
## [3.96.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.5-beta.0...v3.96.0-beta.0) (2026-07-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **gallery:** quick return from zoomed to fit-to-screen in the lightbox ([#886](https://github.com/PicPeak/picpeak/issues/886)) ([#891](https://github.com/PicPeak/picpeak/issues/891)) ([97f6889](https://github.com/PicPeak/picpeak/commit/97f68899a221e3bc9b4e30c7d9a19eb16f062ba6))
|
||||
|
||||
## [3.95.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.4-beta.0...v3.95.5-beta.0) (2026-07-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** don't close the lightbox when clicking beside the photo ([#883](https://github.com/PicPeak/picpeak/issues/883)) ([#890](https://github.com/PicPeak/picpeak/issues/890)) ([34c2992](https://github.com/PicPeak/picpeak/commit/34c2992521fcb4a495398f27f6044d696b4d17c3))
|
||||
|
||||
## [3.95.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.3-beta.0...v3.95.4-beta.0) (2026-07-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* sync gallery feedback filters after lightbox like/rating in simple mode ([#882](https://github.com/PicPeak/picpeak/issues/882)) ([33f1bc4](https://github.com/PicPeak/picpeak/commit/33f1bc42a9441cba4c4cef81217a3073bbfd4e8b))
|
||||
|
||||
## [3.95.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.2-beta.0...v3.95.3-beta.0) (2026-07-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** close 5 Trivy alerts — postcss/tar bumps + drop npm from the runtime image ([#878](https://github.com/PicPeak/picpeak/issues/878)) ([08be2b8](https://github.com/PicPeak/picpeak/commit/08be2b84f18073b63fa131c692c50b6df849a0ca))
|
||||
|
||||
## [3.95.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.1-beta.0...v3.95.2-beta.0) (2026-07-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backup:** make backup settings actually apply ([#871](https://github.com/PicPeak/picpeak/issues/871)) ([#874](https://github.com/PicPeak/picpeak/issues/874)) ([a2e7234](https://github.com/PicPeak/picpeak/commit/a2e723413e64819f0d8c0c03636ed04af42a47e4))
|
||||
|
||||
## [3.95.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.0-beta.0...v3.95.1-beta.0) (2026-07-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** bump backend deps to close all 14 open Trivy code-scanning alerts ([#869](https://github.com/PicPeak/picpeak/issues/869)) ([38b8d47](https://github.com/PicPeak/picpeak/commit/38b8d476d17d5a28724dbd81c79d57e23d65a2fa))
|
||||
|
||||
## [3.95.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.94.2-beta.0...v3.95.0-beta.0) (2026-07-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** OIDC logout-to-IdP — phase 3 ([#798](https://github.com/PicPeak/picpeak/issues/798)) ([#865](https://github.com/PicPeak/picpeak/issues/865)) ([219d07b](https://github.com/PicPeak/picpeak/commit/219d07b04adf54756317d3cc3069f834aa2b460e))
|
||||
|
||||
## [3.94.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.94.1-beta.0...v3.94.2-beta.0) (2026-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** block password form in Instagram in-app browser and unmask login errors ([#863](https://github.com/PicPeak/picpeak/issues/863)) ([323dcae](https://github.com/PicPeak/picpeak/commit/323dcae91702b8a77d2db801b63398a76f16fee2))
|
||||
|
||||
## [3.94.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.94.0-beta.0...v3.94.1-beta.0) (2026-07-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tests:** raise jest timeouts to survive the growing migration chain ([#860](https://github.com/PicPeak/picpeak/issues/860)) ([40eb03f](https://github.com/PicPeak/picpeak/commit/40eb03f0d80458f6c7dc4f6e6430668451edadac))
|
||||
|
||||
## [3.94.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.93.0-beta.0...v3.94.0-beta.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** OIDC role mapping + login policy — phase 2 ([#798](https://github.com/PicPeak/picpeak/issues/798)) ([#854](https://github.com/PicPeak/picpeak/issues/854)) ([f8a95d2](https://github.com/PicPeak/picpeak/commit/f8a95d29d2feb5f651ff6a0bcfa1b5b1540f114a))
|
||||
* **feedback:** emoji reactions on photos ([#839](https://github.com/PicPeak/picpeak/issues/839)) ([#855](https://github.com/PicPeak/picpeak/issues/855)) ([3d6c984](https://github.com/PicPeak/picpeak/commit/3d6c9848dcbace1d1ce74890460e369854be65c7))
|
||||
* **gallery:** reveal mode — hide gallery from guests until reveal ([#838](https://github.com/PicPeak/picpeak/issues/838)) ([#856](https://github.com/PicPeak/picpeak/issues/856)) ([2f05fcc](https://github.com/PicPeak/picpeak/commit/2f05fcc39deaf226a6cc8796ebee9b40bc89e9ae))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **dates:** normalize SQLite epoch timestamps at remaining API surfaces ([#485](https://github.com/PicPeak/picpeak/issues/485) follow-up) ([#857](https://github.com/PicPeak/picpeak/issues/857)) ([c6ec93e](https://github.com/PicPeak/picpeak/commit/c6ec93eef9f18e8867e86691800a60379bb16591))
|
||||
|
||||
## [3.93.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.92.2-beta.0...v3.93.0-beta.0) (2026-07-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **events:** gallery QR code + printable table-card/poster PDFs ([#847](https://github.com/PicPeak/picpeak/issues/847)) ([60cdd07](https://github.com/PicPeak/picpeak/commit/60cdd07085c750cee358cbe59420668cbc538473))
|
||||
* **notifications:** surface guest activity in the admin bell ([#849](https://github.com/PicPeak/picpeak/issues/849)) ([cb5b319](https://github.com/PicPeak/picpeak/commit/cb5b319f1022655fbc1e442d0d1e6d8337f0e637))
|
||||
* **slideshow:** guest-scannable share-link QR overlay ([#848](https://github.com/PicPeak/picpeak/issues/848)) ([e8dad4b](https://github.com/PicPeak/picpeak/commit/e8dad4b40ddb816cc2f9a94be456f793adce20d7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **crm:** pass trx to logActivity inside transactions — audit rows silently lost on SQLite ([#851](https://github.com/PicPeak/picpeak/issues/851)) ([a6a3c9f](https://github.com/PicPeak/picpeak/commit/a6a3c9f9f8ecb84500d5ac68e90639c362f2461a))
|
||||
|
||||
## [3.92.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.92.1-beta.0...v3.92.2-beta.0) (2026-07-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **file-watcher:** bound concurrent photo processing ([#846](https://github.com/PicPeak/picpeak/issues/846)) ([8337a71](https://github.com/PicPeak/picpeak/commit/8337a716b169e66f8edf8619c64622e6853dae81))
|
||||
* **security:** read the password-complexity key the settings UI writes ([#843](https://github.com/PicPeak/picpeak/issues/843)) ([8060fed](https://github.com/PicPeak/picpeak/commit/8060fedf6aaea5359c3bf04696fd00ec8500b51a))
|
||||
* **uploads:** keep videos when thumbnail generation fails ([#845](https://github.com/PicPeak/picpeak/issues/845)) ([0310c46](https://github.com/PicPeak/picpeak/commit/0310c46fdd5b03274f761abfb4c8b552e2f8b666))
|
||||
|
||||
## [3.92.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.92.0-beta.0...v3.92.1-beta.0) (2026-07-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **uploads:** support configured raw formats ([f7fd893](https://github.com/PicPeak/picpeak/commit/f7fd89387be80ea9b3b5c11d06828a4c1a0d4af5))
|
||||
|
||||
## [3.92.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.91.0-beta.0...v3.92.0-beta.0) (2026-07-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **uploads:** DNG / camera-RAW support via embedded-preview extraction ([#821](https://github.com/PicPeak/picpeak/issues/821)) ([8c260c4](https://github.com/PicPeak/picpeak/commit/8c260c4eebedb69f349505d0befbbb5afb182b2c))
|
||||
|
||||
## [3.91.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.2-beta.0...v3.91.0-beta.0) (2026-07-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **uploads:** HEIC/HEIF support + dynamic format hint on guest upload ([#821](https://github.com/PicPeak/picpeak/issues/821)) ([ee9d2f7](https://github.com/PicPeak/picpeak/commit/ee9d2f70d3342d65edb795a688f0f5f611429964))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** serve JPEG preview for non-displayable originals in lightbox (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([808d305](https://github.com/PicPeak/picpeak/commit/808d3055497bb4e4a372acafa49ef9baf257f008))
|
||||
* **uploads:** register HEIC/HEIF with the file validator + fix admin format hint (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([c9b64d9](https://github.com/PicPeak/picpeak/commit/c9b64d9c1a8744c9ee5e068366a500ae0dab36bc))
|
||||
|
||||
## [3.90.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.1-beta.0...v3.90.2-beta.0) (2026-07-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([0245e44](https://github.com/PicPeak/picpeak/commit/0245e445cafd165ada3c5a15abb258ae2c1c857e))
|
||||
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([b97b130](https://github.com/PicPeak/picpeak/commit/b97b130cadebaef38e59cc227fa6578ac886110f))
|
||||
* **update:** target docker-compose.production.yml in dashboard update steps ([51a505e](https://github.com/PicPeak/picpeak/commit/51a505e3798895e544f943673e81a365265f319c))
|
||||
* **update:** target docker-compose.production.yml in dashboard update steps + gate mailhog ([2a0361a](https://github.com/PicPeak/picpeak/commit/2a0361a83b4ca0a600bb4fd447e338533ce63420))
|
||||
* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([29f1d23](https://github.com/PicPeak/picpeak/commit/29f1d23a0a645208f22453e62d99fe79b55c7db4))
|
||||
* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([1e38d84](https://github.com/PicPeak/picpeak/commit/1e38d84808ee2a2b176c75d5ec4975fba710e63c))
|
||||
* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([43c6d22](https://github.com/PicPeak/picpeak/commit/43c6d22bdd93179865703da6350094c9b95388d8))
|
||||
* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([e03d13e](https://github.com/PicPeak/picpeak/commit/e03d13efde843c7a7275cd41c855b402538756e7))
|
||||
|
||||
## [3.90.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.0-beta.0...v3.90.1-beta.0) (2026-07-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq) ([e7ca8bd](https://github.com/PicPeak/picpeak/commit/e7ca8bdb7f30d999039125c0f0ef89bdc92d5a69))
|
||||
* **security:** remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq) ([6cd546e](https://github.com/PicPeak/picpeak/commit/6cd546e86ae38819c0fdc24044f86106503fa020))
|
||||
|
||||
## [3.90.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.89.0-beta.0...v3.90.0-beta.0) (2026-07-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** OIDC SSO for admin users — phase 1 ([f12606b](https://github.com/PicPeak/picpeak/commit/f12606b4e0d2fbe4f2f57a345b393448063d6614))
|
||||
|
||||
## [3.89.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.88.1-beta.0...v3.89.0-beta.0) (2026-07-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **security:** harden .picpeak restore robustness — sessions, roles, sequences ([a77c2c2](https://github.com/PicPeak/picpeak/commit/a77c2c2c573a79f0194ff2b911acaa5f46c11f26))
|
||||
* **security:** harden .picpeak restore robustness — sessions, roles, sequences ([340d91b](https://github.com/PicPeak/picpeak/commit/340d91bdd53a595694edfa6f3d691b240a2babcd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** close 4 open security advisories (backup takeover, share-login bypass, ZIP slip, chunked-upload traversal) ([7ebc232](https://github.com/PicPeak/picpeak/commit/7ebc2326204ad0572e6a1fc121b5d232da06cec3))
|
||||
* **security:** harden .picpeak restore operator-preservation (GHSA-qxfx follow-up) ([38fd41a](https://github.com/PicPeak/picpeak/commit/38fd41aad3fcb12a249aaa2eb3d98fbffbde537a))
|
||||
* **security:** preserve current admin on .picpeak restore (GHSA-qxfx-4493-4v8f) ([348894e](https://github.com/PicPeak/picpeak/commit/348894efefa5a7b49d32feb22a98045b93076138))
|
||||
* **security:** reject ZIP-slip entries in archive/backup restore (GHSA-jfhw-fj23-fx6x) ([9cd6b08](https://github.com/PicPeak/picpeak/commit/9cd6b08441e8633751b9fb73daca5ca0555c950b))
|
||||
* **security:** sanitize chunked-upload filename (GHSA-pc72-jf53-w28j) ([31bc01c](https://github.com/PicPeak/picpeak/commit/31bc01cb4bbf65b48b3a5c3c94ad35e487df9fcc))
|
||||
* **security:** share-login must not bypass gallery password (GHSA-9hmx-68vc-qpqw) ([7dace04](https://github.com/PicPeak/picpeak/commit/7dace044dcc1c3b5a13c4704510c87616632618c))
|
||||
|
||||
## [3.88.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.88.0-beta.0...v3.88.1-beta.0) (2026-07-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** mask backup credentials on read + unblock MFA login during maintenance ([eadf282](https://github.com/PicPeak/picpeak/commit/eadf282755829cb51e6ea37221be31d8c9af41c5))
|
||||
* **security:** mask backup credentials on read + unblock MFA login during maintenance ([07f2c90](https://github.com/PicPeak/picpeak/commit/07f2c900556738e993fb63764210b541d7692c9d))
|
||||
|
||||
## [3.88.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.87.0-beta.0...v3.88.0-beta.0) (2026-07-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **setup:** event-types step in first-run wizard + un-hardcode event type dependencies ([109aba8](https://github.com/PicPeak/picpeak/commit/109aba859820bf80440d056baf183ecf2657fee3))
|
||||
* **setup:** event-types step in first-run wizard + un-hardcode event type deps ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([7eb6357](https://github.com/PicPeak/picpeak/commit/7eb6357b4a9bf3914674a63afa386a5fcf8c2161))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **event-types:** harden setup window + catalog validation (codex review) ([f8ba669](https://github.com/PicPeak/picpeak/commit/f8ba6697163b4d9aa0fa0014cb5b0810371c04ae))
|
||||
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([d64eef8](https://github.com/PicPeak/picpeak/commit/d64eef8abf2915230b3cdd38a3bbb8af1a12c6d2))
|
||||
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([5da1c3a](https://github.com/PicPeak/picpeak/commit/5da1c3a12f603a230091426b1d7be0eac83da22c))
|
||||
* **gallery:** show feedback filter chips on desktop for galleries without categories ([0751a08](https://github.com/PicPeak/picpeak/commit/0751a08aa661a430c1609cd8c118347291cbaa14))
|
||||
* **gallery:** show feedback filter chips on desktop for galleries without categories ([#802](https://github.com/PicPeak/picpeak/issues/802)) ([b928338](https://github.com/PicPeak/picpeak/commit/b9283386a57431ac8bd395347f9acb9bbdf82e8e))
|
||||
|
||||
## [3.87.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.86.0-beta.0...v3.87.0-beta.0) (2026-07-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **invoices:** configurable VAT note under MwSt. line + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([ffd4a7e](https://github.com/PicPeak/picpeak/commit/ffd4a7eee64b6418df1c9cc6843d86dc0f41d2ec))
|
||||
* **invoices:** configurable VAT/free-text note + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([1476884](https://github.com/PicPeak/picpeak/commit/1476884dd04202f5f18d50d458b6176b0535c71b))
|
||||
|
||||
## [3.86.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.85.0-beta.0...v3.86.0-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([d51112e](https://github.com/PicPeak/picpeak/commit/d51112e761d2fd83f1939841fbf4c05e625fc34d))
|
||||
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([4698402](https://github.com/PicPeak/picpeak/commit/4698402b5493cfbdb1e2b6d81c6f58829e17a703))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **categories:** address PR [#790](https://github.com/PicPeak/picpeak/issues/790) review — event ownership, migration renumber, nits ([a4b4485](https://github.com/PicPeak/picpeak/commit/a4b4485d322514690c5400ca7ab9a91bc25c3e48))
|
||||
|
||||
## [3.85.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.1-beta.0...v3.85.0-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **slideshow:** per-event play order + category filter ([#202](https://github.com/PicPeak/picpeak/issues/202)) ([5467642](https://github.com/PicPeak/picpeak/commit/54676424f2f7ed50e74cb8e144cbdaa5a96e65c3))
|
||||
|
||||
## [3.84.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.0-beta.0...v3.84.1-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([1f3bc3c](https://github.com/PicPeak/picpeak/commit/1f3bc3c3430414b5b6cb2141d887a8b5855a04af))
|
||||
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([39db7bf](https://github.com/PicPeak/picpeak/commit/39db7bf6cb5c39fcdf71c875a4aaf704f34447fa))
|
||||
|
||||
## [3.84.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.1-beta.0...v3.84.0-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([279e047](https://github.com/PicPeak/picpeak/commit/279e0472c71c6a37ba091a9c7a31f5571c0a8df6))
|
||||
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([d3d7df4](https://github.com/PicPeak/picpeak/commit/d3d7df46f214028ba89063079d356bc0430083f5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([2ee4146](https://github.com/PicPeak/picpeak/commit/2ee4146d9a6fd026e7b7be3ba774de9a0cf6e96a))
|
||||
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([784d059](https://github.com/PicPeak/picpeak/commit/784d059c3da5b36e2b6794ebf2e34bc15c8a9824))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **releasing:** align stable version to main on promote (Option A) ([df5aeab](https://github.com/PicPeak/picpeak/commit/df5aeaba416726cc0123f32ddf88e4a30dc28908))
|
||||
* **releasing:** align stable version to main on promote (Option A) ([5dea0c9](https://github.com/PicPeak/picpeak/commit/5dea0c969558f50833973ff742257780f5842612))
|
||||
|
||||
## [3.83.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.0-beta.0...v3.83.1-beta.0) (2026-07-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** target stable in release-please + undo bogus 2.7.0 bump ([274ef0c](https://github.com/PicPeak/picpeak/commit/274ef0cd731765b057a5d62d5f41c14cb3a1564b))
|
||||
* **release:** target stable in release-please.yml + undo the bogus 2.7.0 bump ([65ac6ed](https://github.com/PicPeak/picpeak/commit/65ac6eddacb79857e9a9651d3c869e7bfdd92887))
|
||||
|
||||
## [3.83.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.6-beta.0...v3.83.0-beta.0) (2026-07-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **messages:** create/select quote, contract, invoice, gallery from a message ([0dbf863](https://github.com/PicPeak/picpeak/commit/0dbf863f60b919560b766f78b107ebac9612bd9d))
|
||||
* **messages:** search bar + Archive/Delete with Archived & Deleted folders ([99d5996](https://github.com/PicPeak/picpeak/commit/99d5996561a2dcff2d431692d5bab5c7286d1f6f))
|
||||
* **messages:** unified Messages email client (flag-gated, default off) ([a71b9b5](https://github.com/PicPeak/picpeak/commit/a71b9b5ed721df17b61062ae3a2361d448c95cf7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) nits — server-side search, bare-email recipient, DE i18n ([1e08a4f](https://github.com/PicPeak/picpeak/commit/1e08a4fb156d34ee8ddff69b0a7612001aa6d67e))
|
||||
* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) review — escape reply sender (XSS), gate backend routes, exact customer match ([bb235e7](https://github.com/PicPeak/picpeak/commit/bb235e72e58359f55f8aeccf2f22c671584fdbd7))
|
||||
* **messages:** show the resolved customer's name in the doc-action modal ([2c5c1d5](https://github.com/PicPeak/picpeak/commit/2c5c1d561bbe567b9d7615e7c2d071d08bb6d63c))
|
||||
|
||||
## [3.82.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.5-beta.0...v3.82.6-beta.0) (2026-07-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **workflows:** backfill existing invoices + anchor dunning grace to due date when enabled ([#750](https://github.com/PicPeak/picpeak/issues/750)) ([9596342](https://github.com/PicPeak/picpeak/commit/9596342d6a9ef107193cfc123487a8061f4a91ca))
|
||||
* **workflows:** scope dunning backfill to its own flow via targetWorkflowId ([da3a77d](https://github.com/PicPeak/picpeak/commit/da3a77dac40a892158167aec939a1458d488a951))
|
||||
|
||||
## [3.82.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.4-beta.0...v3.82.5-beta.0) (2026-07-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** stop the event-date field crashing the page on backspace ([760a201](https://github.com/PicPeak/picpeak/commit/760a201b6070a4edfe8192bcddce948c5f0c3fec))
|
||||
|
||||
## [3.82.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.3-beta.0...v3.82.4-beta.0) (2026-07-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([0c2d319](https://github.com/PicPeak/picpeak/commit/0c2d319fc1ed67843cc60afdcaea5807ea49226f))
|
||||
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([fcc3e91](https://github.com/PicPeak/picpeak/commit/fcc3e9195d6f63b2dffddfa72a867a3e32325e81))
|
||||
* **email:** sibling billing emails follow customer language too ([c0008be](https://github.com/PicPeak/picpeak/commit/c0008be39bc8a9d354e48ce8d6bd89662bc53ebb))
|
||||
|
||||
## [3.82.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.2-beta.0...v3.82.3-beta.0) (2026-07-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([a88da99](https://github.com/PicPeak/picpeak/commit/a88da99c8d35c0c7cb7f96a235e984edad74ac7c))
|
||||
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([96fe478](https://github.com/PicPeak/picpeak/commit/96fe478bf87a3350185206b3d6f15133138b995d))
|
||||
* **branding:** unify hero logo SIZE the same way as visibility ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([60b03b1](https://github.com/PicPeak/picpeak/commit/60b03b17287539b3ad5e5d32f4eda8622f0575e4))
|
||||
|
||||
## [3.82.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.1-beta.0...v3.82.2-beta.0) (2026-07-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **og:** broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.) ([a0a28a4](https://github.com/PicPeak/picpeak/commit/a0a28a47777db9ca9e60a5134c8d86503c060e79))
|
||||
* **og:** route branded short URLs + slideshow links to OG, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([0dffe0c](https://github.com/PicPeak/picpeak/commit/0dffe0ce92339e0608b3ef660e84c31a62f4a98c))
|
||||
* **og:** route branded short URLs + slideshow to OG handler, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a87ad77](https://github.com/PicPeak/picpeak/commit/a87ad77d8d5215c88f5d95cc7aebaa1769938ec0))
|
||||
|
||||
## [3.82.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.0-beta.0...v3.82.1-beta.0) (2026-07-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **invoices:** correct payment-check email template key so dunning email sends ([9a76333](https://github.com/PicPeak/picpeak/commit/9a763337b658299aae0d7c985071c4a775000f99))
|
||||
* **invoices:** correct payment-check email template key so dunning email sends ([3682de1](https://github.com/PicPeak/picpeak/commit/3682de195b46eae692db3ff4a1476b00d3a6e216))
|
||||
|
||||
## [3.82.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.81.0-beta.0...v3.82.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **setup:** final community step ([#732](https://github.com/PicPeak/picpeak/issues/732)) + fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([a5f49e3](https://github.com/PicPeak/picpeak/commit/a5f49e32350564ee4d3894f33e9611e9244cc994))
|
||||
* **setup:** final community/thank-you step ([#732](https://github.com/PicPeak/picpeak/issues/732)); fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([dadaaee](https://github.com/PicPeak/picpeak/commit/dadaaeea7781cb62811256b512003e5c4d6ad95e))
|
||||
|
||||
## [3.81.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.80.0-beta.0...v3.81.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* admin two-factor authentication (TOTP) with recovery codes + CLI reset ([cf07361](https://github.com/PicPeak/picpeak/commit/cf073615effa8a91e19374ad3e9924e6e7322950))
|
||||
* **admin-ui:** TOTP MFA enrollment + two-step login; remove stub 2FA toggle ([96e3c68](https://github.com/PicPeak/picpeak/commit/96e3c68b9d6b35a82abcad664a6da7b19150b4fd))
|
||||
* **auth:** admin TOTP MFA — enrollment, login challenge, recovery, CLI reset ([72e2ef6](https://github.com/PicPeak/picpeak/commit/72e2ef6721b0572ed34455de901aa357eacd8c76))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* event creation 500s on PostgreSQL (NaN slideshow seed) + stray "0" boolean renders ([b187f58](https://github.com/PicPeak/picpeak/commit/b187f588b4d12af7a7849f8558c0085573d4af76))
|
||||
* **security:** close cross-event thumbnail leak, bulk-op ownership bypass, + hardening ([081f3ed](https://github.com/PicPeak/picpeak/commit/081f3edcdffc65a77000cc638e364ea9dc03767f))
|
||||
* **security:** cross-event thumbnail leak, bulk-op ownership bypass + auth hardening ([b732974](https://github.com/PicPeak/picpeak/commit/b732974779803b67097c81ae6bce2de0f2910794))
|
||||
|
||||
## [3.80.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.1-beta.0...v3.80.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
|
||||
+11
-20
@@ -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
|
||||
|
||||
|
||||
-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:** [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>
|
||||
|
||||
+4
-18
@@ -52,19 +52,13 @@ 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)
|
||||
|
||||
@@ -89,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.
|
||||
|
||||
+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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
storage
|
||||
storage/events/active/*
|
||||
storage/events/archived/*
|
||||
storage/thumbnails/*
|
||||
data/*.db
|
||||
logs/*
|
||||
coverage
|
||||
|
||||
@@ -106,12 +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
|
||||
|
||||
# Analytics Backend Configuration (OPTIONAL)
|
||||
# Used for server-side tracking only
|
||||
# Primary configuration should be done through Admin UI > Settings > Analytics
|
||||
|
||||
+11
-32
@@ -27,35 +27,17 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# knexfile.js picks its config block by NODE_ENV, and the `development` block
|
||||
# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that
|
||||
# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
|
||||
# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while
|
||||
# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in
|
||||
# the same log. The compose files still override this, so nothing changes for
|
||||
# compose users. See #1038.
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
|
||||
# stage's declaration never reached this stage. Consuming it in the RUN below
|
||||
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
|
||||
# image always picks up current Alpine security updates instead of reusing a
|
||||
# stale cached upgrade layer.
|
||||
ARG CACHEBUST=1
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Remove the npm CLI from the final image. Nothing runs npm here: the
|
||||
# entrypoint is node, runtime deps are COPY'd from the builder stage, and
|
||||
# wait-for-db.sh invokes the migration runners via node directly. npm's
|
||||
# bundled node_modules kept tripping Trivy (sigstore, tar 7.5.19,
|
||||
# brace-expansion 5.0.7 — even npm 12.0.1 still ships the vulnerable
|
||||
# copies), so shipping no npm ends that alert class instead of chasing
|
||||
# per-release patches. Note: `docker exec … npm run <script>` no longer
|
||||
# works in the container — use `node migrations/run-migrations-safe.js`
|
||||
# and friends instead.
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
# Upgrade 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 +59,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 ./
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,253 +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;
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
// 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; give it room to finish. */
|
||||
const drain = () => new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Smoke tests for backupService's config resolution + file-collection
|
||||
* and manifest validation paths — safety net ahead of the god-file
|
||||
* decomposition.
|
||||
*
|
||||
* Uses the same real-SQLite harness as
|
||||
* backupService.configurableWalker.test.js (bootCrmDb + a temp
|
||||
* STORAGE_PATH) rather than the broken deep-mock approach in
|
||||
* backupService.enhanced.test.js.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — config + file collection + manifest (smoke)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
let backupManifest;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
backupManifest = require('../../src/services/backupManifest');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').del();
|
||||
// Reset the storage tree so each test starts from a pristine walk.
|
||||
await fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
});
|
||||
|
||||
function seedFile(relPath, content = 'dummy bytes') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
return abs;
|
||||
}
|
||||
|
||||
async function insertBackupSetting(key, value) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: value,
|
||||
setting_type: 'backup',
|
||||
});
|
||||
}
|
||||
|
||||
describe('getBackupConfig', () => {
|
||||
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
|
||||
await insertBackupSetting('backup_enabled', 'true');
|
||||
await insertBackupSetting('backup_include_archived', 'false');
|
||||
await insertBackupSetting('backup_retention_days', '30');
|
||||
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
|
||||
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
|
||||
// Non-backup settings must not leak into the backup config.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'general_site_name',
|
||||
setting_value: 'PicPeak',
|
||||
setting_type: 'general',
|
||||
});
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config.backup_enabled).toBe(true);
|
||||
expect(config.backup_include_archived).toBe(false);
|
||||
expect(config.backup_retention_days).toBe(30);
|
||||
expect(config.backup_destination_path).toBe('/backups/picpeak');
|
||||
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
|
||||
expect(config).not.toHaveProperty('general_site_name');
|
||||
// Raw (unparsed) values are preserved on the non-enumerable __raw.
|
||||
expect(String(config.__raw.backup_retention_days)).toBe('30');
|
||||
});
|
||||
|
||||
it('returns an empty config object (not null) when nothing is configured', async () => {
|
||||
const config = await backupService.getBackupConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(Object.keys(config)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilesToBackup', () => {
|
||||
it('returns an empty list on a pristine storage tree', async () => {
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
|
||||
const content = 'not really a jpeg';
|
||||
const abs = seedFile('events/active/E9/pic.jpg', content);
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.path).toBe(abs);
|
||||
expect(entry.size).toBe(Buffer.byteLength(content));
|
||||
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
|
||||
// realm under Jest and fails the cross-realm instanceof check.
|
||||
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBackupManifest', () => {
|
||||
it('round-trips a generated manifest as valid', async () => {
|
||||
seedFile('events/active/E1/a.jpg', 'aaa');
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
|
||||
const manifest = await backupManifest.generateManifest({
|
||||
backupType: 'full',
|
||||
backupPath: '/backup/run-1',
|
||||
files,
|
||||
});
|
||||
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
|
||||
await backupManifest.saveManifest(manifest, manifestPath, 'json');
|
||||
|
||||
const result = await backupService.validateBackupManifest(manifestPath);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.manifest.backup.type).toBe('full');
|
||||
expect(result.manifest.files.count).toBe(files.length);
|
||||
expect(result.manifest.verification.total_checksum).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags a manifest missing required sections as invalid', async () => {
|
||||
const badPath = path.join(storagePath, 'manifest-broken.json');
|
||||
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
|
||||
|
||||
const result = await backupService.validateBackupManifest(badPath);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toMatch(/Missing required section/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,202 +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);
|
||||
});
|
||||
});
|
||||
@@ -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,413 +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
|
||||
// `process.cwd()/storage/business-docs/...` — chdir into the temp dir
|
||||
// so every test artifact lands isolated and gets cleaned up.
|
||||
process.chdir(tmpDir);
|
||||
storageRoot = path.join(fs.realpathSync(tmpDir), 'storage', '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,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,221 +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' }
|
||||
);
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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,233 +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');
|
||||
expect(names.sort()).toEqual(['photo_capture_date_backfill', 'photo_dimension_repair']);
|
||||
});
|
||||
|
||||
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,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,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,458 +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.
|
||||
const keys = imageProcessor.previewTierKeys({ id: 5, path: 'e/a.jpg', source_origin: 'managed' });
|
||||
expect(keys).toHaveLength(imageProcessor.PREVIEW_WIDTHS.length - 1);
|
||||
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,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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ const { bootCrmDb } = 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;
|
||||
@@ -239,7 +239,7 @@ describe('workflow engine', () => {
|
||||
expect(again.already).toBe(true);
|
||||
});
|
||||
|
||||
test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => {
|
||||
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
|
||||
const noopLogger = { info() {}, warn() {} };
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||
@@ -248,7 +248,7 @@ describe('workflow engine', () => {
|
||||
expect(wf).toBeTruthy();
|
||||
expect(!!wf.is_builtin).toBe(true);
|
||||
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
|
||||
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(7);
|
||||
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
|
||||
|
||||
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
|
||||
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
|
||||
@@ -273,7 +273,7 @@ describe('workflow engine', () => {
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||
const reseeded = await db('workflows').where({ id: wf.id }).first();
|
||||
expect(reseeded.version).toBe(wf.version + 1); // bumped
|
||||
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(7);
|
||||
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6);
|
||||
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
|
||||
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
|
||||
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
|
||||
|
||||
@@ -9,7 +9,7 @@ const {
|
||||
// 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,112 +0,0 @@
|
||||
/**
|
||||
* The roles-join fallback in adminAuth fabricates `role_name = 'super_admin'`
|
||||
* to keep existing sessions working across the RBAC upgrade window. The catch
|
||||
* around it used to be unconditional, so ANY transient database failure —
|
||||
* connection reset, deadlock, statement timeout, pool exhaustion — took the
|
||||
* same branch and handed the caller super_admin for the duration of the fault.
|
||||
*
|
||||
* `roleName` is the sole discriminator for every ownership check (ownership.js,
|
||||
* adminProjects, adminUsers, adminApiTokens, projectService, ...), so that
|
||||
* inverted the whole authorization model rather than failing the request.
|
||||
* Issue #968. Same treatment apiTokenAuth already got for the v1 surface.
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
|
||||
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) }));
|
||||
jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), info: jest.fn() }));
|
||||
|
||||
// The joined query throws whatever the test stages; the role-less fallback
|
||||
// query (no .leftJoin) always succeeds, which is what made the original bug
|
||||
// reachable — it is the cheaper single-table read.
|
||||
// `mock`-prefixed so jest's module-factory hoisting allows the reference.
|
||||
let mockJoinError = null;
|
||||
const mockAdminRow = { id: 7, username: 'scoped', email: 's@example.com', password_changed_at: null };
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({
|
||||
_joined: false,
|
||||
leftJoin() { this._joined = true; return this; },
|
||||
where() { return this; },
|
||||
select() { return this; },
|
||||
first() {
|
||||
if (this._joined && mockJoinError) return Promise.reject(mockJoinError);
|
||||
return Promise.resolve({ ...mockAdminRow });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { adminAuth } = require('../../src/middleware/auth');
|
||||
|
||||
const SECRET = 'test-secret-for-admin-auth-fallback';
|
||||
|
||||
function makeReq() {
|
||||
const token = jwt.sign(
|
||||
{ id: mockAdminRow.id, type: 'admin' },
|
||||
SECRET,
|
||||
{ algorithm: 'HS256', issuer: 'picpeak-auth' },
|
||||
);
|
||||
return { headers: { authorization: `Bearer ${token}` }, ip: '127.0.0.1', connection: {} };
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
return {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
}
|
||||
|
||||
describe('adminAuth roles-join fallback (#968)', () => {
|
||||
const OLD_SECRET = process.env.JWT_SECRET;
|
||||
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
|
||||
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
|
||||
beforeEach(() => { mockJoinError = null; });
|
||||
|
||||
it('grants the upgrade-window fallback only for a genuinely missing roles table', async () => {
|
||||
mockJoinError = new Error('SQLITE_ERROR: no such table: roles');
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.admin.roleName).toBe('super_admin');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['connection reset', new Error('Connection terminated unexpectedly')],
|
||||
['deadlock', new Error('deadlock detected')],
|
||||
['pool exhaustion', new Error('Knex: Timeout acquiring a connection')],
|
||||
['statement timeout', new Error('canceling statement due to statement timeout')],
|
||||
])('does NOT fabricate super_admin on a transient failure (%s)', async (_label, err) => {
|
||||
mockJoinError = err;
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
// Fails closed: request rejected, req.admin never populated. The specific
|
||||
// status is 401 (adminAuth's blanket outer catch) — what matters is that
|
||||
// the caller is not elevated and does not reach the route.
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(req.admin).toBeUndefined();
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('does NOT fabricate super_admin when an unrelated table is missing', async () => {
|
||||
mockJoinError = new Error('SQLITE_ERROR: no such table: admin_sessions');
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(req.admin).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* The roles-join fallback in apiTokenAuth grants `super_admin` (upgrade-path
|
||||
* parity with adminAuth). It must therefore fire ONLY when the roles schema is
|
||||
* genuinely absent — a catch-all turns any transient database failure into a
|
||||
* privilege escalation that reopens GHSA-9697 for a demoted token owner.
|
||||
*/
|
||||
|
||||
const { isMissingRolesSchema } = require('../../src/middleware/apiTokenAuth');
|
||||
|
||||
describe('apiTokenAuth roles-schema fallback predicate (GHSA-9697)', () => {
|
||||
it('accepts a genuinely missing roles table on both engines', () => {
|
||||
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: roles'))).toBe(true);
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(new Error('relation "roles" does not exist'), { code: '42P01' }),
|
||||
)).toBe(true);
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(new Error('column roles.name does not exist'), { code: '42703' }),
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects transient failures that must not elevate the caller', () => {
|
||||
expect(isMissingRolesSchema(new Error('Connection terminated unexpectedly'))).toBe(false);
|
||||
expect(isMissingRolesSchema(new Error('deadlock detected'))).toBe(false);
|
||||
expect(isMissingRolesSchema(new Error('Knex: Timeout acquiring a connection'))).toBe(false);
|
||||
expect(isMissingRolesSchema(new Error('canceling statement due to statement timeout'))).toBe(false);
|
||||
expect(isMissingRolesSchema(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing-table error for an unrelated table', () => {
|
||||
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: api_tokens'))).toBe(false);
|
||||
});
|
||||
|
||||
// knex prefixes the failing SQL to err.message, and that SQL always names
|
||||
// `roles` on this join — so the message substring proves nothing about the
|
||||
// error, and only an exact driver phrase (or a SQLSTATE) may be trusted.
|
||||
// These are real knex message shapes, captured from the actual query.
|
||||
describe('with knex\'s SQL prefix on the message (#968)', () => {
|
||||
const withSql = (driverMessage) => new Error(
|
||||
'select `roles`.`name` as `role_name` from `admin_users` '
|
||||
+ 'left join `roles` on `roles`.`id` = `admin_users`.`role_id` '
|
||||
+ `where \`admin_users\`.\`id\` = 1 limit 1 - ${driverMessage}`,
|
||||
);
|
||||
|
||||
it('accepts both legitimate upgrade-window states', () => {
|
||||
// pre-054: the roles table does not exist yet
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(withSql('SQLITE_ERROR: no such table: roles'), { code: 'SQLITE_ERROR' }),
|
||||
)).toBe(true);
|
||||
// post-054, pre-057: roles exists, admin_users.role_id not added yet
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(withSql('SQLITE_ERROR: no such column: admin_users.role_id'), { code: 'SQLITE_ERROR' }),
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unrelated "does not exist" fault despite the SQL naming roles', () => {
|
||||
// pgbouncer transaction pooling loses a named prepared statement
|
||||
// (SQLSTATE 26000). Transient — the fallback query would succeed on a
|
||||
// fresh connection, so accepting this would fabricate super_admin.
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(withSql('prepared statement "S_1" does not exist'), { code: '26000' }),
|
||||
)).toBe(false);
|
||||
// The DB role/user, not the roles table.
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(withSql('role "picpeak" does not exist'), { code: '28000' }),
|
||||
)).toBe(false);
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(withSql('database "picpeak" does not exist'), { code: '3D000' }),
|
||||
)).toBe(false);
|
||||
expect(isMissingRolesSchema(withSql('Connection terminated unexpectedly'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* #868 — the admin gallery-preview gate. isAdminPreview must fail CLOSED: it
|
||||
* grants the draft/password bypass only for an explicit `?admin_preview=1` flag
|
||||
* AND a verified admin JWT (type 'admin', issuer 'picpeak-auth') read from the
|
||||
* httpOnly admin_token cookie or a Bearer header — never from the URL, never for
|
||||
* a guest/gallery token.
|
||||
*/
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-preview-test-secret';
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { isAdminPreview } = require('../../src/middleware/gallery');
|
||||
|
||||
// Read the secret at call time — a jest setup file can set JWT_SECRET after this
|
||||
// module loads, and isAdminPreview verifies against the live value.
|
||||
const adminToken = () => jwt.sign({ type: 'admin', id: 1 }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
const galleryToken = () => jwt.sign({ type: 'gallery', eventId: 1 }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
|
||||
function req({ flag, cookie, bearer } = {}) {
|
||||
return {
|
||||
query: flag === undefined ? {} : { admin_preview: flag },
|
||||
cookies: cookie ? { admin_token: cookie } : {},
|
||||
headers: bearer ? { authorization: `Bearer ${bearer}` } : {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('isAdminPreview (#868) fails closed', () => {
|
||||
it('false without the explicit flag, even with a valid admin cookie (plain link stays guest-identical)', () => {
|
||||
expect(isAdminPreview(req({ cookie: adminToken() }))).toBe(false);
|
||||
});
|
||||
|
||||
it('false with the flag but no session token', () => {
|
||||
expect(isAdminPreview(req({ flag: '1' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('true with the flag + a valid admin cookie', () => {
|
||||
expect(isAdminPreview(req({ flag: '1', cookie: adminToken() }))).toBe(true);
|
||||
});
|
||||
|
||||
it('true with the flag + a valid admin Bearer header', () => {
|
||||
expect(isAdminPreview(req({ flag: '1', bearer: adminToken() }))).toBe(true);
|
||||
});
|
||||
|
||||
it('false for a gallery (guest) token — must be type admin', () => {
|
||||
expect(isAdminPreview(req({ flag: '1', cookie: galleryToken() }))).toBe(false);
|
||||
});
|
||||
|
||||
it('true from the admin cookie even when a gallery Bearer is also present (#981 coexisting session)', () => {
|
||||
expect(isAdminPreview(req({ flag: '1', cookie: adminToken(), bearer: galleryToken() }))).toBe(true);
|
||||
});
|
||||
|
||||
it('false when only a gallery Bearer is present — a gallery header can never satisfy it (#981)', () => {
|
||||
expect(isAdminPreview(req({ flag: '1', bearer: galleryToken() }))).toBe(false);
|
||||
});
|
||||
|
||||
it('false on a tampered token', () => {
|
||||
expect(isAdminPreview(req({ flag: '1', cookie: `${adminToken()}x` }))).toBe(false);
|
||||
});
|
||||
|
||||
it('false on the wrong issuer', () => {
|
||||
const t = jwt.sign({ type: 'admin' }, process.env.JWT_SECRET, { issuer: 'not-picpeak' });
|
||||
expect(isAdminPreview(req({ flag: '1', cookie: t }))).toBe(false);
|
||||
});
|
||||
|
||||
it('false when the flag is anything other than exactly "1"', () => {
|
||||
expect(isAdminPreview(req({ flag: 'true', cookie: adminToken() }))).toBe(false);
|
||||
expect(isAdminPreview(req({ flag: '0', cookie: adminToken() }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* Regression test for the maintenance-mode lockout in single-container mode.
|
||||
*
|
||||
* In the compose stack nginx serves the frontend, so a request for /admin/login
|
||||
* or /gallery/<slug> never reaches Express. The all-in-one image (#1042) has no
|
||||
* nginx: server.js serves the SPA itself, and maintenanceMiddleware is mounted
|
||||
* far ahead of that static block. Gating those paths therefore answered the
|
||||
* HTML document with 503 JSON, which broke two things at once —
|
||||
*
|
||||
* 1. an admin who enabled maintenance mode could never disable it, because
|
||||
* /admin/login and its /assets/ bundle would not load (the login *API* was
|
||||
* already exempt, but nothing could call it), and
|
||||
* 2. a guest saw raw JSON instead of the branded maintenance screen the
|
||||
* frontend already ships.
|
||||
*
|
||||
* The shell is inert HTML: it boots, calls /api/public/settings (exempt) and
|
||||
* renders MaintenanceMode itself, so letting it through costs nothing.
|
||||
*
|
||||
* The dividing line is taken from frontend/nginx.conf rather than invented:
|
||||
* paths nginx answers from the frontend container are exempt, paths it
|
||||
* proxy_passes to the backend stay gated. That makes the all-in-one image
|
||||
* behave exactly like compose in both directions. The gated half is where the
|
||||
* risk lives — a negative "everything that is not an API is a shell" rule
|
||||
* looks right and quietly un-gates /og/ (event names, cover images) and the
|
||||
* public CMS at the site root — so most of the cases below assert it.
|
||||
*/
|
||||
|
||||
const { maintenanceMiddleware } = require('../../src/middleware/maintenance');
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const settings = { setting_key: 'general_maintenance_mode', setting_value: 'true' };
|
||||
const db = jest.fn(() => ({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
first: jest.fn().mockResolvedValue(settings),
|
||||
}));
|
||||
return { db };
|
||||
});
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
// Maintenance state is cached for a minute; each case starts from a clean read.
|
||||
const { clearMaintenanceCache } = require('../../src/middleware/maintenance');
|
||||
|
||||
async function run(path, { method = 'GET', authorization } = {}) {
|
||||
clearMaintenanceCache();
|
||||
const req = { path, method, headers: authorization ? { authorization } : {} };
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
const next = jest.fn();
|
||||
await maintenanceMiddleware(req, res, next);
|
||||
return { passed: next.mock.calls.length === 1, status: res.statusCode, body: res.body };
|
||||
}
|
||||
|
||||
describe('maintenanceMiddleware — SPA shell vs API split', () => {
|
||||
describe('passes the frontend shell through so the branded screen can render', () => {
|
||||
it.each([
|
||||
['/admin', 'admin shell entry'],
|
||||
['/admin/login', 'the page that calls the exempt login API'],
|
||||
['/assets/index-abc123.js', 'hashed bundle the shell loads'],
|
||||
['/gallery/some-event', 'guest gallery route'],
|
||||
['/customer/portal', 'customer portal route'],
|
||||
])('%s (%s)', async (path) => {
|
||||
const { passed } = await run(path);
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('still gates everything that is not a shell', () => {
|
||||
it.each([
|
||||
['/api/gallery/some-event/verify', 'public gallery API'],
|
||||
['/api/photos/1', 'photo API'],
|
||||
['/photos/anything.jpg', 'backend-owned photo mount'],
|
||||
['/thumbnails/anything.jpg', 'backend-owned thumbnail mount'],
|
||||
['/fonts/anything.woff2', 'backend-owned font mount'],
|
||||
// nginx proxy_passes these to the backend, so compose gates them today
|
||||
// and the all-in-one image must not be the one deployment that does not.
|
||||
['/', 'site root — nginx `location = /` hands this to the public CMS'],
|
||||
['/og/gallery/some-event', 'OG renderer: leaks the event name'],
|
||||
['/og/gallery/some-event/cover', 'OG cover: leaks the hero thumbnail'],
|
||||
['/s/abc123', 'short-link renderer'],
|
||||
['/robots.txt', 'proxied one-to-one by nginx'],
|
||||
['/favicon.ico', 'proxied one-to-one by nginx'],
|
||||
])('%s (%s) returns 503', async (path) => {
|
||||
const { passed, status, body } = await run(path);
|
||||
expect(passed).toBe(false);
|
||||
expect(status).toBe(503);
|
||||
expect(body).toMatchObject({ maintenance: true });
|
||||
});
|
||||
|
||||
it('does not let a non-GET request masquerade as a shell load', async () => {
|
||||
const { passed, status } = await run('/api/gallery/some-event/verify', { method: 'POST' });
|
||||
expect(passed).toBe(false);
|
||||
expect(status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keeps the pre-existing admin exemptions', () => {
|
||||
it('admin login API stays reachable', async () => {
|
||||
expect((await run('/api/auth/admin/login', { method: 'POST' })).passed).toBe(true);
|
||||
});
|
||||
|
||||
it('/api/public/settings stays reachable so the shell can read the flag', async () => {
|
||||
expect((await run('/api/public/settings')).passed).toBe(true);
|
||||
});
|
||||
|
||||
it('an authenticated admin still reaches /api/admin', async () => {
|
||||
const { passed } = await run('/api/admin/events', { authorization: 'Bearer token' });
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
|
||||
it('an unauthenticated /api/admin request is not served by this middleware', async () => {
|
||||
// isAdminRoute suppresses the 503 so the auth layer can answer 401.
|
||||
const { passed, status } = await run('/api/admin/events');
|
||||
expect(passed).toBe(true);
|
||||
expect(status).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Regression test for the bulk archive/delete ownership bypass.
|
||||
*
|
||||
* bulk-archive and bulk-delete acted on body-supplied event ids with no
|
||||
* ownership filter, so an admin/editor scoped to their own events (the
|
||||
* single-event routes enforce requireEventOwnership) could archive or
|
||||
* cascade-delete ANY event by id. filterOwnedEventIds is the helper those
|
||||
* routes now use to drop foreign/non-existent ids.
|
||||
*/
|
||||
|
||||
// events owned by admin 7; event 3 owned by someone else; event 4 is
|
||||
// ownerless (legacy). The mock models:
|
||||
// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id)
|
||||
const EVENTS = [
|
||||
{ id: 1, created_by: 7 },
|
||||
{ id: 2, created_by: 7 },
|
||||
{ id: 3, created_by: 99 }, // foreign
|
||||
{ id: 4, created_by: null }, // ownerless/legacy
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => {
|
||||
const q = {
|
||||
_ids: null,
|
||||
_adminId: null,
|
||||
whereIn(_col, ids) { this._ids = ids; return this; },
|
||||
andWhere(cb) {
|
||||
// Emulate the (created_by IS NULL OR created_by = admin.id) builder
|
||||
// by capturing the admin id the callback closes over via a probe.
|
||||
const probe = {
|
||||
_adminId: null,
|
||||
whereNull() { return this; },
|
||||
orWhere(_col, id) { this._adminId = id; return this; },
|
||||
};
|
||||
cb(probe);
|
||||
this._adminId = probe._adminId;
|
||||
return this;
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve(
|
||||
EVENTS
|
||||
.filter((e) => this._ids.includes(e.id))
|
||||
.filter((e) => e.created_by === null || e.created_by === this._adminId)
|
||||
.map((e) => ({ id: e.id }))
|
||||
);
|
||||
},
|
||||
};
|
||||
return q;
|
||||
},
|
||||
}));
|
||||
|
||||
const { filterOwnedEventIds } = require('../../src/middleware/ownership');
|
||||
|
||||
describe('filterOwnedEventIds', () => {
|
||||
it('super_admin gets every id, nothing denied', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'super_admin' }, [1, 3, 4, 999]
|
||||
);
|
||||
expect(allowed).toEqual([1, 3, 4, 999]);
|
||||
expect(denied).toEqual([]);
|
||||
});
|
||||
|
||||
it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999]
|
||||
);
|
||||
expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless
|
||||
expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing
|
||||
});
|
||||
|
||||
it('foreign-only request yields empty allowed', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'editor' }, [3]
|
||||
);
|
||||
expect(allowed).toEqual([]);
|
||||
expect(denied).toEqual([3]);
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Regression test for the cross-event thumbnail enumeration leak.
|
||||
*
|
||||
* Thumbnails are served flat from /thumbnails/thumb_<name> with
|
||||
* deterministic, enumerable filenames. photoAuth previously granted any
|
||||
* holder of a gallery token for ANY active event access to ANY thumbnail
|
||||
* (it set eventSlug=null and returned next() as long as the token's event
|
||||
* existed), so a visitor to one gallery could pull another (password-
|
||||
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
|
||||
* access to the token's event by matching the requested file against
|
||||
* photos.thumbnail_path for that event_id.
|
||||
*/
|
||||
|
||||
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Two events, each owning one thumbnail. The photos mock resolves a row
|
||||
// only when BOTH event_id and thumbnail_path match — i.e. it models the
|
||||
// real ownership query.
|
||||
const EVENTS = [
|
||||
{ id: 10, slug: 'event-a', is_active: 1 },
|
||||
{ id: 20, slug: 'event-b', is_active: 1 },
|
||||
];
|
||||
const PHOTOS = [
|
||||
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
|
||||
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: (table) => ({
|
||||
_cond: null,
|
||||
where(cond) { this._cond = cond; return this; },
|
||||
first() {
|
||||
if (table === 'events') {
|
||||
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
|
||||
}
|
||||
if (table === 'photos') {
|
||||
return Promise.resolve(
|
||||
PHOTOS.find((p) => p.event_id === this._cond.event_id
|
||||
&& p.thumbnail_path === this._cond.thumbnail_path) || null
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const photoAuth = require('../../src/middleware/photoAuth');
|
||||
|
||||
function galleryToken(eventId) {
|
||||
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
}
|
||||
|
||||
function makeReqRes(token, thumbPath) {
|
||||
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
describe('photoAuth — thumbnail ownership scoping', () => {
|
||||
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
// Access denied: middleware must not pass the request through.
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.event).toMatchObject({ id: 20 });
|
||||
});
|
||||
|
||||
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Migration 167 (projects.created_by) — idempotent on re-run, reversible,
|
||||
* and backfills the owner from a project's single linked event (GHSA-wrg5).
|
||||
*/
|
||||
const path=require('path'), fs=require('fs'), os=require('os');
|
||||
process.env.NODE_ENV='test';
|
||||
process.env.TEST_DATABASE_PATH=path.join(fs.mkdtempSync(path.join(os.tmpdir(),'picpeak-mig167-')),'db.sqlite');
|
||||
process.env.JWT_SECRET='mig';
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const mig = require('../../migrations/core/167_add_projects_created_by');
|
||||
describe('migration 167', () => {
|
||||
let db, cleanup;
|
||||
beforeAll(async()=>{ ({db,cleanup}=await bootCrmDb()); await seedMinimal(db); },120000);
|
||||
afterAll(async()=>{ if(cleanup) await cleanup(); });
|
||||
it('is idempotent on re-run and reversible', async () => {
|
||||
await mig.up(db); // already applied by boot; must no-op
|
||||
await mig.up(db); // and again
|
||||
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
|
||||
await mig.down(db);
|
||||
expect(await db.schema.hasColumn('projects','created_by')).toBe(false);
|
||||
await mig.up(db); // re-apply cleanly
|
||||
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
|
||||
});
|
||||
it('backfills created_by from a single linked event owner', async () => {
|
||||
const p = await db('projects').insert({name:'bf',status:'active',created_at:new Date(),updated_at:new Date()}).returning('id');
|
||||
const pid = p[0]?.id ?? p[0];
|
||||
await db('events').insert({slug:'bf-ev',event_type:'wedding',event_name:'bf',event_date:'2026-08-01',
|
||||
host_email:'h@e.com',admin_email:'a@e.com',password_hash:'x',share_token:'t1',share_link:'/g/bf-ev/t1',
|
||||
created_by: 4242, project_id: pid, expires_at:new Date(Date.now()+864e5).toISOString(),
|
||||
is_active:1,is_archived:0,is_draft:0,created_at:new Date().toISOString()});
|
||||
await mig.up(db);
|
||||
const row = await db('projects').where({id:pid}).first();
|
||||
expect(row.created_by).toBe(4242);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* GHSA-jhcf round 3: scoping the activity feed does nothing about the rows
|
||||
* already on disk. expenseService used to pass adminId into logActivity's
|
||||
* `eventId` slot, so upgraded instances carry accounting rows whose event_id
|
||||
* is an ADMIN id — and the scope predicate happily matches those against a
|
||||
* same-numbered event the caller owns.
|
||||
*/
|
||||
|
||||
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-mig168-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mig168-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('../integration/helpers/crmDb');
|
||||
const migration = require('../../migrations/core/168_fix_expense_activity_event_id');
|
||||
|
||||
describe('migration 168 — legacy accounting activity rows (GHSA-jhcf)', () => {
|
||||
let db; let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('re-attributes the admin id and clears event_id, leaving real rows alone', async () => {
|
||||
await db('activity_logs').insert([
|
||||
// Legacy shape: event_id is really admin #7, no actor recorded.
|
||||
{
|
||||
activity_type: 'expense_created',
|
||||
actor_type: 'system',
|
||||
actor_id: null,
|
||||
event_id: 7,
|
||||
metadata: JSON.stringify({ expenseId: 1 }),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
activity_type: 'incoming_invoice_captured',
|
||||
actor_type: 'system',
|
||||
actor_id: null,
|
||||
event_id: 9,
|
||||
metadata: JSON.stringify({ inboundDocumentId: 2 }),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
// A genuine event-scoped row from another subsystem must survive intact.
|
||||
{
|
||||
activity_type: 'photo_uploaded',
|
||||
actor_type: 'admin',
|
||||
actor_id: 3,
|
||||
event_id: 7,
|
||||
metadata: JSON.stringify({}),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
await migration.up(db);
|
||||
|
||||
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
|
||||
expect(expense.event_id == null).toBe(true);
|
||||
expect(Number(expense.actor_id)).toBe(7);
|
||||
expect(expense.actor_type).toBe('admin');
|
||||
|
||||
const captured = await db('activity_logs').where({ activity_type: 'incoming_invoice_captured' }).first();
|
||||
expect(captured.event_id == null).toBe(true);
|
||||
expect(Number(captured.actor_id)).toBe(9);
|
||||
|
||||
const photo = await db('activity_logs').where({ activity_type: 'photo_uploaded' }).first();
|
||||
expect(Number(photo.event_id)).toBe(7);
|
||||
expect(Number(photo.actor_id)).toBe(3);
|
||||
});
|
||||
|
||||
it('is idempotent on re-run', async () => {
|
||||
await expect(migration.up(db)).resolves.toBeUndefined();
|
||||
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
|
||||
expect(Number(expense.actor_id)).toBe(7);
|
||||
expect(expense.event_id == null).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Migration 177 (#1074) — face recognition schema.
|
||||
*
|
||||
* The acceptance criteria for #1074 name three properties explicitly, so
|
||||
* they get tests rather than a manual check:
|
||||
*
|
||||
* - idempotent on re-run,
|
||||
* - a working down(),
|
||||
* - and — the one that matters most — installing it must NOT enqueue
|
||||
* anything. A `face_status` column defaulting to 'pending' would put
|
||||
* every existing photo on every install into a queue the operator never
|
||||
* asked for, on installs with no sidecar at all.
|
||||
*/
|
||||
|
||||
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-mig177-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mig177-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('../integration/helpers/crmDb');
|
||||
const migration = require('../../migrations/core/177_add_face_recognition');
|
||||
|
||||
describe('migration 177 — face recognition schema', () => {
|
||||
let db; let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('creates both tables with the columns the pipeline writes', async () => {
|
||||
expect(await db.schema.hasTable('photo_faces')).toBe(true);
|
||||
expect(await db.schema.hasTable('event_people')).toBe(true);
|
||||
|
||||
for (const col of [
|
||||
'photo_id', 'event_id', 'bbox_x', 'bbox_y', 'bbox_w', 'bbox_h',
|
||||
'det_score', 'yaw', 'pitch', 'blur', 'embedding', 'model_version',
|
||||
'person_id', 'created_at',
|
||||
]) {
|
||||
expect(await db.schema.hasColumn('photo_faces', col)).toBe(true);
|
||||
}
|
||||
|
||||
for (const col of [
|
||||
'event_id', 'label', 'cover_face_id', 'centroid', 'face_count_total',
|
||||
'model_version', 'is_hidden', 'is_ignored',
|
||||
]) {
|
||||
expect(await db.schema.hasColumn('event_people', col)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('adds the photos and events columns', async () => {
|
||||
for (const col of ['face_status', 'face_count', 'face_started_at', 'face_error']) {
|
||||
expect(await db.schema.hasColumn('photos', col)).toBe(true);
|
||||
}
|
||||
for (const col of [
|
||||
'face_recognition_enabled', 'faces_visible_to_guests', 'faces_last_scan_at',
|
||||
]) {
|
||||
expect(await db.schema.hasColumn('events', col)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('enqueues nothing — face_status has no default', async () => {
|
||||
// The whole "zero behaviour change by default" guarantee rests on this.
|
||||
const [{ id: eventId }] = await db('events').insert({
|
||||
slug: 'mig177-event',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Migration 177',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: 'mig177-share',
|
||||
expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
|
||||
const eid = typeof eventId === 'object' ? eventId.id : eventId;
|
||||
await db('photos').insert({
|
||||
event_id: eid, filename: 'a.jpg', path: '/tmp/a.jpg', type: 'individual',
|
||||
});
|
||||
|
||||
const row = await db('photos').where({ event_id: eid }).first();
|
||||
expect(row.face_status).toBeNull();
|
||||
expect(await db('photo_faces').count({ c: '*' }).first()).toMatchObject({ c: 0 });
|
||||
});
|
||||
|
||||
it('seeds the tunable thresholds rather than hardcoding them', async () => {
|
||||
// Immich's clustering guide exists because no single threshold survives
|
||||
// contact with every library — these must be operator-reachable.
|
||||
const keys = [
|
||||
'face_match_threshold', 'face_min_cluster_size',
|
||||
'face_quality_min_score', 'face_quality_min_px',
|
||||
];
|
||||
const rows = await db('app_settings').whereIn('setting_key', keys);
|
||||
expect(rows).toHaveLength(keys.length);
|
||||
expect(rows.every((r) => r.setting_type === 'faces')).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent on re-run', async () => {
|
||||
await expect(migration.up(db)).resolves.not.toThrow();
|
||||
// And did not duplicate the settings rows.
|
||||
const rows = await db('app_settings').where('setting_key', 'face_match_threshold');
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('down() removes everything it added, and up() restores it', async () => {
|
||||
await migration.down(db);
|
||||
|
||||
expect(await db.schema.hasTable('photo_faces')).toBe(false);
|
||||
expect(await db.schema.hasTable('event_people')).toBe(false);
|
||||
expect(await db.schema.hasColumn('photos', 'face_status')).toBe(false);
|
||||
expect(await db.schema.hasColumn('events', 'face_recognition_enabled')).toBe(false);
|
||||
expect(await db('app_settings').where('setting_key', 'face_match_threshold')).toHaveLength(0);
|
||||
|
||||
await migration.up(db);
|
||||
expect(await db.schema.hasTable('photo_faces')).toBe(true);
|
||||
expect(await db.schema.hasColumn('photos', 'face_status')).toBe(true);
|
||||
});
|
||||
|
||||
it('cascades face rows when a photo is deleted', async () => {
|
||||
// #1074 acceptance criterion: deleting a photo removes its face rows.
|
||||
//
|
||||
// SQLite ignores foreign keys unless the pragma is on, and PicPeak does
|
||||
// NOT enable it globally (a large amount of existing data and fixtures
|
||||
// would start failing). So the cascade below proves only that the schema
|
||||
// declares it correctly — the code does not RELY on it. Deletion paths
|
||||
// purge face rows explicitly; see faceProcessor.purgeEvent /
|
||||
// purgePhotoFaces and the erasure tests in facePrivacy.test.js.
|
||||
await db.raw('PRAGMA foreign_keys = ON');
|
||||
|
||||
const [{ id: eventId }] = await db('events').insert({
|
||||
slug: 'mig177-cascade',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Cascade',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: 'mig177-cascade-share',
|
||||
expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const eid = typeof eventId === 'object' ? eventId.id : eventId;
|
||||
|
||||
const [{ id: photoId }] = await db('photos')
|
||||
.insert({ event_id: eid, filename: 'c.jpg', path: '/tmp/c.jpg', type: 'individual' })
|
||||
.returning('id');
|
||||
const pid = typeof photoId === 'object' ? photoId.id : photoId;
|
||||
|
||||
await db('photo_faces').insert({
|
||||
photo_id: pid,
|
||||
event_id: eid,
|
||||
bbox_x: 1, bbox_y: 2, bbox_w: 3, bbox_h: 4,
|
||||
model_version: 'test',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
expect(await db('photo_faces').where({ photo_id: pid })).toHaveLength(1);
|
||||
|
||||
await db('photos').where({ id: pid }).del();
|
||||
expect(await db('photo_faces').where({ photo_id: pid })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Repairing the bundled templates' fixed image height (#1131).
|
||||
*
|
||||
* The risk in a migration that rewrites user-visible CSS is doing too much,
|
||||
* so most of what is pinned here is what it must NOT touch: the other pixel
|
||||
* heights inside the very same templates (a 1px divider, an 8px scrollbar),
|
||||
* and any rule a user wrote themselves.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const migration = require('../../migrations/core/181_fix_css_template_photo_height');
|
||||
|
||||
const ELEGANT_DARK = `
|
||||
.photo-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
`;
|
||||
|
||||
const LIQUID_GLASS_DARK = `
|
||||
.gallery-page::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #fff, transparent);
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
object-fit: cover;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.gallery-page ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.photo-card img {
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
describe('migration 181 — CSS template image height (#1131)', () => {
|
||||
let knex; let tmpDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig181-'));
|
||||
knex = require('knex')({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: path.join(tmpDir, 'db.sqlite') },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
await knex.schema.createTable('css_templates', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('name');
|
||||
t.text('css_content');
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (knex) await knex.destroy();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => { await knex('css_templates').del(); });
|
||||
|
||||
const contentOf = async (name) =>
|
||||
(await knex('css_templates').where({ name }).first()).css_content;
|
||||
|
||||
it('relaxes the default template so the layouts h-full can win', async () => {
|
||||
await knex('css_templates').insert({ name: 'Elegant Dark', css_content: ELEGANT_DARK });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Elegant Dark');
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toContain('height: 200px');
|
||||
// Everything else about the rule survives.
|
||||
expect(css).toContain('object-fit: cover');
|
||||
expect(css).toContain('transition: transform 0.3s ease');
|
||||
});
|
||||
|
||||
it('fixes both the base rule and the mobile override of the dark glass template', async () => {
|
||||
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Liquid Glass Dark');
|
||||
expect(css).not.toContain('height: 240px');
|
||||
expect(css).not.toContain('height: 180px');
|
||||
expect(css.match(/height: 100%/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('leaves the divider and the scrollbar alone', async () => {
|
||||
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
// The whole reason this matches full rule bodies rather than every
|
||||
// `height: <n>px`: these are in the same stylesheet and are correct.
|
||||
const css = await contentOf('Liquid Glass Dark');
|
||||
expect(css).toContain('height: 1px');
|
||||
expect(css).toContain('width: 8px');
|
||||
expect(css).toContain('height: 8px');
|
||||
});
|
||||
|
||||
/**
|
||||
* The case that forced the scope wider. `sanitizeCSS` strips control
|
||||
* characters, so any template ever saved through the editor — including a
|
||||
* save that only changed its name — has had every newline REMOVED. An
|
||||
* exact-text migration finds nothing on those installs, is recorded as
|
||||
* applied, and leaves them broken permanently.
|
||||
*/
|
||||
it('fixes a template that has been through the editor, newlines and all', async () => {
|
||||
const { sanitizeCSS } = require('../../src/utils/cssSanitizer');
|
||||
const { sanitized } = sanitizeCSS(ELEGANT_DARK);
|
||||
// Precondition: the sanitizer really did flatten it.
|
||||
expect(sanitized).not.toContain('\n');
|
||||
expect(sanitized).toContain('height: 200px');
|
||||
await knex('css_templates').insert({ name: 'Saved Once', css_content: sanitized });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Saved Once');
|
||||
expect(css).not.toContain('200px');
|
||||
expect(css).toContain('height: 100%');
|
||||
});
|
||||
|
||||
it('relaxes a user-authored fixed height too, but only on .photo-card img', async () => {
|
||||
// Deliberately broader than the seeded text — see the migration header. A
|
||||
// pixel height on the image cannot be right under any of the seven
|
||||
// layouts, whoever wrote it; a height anywhere else is none of our
|
||||
// business.
|
||||
const mine = '.photo-card img {\n height: 220px;\n}\n.hero { height: 400px; }';
|
||||
await knex('css_templates').insert({ name: 'My Own', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('My Own');
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toContain('220px');
|
||||
expect(css).toContain('.hero { height: 400px; }');
|
||||
});
|
||||
|
||||
it('does not rewrite other properties that merely end in -height', async () => {
|
||||
// `line-height: 200px` contains `height: 200px` as a substring, so an
|
||||
// unanchored pattern silently rewrites it — in a migration that cannot be
|
||||
// undone.
|
||||
const mine = [
|
||||
'.photo-card img {',
|
||||
' line-height: 200px;',
|
||||
' max-height: 300px;',
|
||||
' min-height: 14px;',
|
||||
' --tile-height: 220px;',
|
||||
' height: 200px;',
|
||||
'}',
|
||||
].join('\n');
|
||||
await knex('css_templates').insert({ name: 'Adjacent Props', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Adjacent Props');
|
||||
expect(css).toContain('line-height: 200px');
|
||||
expect(css).toContain('max-height: 300px');
|
||||
expect(css).toContain('min-height: 14px');
|
||||
expect(css).toContain('--tile-height: 220px');
|
||||
// Only the real one moved.
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toMatch(/(?<![\w-])height:\s*200px/);
|
||||
});
|
||||
|
||||
it('handles a grouped selector list', async () => {
|
||||
// Requiring `{` straight after `img` skipped these entirely — and the
|
||||
// migration is still recorded as applied, so the template kept the bug.
|
||||
const mine = '.photo-card img, .thumbnail img {\n height: 200px;\n}';
|
||||
await knex('css_templates').insert({ name: 'Grouped', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Grouped');
|
||||
expect(css).toContain('.photo-card img, .thumbnail img {');
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toContain('200px');
|
||||
});
|
||||
|
||||
it('skips a nested rule rather than rewriting the wrong declaration', async () => {
|
||||
// Valid nested CSS that passes the validator. A brace-greedy body would
|
||||
// capture the inner block and rewrite the CAPTION's height, which cannot
|
||||
// be undone. Leaving it untouched is the lesser evil.
|
||||
const mine = '.photo-card img {\n & + .caption { height: 200px; }\n}';
|
||||
await knex('css_templates').insert({ name: 'Nested', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await contentOf('Nested')).toBe(mine);
|
||||
});
|
||||
|
||||
it('leaves non-pixel heights on the image alone', async () => {
|
||||
const mine = '.photo-card img { height: 50vh; }\n.photo-card img { height: auto; }';
|
||||
await knex('css_templates').insert({ name: 'Relative', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await contentOf('Relative')).toBe(mine);
|
||||
});
|
||||
|
||||
it('is idempotent and safe on a row with no CSS', async () => {
|
||||
await knex('css_templates').insert([
|
||||
{ name: 'Elegant Dark', css_content: ELEGANT_DARK },
|
||||
{ name: 'Empty', css_content: null },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
const once = await contentOf('Elegant Dark');
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await contentOf('Elegant Dark')).toBe(once);
|
||||
expect(await contentOf('Empty')).toBeNull();
|
||||
});
|
||||
|
||||
it('no-ops when the table does not exist yet', async () => {
|
||||
await knex.schema.dropTable('css_templates');
|
||||
await expect(migration.up(knex)).resolves.toBeUndefined();
|
||||
await knex.schema.createTable('css_templates', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('name');
|
||||
t.text('css_content');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,554 +0,0 @@
|
||||
/**
|
||||
* One row per external file per event (#1162).
|
||||
*
|
||||
* The migration has two halves and they fail differently: the cleanup can take
|
||||
* out the wrong row of a pair (losing a thumbnail, orphaning an event's hero),
|
||||
* and the index can fail to be created at all — leaving an install that looks
|
||||
* migrated and is still racing. Both are pinned here.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const migration = require('../../migrations/core/186_external_relpath_unique');
|
||||
|
||||
describe('migration 186 — unique (event_id, external_relpath) (#1162)', () => {
|
||||
let knex; let tmpDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig186-'));
|
||||
knex = require('knex')({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: path.join(tmpDir, 'db.sqlite') },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (knex) await knex.destroy();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const table of [
|
||||
'photos', 'events', 'photo_categories', 'photo_feedback',
|
||||
'photo_admin_marks', 'photo_faces', 'image_access_logs', 'transfer_files',
|
||||
'event_people', 'event_people_merge_dismissals',
|
||||
]) {
|
||||
await knex.schema.dropTableIfExists(table);
|
||||
}
|
||||
await knex.schema.createTable('events', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('hero_photo_id');
|
||||
t.string('download_zip_path');
|
||||
t.string('download_zip_generated_at');
|
||||
});
|
||||
await knex.schema.createTable('photo_categories', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('hero_photo_id');
|
||||
});
|
||||
await knex.schema.createTable('photos', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('event_id');
|
||||
t.string('external_relpath');
|
||||
t.string('thumbnail_path');
|
||||
t.string('source_origin').defaultTo('managed');
|
||||
t.integer('feedback_count').defaultTo(0);
|
||||
t.integer('like_count').defaultTo(0);
|
||||
t.decimal('average_rating', 3, 2).defaultTo(0);
|
||||
t.integer('favorite_count').defaultTo(0);
|
||||
t.integer('reaction_count').defaultTo(0);
|
||||
t.integer('color_label_count').defaultTo(0);
|
||||
t.string('face_status');
|
||||
t.integer('view_count').defaultTo(0);
|
||||
t.integer('download_count').defaultTo(0);
|
||||
t.integer('face_count');
|
||||
t.string('face_started_at');
|
||||
t.text('face_error');
|
||||
});
|
||||
// Declared exactly as the real schema declares them — CASCADE and all.
|
||||
// The point of these tables here is that SQLite does NOT enforce any of
|
||||
// it (PicPeak never sets `PRAGMA foreign_keys = ON`), so a bare delete of
|
||||
// the photo row leaves every one of them dangling.
|
||||
await knex.schema.createTable('photo_feedback', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
|
||||
t.integer('event_id');
|
||||
t.string('feedback_type');
|
||||
t.text('comment_text');
|
||||
t.string('guest_identifier');
|
||||
// Per-person guest identity (migration 078). Nullable: galleries without
|
||||
// guest identity leave it NULL and fall back to guest_identifier.
|
||||
t.integer('guest_id');
|
||||
t.integer('rating');
|
||||
t.boolean('is_hidden').defaultTo(false);
|
||||
t.boolean('is_approved').defaultTo(true);
|
||||
});
|
||||
await knex.schema.createTable('photo_admin_marks', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('photo_id').notNullable().references('id').inTable('photos').onDelete('CASCADE');
|
||||
t.integer('event_id');
|
||||
t.integer('admin_id');
|
||||
t.integer('rating');
|
||||
// Independently writable alongside rating, per photoAdminMarksService.
|
||||
t.string('color_label', 16);
|
||||
t.unique(['photo_id', 'admin_id'], 'photo_admin_marks_photo_admin_uniq');
|
||||
});
|
||||
await knex.schema.createTable('photo_faces', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
|
||||
t.integer('event_id');
|
||||
// purgePhotoFaces rebuilds the people that lose members, so the cluster
|
||||
// link and the vectors recomputeCentroid reads have to be here for this
|
||||
// to exercise the real path rather than a stub.
|
||||
t.integer('person_id');
|
||||
t.binary('embedding');
|
||||
t.float('det_score');
|
||||
});
|
||||
await knex.schema.createTable('event_people', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('event_id');
|
||||
t.binary('centroid');
|
||||
t.integer('face_count').defaultTo(0);
|
||||
});
|
||||
await knex.schema.createTable('event_people_merge_dismissals', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('event_id');
|
||||
t.binary('centroid_a');
|
||||
t.binary('centroid_b');
|
||||
});
|
||||
await knex.schema.createTable('image_access_logs', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('photo_id');
|
||||
});
|
||||
await knex.schema.createTable('transfer_files', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('transfer_id');
|
||||
t.integer('photo_id');
|
||||
t.unique(['transfer_id', 'photo_id'], 'transfer_files_unique');
|
||||
});
|
||||
});
|
||||
|
||||
/** Two duplicate rows for the same file: id 1 survives, id 2 is doomed. */
|
||||
const seedPair = async () => {
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
|
||||
]);
|
||||
};
|
||||
|
||||
const rows = () => knex('photos').orderBy('id', 'asc').select('*');
|
||||
|
||||
it('collapses a duplicated pair to one row and leaves distinct paths alone', async () => {
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't1', source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't2', source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'a/y.jpg', thumbnail_path: 't3', source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const after = await rows();
|
||||
expect(after.map((r) => r.external_relpath)).toEqual(['a/x.jpg', 'a/y.jpg']);
|
||||
// Lowest id survives when both sides are equally complete.
|
||||
expect(after[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('does not collapse the same path across different events', async () => {
|
||||
// The constraint is per event. Two events referencing the same NAS folder
|
||||
// is a supported setup, and treating those as duplicates would delete one
|
||||
// event's entire library.
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
|
||||
{ event_id: 2, external_relpath: 'a/x.jpg', source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
|
||||
});
|
||||
|
||||
it('never touches managed rows, however many carry NULL', async () => {
|
||||
// Every managed photo has external_relpath NULL. Grouping on it without
|
||||
// the NOT NULL filter would make them all one enormous "duplicate" group
|
||||
// and delete the entire library bar one row.
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
|
||||
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
|
||||
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await knex('photos').count('* as c').first()).toEqual({ c: 3 });
|
||||
});
|
||||
|
||||
it('keeps the row that has a thumbnail, not merely the lowest id', async () => {
|
||||
// An import killed mid-flight leaves rows without a thumbnail. Dropping
|
||||
// the completed one would blank a tile in the grid for no reason.
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: null, source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 'thumb.jpg', source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const after = await rows();
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0].thumbnail_path).toBe('thumb.jpg');
|
||||
});
|
||||
|
||||
it('repoints a hero that pointed at the row being removed', async () => {
|
||||
// events.hero_photo_id is ON DELETE SET NULL, so without this the cleanup
|
||||
// silently strips the event's hero image — a visible regression caused
|
||||
// entirely by the fix.
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
|
||||
]);
|
||||
await knex('events').insert({ id: 1, hero_photo_id: 2 });
|
||||
await knex('photo_categories').insert({ id: 1, hero_photo_id: 2 });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await knex('events').where({ id: 1 }).first()).hero_photo_id).toBe(1);
|
||||
expect((await knex('photo_categories').where({ id: 1 }).first()).hero_photo_id).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves a hero that pointed at the survivor untouched', async () => {
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
|
||||
]);
|
||||
await knex('events').insert({ id: 1, hero_photo_id: 1 });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await knex('events').where({ id: 1 }).first()).hero_photo_id).toBe(1);
|
||||
});
|
||||
|
||||
it('makes a second insert of the same path impossible afterwards', async () => {
|
||||
// The whole point. Without this the route is still racing, and the
|
||||
// migration is recorded as applied.
|
||||
await knex('photos').insert({ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
await expect(
|
||||
knex('photos').insert({ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' })
|
||||
).rejects.toThrow(/unique/i);
|
||||
});
|
||||
|
||||
it('still admits managed rows once the index exists', async () => {
|
||||
await migration.up(knex);
|
||||
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
|
||||
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
|
||||
]);
|
||||
|
||||
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
|
||||
});
|
||||
|
||||
it('leaves nothing dangling behind the deleted row', async () => {
|
||||
// SQLite never enforces the ON DELETE CASCADE these tables declare, so a
|
||||
// bare delete strands biometric embeddings, feedback and marks pointing at
|
||||
// a photo id that no longer exists — on every SQLite install.
|
||||
await seedPair();
|
||||
await knex('photo_faces').insert({ photo_id: 2, event_id: 1 });
|
||||
await knex('image_access_logs').insert({ photo_id: 2 });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await knex('photo_faces').where('photo_id', 2).first()).toBeUndefined();
|
||||
expect(await knex('image_access_logs').where('photo_id', 2).first()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not carry the duplicate\'s faces over to the survivor', async () => {
|
||||
// Both rows were scanned independently, so the survivor already holds its
|
||||
// own embeddings. Moving these would fabricate a second copy of every face
|
||||
// and split the person clusters built from them.
|
||||
await seedPair();
|
||||
await knex('photo_faces').insert([{ photo_id: 1, event_id: 1 }, { photo_id: 2, event_id: 1 }]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await knex('photo_faces').count('* as c').first()).toEqual({ c: 1 });
|
||||
});
|
||||
|
||||
it('moves a guest comment to the survivor rather than deleting it', async () => {
|
||||
// The duplicates were separate tiles in the grid, so a guest could have
|
||||
// commented on either. Silently dropping that inside a fix for silent data
|
||||
// loss would be its own bug.
|
||||
await seedPair();
|
||||
await knex('photo_feedback').insert({
|
||||
photo_id: 2, event_id: 1, feedback_type: 'comment',
|
||||
comment_text: 'lovely shot', guest_identifier: 'guest-a',
|
||||
});
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const rows = await knex('photo_feedback');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].photo_id).toBe(1);
|
||||
expect(rows[0].comment_text).toBe('lovely shot');
|
||||
});
|
||||
|
||||
it('keeps both comments when the same guest commented on both tiles', async () => {
|
||||
await seedPair();
|
||||
await knex('photo_feedback').insert([
|
||||
{ photo_id: 1, event_id: 1, feedback_type: 'comment', comment_text: 'one', guest_identifier: 'g' },
|
||||
{ photo_id: 2, event_id: 1, feedback_type: 'comment', comment_text: 'two', guest_identifier: 'g' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const rows = await knex('photo_feedback').orderBy('id');
|
||||
expect(rows.map((r) => r.comment_text)).toEqual(['one', 'two']);
|
||||
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not double-count a like the same guest left on both tiles', async () => {
|
||||
// Unlike comments, a like is a per-guest toggle: moving it would show two
|
||||
// likes from one person.
|
||||
await seedPair();
|
||||
await knex('photo_feedback').insert([
|
||||
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'g' },
|
||||
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 1 });
|
||||
});
|
||||
|
||||
it('moves a like from a guest the survivor has never seen', async () => {
|
||||
await seedPair();
|
||||
await knex('photo_feedback').insert({
|
||||
photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'other',
|
||||
});
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const rows = await knex('photo_feedback');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].photo_id).toBe(1);
|
||||
});
|
||||
|
||||
it('moves an admin mark, and drops it when that admin already marked the survivor', async () => {
|
||||
// photo_admin_marks is UNIQUE(photo_id, admin_id), so a blind move would
|
||||
// throw and abort the migration.
|
||||
await seedPair();
|
||||
await knex('photo_admin_marks').insert([
|
||||
{ photo_id: 1, event_id: 1, admin_id: 7, rating: 5 },
|
||||
{ photo_id: 2, event_id: 1, admin_id: 7, rating: 2 },
|
||||
{ photo_id: 2, event_id: 1, admin_id: 9, rating: 4 },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const rows = await knex('photo_admin_marks').orderBy('admin_id');
|
||||
expect(rows.map((r) => [r.admin_id, r.rating])).toEqual([[7, 5], [9, 4]]);
|
||||
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('respects the transfer_files uniqueness when moving membership', async () => {
|
||||
await seedPair();
|
||||
await knex('transfer_files').insert([
|
||||
{ transfer_id: 3, photo_id: 1 },
|
||||
{ transfer_id: 3, photo_id: 2 },
|
||||
{ transfer_id: 4, photo_id: 2 },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const rows = await knex('transfer_files').orderBy('transfer_id');
|
||||
expect(rows.map((r) => r.transfer_id)).toEqual([3, 4]);
|
||||
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('recomputes the survivor\'s feedback totals after reparenting rows', async () => {
|
||||
// photos carries denormalized counters (migration 033). A survivor that
|
||||
// now OWNS the feedback but still renders zero is the visible half of
|
||||
// getting this wrong.
|
||||
await seedPair();
|
||||
await knex('photo_feedback').insert([
|
||||
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g1' },
|
||||
{ photo_id: 2, event_id: 1, feedback_type: 'rating', rating: 4, guest_identifier: 'g1' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const survivor = await knex('photos').where('id', 1).first();
|
||||
expect(survivor.like_count).toBe(1);
|
||||
expect(Number(survivor.average_rating)).toBe(4);
|
||||
expect(survivor.feedback_count).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps two people who share a device apart', async () => {
|
||||
// guest_identifier is per-device; guest_id is per-person (migration 078),
|
||||
// and feedbackService scopes by guest_id when it is present. Keying on the
|
||||
// identifier alone would read these as one person and delete a rating.
|
||||
await seedPair();
|
||||
await knex('photo_feedback').insert([
|
||||
{ photo_id: 1, event_id: 1, feedback_type: 'rating', rating: 5, guest_identifier: 'shared', guest_id: 10 },
|
||||
{ photo_id: 2, event_id: 1, feedback_type: 'rating', rating: 2, guest_identifier: 'shared', guest_id: 11 },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const rows = await knex('photo_feedback').orderBy('guest_id');
|
||||
expect(rows.map((r) => [r.guest_id, r.rating])).toEqual([[10, 5], [11, 2]]);
|
||||
});
|
||||
|
||||
it('still dedupes one person voting on both tiles', async () => {
|
||||
await seedPair();
|
||||
await knex('photo_feedback').insert([
|
||||
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'shared', guest_id: 10 },
|
||||
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'shared', guest_id: 10 },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 1 });
|
||||
});
|
||||
|
||||
it('rebuilds the people that lose members, rather than deleting faces raw', async () => {
|
||||
// purgePhotoFaces is "called from every photo-deletion path" precisely
|
||||
// because event_people counts and centroids are derived from the rows
|
||||
// being removed. A bare delete leaves a ghost person behind.
|
||||
await seedPair();
|
||||
await knex('event_people').insert({ id: 5, event_id: 1, face_count: 1 });
|
||||
await knex('photo_faces').insert({ photo_id: 2, event_id: 1, person_id: 5 });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await knex('photo_faces').count('* as c').first()).toEqual({ c: 0 });
|
||||
// The person had exactly one member and loses it, so it goes with it.
|
||||
expect(await knex('event_people').where('id', 5).first()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a hidden moderation record from swallowing the visible replacement', async () => {
|
||||
// feedbackService lets both coexist and counts only the visible one.
|
||||
await seedPair();
|
||||
await knex('photo_feedback').insert([
|
||||
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'g', is_hidden: true },
|
||||
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g', is_hidden: false },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 2 });
|
||||
});
|
||||
|
||||
it('merges the independent halves of one admin\'s mark', async () => {
|
||||
// rating and color_label are written independently, so the same admin can
|
||||
// have rated one tile and coloured the other.
|
||||
await seedPair();
|
||||
await knex('photo_admin_marks').insert([
|
||||
{ photo_id: 1, event_id: 1, admin_id: 7, rating: 5, color_label: null },
|
||||
{ photo_id: 2, event_id: 1, admin_id: 7, rating: null, color_label: 'red' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const rows = await knex('photo_admin_marks');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect([rows[0].rating, rows[0].color_label]).toEqual([5, 'red']);
|
||||
});
|
||||
|
||||
it('requeues the survivor when the duplicate held the only scan', async () => {
|
||||
// Otherwise the sole embeddings go with the purge and nothing re-queues:
|
||||
// the photo just silently stops having a face.
|
||||
await seedPair();
|
||||
await knex('photo_faces').insert({ photo_id: 2, event_id: 1 });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await knex('photos').where('id', 1).first()).face_status).toBe('pending');
|
||||
});
|
||||
|
||||
it('carries the duplicate\'s views and downloads over', async () => {
|
||||
await seedPair();
|
||||
await knex('photos').where('id', 1).update({ view_count: 2, download_count: 1 });
|
||||
await knex('photos').where('id', 2).update({ view_count: 5, download_count: 3 });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const survivor = await knex('photos').where('id', 1).first();
|
||||
expect([survivor.view_count, survivor.download_count]).toEqual([7, 4]);
|
||||
});
|
||||
|
||||
it('fails loudly rather than recording itself applied without the index', async () => {
|
||||
// Swallowing a failed CREATE INDEX would leave the install permanently
|
||||
// racy — the in-flight guard only covers one process — with nothing to
|
||||
// trigger a retry. Driven through the helper the migration calls, against
|
||||
// a table that still holds duplicates — i.e. what it would face if the
|
||||
// dedupe above had not achieved uniqueness.
|
||||
await seedPair();
|
||||
const { createExternalRelpathIndex } = require('../../src/services/externalPhotoDedupe');
|
||||
|
||||
await expect(createExternalRelpathIndex(knex)).rejects.toThrow(/unique/i);
|
||||
});
|
||||
|
||||
it('invalidates the pre-built download zip for the affected event', async () => {
|
||||
// The cached archive still contains the rows just removed, and every
|
||||
// ordinary photo-deletion path invalidates it for exactly that reason.
|
||||
// getZipInfo treats a cleared record as a miss and rebuilds on request.
|
||||
await seedPair();
|
||||
await knex('events').insert({
|
||||
id: 1, download_zip_path: 'events/active/x/.download-cache/all.zip',
|
||||
download_zip_generated_at: '2026-01-01',
|
||||
});
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const ev = await knex('events').where('id', 1).first();
|
||||
expect(ev.download_zip_path).toBeNull();
|
||||
expect(ev.download_zip_generated_at).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves an untouched event\'s zip alone', async () => {
|
||||
await seedPair();
|
||||
await knex('events').insert([
|
||||
{ id: 1, download_zip_path: 'a.zip', download_zip_generated_at: '2026-01-01' },
|
||||
{ id: 2, download_zip_path: 'b.zip', download_zip_generated_at: '2026-01-01' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await knex('events').where('id', 2).first()).download_zip_path).toBe('b.zip');
|
||||
});
|
||||
|
||||
it('is idempotent', async () => {
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
const once = await rows();
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await rows()).toEqual(once);
|
||||
});
|
||||
|
||||
it('rolls back to an unconstrained table', async () => {
|
||||
await migration.up(knex);
|
||||
await migration.down(knex);
|
||||
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
|
||||
]);
|
||||
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
|
||||
});
|
||||
|
||||
it('no-ops before 041 has added the column', async () => {
|
||||
await knex.schema.dropTableIfExists('photos');
|
||||
await knex.schema.createTable('photos', (t) => { t.increments('id').primary(); });
|
||||
|
||||
await expect(migration.up(knex)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,364 +0,0 @@
|
||||
/**
|
||||
* Folding the event's base path into every external row (#1163).
|
||||
*
|
||||
* Two things can go wrong and both are silent, which is why they are pinned
|
||||
* here rather than left to review: folding a path that was ALREADY folded
|
||||
* (every original moves), and "repairing" a healthy install because the media
|
||||
* root happened to be unmounted when the migration ran (every original moves).
|
||||
*
|
||||
* The repair itself is driven against a real temp directory tree, because the
|
||||
* whole mechanism is "is this file actually there" and a mocked fs would only
|
||||
* be testing the mock.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
describe('migration 187 — external_relpath from the media root (#1163)', () => {
|
||||
let knex; let tmpDir; let mediaRoot; let migration;
|
||||
|
||||
/** Writes `bytes` bytes and returns the size, so fixtures can record it the
|
||||
* way an import would have. */
|
||||
const touch = async (rel, bytes = 8) => {
|
||||
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 () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig187-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
|
||||
// The service caches the root on first call, so it must not have been
|
||||
// resolved before EXTERNAL_MEDIA_ROOT was set above.
|
||||
jest.resetModules();
|
||||
migration = require('../../migrations/core/187_external_relpath_from_root');
|
||||
|
||||
knex = require('knex')({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: path.join(tmpDir, 'db.sqlite') },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (knex) await knex.destroy();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
delete process.env.EXTERNAL_MEDIA_ROOT;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await knex.schema.dropTableIfExists('photos');
|
||||
await knex.schema.dropTableIfExists('events');
|
||||
await knex.schema.dropTableIfExists('app_settings');
|
||||
await knex.schema.createTable('events', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('external_path');
|
||||
});
|
||||
await knex.schema.createTable('photos', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('event_id');
|
||||
t.string('external_relpath');
|
||||
t.integer('size_bytes');
|
||||
t.string('source_origin').defaultTo('managed');
|
||||
});
|
||||
await knex.schema.createTable('app_settings', (t) => {
|
||||
t.increments('id').primary();
|
||||
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 knex('photos').orderBy('id', 'asc').select('external_relpath'))
|
||||
.map((r) => r.external_relpath);
|
||||
|
||||
it('folds the base path into every row of a healthy event', async () => {
|
||||
await touch('Trip/Leknes/a.jpg');
|
||||
await touch('Trip/Leknes/b.jpg');
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'Leknes/a.jpg', source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'Leknes/b.jpg', source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/Leknes/a.jpg', 'Trip/Leknes/b.jpg']);
|
||||
});
|
||||
|
||||
it('repairs rows an earlier import had rebased', async () => {
|
||||
// The reported shape: a parent imported first, a child imported second, so
|
||||
// events.external_path is the child and the parent's rows resolve into a
|
||||
// path that does not exist.
|
||||
const oldSize = await touch('Trip/Leknes/old.jpg', 11); // from the first import
|
||||
const newSize = await touch('Trip/Sub/new.jpg', 22); // from the second
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'Leknes/old.jpg', size_bytes: oldSize, source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'new.jpg', size_bytes: newSize, source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
// The old row is placed where the file actually is; the new one keeps
|
||||
// resolving exactly where it resolved before.
|
||||
expect(await relpaths()).toEqual(['Trip/Leknes/old.jpg', 'Trip/Sub/new.jpg']);
|
||||
});
|
||||
|
||||
it('refuses an ancestor whose file is a different size', async () => {
|
||||
// The dangerous case: the row's own file was simply deleted, and an
|
||||
// UNRELATED file one directory up happens to share its name. Adopting it
|
||||
// would make downloads serve the wrong original — worse than a dead link.
|
||||
await touch('Trip/photo.jpg', 999);
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
||||
await knex('photos').insert({
|
||||
event_id: 1, external_relpath: 'photo.jpg', size_bytes: 42, source_origin: 'external',
|
||||
});
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/Sub/photo.jpg']);
|
||||
});
|
||||
|
||||
it('refuses an ancestor when the row records no size to check against', async () => {
|
||||
// Nothing to verify provenance with, so the row stays where it resolves
|
||||
// today rather than adopting a same-named stranger.
|
||||
await touch('Trip/photo.jpg', 100);
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
||||
await knex('photos').insert({
|
||||
event_id: 1, external_relpath: 'photo.jpg', size_bytes: null, source_origin: 'external',
|
||||
});
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/Sub/photo.jpg']);
|
||||
});
|
||||
|
||||
it('leaves nothing folded when a rewrite fails partway', async () => {
|
||||
// Without a transaction, a crash between the first event's UPDATE and the
|
||||
// marker leaves mixed formats behind — and the next run folds the already
|
||||
// folded rows a second time, putting every original one directory deeper.
|
||||
await touch('A/one.jpg');
|
||||
await touch('B/two.jpg');
|
||||
await knex('events').insert([
|
||||
{ id: 1, external_path: 'A' },
|
||||
{ id: 2, external_path: 'B' },
|
||||
]);
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'one.jpg', source_origin: 'external' },
|
||||
{ event_id: 2, external_relpath: 'two.jpg', source_origin: 'external' },
|
||||
]);
|
||||
// app_settings is written last, in the same transaction as the rewrites.
|
||||
await knex.schema.dropTableIfExists('app_settings_backup');
|
||||
await knex.raw('CREATE TRIGGER fail_marker BEFORE INSERT ON app_settings '
|
||||
+ "BEGIN SELECT RAISE(ABORT, 'boom'); END");
|
||||
|
||||
await expect(migration.up(knex)).rejects.toThrow(/boom/);
|
||||
|
||||
await knex.raw('DROP TRIGGER fail_marker');
|
||||
// Every row still base-relative, and no marker — so a retry is correct.
|
||||
expect(await relpaths()).toEqual(['one.jpg', 'two.jpg']);
|
||||
expect(await knex('app_settings').where('setting_key', 'external_relpath_root_relative').first())
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes the losing row when two paths converge, instead of stranding it', async () => {
|
||||
// Trip/Sub/c.jpg imported once via `Trip` (as `Sub/c.jpg`) and once via
|
||||
// `Trip/Sub` (as `c.jpg`). Both fold to the same path. Skipping the loser
|
||||
// would leave it base-relative under a root-only resolver — pointing at
|
||||
// <root>/c.jpg — with the marker claiming the conversion is complete.
|
||||
const size = await touch('Trip/Sub/c.jpg', 33);
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'Sub/c.jpg', size_bytes: size, source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'c.jpg', size_bytes: size, source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const rows = await knex('photos').select('external_relpath');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].external_relpath).toBe('Trip/Sub/c.jpg');
|
||||
});
|
||||
|
||||
it('survives a final path that equals another row\'s current path', async () => {
|
||||
// `photo.jpg` repairs to `Trip/photo.jpg` while the row already holding
|
||||
// `Trip/photo.jpg` folds to `Trip/Sub/Trip/photo.jpg`. Every FINAL value is
|
||||
// distinct, but a one-pass rewrite collides halfway through — and on
|
||||
// Postgres that 23505 is misread by the migration runner as "already
|
||||
// applied", leaving everything unconverted.
|
||||
const a = await touch('Trip/photo.jpg', 11);
|
||||
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
||||
await knex('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 migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
|
||||
});
|
||||
|
||||
it('does not re-prefix a row inserted while the probe was running', async () => {
|
||||
// Phase 1 runs outside the transaction and can take minutes on a cold
|
||||
// mount. An import finishing in that window writes an already
|
||||
// root-relative row, which a `where event_id` bulk update would prefix a
|
||||
// second time with the stale base.
|
||||
await touch('Trip/a.jpg');
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
|
||||
|
||||
const { foldExternalRelpaths } = require('../../src/services/externalRelpathFold');
|
||||
const realStat = fs.promises.stat;
|
||||
let injected = false;
|
||||
jest.spyOn(fs.promises, 'access').mockImplementation(async (...args) => {
|
||||
if (!injected) {
|
||||
injected = true;
|
||||
await knex('photos').insert({
|
||||
event_id: 1, external_relpath: 'Trip/late.jpg', source_origin: 'external',
|
||||
});
|
||||
}
|
||||
return realStat(args[0]).then(() => undefined);
|
||||
});
|
||||
|
||||
await foldExternalRelpaths(knex);
|
||||
fs.promises.access.mockRestore();
|
||||
|
||||
expect((await relpaths()).sort()).toEqual(['Trip/a.jpg', 'Trip/late.jpg']);
|
||||
});
|
||||
|
||||
it('leaves a row it cannot place resolving where it resolves today', async () => {
|
||||
// Never guess below current behaviour: a file that is genuinely gone must
|
||||
// not have its path rewritten to some other file that happens to exist.
|
||||
await touch('Trip/Sub/present.jpg');
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'present.jpg', source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'vanished.jpg', source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/Sub/present.jpg', 'Trip/Sub/vanished.jpg']);
|
||||
});
|
||||
|
||||
it('folds without repairing when the media root is unmounted', async () => {
|
||||
// An unmounted share leaves the mountpoint as an empty directory, so every
|
||||
// file looks missing. Repairing off that signal would move every original
|
||||
// on a perfectly healthy install.
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'Leknes/a.jpg', source_origin: 'external' },
|
||||
]);
|
||||
// mediaRoot is empty — see beforeEach.
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/Sub/Leknes/a.jpg']);
|
||||
});
|
||||
|
||||
it('leaves managed rows alone', async () => {
|
||||
await touch('Trip/a.jpg');
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
|
||||
{ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual([null, 'Trip/a.jpg']);
|
||||
});
|
||||
|
||||
it('leaves an event with no base path alone — its rows are already root-relative', async () => {
|
||||
await touch('a.jpg');
|
||||
await knex('events').insert({ id: 1, external_path: null });
|
||||
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['a.jpg']);
|
||||
});
|
||||
|
||||
it('folds each event with its own base', async () => {
|
||||
await touch('A/one.jpg');
|
||||
await touch('B/two.jpg');
|
||||
await knex('events').insert([
|
||||
{ id: 1, external_path: 'A' },
|
||||
{ id: 2, external_path: 'B' },
|
||||
]);
|
||||
await knex('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'one.jpg', source_origin: 'external' },
|
||||
{ event_id: 2, external_relpath: 'two.jpg', source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['A/one.jpg', 'B/two.jpg']);
|
||||
});
|
||||
|
||||
it('tolerates a base path with stray slashes', async () => {
|
||||
await touch('Trip/a.jpg');
|
||||
await knex('events').insert({ id: 1, external_path: '/Trip/' });
|
||||
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/a.jpg']);
|
||||
});
|
||||
|
||||
it('does not fold twice when run again', async () => {
|
||||
// The failure this guards is total: every original on the install moves one
|
||||
// directory deeper, and there is no undo.
|
||||
await touch('Trip/a.jpg');
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
|
||||
|
||||
await migration.up(knex);
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/a.jpg']);
|
||||
});
|
||||
|
||||
it('does not fold twice when the base repeats in the relpath', async () => {
|
||||
// The inference this migration deliberately does NOT use: `Trip/x.jpg`
|
||||
// under base `Trip` already "starts with the base", but has not been
|
||||
// folded — it is a subfolder that shares its parent's name.
|
||||
await touch('Trip/Trip/x.jpg');
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await knex('photos').insert({ event_id: 1, external_relpath: 'Trip/x.jpg', source_origin: 'external' });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/Trip/x.jpg']);
|
||||
});
|
||||
|
||||
it('rollback does not clear the marker, so a re-run cannot double-fold', async () => {
|
||||
await touch('Trip/a.jpg');
|
||||
await knex('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
|
||||
|
||||
await migration.up(knex);
|
||||
await migration.down(knex);
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/a.jpg']);
|
||||
});
|
||||
|
||||
it('no-ops before 041 has added the column', async () => {
|
||||
await knex.schema.dropTableIfExists('photos');
|
||||
await knex.schema.createTable('photos', (t) => { t.increments('id').primary(); });
|
||||
|
||||
await expect(migration.up(knex)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Legacy preview keys must not survive the encoder change.
|
||||
*
|
||||
* The old generator kept the SOURCE basename verbatim while always writing
|
||||
* JPEG, so a `.webp` upload produced `preview_shot.webp` holding a JPEG. The
|
||||
* route now derives Content-Type from the key, and sets `nosniff` — so that
|
||||
* legacy object would be announced as image/webp and render as a broken image.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const migration = require('../../migrations/core/188_reset_legacy_preview_paths');
|
||||
|
||||
describe('migration 188 — legacy preview keys (#1166 follow-up)', () => {
|
||||
let knex; let tmpDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig188-'));
|
||||
knex = require('knex')({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: path.join(tmpDir, 'db.sqlite') },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (knex) await knex.destroy();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await knex.schema.dropTableIfExists('photos');
|
||||
await knex.schema.createTable('photos', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('preview_path');
|
||||
t.string('thumbnail_path');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the mislabelled .webp keys that would render broken', async () => {
|
||||
await knex('photos').insert({ preview_path: 'previews/preview_shot.webp' });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await knex('photos').first()).preview_path).toBeNull();
|
||||
});
|
||||
|
||||
it('clears .jpg keys too, because a byte-correct one can still be flattened', async () => {
|
||||
// A legacy .jpg key is valid JPEG, but it may be a flattened rendition of a
|
||||
// transparent or animated source, and nothing in the key says so. One lazy
|
||||
// regeneration is cheaper than reasoning about which of them lied.
|
||||
await knex('photos').insert([
|
||||
{ preview_path: 'previews/preview_a.jpg' },
|
||||
{ preview_path: 'previews/preview_b.png' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await knex('photos').whereNotNull('preview_path').count('* as c').first()).toEqual({ c: 0 });
|
||||
});
|
||||
|
||||
it('leaves thumbnails alone — they are a different cache', async () => {
|
||||
await knex('photos').insert({ preview_path: 'previews/p.jpg', thumbnail_path: 'thumbnails/t.jpg' });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await knex('photos').first()).thumbnail_path).toBe('thumbnails/t.jpg');
|
||||
});
|
||||
|
||||
it('is idempotent and safe with nothing to clear', async () => {
|
||||
await migration.up(knex);
|
||||
await expect(migration.up(knex)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('no-ops before 104 has added the column', async () => {
|
||||
await knex.schema.dropTableIfExists('photos');
|
||||
await knex.schema.createTable('photos', (t) => { t.increments('id').primary(); });
|
||||
|
||||
await expect(migration.up(knex)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* The person-faces query must table-qualify its WHERE (#1096).
|
||||
*
|
||||
* `photo_faces` and `photos` BOTH have an event_id, so the moment the join was
|
||||
* added a bare `where({ event_id })` became ambiguous. Postgres refuses it —
|
||||
*
|
||||
* column reference "event_id" is ambiguous
|
||||
*
|
||||
* — and the endpoint 500s, which took the Split dialog down with it on every
|
||||
* PostgreSQL install. SQLite resolves the ambiguity silently, which is why the
|
||||
* suite stayed green and this reached production.
|
||||
*
|
||||
* Two deliberate choices about HOW this is tested:
|
||||
*
|
||||
* 1. It imports the builder the route actually calls. An earlier version of
|
||||
* this file re-declared the query locally, which meant the route could
|
||||
* regress to the bare form while these assertions kept passing — a test
|
||||
* that documents a bug without guarding it.
|
||||
* 2. It asserts on the emitted SQL rather than executing it. A round-trip test
|
||||
* would run against the SQLite the suite uses and prove nothing about the
|
||||
* engine the bug affects.
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const knex = require('knex')({ client: 'pg' });
|
||||
const { buildPersonFacesQuery, PERSON_FACES_LIMIT } = require('../src/routes/adminEvents/faces');
|
||||
|
||||
const sql = () => buildPersonFacesQuery(knex, 857, 143).toString();
|
||||
|
||||
describe('person faces query', () => {
|
||||
it('is the query the route runs, not a copy of it', () => {
|
||||
expect(typeof buildPersonFacesQuery).toBe('function');
|
||||
expect(sql()).toContain('from "photo_faces"');
|
||||
});
|
||||
|
||||
it('qualifies event_id with its table', () => {
|
||||
// The bare form is what Postgres rejects.
|
||||
expect(sql()).toContain('"photo_faces"."event_id"');
|
||||
expect(sql()).not.toMatch(/where\s+"event_id"/i);
|
||||
});
|
||||
|
||||
it('qualifies person_id too, so the join cannot shadow it either', () => {
|
||||
expect(sql()).toContain('"photo_faces"."person_id"');
|
||||
expect(sql()).not.toMatch(/and\s+"person_id"\s*=/i);
|
||||
});
|
||||
|
||||
it('still joins photos for the original dimensions', () => {
|
||||
// The dimensions are what faceCropStyle scales the bbox against; without
|
||||
// the join the crop maths has nothing to work from.
|
||||
const s = sql();
|
||||
expect(s).toContain('inner join "photos"');
|
||||
expect(s).toContain('"photos"."width"');
|
||||
expect(s).toContain('"photos"."height"');
|
||||
});
|
||||
|
||||
it('caps the list at the limit the UI is told about', () => {
|
||||
// The viewer reports truncation using this same number; if they drift, it
|
||||
// silently claims a person has fewer appearances than they do.
|
||||
expect(PERSON_FACES_LIMIT).toBe(500);
|
||||
expect(sql()).toContain(`limit ${PERSON_FACES_LIMIT}`);
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,7 @@ describe('admin CRM routes — auth + permission gate', () => {
|
||||
// Invalid: signed with a different secret. adminAuth must reject.
|
||||
const jwt = require('jsonwebtoken');
|
||||
invalidToken = jwt.sign({ id: adminId, type: 'admin' }, 'WRONG-SECRET', { issuer: 'picpeak-auth' });
|
||||
}, 120000);
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* HTTP tests for the gallery QR endpoints (#836):
|
||||
* GET /api/admin/events/:id/qr (PNG / SVG)
|
||||
* GET /api/admin/events/:id/qr-print (table-card / poster PDF)
|
||||
* Same real-SQLite harness as adminEvents.smoke.test.js.
|
||||
*/
|
||||
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-events-qr-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-qr-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
async function insertEvent(db, adminId, over = {}) {
|
||||
const base = {
|
||||
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'QR Test Wedding',
|
||||
event_date: '2026-05-29',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('admin event QR endpoints', () => {
|
||||
let db; let cleanup; let app; let adminId; let token;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
beforeEach(async () => { await db('events').del(); });
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
it('401s without an admin token', async () => {
|
||||
const eventId = await insertEvent(db, adminId);
|
||||
const res = await request(app).get(`/api/admin/events/${eventId}/qr`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns a PNG QR by default', async () => {
|
||||
const eventId = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr`)).buffer();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
// PNG magic bytes
|
||||
expect(res.body.slice(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
});
|
||||
|
||||
it('returns an SVG QR when requested', async () => {
|
||||
const eventId = await insertEvent(db, adminId);
|
||||
// supertest doesn't text-parse image/svg+xml — buffer and decode manually.
|
||||
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr?format=svg`)).buffer();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/image\/svg\+xml/);
|
||||
expect(Buffer.from(res.body).toString('utf8')).toContain('<svg');
|
||||
});
|
||||
|
||||
it('sets attachment disposition with download=1', async () => {
|
||||
const eventId = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr?download=1`)).buffer();
|
||||
expect(res.headers['content-disposition']).toMatch(/^attachment/);
|
||||
});
|
||||
|
||||
// 30s: the print PDFs embed the full IBM Plex Sans TTFs (~200 KB each) —
|
||||
// font parsing + subsetting exceeds jest's 5s default on slower CI runners.
|
||||
it.each(['table-card', 'poster'])('renders the %s print PDF', async (template) => {
|
||||
const eventId = await insertEvent(db, adminId);
|
||||
const res = await auth(
|
||||
request(app).get(`/api/admin/events/${eventId}/qr-print?template=${template}&lang=de`)
|
||||
).buffer();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('application/pdf');
|
||||
expect(res.body.slice(0, 4).toString()).toBe('%PDF');
|
||||
}, 120000);
|
||||
|
||||
it('409s when the event has no share link', async () => {
|
||||
// events.share_link is NOT NULL — an empty string is the closest real-world
|
||||
// "no share link" shape (no token extractable from it either).
|
||||
const eventId = await insertEvent(db, adminId, { share_link: '', share_token: null });
|
||||
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr`));
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('404s for a non-existent event', async () => {
|
||||
const res = await auth(request(app).get('/api/admin/events/999999/qr'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user