Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Nothaft e94e440858 screenshot: admin github button (#778) 2026-07-10 09:50:18 +02:00
467 changed files with 4269 additions and 39741 deletions
+2 -16
View File
@@ -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,9 +56,8 @@ 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
@@ -115,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)
+4 -97
View File
@@ -95,15 +95,6 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Prepare platform pair
run: |
@@ -242,15 +233,6 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Download digest artifacts
uses: actions/download-artifact@v4
@@ -284,24 +266,11 @@ jobs:
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Backend
id: meta-backend
uses: docker/metadata-action@v5
with:
# GHCR always; Docker Hub (picpeak/backend) added on the canonical repo so
# the same tag scheme is mirrored to both registries. metadata-action drops
# the blank second line on forks → GHCR-only there.
images: |
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/backend' || '' }}
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Backend
org.opencontainers.image.description=PicPeak photo sharing platform backend service
@@ -313,10 +282,6 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
# so users can pin the same string as the GitHub release. metadata-action's
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
type=ref,event=tag
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
@@ -333,15 +298,10 @@ jobs:
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest (GHCR)
- name: Inspect manifest
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/backend:${{ steps.meta-backend.outputs.version }}
# -----------------------------------------------------------------------------
# Frontend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
@@ -371,15 +331,6 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Prepare platform pair
run: |
@@ -499,15 +450,6 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Download digest artifacts
uses: actions/download-artifact@v4
@@ -541,24 +483,11 @@ jobs:
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
# GHCR always; Docker Hub (picpeak/frontend) added on the canonical repo so
# the same tag scheme is mirrored to both registries. metadata-action drops
# the blank second line on forks → GHCR-only there.
images: |
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/frontend' || '' }}
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Frontend
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
@@ -570,10 +499,6 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
# so users can pin the same string as the GitHub release. metadata-action's
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
type=ref,event=tag
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
@@ -590,15 +515,10 @@ jobs:
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest (GHCR)
- name: Inspect manifest
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/frontend:${{ steps.meta-frontend.outputs.version }}
summary:
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
if: always()
@@ -612,15 +532,6 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Build Summary
run: |
@@ -659,10 +570,6 @@ jobs:
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
+26 -7
View File
@@ -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
+2 -28
View File
@@ -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:
@@ -30,29 +30,6 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
# The .picpeak restore suites gate their real-Postgres cases behind
# PICPEAK_PG_TEST_URL and `describe.skip` themselves out when it is
# unset — so until now they never ran here. That hid the half that
# matters: sequence resync, operator/role preservation across a
# cross-instance restore, and (with #1041) whether a SQLite-shaped
# row actually lands in Postgres with the right STORED VALUES rather
# than merely not throwing. Everything else in the suite still runs
# on SQLite; this service only un-gates those cases.
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: picpeak
POSTGRES_PASSWORD: testpass
POSTGRES_DB: picpeak_test
options: >-
--health-cmd "pg_isready -U picpeak -d picpeak_test"
--health-interval 2s
--health-timeout 2s
--health-retries 30
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -75,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:
+2 -3
View File
@@ -130,6 +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/
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.105.1-beta.0"
".": "3.82.4-beta.0"
}
+3 -1
View File
@@ -1 +1,3 @@
{".":"3.44.0"}
{
".": "2.6.1"
}
-569
View File
@@ -5,575 +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.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 &lt;PoweredBy /&gt; ([#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)
+459 -101
View File
@@ -1,48 +1,90 @@
# 📸 PicPeak - Open Source Photo Sharing for Events
> [!IMPORTANT]
> **PicPeak has moved to its own GitHub organization.**
>
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
>
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
# 📸 PicPeak
**Open-source, self-hosted photo sharing for events.**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-theluap-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/theluap)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support ☕](https://buymeacoffee.com/theluap)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support the project ](https://buymeacoffee.com/theluap)
</div>
---
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
> [!IMPORTANT]
> **PicPeak has moved to its own GitHub organization.** Docker images are now at `ghcr.io/picpeak/picpeak/{backend,frontend}` and active development is on `main`. The old `ghcr.io/the-luap/...` path still responds but its tags are **frozen** at 2026-05-27 — if updates never arrive, check your image path first. See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit.
## Contents
- [Live Demo](#-live-demo)
- [Quick Start](#-quick-start)
- [Why PicPeak?](#-why-picpeak)
- [Features](#-features)
- [Documentation](#-documentation)
- [Comparison](#-comparison-with-alternatives)
- [Tech Stack](#-tech-stack)
- [Contributing & Support](#-contributing)
- [License](#-license)
## 🎮 Live Demo
Try PicPeak without installing anything — [demo.picpeak.app](https://demo.picpeak.app) · [admin panel](https://demo.picpeak.app/admin)
Try PicPeak without installing anything:
| Email | Password |
| | |
|---|---|
| `demo@picpeak.app` | `Demo2026!` |
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
| **Email** | `demo@picpeak.app` |
| **Password** | `Demo2026!` |
> The demo resets periodically. Uploaded content may be removed without notice.
## 🌟 Why Choose PicPeak?
Unlike expensive SaaS solutions, PicPeak gives you:
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
- **🔒 Complete Data Control** - Your photos stay on your server
- **🎨 White-Label Ready** - Full branding customization
- **📱 Mobile-First Design** - Beautiful on all devices
- **🚀 Lightning Fast** - Optimized performance and caching
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
## ✨ Key Features
### For Photographers
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
- 🔗 **External Media (Reference Mode)** - Browse and import from a readonly external folder library without copying originals
-**Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
- 🔐 **Password Protection** - Secure client galleries
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 📽️ **Live Slideshow** - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options ([guide](docs/live-slideshow.md))
- 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
### For Clients
- 🖼️ **Beautiful Galleries** - Clean, modern interface
- 📱 **Mobile Optimized** - Swipe through photos on any device
- ⬇️ **Bulk Downloads** - Download all photos with one click
- 🔍 **Smart Search** - Find photos quickly
- 📤 **Guest Uploads** - Optional client photo uploads
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
### Technical Excellence
- 🐳 **Docker Ready** - Deploy in minutes
- 🔄 **Auto-Processing** - Automatic thumbnail generation
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
- 💾 **Smart Storage** - Automatic archiving of expired galleries
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies
### For Studios — CRM & Accounting (Beta · off by default)
- 📝 **Quotes → Contracts → Invoices** - One deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only
- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below)
## 🚀 Quick Start
Get PicPeak running in under 5 minutes:
@@ -54,8 +96,8 @@ cd picpeak
# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser. Edit .env only to customise
# (domain, SMTP, storage paths, …) — nothing is required.
# admin account is created in the browser (see below). Edit .env only to
# customise (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env
# Start with Docker Compose
@@ -64,61 +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`:
## 🌟 Why PicPeak?
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.
Unlike expensive SaaS solutions, PicPeak gives you:
> 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`).
- **💰 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)
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).
## ✨ Features
**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.
**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.
## 🔄 Release Channels
**For clients** — clean mobile-optimized galleries, one-click bulk downloads, smart search, optional guest uploads, and download protection (watermarking + right-click prevention).
PicPeak offers two release channels for different needs. Stable promotions are cut from a known-good beta point every 46 weeks — see [RELEASING.md](RELEASING.md) for the maintainer's promotion criteria and cadence policy.
**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).
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
<details>
<summary><strong>🧾 For studios — CRM &amp; Accounting (Beta, off by default)</strong></summary>
### 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`
- 📝 **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
### Switching Channels
</details>
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
> [!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
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
Then update your containers:
```bash
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
```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) |
| ⚙️ 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) |
| 💾 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
@@ -135,79 +404,168 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
| Quotes / Contracts / Invoices | 🧪 Beta | ❌ | ❌ | ✅ |
| Incoming Invoices & Accounting | 🧪 Beta | ❌ | ❌ | ❌ |
<sub>*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.</sub>
*You still bring your own server (own hardware or a VPS) and, if you want one, a domain.
**Limited only by your server storage.
***Pixieset's "unlimited" is photos only; video is capped by plan (roughly 010 h depending on tier).
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
## 🏗 Tech Stack
## 🛡 Security
- **Backend**: Node.js, Express, SQLite/PostgreSQL
- **Frontend**: React, Tailwind CSS, Framer Motion
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](https://docs.picpeak.app/features/storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
- **External media**: point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals read-only, index quickly, and generate thumbnails on demand
PicPeak takes security seriously:
- 🔐 Password hashing with bcrypt
- 🎫 JWT-based authentication
- 🚦 Rate limiting on all endpoints
- 🛡️ CORS protection
- 📝 Activity logging
- 🔒 Secure file access
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
## 📸 Screenshots
<details>
<summary>Click to see the admin dashboard, analytics, and event management</summary>
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
### 🎛️ Admin Dashboard
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
### 📊 Analytics & Insights
### 📊 **Analytics & Insights**
Track gallery performance, view statistics, and monitor user engagement.
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
### 📁 Event Management
### 📁 **Event Management**
Organize and manage your photo galleries with intuitive event management tools.
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
### ✨ **Key Interface Highlights**
<details>
<summary>👆 Click to see more interface details</summary>
#### What makes PicPeak's interface special:
- **🎨 Clean Design**: Modern, photographer-friendly interface
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details>
## 🤝 Contributing
## 🗺️ Roadmap
We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the [Contributing Guide](CONTRIBUTING.md) to get started.
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security). See [SECURITY.md](SECURITY.md) for the policy.
### 🚧 Beta Features (Use at your own risk)
These features are currently in beta testing and may have limited functionality or stability:
| Feature | Description | Status |
|---------|-------------|--------|
| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements
| Feature | Description | Priority | Status |
|---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **External Media Library (Reference Mode)** | Use an external folder library as a readonly source with import and ondemand thumbnail generation | High | ✅ Implemented |
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
## ☕ Support the Project
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider [buying me a coffee](https://buymeacoffee.com/theluap) — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also ⭐ star the repo, share it, file good bug reports, or open a PR.
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
<p align="left">
<a href="https://buymeacoffee.com/theluap" target="_blank">
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
</a>
</p>
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
## 🙏 Acknowledgments
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
### 👥 Contributors
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
**[@the-luap](https://github.com/the-luap)** — creator and lead maintainer
- Gallery foundation (events, uploads, sharing, download protection, templates)
- Backup & restore, analytics, branding/theming
- The architecture every later feature builds on
**[@Luca-Timo](https://github.com/Luca-Timo)**
- Native Apple Silicon multi-arch images
- CRM & accounting suite (quotes/contracts/invoices)
- Hours logging & Treuhänder/Banana tax export
- Gallery header/banner decoupling
**[@Rekoo-PS](https://github.com/Rekoo-PS)** — bug reports & product feedback
- Login-loop fix, mobile-lightbox overhaul, bulk-delete workflow
- Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
### 🤖 AI-Assisted Development
This project was generated with the assistance of AI technology, but has been:
- ✅ **Fully tested end-to-end** by human developers
- 🔒 **Security audited** with comprehensive security checks
- 👨‍💻 **Human-reviewed** for code quality and best practices
- 🧪 **Production-tested** in real-world scenarios
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
## ⚠️ CRM & Accounting disclaimers — examples only, verify locally
The CRM & accounting modules (contracts, invoices, QR-bills, the tax
report and the accountant exports) ship seeded content and computed
figures that are intended as a **starting point only**:
- **Contract blocks** (image rights, NDA, model release, cancellation,
jurisdiction, …) are written by the maintainer, **not by a lawyer**.
Every operator must have their lawyer review and adapt them before
sending any contract to a customer.
- **QR-bills and SEPA EPC payloads** are rendered from the data you
typed. Picpeak is open source — please scan a test invoice with your
bank's app to check the QR actually works. We are not responsible for
any mistakes that come from sending an invoice with bad data on it.
- **Tax, VAT & accounting figures** (the tax report, VAT-payable, the
per-rate breakdown, the Treuhänder / Banana export, etc.) are computed
from the data you enter and the defaults you configure. They are
**guidance only and jurisdiction-specific** — tax rules, VAT rates,
deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat
rate) and filing duties differ by country and change over time. **Every
operator must check their own tax / VAT regulations and verify the
numbers with their accountant / Treuhänder / tax authority before
relying on any figure or export.** Picpeak makes no warranty that the
output is correct for your jurisdiction or situation.
Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before
enabling the Contracts, Invoices or Accounting features.
## 📄 License
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
## 🚀 Ready to Get Started?
1.**Star this repository** to show your support
2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app)
3. 🐛 Report issues or request features
4. 🤝 Join our community and contribute!
---
<p align="center">
Made with ❤️ by photographers, for photographers
<br>
<a href="https://www.picpeak.app">Homepage</a> ·
<a href="https://demo.picpeak.app">Live Demo</a> ·
<a href="https://docs.picpeak.app">Documentation</a> ·
<a href="https://www.picpeak.app">Homepage</a>
<a href="https://demo.picpeak.app">Live Demo</a>
<a href="https://github.com/PicPeak/picpeak">GitHub</a>
<a href="https://docs.picpeak.app">Documentation</a> •
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
</p>
+4 -18
View File
@@ -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
View File
@@ -170,12 +170,10 @@ If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your ad
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is not logged — that would leave a live credential in `docker logs`):
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
```bash
docker compose exec backend cat /app/data/SETUP_TOKEN
docker compose logs backend | grep -i "setup token"
```
Only if that write fails does the backend log the token instead.
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods
+3 -1
View File
@@ -1,7 +1,9 @@
node_modules
npm-debug.log
.env
storage
storage/events/active/*
storage/events/archived/*
storage/thumbnails/*
data/*.db
logs/*
coverage
-6
View File
@@ -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
View File
@@ -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
+1 -4
View File
@@ -8,10 +8,7 @@ RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
# exiftool: extract embedded JPEG previews from RAW/DNG uploads (#821) — kept in
# sync with the production Dockerfile so dev/native runtimes don't accept a DNG
# and then fail it with ENOENT.
RUN apk add --no-cache dumb-init ffmpeg exiftool
RUN apk add --no-cache dumb-init ffmpeg
# Copy package files
COPY package*.json ./
@@ -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,109 +0,0 @@
/**
* Backup credential exposure regression tests.
*
* The generic settings reads (GET /admin/settings, GET /admin/settings/:type)
* masked the recaptcha/umami/rybbit keys but returned backup_s3_secret_key
* and backup_rsync_ssh_key (an SSH PRIVATE KEY) in plaintext to any
* settings.view holder; GET /admin/backup/config returned them too. Both now
* mask, and PUT /admin/backup/config skips the mask sentinel so the edit
* form round-trips without clobbering stored credentials.
*/
const request = require('supertest');
const express = require('express');
const { bootCrmDb } = require('./helpers/crmDb');
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => {
req.admin = { id: 1, username: 'test-admin' };
next();
},
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
requireSuperAdmin: () => (_req, _res, next) => next(),
}));
describe('backup credential masking', () => {
let db;
let cleanup;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Upsert: several backup_* keys are pre-seeded by the backup migrations.
const seed = [
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('s3'), setting_type: 'backup' },
{ setting_key: 'backup_s3_endpoint', setting_value: JSON.stringify('https://s3.example.com'), setting_type: 'backup' },
{ setting_key: 'backup_s3_bucket', setting_value: JSON.stringify('backups'), setting_type: 'backup' },
{ setting_key: 'backup_s3_access_key', setting_value: JSON.stringify('AKIAEXAMPLE'), setting_type: 'backup' },
{ setting_key: 'backup_s3_secret_key', setting_value: JSON.stringify('super-secret-s3-key'), setting_type: 'backup' },
{ setting_key: 'backup_rsync_ssh_key', setting_value: JSON.stringify('-----BEGIN OPENSSH PRIVATE KEY-----abc'), setting_type: 'backup' },
];
for (const row of seed) {
await db('app_settings').insert(row).onConflict('setting_key').merge();
}
app = express();
app.use(express.json());
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('masks the credentials in GET /admin/backup/config', async () => {
const res = await request(app).get('/api/admin/backup/config').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
// Non-secret fields stay readable for the form.
expect(res.body.backup_s3_bucket).toBe('backups');
});
it('masks the credentials in the generic GET /admin/settings/:type read', async () => {
const res = await request(app).get('/api/admin/settings/backup').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
});
it('masks the credentials in the generic GET /admin/settings read', async () => {
const res = await request(app).get('/api/admin/settings').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
});
it('PUT /admin/backup/config keeps the stored secret when the sentinel round-trips', async () => {
await request(app)
.put('/api/admin/backup/config')
.send({
backup_destination_type: 's3',
backup_s3_endpoint: 'https://s3.example.com',
backup_s3_bucket: 'renamed-bucket',
backup_s3_access_key: 'AKIAEXAMPLE',
backup_s3_secret_key: '••••••••',
backup_rsync_ssh_key: '••••••••',
})
.expect(200);
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
expect(JSON.parse(secret.setting_value)).toBe('super-secret-s3-key');
const sshKey = await db('app_settings').where({ setting_key: 'backup_rsync_ssh_key' }).first();
expect(JSON.parse(sshKey.setting_value)).toBe('-----BEGIN OPENSSH PRIVATE KEY-----abc');
const bucket = await db('app_settings').where({ setting_key: 'backup_s3_bucket' }).first();
expect(JSON.parse(bucket.setting_value)).toBe('renamed-bucket');
});
it('PUT /admin/backup/config stores a genuinely new secret', async () => {
await request(app)
.put('/api/admin/backup/config')
.send({ backup_s3_secret_key: 'rotated-s3-key' })
.expect(200);
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
expect(JSON.parse(secret.setting_value)).toBe('rotated-s3-key');
});
});
@@ -23,7 +23,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — configurable walker (backup_paths)', () => {
let db;
@@ -177,203 +177,4 @@ describe('backupService — configurable walker (backup_paths)', () => {
const filesOn = await backupService.getFilesToBackup(true);
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
});
// Issue #871 — the "What to Backup" checkboxes were stored but never read.
describe('UI opt-out toggles (issue #871)', () => {
it('unchecking Thumbnails excludes thumbnails/', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_thumbnails: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
});
it('unchecking Photos excludes events/active', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_photos: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).not.toContain('events/active/E1/photo.jpg');
});
it('defaults to including everything when the keys were never saved', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).toContain('events/active/E1/photo.jpg');
});
it("accepts the UI's plural backup_include_archives for the archived gate", async () => {
seedFile('events/archived/E4/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archives: true,
});
expect(files.map((f) => f.relativePath)).toContain('events/archived/E4/archived.jpg');
});
it('the UI plural key beats the migration-seeded singular key', async () => {
// Migration seeds backup_include_archived=true on every install; the
// form only ever writes the plural key, so unchecking Archives must
// win over the stale seeded value.
seedFile('events/archived/E5/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archived: true, // seeded default
backup_include_archives: false, // what the admin actually chose
});
expect(files.map((f) => f.relativePath)).not.toContain('events/archived/E5/archived.jpg');
});
it('rsync gets the de-selected paths and noise filters as --exclude args', async () => {
const excluded = await backupService.resolveExcludedBackupPaths({
backup_include_thumbnails: false,
backup_include_archives: false,
});
expect(excluded.map((r) => r.path)).toEqual(
expect.arrayContaining(['thumbnails', 'events/archived'])
);
const args = backupService.buildRsyncArgs(
{ backup_rsync_host: 'backup.example.com', backup_rsync_path: '/srv/backups' },
excluded.map((r) => `/${r.path}/`)
);
const excludes = args
.map((a, i) => (a === '--exclude' ? args[i + 1] : null))
.filter(Boolean);
expect(excludes).toEqual(expect.arrayContaining([
'.nfs*',
'/thumbnails/',
'/events/archived/',
]));
});
it('rows toggled off via include_in_default also become rsync excludes', async () => {
// The enabled-only loader hides these rows from the walker, but rsync
// syncs the whole storage root, so they must still appear as excludes.
await db('backup_paths').where('path', 'previews').update({
include_in_default: false,
});
const excluded = await backupService.resolveExcludedBackupPaths({});
expect(excluded.map((r) => r.path)).toContain('previews');
});
});
// Issue #871 — .nfs* silly-rename artifacts were uploaded to S3.
it('never backs up filesystem noise (.nfs*, .DS_Store)', async () => {
seedFile('thumbnails/E1/.nfs000000000000006600000008');
seedFile('events/active/E1/.DS_Store');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels.some((r) => r.includes('.nfs'))).toBe(false);
expect(rels.some((r) => r.includes('.DS_Store'))).toBe(false);
});
it('the walker honors backup_exclude_patterns (previously rsync-only)', async () => {
seedFile('events/active/E1/photo.jpg');
seedFile('events/active/E1/scratch.tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('events/active/E1/scratch.tmp');
});
it('glob patterns are literal outside the star (.nfs* must not eat anfs-…)', async () => {
seedFile('events/active/E1/anfs-photo.jpg');
seedFile('events/active/E1/notes-tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
// '.nfs*' used to compile to /^.nfs.*$/ whose dot matched any char;
// '*.tmp' used to compile to /^.*.tmp$/ which also matched 'notes-tmp'.
expect(rels).toContain('events/active/E1/anfs-photo.jpg');
expect(rels).toContain('events/active/E1/notes-tmp');
});
// Issue #871 — weekly schedules silently ran daily, and the dashboard's
// "next backup" was a hardcoded "tomorrow 02:00".
describe('schedule resolution + next run (issue #871)', () => {
it('a named label beats the stray default cron the UI used to send', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *', // old UI default, sent unconditionally
})).toBe('0 3 * * 0');
});
it('custom schedules use the cron field', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'custom',
backup_schedule_cron: '15 5 * * 2',
})).toBe('15 5 * * 2');
});
it('falls back to the default daily cron', () => {
expect(backupService.resolveScheduleCron({})).toBe('0 2 * * *');
});
it('getNextScheduledRun is null when backups are disabled', () => {
expect(backupService.getNextScheduledRun(null)).toBeNull();
expect(backupService.getNextScheduledRun({ backup_enabled: false })).toBeNull();
});
it('getNextScheduledRun returns the real next weekly fire time', () => {
const iso = backupService.getNextScheduledRun({
backup_enabled: true,
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *',
});
const next = new Date(iso);
expect(Number.isNaN(next.getTime())).toBe(false);
expect(next.getTime()).toBeGreaterThan(Date.now());
expect(next.getDay()).toBe(0); // Sunday
expect(next.getHours()).toBe(3); // 03:00
});
});
// Issue #871 — "Backup Size: 167.6 TB": file_size_bytes is a bigInteger
// column, node-postgres returns int8 as a string, and the S3 path did
// `backedUpSize += size` — string concatenation.
it('getDatabaseBackupInfo coerces file_size_bytes to a number', async () => {
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
backup_type: 'full',
status: 'completed',
file_path: '/backups/db/dump.sql.gz',
// Simulate the PG int8-as-string driver behaviour (sqlite stores
// whatever it is handed, so the string round-trips).
file_size_bytes: '421988',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
});
const info = await backupService.getDatabaseBackupInfo();
expect(typeof info.size).toBe('number');
expect(info.size).toBe(421988);
});
});
@@ -34,7 +34,7 @@ jest.mock('../../src/services/databaseBackup', () => ({
DatabaseBackupService: class {},
}));
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — inline DB dump + fail-loud guard', () => {
let db;
@@ -23,7 +23,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — per-Stage-B-path statistics', () => {
let db;
@@ -14,7 +14,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — config + file collection + manifest (smoke)', () => {
let db;
@@ -7,7 +7,7 @@
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('booking cutover — draft invoices on hold', () => {
let db; let cleanup; let adminId; let customerId; let quoteService;
@@ -1,211 +0,0 @@
/**
* Layered per-event category ordering (#782).
*
* Two ordering layers, resolved per event:
* - GLOBAL default — photo_categories.display_order (migration 159),
* set via POST /reorder-global; applies everywhere.
* - PER-EVENT override — event_category_order (migration 160), set via
* POST /reorder; overrides the default for one gallery.
* - DELETE /reorder/:eventId clears an event's override.
*
* Verified against a real SQLite DB with the full core-migration set applied.
*/
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
jest.setTimeout(120000);
describe('category ordering (#782)', () => {
let db;
let cleanup;
let token;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const auth = (r) => r.set('Authorization', `Bearer ${token}`);
async function insertEvent(slug) {
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug, share_link: slug,
event_name: slug, event_date: '2026-01-01',
});
return (await db('events').where({ slug }).first()).id;
}
async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) {
const res = await db('photo_categories').insert({
name,
slug: name.toLowerCase().replace(/\s+/g, '-'),
is_global: is_global ? 1 : 0,
event_id,
display_order,
}).returning('id');
return res[0]?.id ?? res[0];
}
const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200);
describe('migration 159 backfill', () => {
it('seeds display_order from alphabetical order, scoped per event', async () => {
const eventId = await insertEvent('backfill-ev');
await insertCat('Reception', { event_id: eventId });
await insertCat('Ceremony', { event_id: eventId });
await insertCat('Pre-Ceremony', { event_id: eventId });
// Re-run the migration: addColumn is guarded (no-op); the backfill loop
// re-runs and assigns per-scope alphabetical order — what an upgrade does.
await require('../../migrations/core/159_add_category_display_order').up(db);
const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc');
expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']);
expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]);
});
});
describe('global default order (POST /reorder-global)', () => {
it('reverses the global order and every non-customised event follows it', async () => {
const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body;
expect(before.length).toBeGreaterThan(1);
const reversedIds = before.map((c) => c.id).reverse();
const res = await auth(request(app).post('/api/admin/categories/reorder-global'))
.send({ orderedIds: reversedIds })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(reversedIds);
// A fresh event (no override) shows globals in the new global order.
const eventId = await insertEvent('follows-global');
const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id);
expect(globalsInEvent).toEqual(reversedIds);
});
});
describe('per-event override (POST /reorder)', () => {
it('pins a custom order for one event without affecting another', async () => {
const eventA = await insertEvent('override-a');
const eventB = await insertEvent('override-b');
const a1 = await insertCat('A-Ceremony', { event_id: eventA });
const a2 = await insertCat('A-Reception', { event_id: eventA });
// Current resolved list for A (globals + A's two categories).
const listA = (await getEvent(eventA)).body;
// Put A-Reception first, then A-Ceremony, then the globals in their order.
const globalsA = listA.filter((c) => c.is_global).map((c) => c.id);
const desired = [a2, a1, ...globalsA];
const res = await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventA, orderedIds: desired })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(desired);
// override_position is set on every row for a customised event.
expect(res.body.every((c) => c.override_position != null)).toBe(true);
// Event B is untouched — no override, follows the global default.
const listB = (await getEvent(eventB)).body;
expect(listB.every((c) => c.override_position == null)).toBe(true);
});
it('accepts global ids but rejects another events category', async () => {
const eventId = await insertEvent('scope-ev');
const own = await insertCat('Own', { event_id: eventId });
const global = (await db('photo_categories').where('is_global', 1).first()).id;
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
// A global id is allowed (globals can be arranged per event).
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, global] })
.expect(200);
// A foreign event's category is out of scope.
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, foreign] })
.expect(400);
});
});
describe('reset (DELETE /reorder/:eventId)', () => {
it('clears the override and reverts to the global default', async () => {
const eventId = await insertEvent('reset-ev');
const c1 = await insertCat('R-One', { event_id: eventId });
const list = (await getEvent(eventId)).body;
const globals = list.filter((c) => c.is_global).map((c) => c.id);
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
.expect(200);
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
expect(res.body.every((c) => c.override_position == null)).toBe(true);
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
});
});
describe('event ownership (PR #790 review)', () => {
let limitedToken;
let foreignEventId;
beforeAll(async () => {
const bcrypt = require('bcrypt');
// A non-super_admin role that DOES hold settings.view + settings.edit —
// the exact case the review flagged (settings.edit is grantable).
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
const roleId = roleRes[0]?.id ?? roleRes[0];
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
const a2 = await db('admin_users').insert({
username: 'limited', email: 'limited@example.com',
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
must_change_password: false, created_at: new Date(),
}).returning('id');
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
// An event owned by a DIFFERENT admin (the seeded super_admin).
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
});
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
});
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
});
});
describe('POST / (create) appends to the end of its scope', () => {
it('assigns display_order = max + 1 within the event', async () => {
const eventId = await insertEvent('append-ev');
await insertCat('First', { event_id: eventId, display_order: 1 });
await insertCat('Second', { event_id: eventId, display_order: 2 });
const res = await auth(request(app).post('/api/admin/categories'))
.send({ name: 'Third', is_global: false, event_id: eventId })
.expect(200);
expect(res.body.display_order).toBe(3);
});
});
});
@@ -1,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,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 };
@@ -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,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,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);
});
});
});
@@ -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,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);
});
});
@@ -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);
});
});
@@ -180,55 +180,6 @@ describe('admin events CRUD endpoints (smoke)', () => {
});
expect(res.status).toBe(404);
});
// #822 — hero_logo_visible/position are nullable (null = "inherit the global
// branding toggle"), but the validator used .optional() without
// { nullable: true }, so an explicit null was rejected with 400.
it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => {
const id = await insertEvent(db, adminId, { hero_logo_visible: 1 });
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: null,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.hero_logo_visible).toBeNull();
});
it('still rejects a non-boolean hero_logo_visible', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: 'maybe',
});
expect(res.status).toBe(400);
});
// #894 — per-event password-page logo toggle: false hides, null
// restores the default (show).
it('stores login_logo_visible: false and clears it back to NULL', async () => {
const id = await insertEvent(db, adminId);
const hide = await auth(request(app).put(`/api/admin/events/${id}`)).send({
login_logo_visible: false,
});
expect(hide.status).toBe(200);
let row = await db('events').where({ id }).first();
expect([false, 0]).toContain(row.login_logo_visible);
const clear = await auth(request(app).put(`/api/admin/events/${id}`)).send({
login_logo_visible: null,
});
expect(clear.status).toBe(200);
row = await db('events').where({ id }).first();
expect(row.login_logo_visible).toBeNull();
// The string "false" passes isBoolean() validation — it must be
// parsed, not treated as a truthy string (would store 1 = show).
const hideStr = await auth(request(app).put(`/api/admin/events/${id}`)).send({
login_logo_visible: 'false',
});
expect(hideStr.status).toBe(200);
row = await db('events').where({ id }).first();
expect([false, 0]).toContain(row.login_logo_visible);
});
});
describe('DELETE /:id', () => {
+2 -2
View File
@@ -39,7 +39,7 @@ const {
bootCrmDb, mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(60000);
let db;
let cleanup;
@@ -95,7 +95,7 @@ beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
}, 120000);
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -1,238 +0,0 @@
/**
* Admin photo view route Content-Type (#908).
*
* The route built `image/<ext>` from the filename, producing invalid
* types like image/mp4 for videos. AdminAuthenticatedVideo fetches this
* URL into a blob whose type inherits the header, and browsers refuse to
* play a <video> blob labeled image/* — blank/grey admin video preview.
*
* Pins (incl. external-review hardening):
* - the header is ALWAYS image/* or video/*: a stored non-media MIME
* (chunked uploads store the client-sent type unvalidated) is never
* echoed — text/html inline under the app origin would be XSS
* - stored video/ MIME wins; MIME-less videos map from the extension
* (.mov → video/quicktime), unknown video extensions get video/mp4
* - images IGNORE the stored MIME (migration 039 backfilled image/jpeg
* onto every legacy row, PNGs included) and use the extension,
* normalized (jpg → image/jpeg); extensionless files get image/jpeg
*/
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-admin-ct-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-ct-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-ct-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'admin-ct-test-event';
describe('admin photo view Content-Type (#908)', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
const addPhoto = async (filename, extra = {}) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from(`bytes-${filename}`));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
...extra,
}).returning('id');
return r[0]?.id ?? r[0];
};
const getPhotoRes = (photoId) => request(app)
.get(`/api/admin/photos/${eventId}/photo/${photoId}`)
.set('Authorization', `Bearer ${adminToken}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Admin CT 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: 'admin-ct-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 superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'admin-ct-admin',
email: 'admin-ct-admin@example.com',
password_hash: await bcrypt.hash('AdminCt123', 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: 'admin-ct-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('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('serves a video with its stored mime_type, not image/<ext>', async () => {
const id = await addPhoto('clip.mp4', { media_type: 'video', mime_type: 'video/mp4' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('maps MIME-less videos from their extension (.mov → video/quicktime)', async () => {
const id = await addPhoto('clip-nomime.mov', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/quicktime');
});
it('falls back to video/mp4 for a video with an unknown extension', async () => {
const id = await addPhoto('clip-unknown.xyz', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('rejects malformed video/ MIME values that would break setHeader', async () => {
// Header-invalid chars in the stored value must not 500 the route —
// fall back to the extension map instead.
const id = await addPhoto('crlf.mp4', {
media_type: 'video',
mime_type: 'video/mp4\r\nX-Evil: 1',
});
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
expect(res.headers['x-evil']).toBeUndefined();
const bare = await addPhoto('bare.webm', { media_type: 'video', mime_type: 'video/' });
const res2 = await getPhotoRes(bare);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/webm');
});
it('preserves an auto-imported avif via the safe stored-MIME allowlist', async () => {
// .avif isn't in EXTENSION_TO_MIME; s3AutoImporter stores image/avif.
// Map-only would mislabel it image/jpeg — the allowlist keeps it.
const id = await addPhoto('imported.avif', { mime_type: 'image/avif' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/avif');
});
it('preserves other importer raster types too (apng, x-icon)', async () => {
const apng = await addPhoto('anim.apng', { mime_type: 'image/apng' });
expect((await getPhotoRes(apng)).headers['content-type']).toBe('image/apng');
const ico = await addPhoto('fav.ico', { mime_type: 'image/x-icon' });
expect((await getPhotoRes(ico)).headers['content-type']).toBe('image/x-icon');
});
it('does NOT honor a stored scriptable image type (image/svg+xml)', async () => {
// svg is inline-scriptable and must never be echoed — allowlist excludes it.
const id = await addPhoto('vector.svg', { mime_type: 'image/svg+xml' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('never echoes a stored non-media MIME type (inline XSS guard)', async () => {
const id = await addPhoto('evil.png', { mime_type: 'text/html' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('ignores the migration-039 image/jpeg backfill on legacy PNG rows', async () => {
const id = await addPhoto('legacy.png', { mime_type: 'image/jpeg' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('normalizes jpg to the canonical image/jpeg', async () => {
const id = await addPhoto('shot.jpg');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('keeps the extension fallback for images without a stored mime_type', async () => {
const id = await addPhoto('shot.png');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('handles Object.prototype key extensions without a 500 (.constructor)', async () => {
// The extension-to-MIME lookup must be own-property only — a raw
// index access returns an inherited function for these keys and the
// downstream startsWith throws. Serve image/jpeg instead of 500.
const id = await addPhoto('payload.constructor');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const id2 = await addPhoto('payload.__proto__', { media_type: 'video' });
const res2 = await getPhotoRes(id2);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/mp4');
});
it('does not synthesize types from unmapped image extensions', async () => {
// Raw interpolation would produce image/svg+xml (scriptable inline)
// or arbitrary strings from client-controlled filenames — the shared
// map is the allowlist, everything else is served as image/jpeg.
const svg = await addPhoto('vector.svg+xml');
const res = await getPhotoRes(svg);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const weird = await addPhoto('weird.xyz');
const res2 = await getPhotoRes(weird);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('image/jpeg');
});
it('extensionless files get image/jpeg, never a bare image/', async () => {
const id = await addPhoto('noext');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
});
@@ -1,86 +0,0 @@
/**
* Role-editor self-amplification guard (migration 175 / adminRoles).
*
* `roles.manage` must be a DELEGATION primitive, not root escalation: a
* non-super_admin holder can only grant permissions their OWN role already
* holds, and can't edit their own role. super_admin bypasses. Pins
* userManagementService.createRole / updateRole (assertActorMayGrant).
*/
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-roleguard-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'roleguard-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-roleguard-storage-'));
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
const svc = require('../../src/services/userManagementService');
const { clearPermissionCache } = require('../../src/middleware/permissions');
describe('role editor — self-amplification guard', () => {
let db; let cleanup;
let superId; let mgrRoleId; let mgrId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: superId } = await seedMinimal(db));
await assignAdminRole(db, superId, 'super_admin');
// A non-super role that CAN manage roles but only holds a couple of perms.
const mgrRole = await svc.createRole(
{ name: 'limited_mgr', permissions: ['roles.manage', 'events.view'] },
superId,
);
mgrRoleId = mgrRole.id;
const ins = await db('admin_users').insert({
username: 'mgr', email: 'mgr@example.com', password_hash: 'x',
role_id: mgrRoleId, must_change_password: false, created_at: new Date(),
}).returning('id');
mgrId = ins[0]?.id ?? ins[0];
clearPermissionCache();
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('super_admin can grant any permission', async () => {
const r = await svc.createRole(
{ name: 'power_role', permissions: ['settings.banking', 'users.delete'] },
superId,
);
expect(r.permissions).toEqual(expect.arrayContaining(['settings.banking', 'users.delete']));
});
it('non-super cannot grant a permission its own role lacks', async () => {
await expect(
svc.createRole({ name: 'sneaky', permissions: ['events.view', 'settings.banking'] }, mgrId),
).rejects.toThrow(/only grant permissions your own role/i);
});
it('non-super can create a role within its own permissions', async () => {
const r = await svc.createRole({ name: 'viewer_lite', permissions: ['events.view'] }, mgrId);
expect(r.permissions).toEqual(['events.view']);
});
it('non-super cannot edit its own role', async () => {
await expect(
svc.updateRole(mgrRoleId, { permissions: ['roles.manage', 'events.view'] }, mgrId),
).rejects.toThrow(/cannot edit your own role/i);
});
it('non-super cannot escalate another role beyond its own permissions', async () => {
const adminRole = await db('roles').where({ name: 'admin' }).first();
await expect(
svc.updateRole(adminRole.id, { permissions: ['settings.banking'] }, mgrId),
).rejects.toThrow(/only grant permissions your own role/i);
});
it('the built-in team_photographer name is reserved', async () => {
await expect(
svc.createRole({ name: 'team_photographer', permissions: [] }, superId),
).rejects.toThrow(/reserved/i);
});
});
@@ -1,95 +0,0 @@
/**
* Protected-key boundary on the generic settings writers (migration 175).
*
* A role with settings.edit but NOT settings.domains (the "office manager" this
* PR enables) must be able to save the General tab — which re-posts
* general_site_url on every save — as long as the URL is UNCHANGED, and must be
* 403'd only when it actually tries to change a protected key. Regression pin for
* the change-detection fix (the presence-only check over-fired on every save).
*/
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-setkeys-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'setkeys-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-setkeys-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken,
} = require('../integration/helpers/crmDb');
const svc = require('../../src/services/userManagementService');
const { clearPermissionCache } = require('../../src/middleware/permissions');
const STORED_URL = 'https://stored.example';
describe('settings protected-key boundary (/general)', () => {
let db; let cleanup; let app;
let superTok; let mgrTok;
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
const readSiteUrl = async () => {
const row = await db('app_settings').where({ setting_key: 'general_site_url' }).first();
return row ? JSON.parse(row.setting_value) : null;
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId: superId } = await seedMinimal(db);
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
// Office-manager role: settings.view + settings.edit, NOT settings.domains.
const mgrRole = await svc.createRole(
{ name: 'office_mgr', permissions: ['settings.view', 'settings.edit'] },
superId,
);
const ins = await db('admin_users').insert({
username: 'office', email: 'office@example.com', password_hash: 'x',
role_id: mgrRole.id, must_change_password: false, created_at: new Date(),
}).returning('id');
mgrTok = mintAdminToken(ins[0]?.id ?? ins[0]);
await db('app_settings').insert({
setting_key: 'general_site_url', setting_value: JSON.stringify(STORED_URL), setting_type: 'general',
});
clearPermissionCache();
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('settings.edit role can save /general when general_site_url is unchanged', async () => {
const res = await auth(request(app).put('/api/admin/settings/general'), mgrTok)
.send({ general_site_url: STORED_URL, general_max_file_size_mb: 50 });
expect(res.status).not.toBe(403);
expect(res.status).toBe(200);
expect(await readSiteUrl()).toBe(STORED_URL);
});
it('settings.edit role is 403d when it actually changes general_site_url', async () => {
const res = await auth(request(app).put('/api/admin/settings/general'), mgrTok)
.send({ general_site_url: 'https://evil.example' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('FORBIDDEN');
expect(res.body.keys.map((k) => k.key)).toContain('general_site_url');
expect(await readSiteUrl()).toBe(STORED_URL); // unchanged
});
it('super_admin can change general_site_url', async () => {
const res = await auth(request(app).put('/api/admin/settings/general'), superTok)
.send({ general_site_url: 'https://new.example' });
expect(res.status).toBe(200);
expect(await readSiteUrl()).toBe('https://new.example');
});
});
@@ -32,17 +32,9 @@ jest.mock('../../src/database/db', () => {
if (table === 'admin_users') {
let rowFilter = () => true;
return {
// The session route joins roles for the adminUser payload (#798);
// fake rows carry no role fields, so the join is a pass-through.
leftJoin() {
return this;
},
where(criteria) {
rowFilter = (row) => {
return Object.entries(criteria).every(([rawKey, v]) => {
// Joined queries prefix columns ('admin_users.id') — the fake
// rows use bare names.
const k = rawKey.replace(/^admin_users\./, '');
return Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
return row[k] === v;
});
@@ -58,12 +50,7 @@ jest.mock('../../src/database/db', () => {
if (!row) return undefined;
if (!this._cols) return row;
const out = {};
for (const c of this._cols) {
// Support 'table.col' and 'table.col as alias' shapes.
const [source, alias] = c.split(/\s+as\s+/i);
const bare = source.includes('.') ? source.split('.').pop() : source;
out[alias || bare] = row[bare];
}
for (const c of this._cols) out[c] = row[c];
return out;
},
};
@@ -1,127 +0,0 @@
/**
* Regression test for GHSA-9hmx-68vc-qpqw — share-link login must not bypass
* the gallery password.
*
* POST /auth/gallery/share-login validates only the share token. For a
* password-protected gallery it previously minted a full `type:'gallery'`
* access token on the share token alone, letting anyone holding the share URL
* read the gallery without the password. The fix: when the gallery requires a
* password, return `{ requires_password: true }` with NO token and NO cookie.
*/
const express = require('express');
const request = require('supertest');
process.env.JWT_SECRET = 'share-login-test-secret';
const events = [];
jest.mock('../../src/database/db', () => {
function dbFn(table) {
if (table === 'events') {
let filter = () => true;
return {
where(criteria) {
filter = (row) => Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
return row[k] === v;
});
return this;
},
async first() { return events.find(filter); },
};
}
return { where() { return this; }, async first() { return undefined; } };
}
dbFn.raw = async () => {};
return { db: dbFn, logActivity: async () => {} };
});
// Share token is stored plainly on the fake event row.
jest.mock('../../src/services/shareLinkService', () => ({
getEventShareToken: (event) => event.share_token,
resolveShareIdentifier: async () => ({ event: null }),
}));
const mockSetGalleryAuthCookies = jest.fn();
jest.mock('../../src/utils/tokenUtils', () => ({
setGalleryAuthCookies: (...args) => mockSetGalleryAuthCookies(...args),
clearGalleryAuthCookies: jest.fn(),
getGalleryTokenFromRequest: jest.fn(),
setAdminAuthCookies: jest.fn(),
}));
jest.mock('../../src/utils/authSecurity', () => ({
trackFailedAttempt: jest.fn(async () => {}),
trackSuccessfulLogin: jest.fn(async () => {}),
checkAccountLockout: jest.fn(async () => ({ isLocked: false })),
resetLockout: jest.fn(async () => {}),
}));
// Collaborators the router imports at load but the share-login path doesn't hit.
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: async () => true }));
jest.mock('../../src/services/mfaService', () => ({}));
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn(), sessionTimeoutMiddleware: (req, res, next) => next() }));
jest.mock('../../src/utils/tokenRevocation', () => ({ revokeToken: jest.fn(async () => {}), isTokenRevoked: async () => false }));
const authRouter = require('../../src/routes/auth');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
return app;
}
const SHARE_TOKEN = 'a'.repeat(64);
beforeEach(() => {
events.length = 0;
mockSetGalleryAuthCookies.mockClear();
});
describe('POST /auth/gallery/share-login password enforcement', () => {
it('does NOT mint a token for a password-protected gallery', async () => {
events.push({
id: 1, slug: 'private-gallery', is_active: 1, is_archived: 0,
require_password: 1, share_token: SHARE_TOKEN, event_name: 'Private',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'private-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(res.body.requires_password).toBe(true);
expect(res.body.token).toBeUndefined();
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
it('mints a token for a public (no-password) gallery', async () => {
events.push({
id: 2, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.event).toBeDefined();
expect(mockSetGalleryAuthCookies).toHaveBeenCalledTimes(1);
});
it('rejects a wrong share token regardless of password setting', async () => {
events.push({
id: 3, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: 'b'.repeat(64) });
expect(res.status).toBe(401);
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
});
@@ -1,213 +0,0 @@
/**
* Authorization / ownership gaps (GHSA permission cluster):
* - jm7j: API-token list must scope to the caller (non-super sees only own)
* - gprq: API-token revoke must be owner-or-super_admin
* - 3rqx: event update must not mass-assign identity/secret columns
* - j2f4: category hero must belong to that category
*/
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-authz-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'authz-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-authz-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken,
} = require('../integration/helpers/crmDb');
describe('authorization / ownership gaps', () => {
let db; let cleanup; let app;
let superId; let superTok; let adminId; let adminTok;
const grantPermissionToRole = async (roleName, permName) => {
const role = await db('roles').where({ name: roleName }).first();
const perm = await db('permissions').where({ name: permName }).first();
const exists = await db('role_permissions')
.where({ role_id: role.id, permission_id: perm.id }).first();
if (!exists) {
await db('role_permissions').insert({ role_id: role.id, permission_id: perm.id });
}
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: superId } = await seedMinimal(db));
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
const pass = await bcrypt.hash('x', 4);
const ins = await db('admin_users').insert({
username: 'plain-admin', email: 'plain@example.com',
password_hash: pass, must_change_password: false, created_at: new Date(),
}).returning('id');
adminId = ins[0]?.id ?? ins[0];
await assignAdminRole(db, adminId, 'admin');
// Grant settings.integrations to the admin role BEFORE any request populates
// the 60s permission cache, so the revoke test exercises the ownership check
// (404) rather than the missing-permission gate (403). Migration 174 split
// API-token management out of the catch-all settings.edit into the dedicated
// settings.integrations perm; this models a custom role that carries it —
// the scenario GHSA-gprq needs.
await grantPermissionToRole('admin', 'settings.integrations');
adminTok = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/api-tokens', require('../../src/routes/adminApiTokens'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/categories', require('../../src/routes/adminCategories'));
// 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 () => { if (cleanup) await cleanup(); });
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
describe('API tokens (jm7j / gprq)', () => {
let superTokenId;
beforeAll(async () => {
const res = await auth(request(app).post('/api/admin/api-tokens'), superTok)
.send({ name: 'super-token', scopes: ['read'] });
expect(res.status).toBe(201);
superTokenId = res.body.id;
});
it('non-super admin does not see another admin\'s tokens in the list', async () => {
const res = await auth(request(app).get('/api/admin/api-tokens'), adminTok);
expect(res.status).toBe(200);
expect(res.body.find((t) => t.id === superTokenId)).toBeUndefined();
});
it('super_admin sees all tokens', async () => {
const res = await auth(request(app).get('/api/admin/api-tokens'), superTok);
expect(res.status).toBe(200);
expect(res.body.find((t) => t.id === superTokenId)).toBeDefined();
});
it('a non-owner (with settings.integrations) cannot revoke another admin\'s token', async () => {
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), adminTok);
expect(res.status).toBe(404);
const row = await db('api_tokens').where({ id: superTokenId }).first();
expect(row.revoked_at).toBeFalsy();
});
it('the owner can revoke their own token', async () => {
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), superTok);
expect(res.status).toBe(200);
});
});
describe('event update mass-assignment (3rqx)', () => {
it('ignores identity/secret columns in the request body', async () => {
const seedShareToken = 'orig-share-token';
const ins = await db('events').insert({
slug: 'authz-mass-assign', event_type: 'wedding', event_name: 'Before',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'orig-hash', share_link: '/gallery/authz/share', share_token: seedShareToken, expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ins[0]?.id ?? ins[0];
const res = await auth(request(app).put(`/api/admin/events/${eventId}`), superTok).send({
event_name: 'After',
created_by: 99999,
slug: 'hijacked-slug',
share_token: 'hijacked-token',
password_hash: 'hijacked-hash',
is_archived: 1,
archive_path: '/hijacked/archive/path',
hero_logo_path: '/etc/passwd',
is_draft: 1,
project_id: 99999,
// Case-variant keys — SQLite matches columns case-insensitively.
Password_Hash: 'case-hijack-hash',
Created_By: 88888,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id: eventId }).first();
expect(row.event_name).toBe('After'); // legit field applied
expect(row.created_by).toBe(superId); // ownership untouched (+ case-variant)
expect(row.slug).toBe('authz-mass-assign'); // routing identity untouched
expect(row.share_token).toBe(seedShareToken); // secret untouched
expect(row.password_hash).toBe('orig-hash'); // secret untouched (+ case-variant)
expect(row.is_archived).toBeFalsy(); // archive lifecycle untouched
expect(row.archive_path).toBeFalsy(); // forged archive path rejected
expect(row.hero_logo_path).toBeFalsy(); // fs.unlink primitive blocked
expect(row.is_draft).toBeFalsy(); // publish workflow not bypassed
expect(row.project_id).toBeFalsy(); // server-managed relationship untouched
});
it('returns 200 (no-op) when the body contains only protected fields', async () => {
const ins = await db('events').insert({
slug: 'authz-empty-update', event_type: 'wedding', event_name: 'Keep',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/authz-empty/share', share_token: 'authz-empty-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const id = ins[0]?.id ?? ins[0];
// Body reduces to {} after the denylist — must not 500 (Knex rejects
// .update({})).
const res = await auth(request(app).put(`/api/admin/events/${id}`), superTok)
.send({ created_by: 1, slug: 'x', is_archived: 1 });
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.event_name).toBe('Keep');
});
});
describe('category hero cross-category (j2f4)', () => {
it('rejects a hero photo that is not in the category', async () => {
const evIns = await db('events').insert({
slug: 'authz-cat', event_type: 'wedding', event_name: 'Cat Event',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/authz-cat/share', share_token: 'authz-cat-share', expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const evId = evIns[0]?.id ?? evIns[0];
const mkCat = async (name) => {
const c = await db('photo_categories').insert({
event_id: evId, name, slug: name.toLowerCase(), created_at: new Date().toISOString(),
}).returning('id');
return c[0]?.id ?? c[0];
};
const cat1 = await mkCat('Cat1');
const cat2 = await mkCat('Cat2');
const pIns = await db('photos').insert({
event_id: evId, filename: 'p.jpg', path: 'authz-cat/p.jpg', type: 'individual',
category_id: cat1, uploaded_at: new Date().toISOString(),
}).returning('id');
const photoInCat1 = pIns[0]?.id ?? pIns[0];
// Pointing cat2's hero at a photo that lives in cat1 must be refused.
const bad = await auth(request(app).put(`/api/admin/categories/${cat2}/hero`), superTok)
.send({ hero_photo_id: photoInCat1 });
expect(bad.status).toBe(404);
// The photo's own category accepts it.
const ok = await auth(request(app).put(`/api/admin/categories/${cat1}/hero`), superTok)
.send({ hero_photo_id: photoInCat1 });
expect(ok.status).toBe(200);
});
});
});
@@ -1,95 +0,0 @@
/**
* Full-instance export is super_admin only (GHSA-pv6w-rj34-wj9v).
*
* GET /api/admin/backup/picpeak/export dumps every table unredacted (bcrypt
* hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3 secrets). It was gated only by
* requirePermission('backup.create'), which the built-in `admin` role holds —
* so any non-super_admin admin could download the whole database. Pins that
* `admin` now gets 403 and `super_admin` passes the gate.
*/
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-bkexport-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkexport-test-secret';
// The export otherwise walks the whole DB and writes a zip — stub it so the
// super_admin happy path is fast and deterministic; the gate is what's tested.
// The route deletes path.dirname(filePath) recursively after download, so the
// stub MUST live in its own dir — a bare os.tmpdir() file would make the route
// wipe the whole temp root (and other jest workers' DB files).
const mockExportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-export-stub-'));
const mockExportPath = path.join(mockExportDir, 'export.picpeak');
fs.writeFileSync(mockExportPath, 'stub');
jest.mock('../../src/services/picpeakExportService', () => ({
createPicpeak: jest.fn(async () => ({ filePath: mockExportPath })),
}));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('backup export super_admin gate (GHSA-pv6w)', () => {
let db;
let cleanup;
let app;
let adminToken; let superToken;
const mkUser = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
return jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
adminToken = await mkUser('limited-admin', 'admin');
superToken = await mkUser('root-admin', 'super_admin');
app = express();
app.use(express.json());
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
fs.rmSync(mockExportDir, { recursive: true, force: true });
});
it('denies the built-in admin role (was: full DB dump)', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(403);
});
it('allows super_admin', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
});
@@ -1,196 +0,0 @@
/**
* Dashboard endpoints must not leak other admins' data to event-scoped
* editors — GHSA-c2jj (/stats), GHSA-gqx7 (/analytics), GHSA-jhcf (/activity).
*
* All three are gated only by `analytics.view`, which the `editor` role holds.
* But the events LIST restricts editors to their own rows
* (adminEvents/crud.js: roleName === 'editor' → created_by = admin.id), so an
* editor saw instance-wide totals — and, via /analytics topGalleries, other
* admins' gallery names and SLUGS (the public gallery URL component) — for
* events invisible to them everywhere else.
*
* Scoping deliberately keys on `editor` to mirror the events list exactly, so
* the `admin` role's dashboard is unchanged.
*/
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-dashscope-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dashscope-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('dashboard scoping (GHSA-c2jj / gqx7 / jhcf)', () => {
let db; let cleanup; let app;
let editorToken; let superToken;
let ownEventId; let foreignEventId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
const token = jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
return { id, token };
};
const mkEvent = async (slug, createdBy) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `${slug}-name`,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: `tok-${slug}`,
share_link: `/gallery/${slug}/tok-${slug}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const editor = await mkAdmin('scoped-editor', 'editor');
const sup = await mkAdmin('root-admin', 'super_admin');
editorToken = editor.token;
superToken = sup.token;
ownEventId = await mkEvent('own-gallery', editor.id);
foreignEventId = await mkEvent('foreign-gallery', sup.id);
// One photo + one view per event so the aggregates are non-zero.
for (const [eventId, name] of [[ownEventId, 'own'], [foreignEventId, 'foreign']]) {
await db('photos').insert({
event_id: eventId,
filename: `${name}.jpg`,
path: `events/active/${name}.jpg`,
type: 'individual',
size_bytes: 1000,
uploaded_at: new Date().toISOString(),
});
await db('access_logs').insert({
event_id: eventId,
action: 'view',
ip_address: `10.0.0.${eventId}`,
user_agent: 'Mozilla/5.0',
timestamp: new Date().toISOString(),
});
await db('activity_logs').insert({
activity_type: 'photo_viewed',
actor_type: 'admin',
actor_name: `${name}-actor`,
event_id: eventId,
created_at: new Date().toISOString(),
});
}
app = express();
app.use(express.json());
app.use('/api/admin/dashboard', require('../../src/routes/adminDashboard'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('/stats counts only the editor\'s own events and photos', async () => {
const res = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
expect(Number(res.body.totalEvents)).toBe(1);
expect(Number(res.body.totalPhotos)).toBe(1);
expect(Number(res.body.storageUsed)).toBe(1000);
});
it('/analytics does not expose a foreign gallery name or slug', async () => {
const res = await request(app)
.get('/api/admin/dashboard/analytics?days=7')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const body = JSON.stringify(res.body);
expect(body).not.toContain('foreign-gallery');
expect(body).not.toContain('foreign-gallery-name');
expect(res.body.topGalleries.map((g) => g.slug)).toEqual(['own-gallery']);
});
it('/activity does not surface a foreign event\'s entries', async () => {
const res = await request(app)
.get('/api/admin/dashboard/activity')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const actors = res.body.map((a) => a.actorName);
expect(actors).toContain('own-actor');
expect(actors).not.toContain('foreign-actor');
});
it('leaves super_admin unscoped across all three', async () => {
const stats = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${superToken}`);
expect(Number(stats.body.totalEvents)).toBe(2);
const analytics = await request(app)
.get('/api/admin/dashboard/analytics?days=7')
.set('Authorization', `Bearer ${superToken}`);
expect(analytics.body.topGalleries.map((g) => g.slug).sort())
.toEqual(['foreign-gallery', 'own-gallery']);
const activity = await request(app)
.get('/api/admin/dashboard/activity')
.set('Authorization', `Bearer ${superToken}`);
expect(activity.body.map((a) => a.actorName)).toContain('foreign-actor');
});
});
/**
* Codex round 2: the /activity filter trusts `activity_logs.event_id`, but
* expenseService was passing `adminId` into logActivity's third positional
* parameter — which is `eventId`. Admin and event id sequences overlap, so a
* foreign admin's expense metadata could surface under an editor's event.
* Those writers now pass the actor instead, leaving event_id NULL.
*/
describe('activity writers do not put admin ids in event_id (GHSA-jhcf)', () => {
it('expenseService passes the actor, not adminId, as the event id', () => {
const fs2 = require('fs');
const src = fs2.readFileSync(
require('path').join(__dirname, '../../src/services/expenseService.js'), 'utf8',
);
// No logActivity call may end with a bare `, adminId)` — that slot is eventId.
const offenders = src.split('\n').filter(
(l) => l.includes('logActivity(') && /,\s*adminId\s*\)/.test(l),
);
expect(offenders).toEqual([]);
// And the actor form must actually be in use.
expect(src).toContain("{ type: 'admin', id: adminId }");
});
});
@@ -1,120 +0,0 @@
/**
* Manual database backup must not honour a caller-supplied destination
* (GHSA-jw8m-43r2-jqrm).
*
* POST /api/admin/database-backup/backup forwarded req.body straight into
* databaseBackupService.backup(), which merges options over its config:
* const { destinationPath = '/backup/database', ... } = { ...config, ...options }
* `destinationPath` is not a persistable setting (the /config allowlist only
* accepts `database_backup_*` keys), so the request body was its ONLY source.
*
* The `admin` role holds backup.create but neither settings.edit nor
* backup.restore — so it could aim a full DB dump (bcrypt hashes, gallery
* password hashes, encrypted SMTP creds) at the PUBLIC /uploads static mount
* (server.js mounts it with no auth middleware) and fetch it unauthenticated.
*
* Pins that destinationPath from the body is ignored, while the legitimate
* knobs still pass through.
*/
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-dbbackup-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-test-secret';
// Capture what the route hands the service; never run a real backup.
const mockBackup = jest.fn(async () => ({ success: true }));
jest.mock('../../src/services/databaseBackup', () => ({
databaseBackupService: {
get isRunning() { return false; },
backup: (...args) => mockBackup(...args),
},
}));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('manual database backup destination (GHSA-jw8m)', () => {
let db; let cleanup; let app; let adminToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'admin' }).first();
const r = await db('admin_users').insert({
username: 'limited-admin',
email: 'limited-admin@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
adminToken = jwt.sign(
{ id, username: 'limited-admin', type: 'admin', role: 'admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
app = express();
app.use(express.json());
app.use('/api/admin/database-backup', require('../../src/routes/adminDatabaseBackup'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(() => mockBackup.mockClear());
it('ignores a caller-supplied destinationPath', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({ destinationPath: '/app/storage/uploads' });
expect(res.status).toBe(200);
// Give the fire-and-forget call a tick to land.
await new Promise((resolve) => setImmediate(resolve));
expect(mockBackup).toHaveBeenCalled();
const opts = mockBackup.mock.calls[0][0];
expect(opts).not.toHaveProperty('destinationPath');
expect(JSON.stringify(opts)).not.toContain('uploads');
});
it('still forwards the legitimate backup knobs', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({ compress: false, validateIntegrity: false, destinationPath: '/tmp/evil' });
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
const opts = mockBackup.mock.calls[0][0];
expect(opts.compress).toBe(false);
expect(opts.validateIntegrity).toBe(false);
expect(opts).not.toHaveProperty('destinationPath');
});
it('omits absent knobs entirely so service/config defaults still apply', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({});
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
// An explicit `{compress: undefined}` would override config on spread —
// absent keys must simply not be present.
expect(mockBackup.mock.calls[0][0]).toEqual({});
});
});
@@ -1,101 +0,0 @@
/**
* GHSA-2qc2 / GHSA-32h4 / GHSA-3335 — feedback moderation, deletion, and the
* pending-moderation list are by-feedback-id (or global) and lacked ownership
* scoping, so a restricted editor could act on / enumerate feedback for events
* it does not own. super_admin keeps global access.
*/
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-fbown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'fbown-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fbown-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
describe('feedback ownership scoping', () => {
let db; let cleanup; let app;
let superTok; let editorTok; let editorId;
let foreignFeedbackId;
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId: superId } = await seedMinimal(db);
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
const ins = await db('admin_users').insert({
username: 'editor', email: 'editor@example.com',
password_hash: await bcrypt.hash('x', 4), must_change_password: false, created_at: new Date(),
}).returning('id');
editorId = ins[0]?.id ?? ins[0];
await assignAdminRole(db, editorId, 'editor');
editorTok = mintAdminToken(editorId);
// Event owned by super_admin (NOT the editor).
const ev = await db('events').insert({
slug: 'fbown-foreign', event_type: 'wedding', event_name: 'Foreign',
event_date: '2026-08-01', host_email: 'h@e.com', admin_email: 'a@e.com',
password_hash: 'x', share_link: '/g/fbown/s', share_token: 'fbown-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ev[0]?.id ?? ev[0];
const ph = await db('photos').insert({
event_id: eventId, filename: 'p.jpg', path: 'fbown-foreign/p.jpg', type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
const photoId = ph[0]?.id ?? ph[0];
const fb = await db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, feedback_type: 'comment',
comment_text: 'hi', is_approved: 0, is_hidden: 0, created_at: new Date().toISOString(),
}).returning('id');
foreignFeedbackId = fb[0]?.id ?? fb[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/feedback', require('../../src/routes/adminFeedback'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('editor cannot moderate feedback on an event it does not own (404)', async () => {
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), editorTok);
expect(res.status).toBe(404);
const row = await db('photo_feedback').where({ id: foreignFeedbackId }).first();
expect([false, 0]).toContain(row.is_approved); // untouched
});
it('editor cannot delete foreign feedback, row survives', async () => {
const res = await auth(request(app).delete(`/api/admin/feedback/feedback/${foreignFeedbackId}`), editorTok);
// Denied either at the events.delete permission layer (editor lacks it →
// 403) or the ownership layer (404) — both must leave the row intact.
expect([403, 404]).toContain(res.status);
expect(await db('photo_feedback').where({ id: foreignFeedbackId }).first()).toBeDefined();
});
it('editor sees no foreign feedback in pending-moderation', async () => {
const res = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), editorTok);
expect(res.status).toBe(200);
expect(res.body.find((f) => f.id === foreignFeedbackId)).toBeUndefined();
});
it('super_admin CAN moderate and see it', async () => {
const pending = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), superTok);
expect(pending.body.find((f) => f.id === foreignFeedbackId)).toBeDefined();
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), superTok);
expect(res.status).toBe(200);
});
});
@@ -1,87 +0,0 @@
/**
* GHSA-rh8r-7x3h-36rv — the unauthenticated GET /api/gallery/resolve/:identifier
* must NOT return a gallery's secret share_token (nor the share links that
* embed it) for a bare *slug* lookup. Slugs appear in gallery URLs and are
* guessable; handing back the secret turns a known slug into share-link
* access to a no-password gallery. The token is only returned when the caller
* resolved via the token / full share link (i.e. already holds it).
*/
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-resolve-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'resolve-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-resolve-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'resolve-test-event';
const SHARE_TOKEN = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
describe('GET /api/gallery/resolve/:identifier (GHSA-rh8r)', () => {
let db; let cleanup; let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Resolve Test',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/${SHARE_TOKEN}`,
share_token: SHARE_TOKEN,
require_password: 0, // no-password → the token IS the access credential
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
});
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('does NOT leak the share_token (or share links) for a bare slug lookup', async () => {
const res = await request(app).get(`/api/gallery/resolve/${SLUG}`);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(SLUG);
expect(res.body.matchType).toBe('slug');
// The secret must be absent — and must not sneak out via the share links.
expect(res.body.token).toBeUndefined();
expect(res.body.share_link).toBeUndefined();
expect(res.body.share_url).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
});
it('DOES return the token when the caller already resolved via the token', async () => {
const res = await request(app).get(`/api/gallery/resolve/${SHARE_TOKEN}`);
expect(res.status).toBe(200);
expect(res.body.token).toBe(SHARE_TOKEN);
expect(res.body.matchType).toMatch(/token/);
});
it('does NOT leak the token via SQL LIKE wildcards in the link_partial fallback', async () => {
// Before the escaping fix, an anonymous request of 32 underscores matched
// any share_link ending in a 32-char token (`_` = single-char wildcard),
// resolved as matchType 'link_partial', and handed back the bearer token.
// The share_token here has no underscores, so an escaped LIKE must miss.
const res = await request(app).get(`/api/gallery/resolve/${'_'.repeat(SHARE_TOKEN.length)}`);
expect(res.status).toBe(404);
expect(res.body.token).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
});
});
@@ -1,190 +0,0 @@
/**
* SQLite boolean coercion in the guest gallery surface (#1028).
*
* SQLite stores booleans as 0/1; Postgres stores true/false. The /photos
* payload and every download guard compared strictly against `true`/`false`,
* so on SQLite:
*
* allow_downloads: 0 !== false → true (button shown while disabled)
* allow_user_uploads: 1 === true → false (button hidden while enabled)
* if (allow_downloads === false) → never fires, so ALL download endpoints
* kept serving with downloads switched off
*
* The harness runs on SQLite, so these assertions exercise the real engine
* values rather than a mock. Every test here fails on the unfixed code.
*/
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-sqlite-flags-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sqlite-flags-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-flags-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'sqlite-flags-gallery';
describe('gallery flags survive SQLite 0/1 storage (#1028)', () => {
let db; let cleanup; let app; let eventId; let photoId;
async function setEventFlags(patch) {
await db('events').where('id', eventId).update(patch);
}
async function getPayload() {
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
return res.body.event;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'SQLite Flags',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/s`,
share_token: 'sqlite-flags-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
// Password-free so verifyGalleryAccess takes the public path and loads
// the row with SELECT * — i.e. the raw 0/1 values, same as production.
require_password: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = ev[0]?.id ?? ev[0];
const ph = await db('photos').insert({
event_id: eventId,
filename: 'p.jpg',
path: `${SLUG}/p.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = ph[0]?.id ?? ph[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(); });
test('the engine under test really is SQLite storing 0/1', async () => {
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
await setEventFlags({ allow_downloads: 0 });
const row = await db('events').where('id', eventId).first('allow_downloads');
expect(row.allow_downloads).toBe(0);
});
describe('with downloads disabled (allow_downloads = 0)', () => {
beforeAll(async () => {
await setEventFlags({ allow_downloads: 0, allow_user_uploads: 1 });
});
test('payload reports allow_downloads false (was true — header button shown)', async () => {
expect((await getPayload()).allow_downloads).toBe(false);
});
test('payload reports allow_user_uploads true (was false — upload button hidden)', async () => {
expect((await getPayload()).allow_user_uploads).toBe(true);
});
test('single-photo download is refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(403);
});
test('download-all is refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
expect(res.status).toBe(403);
});
test('download-selected is refused', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.send({ photo_ids: [photoId] });
expect(res.status).toBe(403);
});
test('download-jobs is refused', async () => {
const res = await request(app).post(`/api/gallery/${SLUG}/download-jobs`).send({});
expect(res.status).toBe(403);
});
});
describe('with downloads enabled (allow_downloads = 1)', () => {
beforeAll(async () => {
await setEventFlags({ allow_downloads: 1, allow_user_uploads: 0 });
});
test('payload reports allow_downloads true / allow_user_uploads false', async () => {
const event = await getPayload();
expect(event.allow_downloads).toBe(true);
expect(event.allow_user_uploads).toBe(false);
});
test('download-all is no longer refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
expect(res.status).not.toBe(403);
});
});
describe('protection flags', () => {
test('0/1 protection toggles are reported the way they are stored', async () => {
await setEventFlags({
disable_right_click: 1,
enable_devtools_protection: 1,
use_canvas_rendering: 1,
watermark_downloads: 1,
overlay_protection: 0,
});
const event = await getPayload();
expect(event.disable_right_click).toBe(true);
expect(event.enable_devtools_protection).toBe(true);
expect(event.use_canvas_rendering).toBe(true);
expect(event.watermark_downloads).toBe(true);
expect(event.overlay_protection).toBe(false);
});
});
describe('per-category download blocking (#640) on SQLite', () => {
test('a category with allow_downloads = 0 is reported as blocked', async () => {
const cat = await db('photo_categories').insert({
name: 'Blocked', slug: 'blocked', event_id: eventId, is_global: 0, allow_downloads: 0,
}).returning('id');
const categoryId = cat[0]?.id ?? cat[0];
await db('photos').where('id', photoId).update({ category_id: categoryId });
await setEventFlags({ allow_downloads: 1 });
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
const category = res.body.categories.find((c) => c.id === categoryId);
expect(category.allow_downloads).toBe(false);
const photo = res.body.photos.find((p) => p.id === photoId);
expect(photo.category_allow_downloads).toBe(false);
// …and the per-category guard on the single-photo route fires.
const dl = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(dl.status).toBe(403);
});
});
});
@@ -1,240 +0,0 @@
/**
* Hidden/client-only photo access control across the bulk + secure photo
* routes (GHSA cluster: fpwq / ghf8 / 3jvw / 9cc4 / 2hqg / jc22).
*
* A photo with visibility='hidden' is client-only. The main photo-list and
* single-photo download/view routes enforced this, but the bulk-download,
* protected-image, and secure-image routes shipped without the check —
* letting an ordinary guest reach hidden photos. These tests pin that
* guests are refused and PIN-clients (accessLevel='client') still succeed.
*/
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-hidden-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-photo-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-hidden-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'hidden-photo-test-event';
describe('hidden-photo access control (GHSA cluster)', () => {
let db;
let cleanup;
let app;
let eventId;
let visibleId;
let hiddenId;
const guestToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const clientToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', accessLevel: 'client' },
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: 'Hidden Photo 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: 'hidden-photo-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(photoDir, { recursive: true });
// A real 1x1 PNG so the protected /view route's Sharp processing path
// succeeds (fake bytes 500 on metadata()). Content, not extension,
// drives Sharp's format detection.
const PNG_1x1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMCAQGV2rY9AAAAAElFTkSuQmCC',
'base64'
);
const mkPhoto = async (filename, visibility) => {
fs.writeFileSync(path.join(photoDir, filename), PNG_1x1);
const p = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
visibility,
uploaded_at: new Date().toISOString(),
}).returning('id');
return p[0]?.id ?? p[0];
};
visibleId = await mkPhoto('visible.jpg', 'visible');
hiddenId = await mkPhoto('hidden.jpg', 'hidden');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/images', require('../../src/routes/protectedImages'));
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('download-selected (GHSA-ghf8, medium)', () => {
it('omits a hidden photo for a guest even when its id is requested', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photo_ids: [visibleId, hiddenId] });
// The visible photo still zips; the hidden one is filtered out. If
// only the hidden id were requested, the filter empties the set → 404.
expect(res.status).toBe(200);
const solo = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photo_ids: [hiddenId] });
expect(solo.status).toBe(404);
});
it('includes the hidden photo for a client', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${clientToken()}`)
.send({ photo_ids: [hiddenId] });
expect(res.status).toBe(200);
});
});
describe('download-all (GHSA-fpwq, medium)', () => {
it('streams for a guest without erroring (hidden photos filtered)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download-all`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(200);
});
});
describe('protected-image view (GHSA-9cc4)', () => {
it('403s a hidden photo for a guest', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('serves a visible photo for a guest', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${visibleId}/view`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(200);
});
it('serves a hidden photo for a client', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
});
});
describe('signed-URL mint (GHSA-3jvw)', () => {
it('403s minting a signed URL for a hidden photo as a guest', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
expect(res.body.url).toContain('/signed/');
});
});
describe('legacy secure-token mint (protectedImages generate-secure-token)', () => {
it('403s a hidden photo for a guest', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
});
describe('secure-token mint (GHSA-2hqg)', () => {
it('403s minting a secure token for a hidden photo as a guest', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photoId: hiddenId });
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${clientToken()}`)
.send({ photoId: hiddenId });
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
});
// A capability minted while a photo is visible must stop serving once the
// photo is hidden — unless minted by a client (clientBypass in the token).
describe('signed-URL TOCTOU (hidden AFTER minting)', () => {
afterEach(async () => {
await db('photos').where({ id: visibleId }).update({ visibility: 'visible' });
});
it("a guest's pre-minted signed URL stops serving once the photo is hidden", async () => {
const mint = await request(app)
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(mint.status).toBe(200);
const url = mint.body.url;
// Still visible → serves.
expect((await request(app).get(url)).status).toBe(200);
// Hide it → the guest token (no clientBypass) must now be refused.
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
expect((await request(app).get(url)).status).toBe(403);
});
it("a client's pre-minted signed URL keeps serving after the photo is hidden", async () => {
const mint = await request(app)
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(mint.status).toBe(200);
const url = mint.body.url;
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
expect((await request(app).get(url)).status).toBe(200);
});
});
});
@@ -1,119 +0,0 @@
/**
* Regression test for GHSA-4j34-x562-5vfq — broken access control in the legacy
* /api/events router.
*
* The legacy router exposed create/list/update/delete/extend guarded by
* adminAuth ALONE (no requirePermission, no requireEventOwnership), so any
* back-office account — down to a read-only viewer — could read every gallery's
* password_hash/share_token and take over any gallery. The fix removes that
* router entirely and migrates its one UI-used route (POST /:id/extend) to the
* canonical /api/admin/events mount, where it inherits the permission +
* ownership guards.
*
* This test pins two invariants:
* 1. The legacy source file is gone (nothing can re-mount it).
* 2. The migrated extend route enforces ownership — a non-owning editor gets
* 403, the owner succeeds.
*/
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-legacy-acl-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'legacy-acl-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, ownerId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Owner Gallery',
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: ownerId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('GHSA-4j34: legacy /api/events router removed + extend guarded', () => {
it('the legacy events router source file no longer exists', () => {
expect(fs.existsSync(path.join(__dirname, '../../src/routes/events.js'))).toBe(false);
});
describe('POST /api/admin/events/:id/extend ownership enforcement', () => {
let db; let cleanup; let app;
let ownerId; let ownerToken;
let editorId; let editorToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: ownerId } = await seedMinimal(db));
await assignAdminRole(db, ownerId, 'super_admin');
ownerToken = mintAdminToken(ownerId);
// A second, non-owning account with the low-trust editor role.
[editorId] = await db('admin_users').insert({
username: 'editor1', email: 'editor1@example.com',
password_hash: 'x', is_active: 1,
}).returning('id');
editorId = editorId?.id ?? editorId;
await assignAdminRole(db, editorId, 'editor');
editorToken = mintAdminToken(editorId);
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(); });
it('lets the owner extend their own gallery', async () => {
const id = await insertEvent(db, ownerId, { expires_at: '2026-06-01T00:00:00.000Z' });
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 10 });
expect(res.status).toBe(200);
expect(new Date(res.body.expires_at).toISOString()).toBe('2026-06-11T00:00:00.000Z');
});
it('403s a non-owning editor trying to extend someone else\'s gallery', async () => {
const id = await insertEvent(db, ownerId); // owned by the super_admin
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ days: 30 });
expect(res.status).toBe(403); // requireEventOwnership blocks it
});
it('validates the days field', async () => {
const id = await insertEvent(db, ownerId);
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 9999 });
expect(res.status).toBe(400);
});
});
});
@@ -1,123 +0,0 @@
/**
* Logo diagnostic must not leak the filesystem layout, and must mirror what
* resolveLogoFile actually tries (GHSA-29vm, codex round 2).
*
* Round 1 relativised `resolvedTo` and the candidate paths but still echoed
* `sources[].value` verbatim — and branding_logo_path is stored ABSOLUTE by
* multer, so the layout went out anyway. It also dropped the raw-absolute
* candidate, which the resolver retains (subject to containment), making the
* diagnostic report every candidate as missing for a legitimately contained
* absolute logo while `resolvedTo` named the file.
*/
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-logodiag-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'logodiag-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('logo diagnostic disclosure (GHSA-29vm)', () => {
let db; let cleanup; let app; let token;
// bootCrmDb() sets STORAGE_PATH itself, so resolve these AFTER it runs.
let STORAGE; let logoDir; let logoPath;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// A legitimately contained absolute logo in a NON-standard storage subdir.
STORAGE = process.env.STORAGE_PATH;
logoDir = path.join(STORAGE, 'custom');
logoPath = path.join(logoDir, 'logo.png');
fs.mkdirSync(logoDir, { recursive: true });
fs.writeFileSync(logoPath, 'png');
const setting = { setting_key: 'branding_logo_path', setting_value: JSON.stringify(logoPath), setting_type: 'branding' };
const existing = await db('app_settings').where({ setting_key: 'branding_logo_path' }).first();
if (existing) await db('app_settings').where({ setting_key: 'branding_logo_path' }).update(setting);
else await db('app_settings').insert(setting);
const role = await db('roles').where({ name: 'super_admin' }).first();
const r = await db('admin_users').insert({
username: 'diag-admin', email: 'diag@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
token = jwt.sign(
{ id, username: 'diag-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('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('does not leak absolute paths, cwd or storage root anywhere in the payload', async () => {
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
const body = JSON.stringify(res.body);
expect(body).not.toContain(STORAGE);
expect(body).not.toContain(process.cwd());
expect(res.body.storageRoot).toBeUndefined();
expect(res.body.cwd).toBeUndefined();
});
it('still finds a contained absolute logo outside the standard subdirs', async () => {
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
expect(source).toBeTruthy();
// The resolver keeps the contained absolute candidate, so the diagnostic
// must show it existing rather than reporting everything missing.
expect(source.candidates.some((c) => c.exists)).toBe(true);
expect(res.body.resolvedTo).toMatch(/^<STORAGE>\//);
});
it('shows the <STORAGE>/<value> candidate for a ROOT-RELATIVE logo URL (round 3)', async () => {
// `/custom/logo.png` is a URL, not a disk path, but path.isAbsolute() says
// true for both. Gating the stripped joins on isAbsolute() therefore hid
// `<STORAGE>/custom/logo.png` — a candidate resolveLogoFile does try and
// can resolve — so the diagnostic claimed nothing existed for a logo that
// renders fine, and collapsed the configured value to its basename.
await db('app_settings').where({ setting_key: 'branding_logo_path' })
.update({ setting_value: JSON.stringify('/custom/logo.png') });
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
expect(source.candidates.some((c) => c.path === '<STORAGE>/custom/logo.png' && c.exists)).toBe(true);
// …and the disclosure guarantee still holds for this shape.
const body = JSON.stringify(res.body);
expect(body).not.toContain(STORAGE);
expect(body).not.toContain(process.cwd());
await db('app_settings').where({ setting_key: 'branding_logo_path' })
.update({ setting_value: JSON.stringify(logoPath) });
});
});
@@ -1,312 +0,0 @@
/**
* Per-photo engagement counters (#895).
*
* Pins the contract that the admin EVENT > IMAGES table depends on:
* - photos.view_count increments when the full-size photo is served
* (it existed in the schema + admin UI but had NO writer at all)
* - the slideshow kiosk never increments views (migration 138 design)
* - single-photo downloads increment download_count (regression pin)
* - zip downloads (download-all, download-selected) increment
* download_count for the contained photos — previously they didn't,
* so zip-heavy galleries showed 0 per-photo downloads forever
* - the admin event-detail total_downloads counts singles AND zips
* (it counted action='download' only, disagreeing with the dashboard)
*/
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-engagement-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'engagement-test-secret';
// Real files on disk so /photo and the zip routes actually stream bytes.
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-storage-'));
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('../integration/helpers/crmDb');
const SLUG = 'engagement-test-event';
describe('photo engagement counters (#895)', () => {
let db;
let cleanup;
let app;
let eventId;
let photoIds;
let adminToken;
const galleryToken = (extra = {}) => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const getPhoto = async (id) => db('photos').where('id', id).first();
// The counter writes are fire-and-forget on purpose — give the event
// loop a beat before asserting.
const settle = () => new Promise((r) => setTimeout(r, 400));
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Engagement 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: 'engagement-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(photoDir, { recursive: true });
photoIds = [];
for (let i = 0; i < 3; i++) {
const filename = `photo-${i}.jpg`;
fs.writeFileSync(path.join(photoDir, filename), Buffer.from(`fake-jpeg-bytes-${i}`));
const p = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(p[0]?.id ?? p[0]);
}
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'engagement-admin',
email: 'engagement-admin@example.com',
password_hash: await bcrypt.hash('EngagementAdmin123', 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: 'engagement-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/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('photos').where('event_id', eventId).update({ view_count: 0, download_count: 0 });
await db('access_logs').where('event_id', eventId).del();
});
describe('view_count via the view beacon (#895 — previously never written)', () => {
const beacon = (photoId, token = galleryToken()) => request(app)
.post(`/api/gallery/${SLUG}/photo/${photoId}/view`)
.set('Authorization', `Bearer ${token}`);
it('increments exactly the beaconed photo', async () => {
expect((await beacon(photoIds[0])).status).toBe(204);
expect((await getPhoto(photoIds[0])).view_count).toBe(1);
expect((await beacon(photoIds[0])).status).toBe(204);
expect((await getPhoto(photoIds[0])).view_count).toBe(2);
// Other photos untouched
expect((await getPhoto(photoIds[1])).view_count).toBe(0);
});
it('serving the image bytes does NOT count (preloads must not inflate)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photo/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
});
it('rejects the slideshow kiosk (migration 138 design)', async () => {
const res = await beacon(photoIds[0], galleryToken({ accessLevel: 'slideshow' }));
expect(res.status).toBeGreaterThanOrEqual(400);
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
});
it("404s a photo that isn't in the event", async () => {
const res = await beacon(999999);
expect(res.status).toBe(404);
});
});
describe('download_count', () => {
it('single-photo download increments (regression pin)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
expect((await getPhoto(photoIds[1])).download_count).toBe(0);
});
it('download-selected increments exactly the selected photos (#895)', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ photo_ids: [photoIds[0], photoIds[1]] });
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
expect((await getPhoto(photoIds[1])).download_count).toBe(1);
expect((await getPhoto(photoIds[2])).download_count).toBe(0);
});
it('download-all increments every downloadable photo (#895)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download-all`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
for (const id of photoIds) {
expect((await getPhoto(id)).download_count).toBe(1);
}
});
it('skipped archive entries do not count (missing source file)', async () => {
// Own event so the on-the-fly archiver path is guaranteed — the
// main event may have a cached zip from the previous test's
// background generation, and racing its build/invalidate hangs.
// The route also fires a background pre-zip build after streaming;
// against this event's intentionally missing file it crashes with
// an async ENOENT that jest attributes to whatever test is running
// by then — neutralize it, it's not under test here.
const downloadZipService = require('../../src/services/downloadZipService');
const generateZipSpy = jest.spyOn(downloadZipService, 'generateZip')
.mockResolvedValue({ success: false, error: 'disabled in test' });
const slug2 = `${SLUG}-skip`;
const ev = await db('events').insert({
slug: slug2,
event_type: 'wedding',
event_name: 'Engagement Skip Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${slug2}/share`,
share_token: 'engagement-skip-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
const eventId2 = ev[0]?.id ?? ev[0];
const dir2 = path.join(process.env.STORAGE_PATH, 'events/active', slug2);
fs.mkdirSync(dir2, { recursive: true });
const ids2 = [];
for (let i = 0; i < 2; i++) {
// Only photo 0 gets a real file — photo 1's source is missing.
if (i === 0) fs.writeFileSync(path.join(dir2, `photo-${i}.jpg`), Buffer.from('skip-test-bytes'));
const p = await db('photos').insert({
event_id: eventId2,
filename: `photo-${i}.jpg`,
path: `${slug2}/photo-${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
ids2.push(p[0]?.id ?? p[0]);
}
const token2 = jwt.sign(
{ eventId: eventId2, eventSlug: slug2, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const res = await request(app)
.get(`/api/gallery/${slug2}/download-all`)
.set('Authorization', `Bearer ${token2}`);
expect(res.status).toBe(200);
await settle();
expect((await db('photos').where('id', ids2[0]).first()).download_count).toBe(1);
// photo-1's source was missing → skipped from the zip → not counted
expect((await db('photos').where('id', ids2[1]).first()).download_count).toBe(0);
generateZipSpy.mockRestore();
});
});
describe('admin photos list exposes the counters (#895 follow-up)', () => {
it('returns view_count and download_count so the Engagement column can render them', async () => {
// The list mapper builds an explicit object — before this fix it
// omitted both fields, so the admin table showed 0 forever even
// though the DB counted correctly.
await request(app)
.post(`/api/gallery/${SLUG}/photo/${photoIds[0]}/view`)
.set('Authorization', `Bearer ${galleryToken()}`);
await request(app)
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
await settle();
const res = await request(app)
.get(`/api/admin/photos/${eventId}/photos`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
const row = res.body.photos.find((p) => p.id === photoIds[0]);
expect(row.view_count).toBe(1);
expect(row.download_count).toBe(1);
const untouched = res.body.photos.find((p) => p.id === photoIds[1]);
expect(untouched.view_count).toBe(0);
expect(untouched.download_count).toBe(0);
});
});
describe('admin event-detail total_downloads (#895 — one definition everywhere)', () => {
it('counts singles and every zip variant, one row each', async () => {
const row = (action) => ({
event_id: eventId,
ip_address: '127.0.0.1',
user_agent: 'jest',
action,
});
await db('access_logs').insert([
row('download'),
row('download_all'),
row('download_all_presigned'),
row('download_selected'),
row('view'), // not a download
]);
const res = await request(app)
.get(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.total_downloads).toBe(4);
});
});
});
@@ -1,181 +0,0 @@
/**
* Project ownership — GHSA-wrg5 (project routes) and GHSA-93x4 (project email
* endpoints).
*
* Project routes authorized on generic events.view / events.edit with NO
* ownership check, so an editor could enumerate, read, update and aggregate
* projects belonging to other admins' events. The email endpoints keyed on an
* email_queue id alone, so any id could be previewed/resent/cancelled.
*
* `projects` had no owner column. It was added in migration 167 (backfilled
* from linked events) rather than relying only on the transitive
* events.project_id -> events.created_by path, because a brand-new EMPTY
* project has no linked event to infer an owner from — which is exactly where
* the create -> attach flow begins.
*/
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-projown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projown-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('project ownership (GHSA-wrg5 / GHSA-93x4)', () => {
let db; let cleanup; let app;
let editorToken; let superToken; let editorId; let superId;
let ownProjectId; let foreignProjectId; let foreignEventId; let foreignEmailId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
return {
id,
token: jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' },
),
};
};
const mkProject = async (name, createdBy) => {
const r = await db('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
await db('feature_flags').insert({ key: 'projects', value: 1 })
.onConflict('key').merge({ value: 1 });
const editor = await mkAdmin('proj-editor', 'editor');
const sup = await mkAdmin('proj-super', 'super_admin');
editorToken = editor.token; editorId = editor.id;
superToken = sup.token; superId = sup.id;
ownProjectId = await mkProject('own-project', editorId);
foreignProjectId = await mkProject('foreign-project', superId);
// A foreign event linked to the foreign project, plus a queued email on it.
const ev = await db('events').insert({
slug: 'foreign-ev',
event_type: 'wedding',
event_name: 'Foreign Event',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: 'ftok', share_link: '/gallery/foreign-ev/ftok',
created_by: superId,
project_id: foreignProjectId,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
foreignEventId = ev[0]?.id ?? ev[0];
const em = await db('email_queue').insert({
event_id: foreignEventId,
recipient_email: 'client@example.com',
email_type: 'gallery_created',
status: 'sent',
created_at: new Date().toISOString(),
}).returning('id');
foreignEmailId = em[0]?.id ?? em[0];
app = express();
app.use(express.json());
app.use('/api/admin/projects', require('../../src/routes/adminProjects'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('lists only the editor\'s own projects', async () => {
const res = await request(app)
.get('/api/admin/projects')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const names = (res.body.projects || res.body.data?.projects || []).map((p) => p.name);
expect(names).toContain('own-project');
expect(names).not.toContain('foreign-project');
});
it('refuses to read a foreign project', async () => {
const res = await request(app)
.get(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
});
it('refuses to update or aggregate a foreign project', async () => {
const update = await request(app)
.put(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ name: 'hijacked' });
expect([403, 404]).toContain(update.status);
const overview = await request(app)
.get(`/api/admin/projects/${foreignProjectId}/overview`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(overview.status);
// And the name must not have changed.
const row = await db('projects').where({ id: foreignProjectId }).first();
expect(row.name).toBe('foreign-project');
});
it('refuses to attach a FOREIGN event to an owned project', async () => {
const res = await request(app)
.post(`/api/admin/projects/${ownProjectId}/events`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ eventId: foreignEventId });
expect([403, 404]).toContain(res.status);
const ev = await db('events').where({ id: foreignEventId }).first();
expect(ev.project_id).toBe(foreignProjectId); // still attached to its own
});
it('refuses to preview or act on a foreign queued email (GHSA-93x4)', async () => {
const preview = await request(app)
.get(`/api/admin/projects/email/${foreignEmailId}/preview`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(preview.status);
const cancel = await request(app)
.post(`/api/admin/projects/email/${foreignEmailId}/cancel`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(cancel.status);
});
it('leaves super_admin unrestricted', async () => {
const res = await request(app)
.get(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).toBe(200);
});
});
@@ -1,115 +0,0 @@
/**
* Project ownership edge cases (GHSA-wrg5, codex round 2).
*
* The first predicate union'd "any linked event I can see" with the stored
* owner, which opened two holes:
* - a project owned by B containing ONE legacy ownerless event became
* readable by everyone (and /overview aggregates B's other events,
* invoices and emails);
* - migration 167 deliberately leaves multi-owner projects NULL, and a NULL
* owner was treated as "everyone's".
* The stored owner is now authoritative, and a NULL owner only derives access
* when EVERY linked event is accessible.
*/
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-projedge-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projedge-test-secret';
const bcrypt3 = require('bcrypt');
const { bootCrmDb: boot3, seedMinimal: seed3 } = require('../integration/helpers/crmDb');
describe('project ownership edge cases (GHSA-wrg5, round 2)', () => {
let db3; let cleanup3; let ownership; let editorA; let editorB;
const mkAdmin3 = async (username, roleName) => {
const role = await db3('roles').where({ name: roleName }).first();
const r = await db3('admin_users').insert({
username, email: `${username}@example.com`,
password_hash: await bcrypt3.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkProject3 = async (name, createdBy) => {
const r = await db3('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkEvent3 = async (slug, createdBy, projectId) => {
const r = await db3('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
created_by: createdBy, project_id: projectId,
expires_at: new Date(Date.now() + 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db: db3, cleanup: cleanup3 } = await boot3());
await seed3(db3);
ownership = require('../../src/middleware/ownership');
editorA = await mkAdmin3('edge-a', 'editor');
editorB = await mkAdmin3('edge-b', 'editor');
}, 120000);
afterAll(async () => { if (cleanup3) await cleanup3(); });
it('one ownerless event in B\'s project does not expose it to A', async () => {
const pid = await mkProject3('b-project', editorB);
await mkEvent3('b-owned-ev', editorB, pid);
await mkEvent3('legacy-ev', null, pid); // ownerless legacy event
const idsA = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(idsA).not.toContain(Number(pid));
const idsB = await ownership.ownedProjectIds({ id: editorB, roleName: 'editor' });
expect(idsB).toContain(Number(pid));
});
it('a mixed-owner project left NULL by migration 167 is not global', async () => {
const pid = await mkProject3('ambiguous', null);
await mkEvent3('mix-a-ev', editorA, pid);
await mkEvent3('mix-b-ev', editorB, pid);
for (const who of [editorA, editorB]) {
const ids = await ownership.ownedProjectIds({ id: who, roleName: 'editor' });
expect(ids).not.toContain(Number(pid));
}
});
it('a NULL-owner project whose events are all mine IS mine', async () => {
const pid = await mkProject3('legacy-mine', null);
await mkEvent3('mine-ev', editorA, pid);
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(ids).toContain(Number(pid));
});
it('a project whose creator was deleted falls back to its events', async () => {
const ghost = await mkAdmin3('ghost-admin', 'editor');
const pid = await mkProject3('orphaned', ghost);
await mkEvent3('orphan-ev', editorA, pid);
await db3('admin_users').where({ id: ghost }).del();
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(ids).toContain(Number(pid));
});
it('super_admin stays unrestricted', async () => {
expect(await ownership.ownedProjectIds({ id: 1, roleName: 'super_admin' })).toBeNull();
});
});
@@ -51,7 +51,7 @@ describe('publicContracts routes', () => {
contractId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
}, 120000);
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -33,7 +33,7 @@ describe('publicPaymentCheck routes', () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck'));
}, 120000);
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -60,7 +60,7 @@ describe('publicQuotes routes', () => {
quoteId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
}, 120000);
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -1,86 +0,0 @@
/**
* Restore path containment must not break the normal restore wizard
* (GHSA-fw4c, codex round 2).
*
* `source` is usually a SOURCE TYPE, not a path: RestoreWizard posts
* 'local' | 's3' | 'upload', and restoreService.restore() branches on those
* literals before deriving a directory. The first version of the containment
* check treated `source` as a path, so path.resolve('local') landed outside
* the configured backup roots and BOTH /validate and /start returned 400 —
* blocking every normal restore.
*/
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-restorepath-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('restore path allowlist (GHSA-fw4c)', () => {
let db; let cleanup; let checkRestorePathsAllowed;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// Configure a backup root so the allowlist is actually active.
for (const [key, value] of [['backup_destination_path', '/backup']]) {
const existing = await db('app_settings').where({ setting_key: key }).first();
if (existing) {
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
} else {
await db('app_settings').insert({
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
});
}
}
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('allows the wizard\'s source TYPE tokens', async () => {
for (const source of ['local', 's3', 'upload']) {
const err = await checkRestorePathsAllowed({
source, manifestPath: '/backup/manifests/backup-manifest-1.json',
});
expect(err).toBeNull();
}
});
it('allows an s3:// source URL', async () => {
const err = await checkRestorePathsAllowed({
source: 's3://bucket/key/backup.tar.gz',
manifestPath: '/backup/manifests/backup-manifest-1.json',
});
expect(err).toBeNull();
});
it('still rejects a manifestPath outside the configured roots', async () => {
const err = await checkRestorePathsAllowed({
source: 'local', manifestPath: '/etc/passwd',
});
expect(err).toMatch(/inside a configured backup location/i);
});
it('still rejects a traversal manifestPath', async () => {
const err = await checkRestorePathsAllowed({
source: 'local', manifestPath: '/backup/../etc/shadow',
});
expect(err).toBeTruthy();
});
it('accepts a real path source inside the roots', async () => {
const err = await checkRestorePathsAllowed({
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
});
expect(err).toBeNull();
});
});
@@ -1,136 +0,0 @@
/**
* Secure-image view route token binding (GHSA-g94x-8vv8-3c9f).
*
* The view route GET /api/secure-images/:slug/secure/:photoId/:token serves
* via <img src> with the token in the URL, so it can't carry a gallery-token
* header like the download sibling. Before the fix it validated only the
* token signature and took the gallery/photo from the URL — so a token minted
* on any PUBLIC gallery read every other gallery's photos with no password.
*
* Pins that the route now enforces the scope inside the token:
* - the URL photoId must equal the token's minted photoId
* - the gallery embedded in the token's sessionId must equal the URL gallery
* A token minted on gallery A cannot read gallery B under either check; a
* token used on its own gallery+photo passes the binding.
*/
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-secimg-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'secimg-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-storage-'));
// Stub the anti-bot/rate-limit middleware so the fingerprint is deterministic
// — the token below is minted with the same fingerprint, so verifySecureToken
// passes and the binding logic under test is what decides the outcome.
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
secureImageAccess: (req, _res, next) => {
req.clientInfo = { fingerprint: 'test-fp', ip: '127.0.0.1', userAgent: 'jest' };
next();
},
getSecurityStatus: (_req, res) => res.json({ ok: true }),
}));
const request = require('supertest');
const express = require('express');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const secureImageService = require('../../src/services/secureImageService');
describe('secure-image view route token binding (GHSA-g94x)', () => {
let db;
let cleanup;
let app;
let galleryA; let galleryB;
let photoA; let photoB;
const mkEvent = async (slug, requirePassword) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
require_password: requirePassword ? 1 : 0,
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 r[0]?.id ?? r[0];
};
const mkPhoto = async (eventId, slug, filename) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', slug);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from('img'));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${slug}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
// Mint a token exactly as the mint route does — bound to (photoId, gallery
// sessionId, fingerprint) — bypassing the anti-bot HTTP path.
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
photoId,
`gallery_public_${eventId}_${Date.now()}`,
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
);
const view = (slug, photoId, token) => request(app)
.get(`/api/secure-images/${slug}/secure/${photoId}/${token}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
galleryA = await mkEvent('secimg-public-a', false); // public — token source
galleryB = await mkEvent('secimg-private-b', true); // password-protected — victim
photoA = await mkPhoto(galleryA, 'secimg-public-a', 'a.jpg');
photoB = await mkPhoto(galleryB, 'secimg-private-b', 'b.jpg');
app = express();
app.use(express.json());
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a gallery-A token used against gallery B (cross-photo)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-private-b', photoB, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this photo/i);
});
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
const token = mint(photoA, galleryA);
// URL photoId matches the token, so the photo check passes — the gallery
// check (sessionId gallery A != URL gallery B) must catch it.
const res = await view('secimg-private-b', photoA, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this gallery/i);
});
it('lets a token read its own gallery + photo (binding passes)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-public-a', photoA, token);
// Binding passes; serving may 200/404/500 depending on the pipeline, but
// it must NOT be rejected as a token mismatch.
expect(res.status).not.toBe(403);
});
});
@@ -75,7 +75,7 @@ describe('admin Live Slideshow endpoints', () => {
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
}, 30000);
afterAll(async () => { await cleanup(); });
@@ -67,11 +67,11 @@ async function insertEvent(db, over = {}) {
describe('public Live Slideshow routes', () => {
let db; let cleanup; let app;
// bootCrmDb runs the full migration set against a fresh SQLite file. The
// chain keeps growing, and a 30s pin here blocked the 3.97.0-beta.0
// release PR on a slow runner. Hook-argument timeouts OVERRIDE the 120s
// jest.config default (same trap as the jest.setTimeout pins raised in
// #860) — keep this at 120000, matching the config.
// bootCrmDb runs the full migration set against a fresh SQLite file, which
// takes <2s locally but has been observed to exceed Jest's default 5s
// `beforeAll` timeout on slower GitHub Actions runners (~5.4s — runner-to-
// runner I/O variance). Raise the hook timeout so this doesn't intermittently
// block PRs on CI; doesn't affect happy-path local runs.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
@@ -86,7 +86,7 @@ describe('public Live Slideshow routes', () => {
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
}, 30000);
afterAll(async () => { await cleanup(); });
@@ -99,12 +99,7 @@ describe('public Live Slideshow routes', () => {
await setFlag(db, 'slideshow', true);
});
// QR overlay: supertest's Host is loopback, and a loopback base is now
// suppressed rather than encoded — the kiosk passes its reachable
// window.location.origin, so the QR tests do the same.
const KIOSK_ORIGIN = 'https://gallery.example.com';
const stateUrl = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state?origin=${encodeURIComponent(KIOSK_ORIGIN)}`;
const stateUrlNoOrigin = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state`;
const stateUrl = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state`;
describe('resolveSlideshow guards', () => {
it('200 + per-event display settings on a live link', async () => {
@@ -233,58 +228,6 @@ describe('public Live Slideshow routes', () => {
});
});
describe('slideshowSettings — QR overlay cascade (#837)', () => {
async function enableGlobalQr() {
await setSetting(db, 'slideshow_qr_enabled', true);
await setSetting(db, 'slideshow_qr_position', 'top-right');
await setSetting(db, 'slideshow_qr_opacity', 80);
await setSetting(db, 'slideshow_qr_size', 18);
}
it('inherits the global QR overlay when show_qr is NULL', async () => {
await insertEvent(db, { show_qr: null });
await enableGlobalQr();
const res = await request(app).get(stateUrl());
expect(res.body.qr).toMatchObject({
position: 'top-right',
opacity: 80,
size: 18,
});
// Share-link QR ships as a PNG data URI — no client QR lib needed.
expect(res.body.qr.data_url).toMatch(/^data:image\/png;base64,/);
});
it('is null by default (global off, no override)', async () => {
await insertEvent(db, { show_qr: null });
const res = await request(app).get(stateUrl());
expect(res.body.qr).toBeNull();
});
it('per-event OFF override hides the QR even when the global is on', async () => {
await insertEvent(db, { show_qr: 0 });
await enableGlobalQr();
const res = await request(app).get(stateUrl());
expect(res.body.qr).toBeNull();
});
it('per-event ON override shows the QR even when the global is off', async () => {
await insertEvent(db, { show_qr: 1 });
const res = await request(app).get(stateUrl());
expect(res.body.qr).not.toBeNull();
expect(res.body.qr.data_url).toMatch(/^data:image\/png;base64,/);
// Look falls back to the global defaults.
expect(res.body.qr.position).toBe('bottom-left');
});
it('suppresses the QR when no guest-reachable origin exists (loopback base, no kiosk origin)', async () => {
await insertEvent(db, { show_qr: 1 });
const res = await request(app).get(stateUrlNoOrigin());
// Encoding localhost would send scanning phones to THEIR localhost —
// no QR beats a broken QR (codex review of #848, confirmation round).
expect(res.body.qr).toBeNull();
});
});
describe('display-only token guards (#646 review concern 1)', () => {
// Mint a real slideshow JWT, then prove it is denied on the
// download / upload / feedback routes (display-only contract).
@@ -1,155 +0,0 @@
/**
* v1 API tokens must respect event ownership (GHSA-9697).
*
* migration 081 documents the intent — "the token's effective permissions are
* the intersection of the user's role permissions and the token's own scope
* flags" — but it was never implemented:
*
* - apiTokenAuth selected only id/username/email/role_id, so
* req.admin.roleName was undefined and every ownership helper (which all
* key on roleName) could not distinguish a super_admin from a viewer.
* - No v1 route applied requirePermission or a created_by predicate, so any
* valid token listed every event and — worst — GET /events/:id/share-link
* returned ANY event's share_token, which is the gallery access credential.
*
* Scenario pinned here: a token owned by a restricted (non-super_admin) admin
* must see only its owner's events, and must not obtain a foreign share_token.
*/
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-v1own-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1own-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1own-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
describe('v1 event ownership (GHSA-9697)', () => {
let db; let cleanup; let app;
let editorToken; let superToken;
let ownEventId; let foreignEventId;
const FOREIGN_SHARE_TOKEN = 'f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0';
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkToken = async (adminId, scopes = 'admin') => {
const { plaintext, hashed } = generateApiToken();
await db('api_tokens').insert({
name: `tok-${adminId}`,
hashed_token: hashed,
scopes,
created_by: adminId,
created_at: new Date().toISOString(),
});
return plaintext;
};
const mkEvent = async (slug, createdBy, shareToken) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: shareToken,
share_link: `/gallery/${slug}/${shareToken}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const editorId = await mkAdmin('restricted-editor', 'editor');
const superId = await mkAdmin('root-admin', 'super_admin');
editorToken = await mkToken(editorId);
superToken = await mkToken(superId);
ownEventId = await mkEvent('own-event', editorId, 'a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
foreignEventId = await mkEvent('foreign-event', superId, FOREIGN_SHARE_TOKEN);
app = express();
app.use(express.json());
app.use('/api/v1', require('../../src/routes/v1/events'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('lists only the token owner\'s events', async () => {
const res = await request(app)
.get('/api/v1/events')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const slugs = res.body.events.map((e) => e.slug);
expect(slugs).toContain('own-event');
expect(slugs).not.toContain('foreign-event');
});
it('refuses to read a foreign event', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
});
it('does NOT hand out a foreign event\'s share_token', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}/share-link`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
expect(JSON.stringify(res.body)).not.toContain(FOREIGN_SHARE_TOKEN);
});
it('still allows the owner to read their own event and share link', async () => {
const detail = await request(app)
.get(`/api/v1/events/${ownEventId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect(detail.status).toBe(200);
const share = await request(app)
.get(`/api/v1/events/${ownEventId}/share-link`)
.set('Authorization', `Bearer ${editorToken}`);
expect(share.status).toBe(200);
expect(share.body.share_token).toBe('a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
});
it('leaves super_admin tokens unrestricted', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}/share-link`)
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).toBe(200);
expect(res.body.share_token).toBe(FOREIGN_SHARE_TOKEN);
});
});
@@ -1,108 +0,0 @@
/**
* v1 token scopes must intersect the owner's CURRENT role permissions
* (GHSA-9697, codex round 2).
*
* Migration 081 documents effective permissions as the intersection of the
* owner's role permissions and the token's scope flags. requireApiScope only
* ever checked the scope half, so a token minted while its owner was
* super_admin kept full write access after the owner was demoted to viewer —
* userManagementService never touches api_tokens, so the token outlives the
* demotion. Ownership scoping alone does not close this: the demoted owner
* still *owns* their events.
*/
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-v1perm-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1perm-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1perm-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
describe('v1 token scopes intersect role permissions (GHSA-9697)', () => {
let db; let cleanup; let app; let viewerToken; let viewerEventId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'viewer' }).first();
const r = await db('admin_users').insert({
username: 'demoted-owner',
email: 'demoted@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const ownerId = r[0]?.id ?? r[0];
// A token still carrying the broad 'admin' scope from before demotion.
const { plaintext, hashed } = generateApiToken();
await db('api_tokens').insert({
name: 'stale-token',
hashed_token: hashed,
scopes: 'admin',
created_by: ownerId,
created_at: new Date().toISOString(),
});
viewerToken = plaintext;
const ev = await db('events').insert({
slug: 'viewer-ev',
event_type: 'wedding',
event_name: 'Viewer Event',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: 'vtok',
share_link: '/gallery/viewer-ev/vtok',
created_by: ownerId,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
viewerEventId = ev[0]?.id ?? ev[0];
app = express();
app.use(express.json());
app.use('/api/v1', require('../../src/routes/v1/events'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('denies event creation to a demoted viewer despite an admin-scope token', async () => {
const res = await request(app)
.post('/api/v1/events')
.set('Authorization', `Bearer ${viewerToken}`)
.send({ event_name: 'Nope', event_type: 'wedding' });
expect(res.status).toBe(403);
});
it('denies photo upload to a demoted viewer on their OWN event', async () => {
const res = await request(app)
.post(`/api/v1/events/${viewerEventId}/photos`)
.set('Authorization', `Bearer ${viewerToken}`)
.attach('photo', Buffer.from('x'), 'a.jpg');
expect(res.status).toBe(403);
});
it('still allows the viewer to READ their own event', async () => {
const res = await request(app)
.get(`/api/v1/events/${viewerEventId}`)
.set('Authorization', `Bearer ${viewerToken}`);
expect(res.status).toBe(200);
});
});
@@ -22,7 +22,7 @@ const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupIntegrityService.verifyDocumentArtefacts', () => {
let db;
@@ -1,241 +0,0 @@
/**
* Backup/restore hardening — GHSA-h652 (unbounded gunzip) and GHSA-hgp8
* (unkeyed manifest checksum).
*
* h652: decompressFile() piped gunzip straight to disk with no expanded-size
* bound, so a small crafted .gz could fill the volume.
*
* hgp8: the manifest checksum is a plain SHA-256 — it proves the manifest was
* not corrupted, not that it is authentic. BACKUP_MANIFEST_KEY upgrades new
* manifests to a keyed HMAC. It is deliberately OPT-IN and verify-if-present:
* the key cannot live in the database (the database is inside the backup), so
* a mandatory HMAC would lock an operator out of the exact disaster-recovery
* case this system exists for.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const zlib = require('zlib');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bkharden-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkharden-test-secret';
const { restoreService } = require('../../src/services/restoreService');
const backupManifest = require('../../src/services/backupManifest');
describe('decompressFile expanded-size bound (GHSA-h652)', () => {
let dir;
beforeAll(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-gz-')); });
afterAll(() => { fs.rmSync(dir, { recursive: true, force: true }); });
afterEach(() => { delete process.env.RESTORE_MAX_DECOMPRESSED_BYTES; });
it('aborts when the decompressed stream exceeds the limit', async () => {
// 5 MB of zeroes compresses to a few KB — the classic shape of the attack.
const gzPath = path.join(dir, 'bomb.gz');
fs.writeFileSync(gzPath, zlib.gzipSync(Buffer.alloc(5 * 1024 * 1024, 0)));
process.env.RESTORE_MAX_DECOMPRESSED_BYTES = String(64 * 1024); // 64 KB
await expect(
restoreService.decompressFile(gzPath, path.join(dir, 'out-bomb'))
).rejects.toThrow(/exceeds limit/i);
});
it('still decompresses a normal file within the limit', async () => {
const payload = Buffer.from('SELECT 1;\n'.repeat(100));
const gzPath = path.join(dir, 'ok.gz');
fs.writeFileSync(gzPath, zlib.gzipSync(payload));
const outPath = path.join(dir, 'out-ok');
await restoreService.decompressFile(gzPath, outPath);
expect(fs.readFileSync(outPath)).toEqual(payload);
});
});
describe('manifest checksum keying (GHSA-hgp8)', () => {
// validateManifest requires all of these sections to be present.
const baseManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: null },
});
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
it('produces a different digest when a key is set', () => {
const m = baseManifest();
const unkeyed = backupManifest.calculateManifestChecksum(m, { keyed: false });
const keyed = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
expect(keyed).not.toBe(unkeyed);
});
it('validates a legacy unkeyed manifest even when a key IS configured', () => {
// Disaster recovery: manifests written before keying must not become
// un-restorable the moment the operator sets a key.
const m = baseManifest();
m.verification.checksum_algorithm = 'sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
it('accepts a keyed manifest when the matching key is configured', () => {
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
it('rejects a keyed manifest whose body was tampered with', () => {
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
m.files.manifest[0].path = '../../etc/passwd';
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
});
it('does NOT brick restore when a keyed manifest meets a missing key', () => {
// Key lost with the host — the precise moment a restore is needed.
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
delete process.env.BACKUP_MANIFEST_KEY;
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
});
describe('manifest checksum coverage (canonicalization)', () => {
const fullManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
});
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
it('covers nested file entries (the old replacer dropped them)', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
// Tampering a file path must now change the digest.
m.files.manifest[0].path = '../../etc/passwd';
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
});
it('still accepts a manifest written with the legacy serialization', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
m, { keyed: false, legacy: true }
);
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
});
describe('checksum verification is shared and downgrade-aware (codex round 2)', () => {
const fullManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
});
afterEach(() => {
delete process.env.BACKUP_MANIFEST_KEY;
delete process.env.BACKUP_MANIFEST_REQUIRE_KEYED;
});
it('accepts a legacy-serialized manifest through the SHARED verifier', () => {
// restoreService recomputed the digest itself with the canonical
// serializer, which rejected every pre-existing backup.
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
m, { keyed: false, legacy: true },
);
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(true);
expect(res.warnings.join(' ')).toMatch(/legacy checksum serialization/i);
});
it('warns but accepts an unkeyed manifest when a key is configured', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(true);
expect(res.warnings.join(' ')).toMatch(/authenticity NOT established/i);
});
it('REJECTS the algorithm downgrade once REQUIRE_KEYED is on', () => {
// Attacker rewrites the manifest, strips checksum_algorithm and recomputes
// a plain SHA-256. With the strict flag set that must not verify.
const m = fullManifest();
m.files.manifest[0].path = '../../etc/passwd';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/downgrade/i);
});
it('rejects a keyed manifest with no key when REQUIRE_KEYED is on', () => {
const m = fullManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'k' });
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
});
it('REJECTS a manifest whose checksum was stripped entirely', () => {
// The cheapest bypass of every rule above: delete the field instead of
// forging it. Both the helper's early return and restoreService's
// `if (…total_checksum)` guard used to wave that through.
const m = fullManifest();
delete m.verification.total_checksum;
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/no checksum/i);
delete m.verification;
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
});
it('REJECTS an unkeyed manifest under REQUIRE_KEYED even with no key configured', () => {
// Strict mode is a claim about the manifests, not about this host — so a
// fresh disaster-recovery box that lost BACKUP_MANIFEST_KEY must not
// silently start accepting plain SHA-256 manifests again.
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
delete process.env.BACKUP_MANIFEST_KEY;
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/downgrade/i);
});
});
@@ -1,52 +0,0 @@
const path = require('path');
const os = require('os');
const fs = require('fs').promises;
// Point storage at a throwaway temp dir before requiring the service so the
// module-level getStoragePath() picks it up if evaluated.
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-test-${process.pid}`);
const chunkedUpload = require('../../src/services/chunkedUploadService');
describe('chunkedUploadService.initializeUpload filename sanitisation (GHSA-pc72-jf53-w28j)', () => {
afterAll(async () => {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
});
it('strips directory-traversal components from the stored filename', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: '../../uploads/logos/evil.svg',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
const meta = chunkedUpload.getUploadStatus(uploadId);
// basename('../../uploads/logos/evil.svg') === 'evil.svg' — the traversal
// is gone, so path.join(tempDir, filename) can no longer escape tempDir.
expect(meta.filename).toBe('evil.svg');
});
it('keeps a normal filename intact', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: 'clip.mp4',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
expect(uploadId).toBeTruthy();
});
it('rejects a filename that collapses to nothing', async () => {
await expect(
chunkedUpload.initializeUpload({
filename: '../',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
})
).rejects.toThrow(/Invalid filename/);
});
});
@@ -1,127 +0,0 @@
/**
* Inbound-mail resource caps (GHSA-2qf9).
*
* emailIntakeService downloaded, parsed and persisted every message with no
* size, attachment-count or attachment-byte limit. Anyone who can email the
* operator's mailbox reaches this path unauthenticated.
*
* The teeth were in the dedup key: on failure the service wrote an error row
* keyed `err-<uid>-<Date.now()>`, which can never match the envelope-derived
* `messageId` the dedup pass compares against. So the same oversized message
* was re-downloaded every poll interval forever — and an OOM-kill/restart just
* resumed the loop. This pins that an over-limit message is (a) never
* downloaded and (b) recorded under its REAL message id so it dedups.
*/
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-intake-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'intake-test-secret';
process.env.EMAIL_INTAKE_MAX_MESSAGE_BYTES = '1000';
const OVERSIZED_UID = 11;
const NORMAL_UID = 12;
const OVERSIZED_MSGID = '<huge@example.com>';
const fetchOneCalls = [];
jest.mock('imapflow', () => ({
ImapFlow: class {
async connect() {}
async logout() {}
async getMailboxLock() { return { release() {} }; }
async search() { return [OVERSIZED_UID, NORMAL_UID]; }
// Envelope pass now also returns `size`.
async *fetch() {
yield { uid: OVERSIZED_UID, size: 50_000, envelope: { messageId: OVERSIZED_MSGID } };
yield { uid: NORMAL_UID, size: 500, envelope: { messageId: '<ok@example.com>' } };
}
async fetchOne(uid) {
fetchOneCalls.push(String(uid));
return { source: Buffer.from('Subject: ok\r\n\r\nbody') };
}
async messageFlagsAdd() { return true; }
},
}));
jest.mock('mailparser', () => ({
simpleParser: async () => ({
messageId: '<ok@example.com>',
subject: 'ok',
date: new Date(),
attachments: [],
text: 'body',
html: null,
}),
}));
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('email intake caps (GHSA-2qf9)', () => {
let db; let cleanup; let intake;
let pollResult;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// pollOnce short-circuits unless the feature flag is on AND an IMAP
// account is configured — without both, this suite would pass vacuously.
await db('feature_flags')
.insert({ key: 'incomingMail', value: 1 })
.onConflict('key').merge({ value: 1 });
// getImapConfig() reads email_configs.first() — seedMinimal may already
// have inserted a row, so update that one rather than adding a second
// (the first row would win and report "unconfigured").
const imapFields = {
imap_host: 'imap.example.com',
imap_user: 'intake@example.com',
imap_pass: 'x',
imap_folder: 'INBOX',
};
const existingCfg = await db('email_configs').first();
if (existingCfg) {
await db('email_configs').where({ id: existingCfg.id }).update(imapFields);
} else {
await db('email_configs').insert({
smtp_host: 'smtp.example.com',
smtp_port: 587,
from_email: 'intake@example.com',
...imapFields,
});
}
intake = require('../../src/services/emailIntakeService');
pollResult = await intake.pollOnce().catch((e) => ({ thrown: e.message }));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('actually ran the poll (guards against a vacuous suite)', () => {
expect(pollResult).toBeDefined();
expect(pollResult.skipped).toBeUndefined();
});
it('never downloads a message whose envelope size exceeds the cap', () => {
// The oversized uid must never reach fetchOne (the source download) —
// that download is the DoS. The normal one must still be processed.
expect(fetchOneCalls).not.toContain(String(OVERSIZED_UID));
expect(fetchOneCalls).toContain(String(NORMAL_UID));
});
it('records the skip under the REAL message id so it dedups next poll', async () => {
const row = await db('received_emails').where({ message_id: OVERSIZED_MSGID }).first();
expect(row).toBeTruthy();
expect(row.status).toBe('error');
expect(String(row.error)).toMatch(/too large/i);
// The whole point: keyed by messageId, NOT err-<uid>-<timestamp>, which
// could never match the dedup pass and so looped forever.
expect(row.message_id).not.toMatch(/^err-/);
});
});
@@ -1,58 +0,0 @@
/**
* Regression tests for the Docker update instructions (environmentService).
*
* A production install (docker-compose.production.yml) must get `-f
* docker-compose.production.yml` in every update command — bare `docker compose`
* targets docker-compose.yml, a different build-based stack that also starts the
* dev-only mailhog, which left production users stranded on the old version
* (reported against 3.44.0 → 3.45.2).
*/
const { detectEnvironment, generateUpdateInstructions } = require('../../src/services/environmentService');
describe('detectEnvironment — production compose detection', () => {
const orig = process.env.PICPEAK_RELEASE_CHANNEL;
afterEach(() => {
if (orig === undefined) delete process.env.PICPEAK_RELEASE_CHANNEL;
else process.env.PICPEAK_RELEASE_CHANNEL = orig;
});
it('flags isProductionCompose when PICPEAK_RELEASE_CHANNEL is set', async () => {
process.env.PICPEAK_RELEASE_CHANNEL = 'stable';
const env = await detectEnvironment();
expect(env.isProductionCompose).toBe(true);
});
it('does not flag it when the var is absent (default docker-compose.yml)', async () => {
delete process.env.PICPEAK_RELEASE_CHANNEL;
const env = await detectEnvironment();
expect(env.isProductionCompose).toBe(false);
});
});
describe('generateUpdateInstructions — Docker commands', () => {
const cmds = (env) => generateUpdateInstructions(env, '3.45.2').steps.map((s) => s.command);
it('targets docker-compose.production.yml for a production install', () => {
const commands = cmds({ isDocker: true, isProductionCompose: true });
expect(commands).toEqual([
'docker compose -f docker-compose.production.yml pull',
'docker compose -f docker-compose.production.yml up -d',
'docker compose -f docker-compose.production.yml logs -f backend',
]);
// And the warning tells them where to run it.
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: true }, '3.45.2');
expect(warnings.join(' ')).toMatch(/docker-compose\.production\.yml/);
});
it('uses bare commands + a hint when not a production compose', () => {
const commands = cmds({ isDocker: true, isProductionCompose: false });
expect(commands).toEqual([
'docker compose pull',
'docker compose up -d',
'docker compose logs -f backend',
]);
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: false }, '3.45.2');
// Still nudges production users to add -f in case detection missed.
expect(warnings.join(' ')).toMatch(/-f docker-compose\.production\.yml/);
});
});
@@ -1,125 +0,0 @@
/**
* Regression tests for the file-watcher concurrency bound.
*
* chokidar fires 'add' once per file — with no ignoreInitial option the boot
* scan fires it for every existing file, and a bulk drop fires it for every
* new one at once. Unbounded handlers each run DB lookups plus a full sharp
* pipeline (sharp.concurrency(2) only caps libvips threads WITHIN one
* operation), which can OOM small hosts. Both 'add' and 'unlink' must go
* through the shared p-limit gate.
*
* Adapted from the filpgame fork (426ca491), extended to cover 'unlink'.
*/
const mockLimit = jest.fn((operation) => Promise.resolve().then(operation));
const mockPLimit = jest.fn(() => mockLimit);
const mockHandlers = {};
const mockWatcher = {
on: jest.fn((event, handler) => {
mockHandlers[event] = handler;
return mockWatcher;
}),
};
// Shared instances captured by the mock factories: jest.isolateModules re-runs
// each factory in a fresh registry, so the factories must return these same
// objects for the test to observe calls made inside the isolated module.
const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
// Chainable no-row query — enough for removePhoto's lookup/delete calls.
const mockDb = jest.fn(() => ({
where: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(null),
delete: jest.fn().mockResolvedValue(0),
}));
jest.mock('p-limit', () => mockPLimit);
jest.mock('chokidar', () => ({
watch: jest.fn(() => mockWatcher),
}));
jest.mock('../../src/database/db', () => ({ db: mockDb }));
jest.mock('../../src/utils/logger', () => mockLogger);
jest.mock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(),
generateVideoPlaceholder: jest.fn(),
}));
jest.mock('../../src/services/videoProcessor', () => ({
isVideoMimeType: jest.fn(() => false),
}));
jest.mock('../../src/services/downloadZipService', () => ({ invalidate: jest.fn() }));
jest.mock('../../src/utils/dbCompat', () => ({
formatBoolean: jest.fn((value) => value),
}));
const loadFileWatcher = () => {
let fileWatcher;
jest.isolateModules(() => {
fileWatcher = require('../../src/services/fileWatcher');
});
return fileWatcher;
};
describe('fileWatcher concurrency bound', () => {
const originalBackend = process.env.STORAGE_BACKEND;
const originalConcurrency = process.env.FILE_WATCHER_CONCURRENCY;
beforeEach(() => {
jest.clearAllMocks();
Object.keys(mockHandlers).forEach((key) => delete mockHandlers[key]);
process.env.STORAGE_BACKEND = 'local';
delete process.env.FILE_WATCHER_CONCURRENCY;
});
afterAll(() => {
if (originalBackend === undefined) delete process.env.STORAGE_BACKEND;
else process.env.STORAGE_BACKEND = originalBackend;
if (originalConcurrency === undefined) delete process.env.FILE_WATCHER_CONCURRENCY;
else process.env.FILE_WATCHER_CONCURRENCY = originalConcurrency;
});
it.each([
[undefined, 2], // default
['3', 3], // explicit
['0', 1], // floored to 1
['-4', 1], // floored to 1
['invalid', 2], // falls back to default
])('configures the limiter with FILE_WATCHER_CONCURRENCY=%s as %i', (configured, expected) => {
if (configured === undefined) delete process.env.FILE_WATCHER_CONCURRENCY;
else process.env.FILE_WATCHER_CONCURRENCY = configured;
loadFileWatcher().startFileWatcher();
expect(mockPLimit).toHaveBeenCalledWith(expected);
});
it('routes add events through the shared limiter', async () => {
loadFileWatcher().startFileWatcher();
expect(mockHandlers.add).toEqual(expect.any(Function));
mockHandlers.add('/outside-watch-root'); // early-returns inside processNewPhoto
expect(mockLimit).toHaveBeenCalledTimes(1);
expect(mockLimit).toHaveBeenCalledWith(expect.any(Function));
await mockLimit.mock.results[0].value;
});
it('routes unlink events through the same limiter', async () => {
loadFileWatcher().startFileWatcher();
expect(mockHandlers.unlink).toEqual(expect.any(Function));
mockHandlers.unlink('/outside-watch-root'); // early-returns inside removePhoto
expect(mockLimit).toHaveBeenCalledTimes(1);
await mockLimit.mock.results[0].value;
});
it('logs instead of rejecting when a queued handler throws', async () => {
loadFileWatcher().startFileWatcher();
const failure = new Error('boom');
mockLimit.mockImplementationOnce(() => Promise.reject(failure));
mockHandlers.add('/whatever');
await new Promise(process.nextTick);
expect(mockLogger.error).toHaveBeenCalledWith('Error processing new photo:', failure);
});
});
@@ -1,30 +0,0 @@
/**
* Locks the process-wide Sharp memory guards. The file-watcher concurrency
* bound (FILE_WATCHER_CONCURRENCY) assumes these caps stay in place — they
* limit libvips threads/cache WITHIN one operation while p-limit bounds the
* number of parallel pipelines. From the filpgame fork (426ca491).
*/
const mockSharp = jest.fn();
mockSharp.cache = jest.fn();
mockSharp.concurrency = jest.fn();
jest.mock('sharp', () => mockSharp);
jest.mock('../../src/utils/logger', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
describe('imageProcessor Sharp configuration', () => {
it('disables the Sharp cache and caps libvips concurrency', () => {
jest.isolateModules(() => {
require('../../src/services/imageProcessor');
});
expect(mockSharp.cache).toHaveBeenCalledWith(false);
expect(mockSharp.concurrency).toHaveBeenCalledWith(2);
});
});
@@ -1,50 +0,0 @@
/**
* Unit tests for the RAW/DNG handling helpers (#821). The actual exiftool
* extraction can only be exercised in the built image (exiftool isn't a dev
* dependency), so these cover the gating logic: which files are treated as RAW,
* and that ordinary images pass through untouched (zero cost / no extraction).
*/
const path = require('path');
const { isRawFilename, withProcessableImage, RAW_EXTENSIONS } = require('../../src/services/imageProcessor');
describe('isRawFilename', () => {
it('recognises common RAW / DNG extensions', () => {
for (const ext of ['dng', 'cr2', 'cr3', 'nef', 'arw', 'raf', 'rw2', 'orf']) {
expect(isRawFilename(`IMG_1234.${ext}`)).toBe(true);
expect(isRawFilename(`IMG_1234.${ext.toUpperCase()}`)).toBe(true); // case-insensitive
}
});
it('does not treat ordinary images/videos as RAW', () => {
for (const name of ['photo.jpg', 'photo.jpeg', 'photo.png', 'photo.webp', 'clip.mp4', 'clip.mov', 'photo.heic']) {
expect(isRawFilename(name)).toBe(false);
}
});
it('is null/empty safe', () => {
expect(isRawFilename(null)).toBe(false);
expect(isRawFilename('')).toBe(false);
expect(isRawFilename('noextension')).toBe(false);
});
it('RAW_EXTENSIONS includes dng (Apple ProRAW)', () => {
expect(RAW_EXTENSIONS.has('dng')).toBe(true);
});
});
describe('withProcessableImage', () => {
it('passes ordinary images through with no extraction and a no-op cleanup', async () => {
const localPath = '/tmp/whatever/photo.jpg';
const proc = await withProcessableImage(localPath, 'photo.jpg');
expect(proc.path).toBe(localPath); // unchanged — sharp reads it directly
expect(proc.outputBasename).toBeUndefined(); // generators keep their default naming
await expect(Promise.resolve(proc.cleanup())).resolves.toBeUndefined();
});
it('routes RAW files to extraction (which fails cleanly without exiftool/preview)', async () => {
// In the dev sandbox exiftool isn't installed, so extraction throws — the
// caller turns that into a normal processing failure. In the built image
// (exiftool present) this instead returns the embedded JPEG preview.
await expect(withProcessableImage('/tmp/whatever/IMG_1234.dng', 'IMG_1234.dng')).rejects.toThrow();
});
});
@@ -1,155 +0,0 @@
/**
* Regression tests for logActivity calls inside transactions (#850 review
* find). createContract / updateContract / createStorno / reissueInvoice
* called logActivity() (and contract paths also adminActor()) from inside
* a knex transaction WITHOUT the trx executor. On single-connection SQLite
* the audit insert then waits on a second pool connection while the trx
* holds the only one — a 60s acquire-timeout stall per call, after which
* logActivity's catch swallows the failure and the audit row is silently
* lost. Postgres was unaffected.
*
* The observable fix: the activity_logs rows now exist, and the calls
* complete without waiting on the pool. The shrunken acquire timeout
* below makes any reintroduced deadlock fail the test quickly instead
* of appearing to pass after a long stall.
*/
const path = require('path');
const {
bootCrmDb, seedMinimal, assignAdminRole,
} = require('../integration/helpers/crmDb');
jest.setTimeout(120000);
let db;
let cleanup;
let tmpDir;
let adminId;
let customerId;
let contractService;
let invoiceService;
const prevCwd = process.cwd();
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
// Business-doc artifacts land under process.cwd()/storage — isolate.
process.chdir(tmpDir);
// A reintroduced in-trx pool grab should fail fast (2s), not stall 60s.
db.client.pool.acquireTimeoutMillis = 2000;
// node-sqlite3 detects Date bindings via the NATIVE realm's Date —
// under jest's vm sandbox that check fails and Dates stringify to
// "[object Object]". Normalize to ISO strings on the client prototype
// (transaction clients are Object.create()d from it). Same shim as
// crmMintPaths.test.js.
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');
contractService = require('../../src/services/contractService');
invoiceService = require('../../src/services/invoiceService');
}, 120000);
afterAll(async () => {
process.chdir(prevCwd);
if (cleanup) await cleanup();
});
test('createContract persists the contract_created audit row (was silently lost on SQLite)', async () => {
const contractId = await contractService.createContract({
customerAccountId: customerId,
title: 'Audit-Trail-Vertrag',
}, adminId);
const row = await db('activity_logs')
.where({ activity_type: 'contract_created' })
.orderBy('id', 'desc')
.first();
expect(row).toBeTruthy();
expect(JSON.parse(row.metadata).contractId).toBe(contractId);
expect(row.actor_type).toBe('admin');
});
test('updateContract persists the contract_updated audit row', async () => {
const contractId = await contractService.createContract({
customerAccountId: customerId,
title: 'Vorher',
}, adminId);
await contractService.updateContract(contractId, { title: 'Nachher' }, adminId);
const row = await db('activity_logs')
.where({ activity_type: 'contract_updated' })
.orderBy('id', 'desc')
.first();
expect(row).toBeTruthy();
expect(JSON.parse(row.metadata).contractId).toBe(contractId);
});
test('cancelInvoice (Storno mint) persists the invoice_cancelled_via_storno audit row', async () => {
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [
{ position: 1, quantity: 1, description: 'Coverage', unit_price_minor: 100000, discount_percent: 0 },
],
}, adminId);
const id = invoiceIds[0];
await db('invoices').where({ id }).update({ status: 'sent', sent_at: new Date(), updated_at: new Date() });
const result = await invoiceService.cancelInvoice(id, adminId);
expect(result.cancelled).toBe(true);
const row = await db('activity_logs')
.where({ activity_type: 'invoice_cancelled_via_storno' })
.orderBy('id', 'desc')
.first();
expect(row).toBeTruthy();
const meta = JSON.parse(row.metadata);
expect(meta.invoiceId).toBe(id);
expect(meta.stornoId).toBe(result.stornoId);
});
test('reissueInvoice completes on SQLite and persists the invoice_reissued audit row', async () => {
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [
{ position: 1, quantity: 1, description: 'Album', unit_price_minor: 50000, discount_percent: 0 },
],
}, adminId);
const id = invoiceIds[0];
await db('invoices').where({ id }).update({ status: 'sent', sent_at: new Date(), updated_at: new Date() });
// Pre-fix this stalled inside the wrapping transaction (createInvoice's
// global-connection reads vs. the single-connection pool) and aborted
// before the replacement existed — with the Storno already committed.
const result = await invoiceService.reissueInvoice(id, adminId);
expect(result.id).toBeGreaterThan(0);
expect(result.replaces).toBe(id);
const replacement = await db('invoices').where({ id: result.id }).first();
expect(replacement.replaces_invoice_id).toBe(id);
const row = await db('activity_logs')
.where({ activity_type: 'invoice_reissued' })
.orderBy('id', 'desc')
.first();
expect(row).toBeTruthy();
expect(JSON.parse(row.metadata).newInvoiceId).toBe(result.id);
});
void path; // referenced for parity with sibling suites
@@ -234,66 +234,3 @@ describe('renderInvoiceToBuffer — Storno branch', () => {
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
});
});
// VAT free-text note (#794) + multi-page page-number placement. Same
// constraint as the Storno tests: PDFKit Flate-compresses content streams,
// so we can't grep the note text — but the page-TREE objects are NOT
// compressed, so `/Type /Page` (not `/Pages`) is countable to assert
// pagination, and a byte-size delta proves the note actually rendered.
describe('renderInvoiceToBuffer — VAT note + multi-page footer (#794)', () => {
function baseCtx(overrides = {}) {
return {
locale: 'de', currency: 'CHF',
issuer: { companyName: 'AcmeCo' },
recipient: {
companyName: 'KundenCo', addressLine1: 'Strasse 1',
city: 'Bern', postalCode: '3000',
},
lineItems: [{
quantity: 1, description: 'Photo session',
unitPriceMinor: 30000, lineTotalMinor: 30000,
parentLineItemId: null, parentPosition: null,
}],
totals: {
netAmountMinor: 30000, vatRate: 0, vatAmountMinor: 0,
shippingAmountMinor: 0, totalAmountMinor: 30000,
},
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
qrFormat: 'none',
paymentTerm: { netDays: 30 },
...overrides,
};
}
const pageCount = (buf) => (buf.toString('latin1').match(/\/Type\s*\/Page(?![s])/g) || []).length;
const VAT_NOTE = 'Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).';
it('renders the VAT note on a single-page invoice (adds content, valid PDF)', async () => {
const withNote = await pdfService.renderInvoiceToBuffer(baseCtx({ vatNote: VAT_NOTE }));
const without = await pdfService.renderInvoiceToBuffer(baseCtx());
expect(withNote.slice(0, 4).toString('ascii')).toBe('%PDF');
expect(pageCount(withNote)).toBe(1);
expect(withNote.length).toBeGreaterThan(without.length);
});
it('paginates a long invoice (with the note) across multiple pages without a stray blank page', async () => {
const manyItems = Array.from({ length: 60 }, (_, i) => ({
quantity: 1, description: `Position ${i + 1} — fotografische Leistung`,
unitPriceMinor: 3225, lineTotalMinor: 3225,
parentLineItemId: null, parentPosition: null,
}));
const buf = await pdfService.renderInvoiceToBuffer(baseCtx({
lineItems: manyItems,
totals: {
netAmountMinor: 193500, vatRate: 0, vatAmountMinor: 0,
shippingAmountMinor: 0, totalAmountMinor: 193500,
},
vatNote: VAT_NOTE,
}));
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
const pages = pageCount(buf);
expect(pages).toBeGreaterThanOrEqual(2);
// 60 short rows fit in 23 pages; a stray blank page (the old margin
// bug) or a runaway loop would blow past this.
expect(pages).toBeLessThanOrEqual(3);
});
});

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