Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Nothaft e94e440858 screenshot: admin github button (#778) 2026-07-10 09:50:18 +02:00
251 changed files with 1594 additions and 15764 deletions
-13
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)
@@ -114,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
-1
View File
@@ -25,7 +25,6 @@ jobs:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
target-branch: stable
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
+2 -2
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:
+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.95.5-beta.0"
".": "3.82.4-beta.0"
}
+3 -1
View File
@@ -1 +1,3 @@
{".":"3.44.0"}
{
".": "2.6.1"
}
-287
View File
@@ -5,293 +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.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)
+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.
+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
+10 -22
View File
@@ -27,26 +27,17 @@ FROM node:22-alpine
WORKDIR /app
# 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
@@ -69,11 +60,8 @@ RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# 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 docs/accounting-inbound-invoices.md).
# exiftool extracts the embedded full-res JPEG preview from RAW/DNG uploads
# (Apple ProRAW etc.) — sharp's libvips has no raw loader, so the pipeline
# thumbnails/displays that preview while keeping the original for download.
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
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,108 +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(),
}));
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,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,272 +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 a bare 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();
expect(decodeURIComponent(cookie.replace('oidc_id_token=', ''))).toBe('sso');
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('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,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 });
});
});
@@ -13,7 +13,7 @@ const { execFileSync } = require('child_process');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(60000);
let db;
let 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,402 +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', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos?preview=${encodeURIComponent(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 });
});
});
});
@@ -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,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');
}, 30000);
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,27 +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);
});
});
describe('DELETE /:id', () => {
+1 -1
View File
@@ -39,7 +39,7 @@ const {
bootCrmDb, mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(60000);
let db;
let cleanup;
@@ -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,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);
});
});
});
@@ -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).
@@ -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,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,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);
});
});
@@ -71,24 +71,15 @@ jest.mock('../../src/services/imageProcessor', () => {
const mockExtractCaptureDate = jest.fn();
return {
generateThumbnail: mockGenerateThumbnail,
generateVideoPlaceholder: jest.fn(async (filename) => `thumbnails/thumb_${filename.replace(/\.[^.]+$/, '')}.jpg`),
extractCaptureDate: mockExtractCaptureDate,
withLocalCopy: jest.fn(async (key, fn) =>
fn(`/tmp/local-copy-${require('path').basename(key)}`)
),
// Pass-through for ordinary (non-RAW) images: returns the path unchanged
// with a no-op cleanup, matching the real helper's behaviour for jpg/png.
withProcessableImage: jest.fn(async (localPath) => ({
path: localPath,
outputBasename: undefined,
cleanup: () => {},
})),
};
});
jest.mock('../../src/services/videoProcessor', () => ({
processUploadedVideo: jest.fn(),
extractVideoMetadata: jest.fn(),
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
}));
@@ -214,44 +205,6 @@ describe('photoProcessor.processPhoto', () => {
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
});
it('keeps a video complete with a placeholder thumbnail when ffmpeg fails', async () => {
dbModule.__setPhoto({
id: 203,
event_id: 9,
filename: 'drone-clip.mp4',
original_filename: 'drone.mp4',
mime_type: 'video/mp4',
media_type: 'video',
size_bytes: 12345,
captured_at: null,
});
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
// ffmpeg thumbnail pipeline throws (e.g. unsupported pixel format)…
videoProcessor.processUploadedVideo.mockRejectedValueOnce(new Error('ffmpeg exited with code 1'));
// …but a plain probe still works.
videoProcessor.extractVideoMetadata.mockResolvedValueOnce({
duration: 42,
videoCodec: 'hevc',
audioCodec: 'aac',
width: 3840,
height: 2160,
});
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(203);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
// The row must complete — 'failed' rows are invisible to guests.
expect(finalUpdate.data.processing_status).toBe('complete');
// Placeholder instead of NULL: a completed video without thumbnail would
// make the grid fetch the original video file for the tile (#845 review).
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_drone-clip.jpg');
expect(imageProcessor.generateVideoPlaceholder).toHaveBeenCalledWith('drone-clip.mp4');
expect(finalUpdate.data.duration).toBe(42);
expect(finalUpdate.data.video_codec).toBe('hevc');
});
it('throws when the photo row no longer exists', async () => {
dbModule.__setPhoto(null);
dbModule.__setEvent({ id: 1 });
@@ -1,111 +0,0 @@
/**
* Regression tests for reinjectCurrentAdmin — the operator-preservation step of
* the .picpeak restore (GHSA-qxfx-4493-4v8f follow-up). Runs against a real
* in-memory SQLite DB so the UNIQUE(email)/UNIQUE(username) constraints behave
* as in production. Reconciliation is non-destructive (update-in-place / rename,
* never delete) so restored rows referenced by FKs keep their ids.
*/
const knex = require('knex');
let db;
let reinjectCurrentAdmin;
beforeAll(() => {
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }), { virtual: false });
reinjectCurrentAdmin = require('../../src/services/picpeakImportService').reinjectCurrentAdmin;
});
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.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');
t.integer('created_by');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
});
afterEach(async () => { await db.destroy(); });
const operator = {
id: 1, username: 'admin', email: 'op@example.com',
password_hash: 'OP_HASH', is_active: 1, must_change_password: 0, role_id: 1, created_by: 99,
two_factor_enabled: 1, two_factor_secret: 'OP_SECRET', two_factor_recovery_codes: '["a","b"]',
};
test('restores login + MFA in place, keeping the row id and its FK columns (FK-safe)', async () => {
await db('admin_users').insert({
id: 7, username: 'someoneelse', email: 'OP@example.com',
password_hash: 'ATTACKER', is_active: 1, must_change_password: 0, role_id: 4, created_by: 5,
two_factor_enabled: 0, two_factor_secret: 'ATTACKER_SECRET', two_factor_recovery_codes: null,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users');
expect(rows).toHaveLength(1);
const row = rows[0];
expect(row.id).toBe(7); // id preserved → FK refs hold
expect(row.username).toBe('admin');
expect(row.password_hash).toBe('OP_HASH');
expect(Boolean(row.two_factor_enabled)).toBe(true);
expect(row.two_factor_secret).toBe('OP_SECRET'); // attacker MFA secret gone
expect(row.two_factor_recovery_codes).toBe('["a","b"]');
// Relationship/audit FKs are NOT forced from the operator snapshot (avoids
// dangling role_id/created_by on a cross-instance restore) — the restored
// row keeps its own already-valid values.
expect(row.role_id).toBe(4);
expect(row.created_by).toBe(5);
});
test('renames (not deletes) a different row holding the operator username', async () => {
await db('admin_users').insert({
id: 3, username: 'admin', email: 'other@instance.test',
password_hash: 'OTHER', is_active: 1, role_id: 4,
});
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // the other admin survives (FK-safe)
const other = rows.find((r) => r.id === 3);
expect(other.username).toBe('admin__restored_3'); // renamed, id kept
expect(other.email).toBe('other@instance.test');
const op = rows.find((r) => r.username === 'admin');
expect(op.password_hash).toBe('OP_HASH');
});
test('reconciles email and username colliding with DIFFERENT rows without deleting either', async () => {
await db('admin_users').insert([
{ id: 4, username: 'someoneelse', email: 'op@example.com', password_hash: 'A', role_id: 4 },
{ id: 5, username: 'admin', email: 'other@instance.test', password_hash: 'B', role_id: 4 },
]);
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // both rows survive
const opRow = rows.find((r) => r.id === 4); // email match updated in place
expect(opRow.username).toBe('admin');
expect(opRow.password_hash).toBe('OP_HASH');
const renamed = rows.find((r) => r.id === 5); // username holder renamed, not deleted
expect(renamed.username).toBe('admin__restored_5');
});
test('inserts the operator with a non-colliding id when neither key exists in the backup', async () => {
await db('admin_users').insert({
id: 9, username: 'backupadmin', email: 'backup@instance.test', password_hash: 'B', role_id: 1,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // backup admin untouched
const opRow = rows.find((r) => r.username === 'admin');
expect(opRow.password_hash).toBe('OP_HASH');
expect(opRow.id).toBe(10); // max(9)+1, no collision
expect(opRow.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
@@ -1,105 +0,0 @@
/**
* Tests for preserveOperatorRole — re-establishing the operator's authorization
* after a restore replaces the roles / permissions / role_permissions tables.
* Real in-memory SQLite so the joins and inserts behave as in production.
*/
const knex = require('knex');
let db;
let svc;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name').notNullable().unique();
t.string('display_name');
t.integer('priority').defaultTo(0);
t.boolean('is_system').defaultTo(false);
});
await db.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name').notNullable().unique();
t.string('display_name');
t.string('category');
});
await db.schema.createTable('role_permissions', (t) => {
t.integer('role_id').notNullable();
t.integer('permission_id').notNullable();
t.primary(['role_id', 'permission_id']);
});
await db.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('email');
t.integer('role_id');
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }));
jest.doMock('../../src/database/db', () => ({ db }));
svc = require('../../src/services/picpeakImportService');
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
await db.destroy();
});
test('captureOperatorRole returns the role + its permission names', async () => {
await db('roles').insert({ id: 1, name: 'super_admin', display_name: 'Super Admin', priority: 100 });
await db('permissions').insert([
{ id: 1, name: 'events.create', display_name: 'Create', category: 'events' },
{ id: 2, name: 'users.manage', display_name: 'Manage', category: 'users' },
]);
await db('role_permissions').insert([{ role_id: 1, permission_id: 1 }, { role_id: 1, permission_id: 2 }]);
const snap = await svc.captureOperatorRole(1);
expect(snap.role.name).toBe('super_admin');
expect(snap.permissions.sort()).toEqual(['events.create', 'users.manage']);
});
test('preserveOperatorRole binds to a restored role of the same NAME (ids remapped)', async () => {
const snapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
// Simulate post-restore RBAC where super_admin now has a DIFFERENT id.
await db('roles').insert({ id: 7, name: 'super_admin', display_name: 'Super Admin (restored)', priority: 100 });
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBe(7); // bound to restored super_admin by name
expect(await db('roles').count({ c: '*' }).first()).toEqual({ c: 1 }); // no duplicate role created
});
test('preserveOperatorRole re-creates the role + grants when the backup omits it', async () => {
const snapshot = {
role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true },
permissions: ['events.create', 'users.manage', 'gone.permission'],
};
// Post-restore RBAC WITHOUT super_admin; only some permissions exist.
await db('roles').insert({ id: 2, name: 'viewer', display_name: 'Viewer', priority: 10 });
await db('permissions').insert([
{ id: 5, name: 'events.create', display_name: 'Create', category: 'events' },
{ id: 6, name: 'users.manage', display_name: 'Manage', category: 'users' },
]);
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
const recreated = await db('roles').where({ name: 'super_admin' }).first();
expect(recreated).toBeTruthy(); // role re-created, not left missing
expect(recreated.id).toBe(3); // max(2)+1
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBe(recreated.id); // operator not locked out / downgraded
const grants = await db('role_permissions').where({ role_id: recreated.id }).pluck('permission_id');
expect(grants.sort()).toEqual([5, 6]); // existing perms re-granted; 'gone.permission' skipped
});
test('preserveOperatorRole no-ops when the operator had no role', async () => {
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, null));
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBeNull();
});
@@ -1,51 +0,0 @@
const fs = require('fs');
const path = require('path');
const {
EXTENSION_TO_MIME,
extensionsToMimeTypes,
} = require('../../src/services/uploadSettings');
const { validateFileType } = require('../../src/utils/fileSecurityUtils');
const RAW_AND_HEIF_TYPES = {
dng: 'image/x-adobe-dng',
heic: 'image/heic',
heif: 'image/heif',
};
function getFrontendExtensionMap() {
const source = fs.readFileSync(
path.join(__dirname, '../../../frontend/src/utils/fileTypes.ts'),
'utf8'
);
const match = source.match(/const EXTENSION_TO_MIME[^=]*= \{([\s\S]*?)\n\};/);
if (!match) throw new Error('Could not find frontend EXTENSION_TO_MIME');
// Parse `key: 'mime',` entries — quoted keys and trailing `//` comments are
// tolerated; any other non-blank, non-comment line inside the map is a parse
// failure, so a syntax the parser can't read fails loudly instead of silently
// dropping the entry from the comparison.
const entries = [];
for (const line of match[1].split('\n')) {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('//')) continue;
const entry = trimmed.match(/^'?(\w+)'?\s*:\s*'([^']+)'\s*,?\s*(?:\/\/.*)?$/);
if (!entry) throw new Error(`Unparsable EXTENSION_TO_MIME line in frontend fileTypes.ts: "${trimmed}"`);
entries.push([entry[1], entry[2]]);
}
return Object.fromEntries(entries);
}
describe('configured upload file types', () => {
test('supports configured DNG, HEIC, and HEIF uploads', () => {
expect(extensionsToMimeTypes('dng,heic,heif')).toEqual(Object.values(RAW_AND_HEIF_TYPES));
for (const [extension, mimeType] of Object.entries(RAW_AND_HEIF_TYPES)) {
expect(validateFileType(`image.${extension}`, mimeType, [mimeType])).toBe(true);
}
});
test('uses the same extension-to-MIME map as the frontend', () => {
expect(getFrontendExtensionMap()).toEqual(EXTENSION_TO_MIME);
});
});
@@ -1,65 +0,0 @@
/**
* Unit tests for the per-file upload size limit getter (general_max_file_size_mb),
* added so the admin's "Max File Size (MB)" setting applies to guest uploads
* (#613 follow-up — mat1990dj). Real in-memory SQLite app_settings so the
* read/parse/cache path runs exactly as in production.
*/
const knex = require('knex');
let db;
let svc;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.text('setting_value');
t.string('setting_type');
t.timestamp('updated_at');
});
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
svc = require('../../src/services/uploadSettings');
svc.clearMaxFileSizeCache();
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
await db.destroy();
});
async function setLimit(mb) {
await db('app_settings')
.insert({ setting_key: 'general_max_file_size_mb', setting_value: JSON.stringify(mb), setting_type: 'general', updated_at: new Date() })
.onConflict('setting_key').merge({ setting_value: JSON.stringify(mb) });
svc.clearMaxFileSizeCache();
}
test('defaults to 50MB when the setting is absent', async () => {
expect(await svc.getMaxFileSizeMb()).toBe(50);
expect(await svc.getMaxFileSizeBytes()).toBe(50 * 1024 * 1024);
});
test('honours a configured value (e.g. 500MB video)', async () => {
await setLimit(500);
expect(await svc.getMaxFileSizeMb()).toBe(500);
expect(await svc.getMaxFileSizeBytes()).toBe(500 * 1024 * 1024);
});
test('clamps a nonsense value to the default and caps absurd values at the ceiling', async () => {
await setLimit(0);
expect(await svc.getMaxFileSizeMb()).toBe(50); // 0 → default
await setLimit(99_999_999);
expect(await svc.getMaxFileSizeMb()).toBe(svc.MAX_ALLOWED_FILE_SIZE_MB); // ceiling
});
test('caches for the TTL — a mid-window DB change is not seen until the cache is cleared', async () => {
await setLimit(200);
expect(await svc.getMaxFileSizeMb()).toBe(200);
// change the DB but do NOT clear cache
await db('app_settings').where({ setting_key: 'general_max_file_size_mb' }).update({ setting_value: JSON.stringify(300) });
expect(await svc.getMaxFileSizeMb()).toBe(200); // still cached
svc.clearMaxFileSizeCache();
expect(await svc.getMaxFileSizeMb()).toBe(300); // refreshed
});
@@ -1,202 +0,0 @@
/**
* Emoji reactions (#839) — pins the contract of the `reaction` feedback type:
* - only emojis from the fixed curated set are accepted
* - one reaction per guest per photo: same emoji again toggles OFF,
* a different emoji SWITCHES the existing row (never a second row)
* - per-guest scoping mirrors likes: guest_id when present, else the
* device-hash guest_identifier — two token-guests on one device react
* independently
* - denormalized photos.reaction_count and the per-emoji tallies follow
* visibility: hidden-by-moderator reactions disappear from both
* - the long and pivoted exports carry the reaction
*/
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-feedback-reactions-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-reactions-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const feedbackService = require('../../src/services/feedbackService');
const { REACTION_EMOJIS } = require('../../src/constants/reactions');
const EVENT_SLUG = 'reactions-test-event';
const GUEST_A = 'guest-a-identifier';
const GUEST_B = 'guest-b-identifier';
let db;
let cleanup;
let eventId;
let photoIds;
async function react(photoId, emoji, { guestIdentifier = GUEST_A, guestId = null } = {}) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'reaction',
reaction: emoji,
guest_id: guestId,
ip_address: '127.0.0.1',
user_agent: 'jest',
}, guestIdentifier);
}
async function reactionCountOf(photoId) {
const row = await db('photos').where('id', photoId).first();
return Number(row.reaction_count) || 0;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: EVENT_SLUG,
event_type: 'wedding',
event_name: 'Reactions Test',
event_date: '2026-07-20',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${EVENT_SLUG}/share`,
share_token: 'reactions-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];
photoIds = [];
for (let i = 0; i < 3; i++) {
const photo = await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `events/reactions/${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(photo[0]?.id ?? photo[0]);
}
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('reaction submission (#839)', () => {
it('rejects emojis outside the curated set', async () => {
await expect(react(photoIds[0], '🦄')).rejects.toThrow('Invalid reaction');
await expect(react(photoIds[0], undefined)).rejects.toThrow('Invalid reaction');
expect(await reactionCountOf(photoIds[0])).toBe(0);
});
it('creates a reaction row and maintains the denormalized count', async () => {
const result = await react(photoIds[0], '❤️');
expect(result.created).toBe(true);
const row = await db('photo_feedback')
.where({ photo_id: photoIds[0], feedback_type: 'reaction' })
.first();
expect(row.reaction).toBe('❤️');
expect(await reactionCountOf(photoIds[0])).toBe(1);
expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '❤️': 1 });
});
it('switches to another emoji in place — never a second row per guest', async () => {
const result = await react(photoIds[0], '🎉');
expect(result.updated).toBe(true);
const rows = await db('photo_feedback')
.where({ photo_id: photoIds[0], feedback_type: 'reaction' });
expect(rows).toHaveLength(1);
expect(rows[0].reaction).toBe('🎉');
expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '🎉': 1 });
});
it('tallies different guests per emoji', async () => {
await react(photoIds[0], '🎉', { guestIdentifier: GUEST_B });
expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '🎉': 2 });
expect(await reactionCountOf(photoIds[0])).toBe(2);
});
it('toggles off with the same emoji', async () => {
const result = await react(photoIds[0], '🎉');
expect(result.removed).toBe(true);
expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '🎉': 1 }); // GUEST_B remains
expect(await reactionCountOf(photoIds[0])).toBe(1);
});
it('scopes per guest_id when present — two token-guests on one device stay independent', async () => {
const first = await react(photoIds[1], '😍', { guestIdentifier: GUEST_A, guestId: 101 });
const second = await react(photoIds[1], '👏', { guestIdentifier: GUEST_A, guestId: 102 });
expect(first.created).toBe(true);
expect(second.created).toBe(true); // NOT treated as guest 101's switch
expect(await feedbackService.getPhotoReactionCounts(photoIds[1])).toEqual({ '😍': 1, '👏': 1 });
});
it('accepts every emoji of the curated set', async () => {
for (const emoji of REACTION_EMOJIS) {
const res = await react(photoIds[2], emoji, { guestIdentifier: `guest-${emoji}` });
expect(res.created).toBe(true);
}
const counts = await feedbackService.getPhotoReactionCounts(photoIds[2]);
expect(Object.keys(counts)).toHaveLength(REACTION_EMOJIS.length);
});
it('hidden reactions leave both the per-emoji tallies and reaction_count', async () => {
const row = await db('photo_feedback')
.where({ photo_id: photoIds[0], feedback_type: 'reaction' })
.first();
await feedbackService.moderateFeedback(row.id, 'hide', 1);
expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({});
expect(await reactionCountOf(photoIds[0])).toBe(0);
await feedbackService.moderateFeedback(row.id, 'approve', 1);
expect(await reactionCountOf(photoIds[0])).toBe(1);
});
it('toggle and switch collapse racy duplicate rows for the same guest', async () => {
// Simulate the check-then-insert race: two rows for one guest+photo.
const mk = (emoji) => ({
photo_id: photoIds[1], event_id: eventId, feedback_type: 'reaction',
reaction: emoji, guest_identifier: 'dup-guest', is_approved: true, is_hidden: false,
created_at: new Date(), updated_at: new Date(),
});
await db('photo_feedback').insert([mk('❤️'), mk('❤️')]);
// Switching converges to exactly ONE row with the new emoji…
const switched = await react(photoIds[1], '🎉', { guestIdentifier: 'dup-guest' });
expect(switched.updated).toBe(true);
let rows = await db('photo_feedback')
.where({ photo_id: photoIds[1], feedback_type: 'reaction', guest_identifier: 'dup-guest' });
expect(rows).toHaveLength(1);
expect(rows[0].reaction).toBe('🎉');
// …and toggle-off removes the full guest-scoped set.
await db('photo_feedback').insert(mk('🎉'));
const removed = await react(photoIds[1], '🎉', { guestIdentifier: 'dup-guest' });
expect(removed.removed).toBe(true);
rows = await db('photo_feedback')
.where({ photo_id: photoIds[1], feedback_type: 'reaction', guest_identifier: 'dup-guest' });
expect(rows).toHaveLength(0);
});
it('summary and exports carry reactions', async () => {
const summary = await feedbackService.getEventFeedbackSummary(eventId);
expect(Number(summary.stats.total_reactions)).toBeGreaterThan(0);
const longRows = await feedbackService.exportEventFeedback(eventId);
const longReaction = longRows.find((r) => r.feedback_type === 'reaction');
expect(longReaction.reaction).toBeTruthy();
const pivotRows = await feedbackService.exportEventFeedbackPivoted(eventId);
const pivotWithReaction = pivotRows.find((r) => r.reaction);
expect(REACTION_EMOJIS).toContain(pivotWithReaction.reaction);
});
});
@@ -1,61 +0,0 @@
/**
* Regression tests for the password-complexity setting read path.
*
* Bug 1 (key mismatch): the settings UI saves the admin's choice as
* `security_password_complexity` (useSettingsState.ts prefixes every
* security field with `security_`), but getPasswordComplexitySettings()
* queried `security_password_complexity_level` — a key nothing writes —
* so the configured level was silently ignored.
*
* Bug 2 (driver shape, codex review of #843): on SQLite the TEXT column
* returns the JSON-stringified value ('"very_strong"'), but on Postgres
* (production default) `setting_value` is a json column and comes back
* already decoded ('very_strong'). A bare JSON.parse throws on the
* decoded shape and the outer catch fell back to 'moderate' — the
* setting stayed unenforced on Postgres even with the right key.
*/
const mockQueriedKeys = [];
let mockStoredValue;
jest.mock('../../src/database/db', () => ({
db: () => ({
where(_col, key) {
mockQueriedKeys.push(key);
return this;
},
first() {
return Promise.resolve(
mockQueriedKeys[mockQueriedKeys.length - 1] === 'security_password_complexity'
? { setting_key: 'security_password_complexity', setting_value: mockStoredValue }
: undefined
);
},
}),
withRetry: (fn) => fn(),
}));
const { getPasswordComplexitySettings } = require('../../src/utils/passwordValidation');
describe('getPasswordComplexitySettings', () => {
beforeEach(() => { mockQueriedKeys.length = 0; });
it('reads the key the settings UI actually writes (SQLite shape: JSON-stringified)', async () => {
mockStoredValue = JSON.stringify('very_strong'); // '"very_strong"'
const level = await getPasswordComplexitySettings();
expect(mockQueriedKeys).toContain('security_password_complexity');
expect(level).toBe('very_strong');
});
it('accepts the Postgres json-column shape (already decoded, no quotes)', async () => {
mockStoredValue = 'very_strong'; // pg driver auto-parses the json column
const level = await getPasswordComplexitySettings();
expect(level).toBe('very_strong');
});
it('falls back to moderate on an empty value', async () => {
mockStoredValue = '';
const level = await getPasswordComplexitySettings();
expect(level).toBe('moderate');
});
});
@@ -1,41 +0,0 @@
const path = require('path');
const { assertZipEntriesWithin } = require('../../src/utils/safePath');
describe('assertZipEntriesWithin (ZIP-slip guard, GHSA-jfhw-fj23-fx6x)', () => {
const root = path.join('/tmp', 'picpeak-extract-root');
it('accepts entries that stay within the extraction root', () => {
const entries = [
{ name: 'photo.jpg' },
{ name: 'category/nested/photo.png' },
{ name: 'photos_manifest.json' },
{ name: 'subdir/' },
];
expect(() => assertZipEntriesWithin(entries, root)).not.toThrow();
});
it('rejects a parent-traversal entry', () => {
const entries = [{ name: '../../uploads/logos/evil.svg' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('rejects an absolute-path entry', () => {
const entries = [{ name: '/etc/cron.d/evil' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('rejects when a safe entry is mixed with a traversal entry', () => {
const entries = [{ name: 'ok.jpg' }, { name: '../escape.txt' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('tolerates empty / nameless entries', () => {
expect(() => assertZipEntriesWithin([{}, { name: '' }, null], root)).not.toThrow();
});
it('does not treat a sibling prefix directory as inside the root', () => {
// root is .../picpeak-extract-root; ../picpeak-extract-root-evil must not pass
const entries = [{ name: '../picpeak-extract-root-evil/x' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
});
@@ -1,56 +0,0 @@
/**
* Unit tests for the global session cutoff (utils/sessionCutoff.js). Uses a
* real in-memory SQLite `app_settings` table so the read/write/parse path is
* exercised exactly as in production.
*/
const knex = require('knex');
let db;
let cutoff;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.text('setting_value');
t.string('setting_type');
t.timestamp('updated_at');
});
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
cutoff = require('../../src/utils/sessionCutoff');
cutoff._resetCache();
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
await db.destroy();
});
test('no cutoff set → nothing is invalidated', async () => {
expect(await cutoff.getSessionsValidAfter()).toBe(0);
expect(await cutoff.isTokenBeforeCutoff({ iat: 1000 })).toBe(false);
});
test('token issued before the cutoff is rejected, at/after is accepted', async () => {
await cutoff.setSessionsValidAfter(2000);
expect(await cutoff.isTokenBeforeCutoff({ iat: 1999 })).toBe(true); // pre-restore session
expect(await cutoff.isTokenBeforeCutoff({ iat: 2000 })).toBe(false); // same second → kept
expect(await cutoff.isTokenBeforeCutoff({ iat: 2001 })).toBe(false); // post-restore login
});
test('setSessionsValidAfter upserts a single row and refreshes the cache', async () => {
await cutoff.setSessionsValidAfter(1000);
await cutoff.setSessionsValidAfter(3000);
const rows = await db('app_settings').where('setting_key', 'security_sessions_valid_after');
expect(rows).toHaveLength(1);
cutoff._resetCache();
expect(await cutoff.getSessionsValidAfter()).toBe(3000);
});
test('a token without iat is never treated as before the cutoff', async () => {
await cutoff.setSessionsValidAfter(2000);
expect(await cutoff.isTokenBeforeCutoff({})).toBe(false);
expect(await cutoff.isTokenBeforeCutoff(null)).toBe(false);
});
Binary file not shown.
Binary file not shown.
@@ -1,93 +0,0 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-5
View File
@@ -1,10 +1,5 @@
module.exports = {
testEnvironment: 'node',
// bootCrmDb() runs EVERY core migration in beforeAll; the chain keeps
// growing (163-165 pushed several suites past jest's default on CI
// runners — the 3.94 release PR failed on exactly this). 120s matches
// the convention the newer suites already pin explicitly.
testTimeout: 120000,
coverageDirectory: 'coverage',
collectCoverageFrom: [
'src/**/*.js',
@@ -1,52 +0,0 @@
/**
* Messages Phase 2 — additional inbound mailboxes + captured message bodies.
*
* `mail_accounts` holds inbound mailboxes BEYOND the primary accounting IMAP
* that already lives in `email_configs` (e.g. the customer `hello@` mailbox).
* The intake poller (emailIntakeService) polls the accounting mailbox AND every
* enabled row here; customer mail is logged with its body but not routed to the
* accounting inbox.
*
* The new `received_emails` columns capture the parsed message so the Messages
* reading pane can show it: `account_key` tags which mailbox it came from,
* `body_html`/`body_text` hold the (server-sanitized) body, `to_address` the
* envelope recipient. All additive + guarded.
*/
exports.up = async function up(knex) {
const hasAccounts = await knex.schema.hasTable('mail_accounts');
if (!hasAccounts) {
await knex.schema.createTable('mail_accounts', (t) => {
t.increments('id').primary();
t.string('account_key', 64).notNullable().unique(); // e.g. 'customers'
t.string('label', 120);
t.string('imap_host', 255);
t.integer('imap_port').defaultTo(993);
t.boolean('imap_secure').defaultTo(true);
t.string('imap_user', 255);
t.string('imap_pass', 512);
t.string('imap_folder', 255).defaultTo('INBOX');
t.boolean('enabled').defaultTo(false);
t.timestamp('created_at').defaultTo(knex.fn.now());
t.timestamp('updated_at').defaultTo(knex.fn.now());
});
}
const cols = [
['account_key', (t) => t.string('account_key', 64)],
['to_address', (t) => t.string('to_address', 512)],
['body_html', (t) => t.text('body_html')],
['body_text', (t) => t.text('body_text')],
];
for (const [name, add] of cols) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn('received_emails', name);
// eslint-disable-next-line no-await-in-loop
if (!has) await knex.schema.alterTable('received_emails', add);
}
};
exports.down = async function down(knex) {
// Non-destructive on the audit log: leave the added columns in place (they're
// nullable and harmless). Only drop the new table.
await knex.schema.dropTableIfExists('mail_accounts');
};
@@ -1,25 +0,0 @@
/**
* Messages Phase 3 — distinguish human-composed sends from system mail.
*
* `origin` is 'system' for everything the app queues automatically (invoices,
* reminders, gallery notices — the Automated stream) and 'manual' for emails an
* admin composed/edited in the Messages composer (replies + document messages —
* the Customers ▸ Sent stream). Existing rows default to 'system'.
*/
exports.up = async function up(knex) {
const has = await knex.schema.hasColumn('email_queue', 'origin');
if (!has) {
await knex.schema.alterTable('email_queue', (t) => {
t.string('origin', 16).defaultTo('system');
});
}
};
exports.down = async function down(knex) {
const has = await knex.schema.hasColumn('email_queue', 'origin');
if (has) {
await knex.schema.alterTable('email_queue', (t) => {
t.dropColumn('origin');
});
}
};
@@ -1,34 +0,0 @@
/**
* Messages Phase 3 follow-up — outgoing (SMTP) settings per mail account.
*
* The customer mailbox (hello@) needs BOTH incoming (IMAP, migration 154) and
* outgoing (SMTP) config, so replies to customers send from hello@ instead of
* the global no-reply@ identity. All additive/guarded.
*/
exports.up = async function up(knex) {
const cols = [
['smtp_host', (t) => t.string('smtp_host', 255)],
['smtp_port', (t) => t.integer('smtp_port')],
['smtp_secure', (t) => t.boolean('smtp_secure').defaultTo(false)],
['smtp_user', (t) => t.string('smtp_user', 255)],
['smtp_pass', (t) => t.string('smtp_pass', 512)],
['from_email', (t) => t.string('from_email', 255)],
['from_name', (t) => t.string('from_name', 120)],
];
for (const [name, add] of cols) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn('mail_accounts', name);
// eslint-disable-next-line no-await-in-loop
if (!has) await knex.schema.alterTable('mail_accounts', add);
}
};
exports.down = async function down(knex) {
const cols = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'from_email', 'from_name'];
for (const name of cols) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn('mail_accounts', name);
// eslint-disable-next-line no-await-in-loop
if (has) await knex.schema.alterTable('mail_accounts', (t) => t.dropColumn(name));
}
};
@@ -1,33 +0,0 @@
/**
* Messages — Archive / Delete (trash) support.
*
* `mailbox_state` on both mail tables: 'active' (normal folders), 'archived'
* (Archived folder), or 'deleted' (Deleted/trash folder). Delete is soft — the
* row moves to 'deleted' and is only removed for good when purged FROM the
* Deleted folder. Legacy rows have NULL, treated as 'active'. Additive/guarded.
*/
exports.up = async function up(knex) {
for (const table of ['email_queue', 'received_emails']) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn(table, 'mailbox_state');
// eslint-disable-next-line no-await-in-loop
if (!has) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable(table, (t) => {
t.string('mailbox_state', 16).defaultTo('active');
});
}
}
};
exports.down = async function down(knex) {
for (const table of ['email_queue', 'received_emails']) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn(table, 'mailbox_state');
// eslint-disable-next-line no-await-in-loop
if (has) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable(table, (t) => { t.dropColumn('mailbox_state'); });
}
}
};
@@ -1,37 +0,0 @@
/**
* Migration 158: per-event slideshow ordering + category filter (#202).
*
* - `show_order` — 'chronological' (default, upload order) | 'random'
* (client-side shuffle). Lets the Live Slideshow play
* photos in a varied order during an event.
* - `show_category_id`— optional FK into `photo_categories`. When set, the
* slideshow only shows photos in that category (NULL =
* all visible photos, the existing behaviour).
*
* Both additive + guarded. Defaults preserve today's behaviour (chronological,
* all photos), so existing slideshows are unchanged.
*/
exports.up = async function up(knex) {
const hasOrder = await knex.schema.hasColumn('events', 'show_order');
if (!hasOrder) {
await knex.schema.alterTable('events', (t) => {
t.string('show_order', 20).defaultTo('chronological');
});
}
const hasCat = await knex.schema.hasColumn('events', 'show_category_id');
if (!hasCat) {
await knex.schema.alterTable('events', (t) => {
t.integer('show_category_id').nullable();
});
}
};
exports.down = async function down(knex) {
for (const col of ['show_order', 'show_category_id']) {
// eslint-disable-next-line no-await-in-loop
if (await knex.schema.hasColumn('events', col)) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable('events', (t) => t.dropColumn(col));
}
}
};
@@ -1,57 +0,0 @@
/**
* Migration 159: per-event category ordering (#782).
*
* Adds a `display_order` integer to `photo_categories` so photographers can
* arrange an event's categories in the flow of the day (Pre-Ceremony →
* Ceremony → Reception …) instead of the hard-coded AZ order. Mirrors the
* `display_order` column + reorder pattern already used by `event_types`.
*
* Preserve existing galleries: backfill `display_order` from the CURRENT
* (alphabetical) order, scoped — globals numbered together, event-specific
* numbered per event — so nothing reshuffles on upgrade. A custom order is
* opt-in via the admin reorder controls. See feedback: migrations should pin
* previously-implicit defaults onto existing rows.
*
* Backfill runs in JS (not a SQL window function) to stay portable across
* SQLite (dev) and Postgres (prod).
*
* Additive + hasColumn-guarded.
*/
async function addColumn(knex, table, column, builder) {
if (!(await knex.schema.hasColumn(table, column))) {
await knex.schema.alterTable(table, builder);
}
}
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
await addColumn(knex, 'photo_categories', 'display_order', (t) => {
t.integer('display_order').notNullable().defaultTo(0);
t.index('display_order');
});
// Backfill from the current alphabetical order, per scope, so existing
// galleries render exactly as before until an admin reorders.
const cats = await knex('photo_categories')
.select('id', 'name', 'is_global', 'event_id')
.orderBy('name', 'asc');
const counters = {};
for (const c of cats) {
const scope = c.is_global ? 'global' : `event:${c.event_id}`;
counters[scope] = (counters[scope] || 0) + 1;
await knex('photo_categories')
.where('id', c.id)
.update({ display_order: counters[scope] });
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (await knex.schema.hasColumn('photo_categories', 'display_order')) {
await knex.schema.alterTable('photo_categories', (t) =>
t.dropColumn('display_order')
);
}
};
@@ -1,46 +0,0 @@
/**
* Migration 160: per-event category order override (#782).
*
* Builds on migration 159 (photo_categories.display_order = the GLOBAL default
* order) by adding a per-event OVERRIDE layer. Global categories are shared
* across every event, so a single display_order can only express one order for
* them. This table lets a single gallery arrange its categories — globals AND
* event-specific, interleaved into the flow of the day — independently of the
* global default.
*
* Resolution (see adminCategories / gallery):
* 1. if the event has override rows -> use override.position;
* 2. else fall back to photo_categories.display_order (the global default);
* 3. else name.
*
* An event is either "using the default" (no rows here) or "customised" (a row
* per category it shows). No backfill: every existing event starts on the
* default order, so nothing reshuffles — a custom order is opt-in per event.
*
* Additive + hasTable-guarded.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (await knex.schema.hasTable('event_category_order')) return;
await knex.schema.createTable('event_category_order', (t) => {
t.increments('id').primary();
t.integer('event_id').notNullable()
.references('id').inTable('events').onDelete('CASCADE');
t.integer('category_id').notNullable()
.references('id').inTable('photo_categories').onDelete('CASCADE');
t.integer('position').notNullable().defaultTo(0);
t.timestamp('created_at').defaultTo(knex.fn.now());
// At most one position per (event, category).
t.unique(['event_id', 'category_id']);
// Ordered reads are always scoped to one event.
t.index(['event_id', 'position']);
});
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('event_category_order')) {
await knex.schema.dropTable('event_category_order');
}
};
@@ -1,43 +0,0 @@
/**
* Migration 161: `setup_wizard_completed` app setting (#800).
*
* The setup wizard gains an event-types step that may rename or DELETE the
* seeded system event types. That is only safe on a pristine install, so the
* backend gates system-type deletion on this flag being unset (plus zero
* usage — see eventTypeService.deleteEventType).
*
* Backfill rule: any install that already has an admin account predates the
* wizard step (or already finished the wizard), so it is marked completed
* here — the deletion window never opens on existing setups. A genuinely
* fresh install runs this migration BEFORE its first admin is created, so
* the flag starts false and the wizard's finish call flips it to true.
*
* Idempotent: skips when the key already exists. Values are JSON-stringified
* to match getAppSetting's JSON.parse on read.
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
const existing = await knex('app_settings')
.where({ setting_key: 'setup_wizard_completed' })
.first();
if (existing) return;
let hasAdmin = false;
if (await knex.schema.hasTable('admin_users')) {
const row = await knex('admin_users').count({ c: '*' }).first();
hasAdmin = Number(row?.c || 0) > 0;
}
await knex('app_settings').insert({
setting_key: 'setup_wizard_completed',
setting_value: JSON.stringify(hasAdmin),
setting_type: 'boolean',
updated_at: new Date(),
});
};
exports.down = async function down(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
await knex('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
};
@@ -1,51 +0,0 @@
/**
* Migration 162: OIDC identity binding for admin users (#798).
*
* - `auth_provider` — 'local' (default) or 'oidc'. Which authority owns the
* account's credentials.
* - `external_issuer` — the validated `iss` of the IdP that owns the subject.
* OIDC only guarantees `sub` uniqueness WITHIN an
* issuer, so bindings match on (iss, sub) — otherwise
* switching `oidc_issuer_url` could map a new
* provider's user onto an old provider's admin when
* their subjects collide.
* - `external_subject` — the IdP's stable subject identifier (OIDC `sub`).
* SSO logins match on (external_issuer,
* external_subject), NEVER on email alone —
* email-matching is an account-takeover vector with
* IdPs that don't verify addresses. Nullable: local
* accounts have neither.
*
* Composite unique index so one IdP identity can't map to two admin rows.
* Additive + guarded; existing rows keep working untouched ('local', NULL).
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasColumn('admin_users', 'auth_provider'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.string('auth_provider', 20).notNullable().defaultTo('local');
});
}
if (!(await knex.schema.hasColumn('admin_users', 'external_issuer'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.string('external_issuer', 512).nullable();
});
}
if (!(await knex.schema.hasColumn('admin_users', 'external_subject'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.string('external_subject', 255).nullable();
t.unique(['external_issuer', 'external_subject'], {
indexName: 'admin_users_issuer_subject_unique',
});
});
}
};
exports.down = async function down(knex) {
for (const col of ['external_subject', 'external_issuer', 'auth_provider']) {
// eslint-disable-next-line no-await-in-loop
if (await knex.schema.hasColumn('admin_users', col)) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable('admin_users', (t) => t.dropColumn(col));
}
}
};
@@ -1,22 +0,0 @@
/**
* #837 — per-event override for the live-slideshow QR overlay.
* Mirrors show_watermark: NULL = inherit the global slideshow_qr_enabled
* setting, true/false force the overlay on/off for this event.
*/
exports.up = async function up(knex) {
const has = await knex.schema.hasColumn('events', 'show_qr');
if (!has) {
await knex.schema.alterTable('events', (t) => {
t.boolean('show_qr').nullable().defaultTo(null);
});
}
};
exports.down = async function down(knex) {
const has = await knex.schema.hasColumn('events', 'show_qr');
if (has) {
await knex.schema.alterTable('events', (t) => {
t.dropColumn('show_qr');
});
}
};
@@ -1,55 +0,0 @@
/**
* Emoji reactions on photos (#839).
*
* - event_feedback_settings.allow_reactions: per-event toggle next to
* allow_likes / allow_ratings / allow_comments. Defaults TRUE for parity
* with the sibling toggles — the master feedback_enabled gate (default
* false, opt-in per event) still decides whether any feedback UI shows.
* - photo_feedback.reaction: the emoji value for feedback_type='reaction'
* rows (validated against the fixed set in constants/reactions.js).
* - photos.reaction_count: denormalized total, maintained by
* updatePhotoFeedbackStats alongside like_count / favorite_count.
*/
exports.up = async function (knex) {
const hasAllowReactions = await knex.schema.hasColumn('event_feedback_settings', 'allow_reactions');
if (!hasAllowReactions) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.boolean('allow_reactions').defaultTo(true);
});
}
const hasReaction = await knex.schema.hasColumn('photo_feedback', 'reaction');
if (!hasReaction) {
await knex.schema.alterTable('photo_feedback', (table) => {
// 16 chars: emoji are multi-byte/multi-codepoint (variation selectors),
// but well under 16 characters each.
table.string('reaction', 16);
});
}
const hasReactionCount = await knex.schema.hasColumn('photos', 'reaction_count');
if (!hasReactionCount) {
await knex.schema.alterTable('photos', (table) => {
table.integer('reaction_count').defaultTo(0);
});
}
};
exports.down = async function (knex) {
if (await knex.schema.hasColumn('photos', 'reaction_count')) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('reaction_count');
});
}
if (await knex.schema.hasColumn('photo_feedback', 'reaction')) {
await knex.schema.alterTable('photo_feedback', (table) => {
table.dropColumn('reaction');
});
}
if (await knex.schema.hasColumn('event_feedback_settings', 'allow_reactions')) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.dropColumn('allow_reactions');
});
}
};
@@ -1,44 +0,0 @@
/**
* Reveal mode (#838): hide the gallery from guests until a manual or
* scheduled reveal — guests can still upload, the host/admin/slideshow see
* everything.
*
* - events.reveal_mode: the per-event toggle (only meaningful together with
* allow_user_uploads; off by default so nothing changes for existing events)
* - events.reveal_at: optional scheduled reveal time. Effective visibility is
* computed at REQUEST time (reveal_at <= now opens the gate even before the
* scheduler runs), the minutely scheduler only stamps revealed_at durably.
* - events.revealed_at: set by "Reveal now" or the scheduler; NULL while
* hidden. Re-enabling reveal_mode clears it (re-hide).
*/
// Each column guarded independently: a partially applied prior run (or a
// fork that added one of them) must not leave the others missing — the
// routes select all three.
exports.up = async function (knex) {
if (!(await knex.schema.hasColumn('events', 'reveal_mode'))) {
await knex.schema.alterTable('events', (table) => {
table.boolean('reveal_mode').defaultTo(false);
});
}
if (!(await knex.schema.hasColumn('events', 'reveal_at'))) {
await knex.schema.alterTable('events', (table) => {
table.timestamp('reveal_at').nullable();
});
}
if (!(await knex.schema.hasColumn('events', 'revealed_at'))) {
await knex.schema.alterTable('events', (table) => {
table.timestamp('revealed_at').nullable();
});
}
};
exports.down = async function (knex) {
for (const column of ['revealed_at', 'reveal_at', 'reveal_mode']) {
if (await knex.schema.hasColumn('events', column)) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn(column);
});
}
}
};
+254 -454
View File
File diff suppressed because it is too large Load Diff
+7 -14
View File
@@ -1,11 +1,8 @@
{
"name": "picpeak-backend",
"version": "3.95.5-beta.0",
"version": "3.82.4-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
"node": "^20.19.0 || >=22"
},
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
@@ -14,7 +11,6 @@
"generate:watermarks": "node scripts/generate-watermarks.js",
"test": "jest",
"test:s3": "SKIP_S3_TESTS=false jest __tests__/integration/backup-s3",
"test:pg": "jest __tests__/integration/picpeakRestorePg",
"lint": "eslint src/"
},
"dependencies": {
@@ -22,12 +18,11 @@
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
"archiver": "^5.3.1",
"axios": "1.18.1",
"axios": "1.16.0",
"bcrypt": "6.0.0",
"chokidar": "4.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"dotenv": "^16.0.3",
"exifr": "^7.1.3",
"express": "^4.18.2",
@@ -51,22 +46,20 @@
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
"openid-client": "^5.7.1",
"otplib": "^12.0.1",
"p-limit": "^3.1.0",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"postcss": "8.5.18",
"postcss": "8.5.10",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
"sharp": "0.35.3",
"sharp": "0.34.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"swissqrbill": "^4.3.0",
"tar": ">=7.5.21",
"tar": ">=7.5.16",
"uuid": "^11.1.1",
"winston": "^3.8.2",
"zxcvbn": "^4.4.2"
@@ -86,8 +79,8 @@
"js-yaml": "^4.2.0",
"fast-xml-parser": ">=5.7.0",
"qs": ">=6.15.2",
"tar": ">=7.5.21",
"brace-expansion": ">=5.0.7",
"tar": ">=7.5.16",
"brace-expansion": ">=5.0.6",
"minimatch": ">=9.0.7",
"path-to-regexp": "0.1.13",
"lodash": ">=4.18.1",
+3 -4
View File
@@ -20,7 +20,6 @@ const path = require('path');
const { initializeDatabase, db } = require('./src/database/db');
const { startFileWatcher } = require('./src/services/fileWatcher');
const { startExpirationChecker } = require('./src/services/expirationChecker');
const { startRevealScheduler } = require('./src/services/revealScheduler');
const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService');
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
const { startBackupService } = require('./src/services/backupService');
@@ -39,6 +38,7 @@ const {
// Import routes
const authRoutes = require('./src/routes/auth');
const eventRoutes = require('./src/routes/events');
const galleryRoutes = require('./src/routes/gallery');
const adminRoutes = require('./src/routes/admin');
const adminAuthRoutes = require('./src/routes/adminAuth');
@@ -695,7 +695,8 @@ app.get('/health', async (req, res) => {
// Routes
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
app.use('/api/auth', authRoutes);
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
app.use('/api/events', eventRoutes);
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
// Gallery routes - main routes first, then feedback routes
app.use('/api/gallery', galleryRoutes);
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
@@ -904,8 +905,6 @@ async function startServer() {
// Start expiration checker
startExpirationChecker();
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
startRevealScheduler();
// CRM invoice scheduler: hourly tick to flush scheduled-send invoices
// + run the overdue reminder ladder. No-op when the `bills` feature
// flag is OFF (the service short-circuits on empty result sets).
@@ -33,13 +33,6 @@ jest.mock('../utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn(),
}));
// The global session cutoff (added for .picpeak restore invalidation) queries
// app_settings; stub it to "no cutoff" so it doesn't consume this suite's
// one-shot db() mock. Its own behaviour is covered by utils/sessionCutoff.test.js.
jest.mock('../utils/sessionCutoff', () => ({
isTokenBeforeCutoff: jest.fn().mockResolvedValue(false),
}));
jest.mock('../utils/tokenUtils', () => ({
getCustomerTokenFromRequest: jest.fn(),
}));
-11
View File
@@ -1,11 +0,0 @@
/**
* Emoji reactions (#839): the fixed, curated reaction set. Guests pick ONE
* of these per photo (changeable). Kept as a shared constant so the
* validator, the service and the export layer can never drift apart.
*
* Mirrored in frontend/src/services/feedback.service.ts (REACTION_EMOJIS) —
* update both together.
*/
const REACTION_EMOJIS = ['❤️', '😂', '😍', '👏', '🎉'];
module.exports = { REACTION_EMOJIS };
+1 -1
View File
@@ -72,7 +72,7 @@ async function apiTokenAuth(req, res, next) {
}
// Touch last_used_at — async, don't block the request.
db('api_tokens').where({ id: row.id }).update({ last_used_at: new Date().toISOString() })
db('api_tokens').where({ id: row.id }).update({ last_used_at: new Date() })
.catch((err) => logger.debug('api_tokens last_used update failed', { err: err.message }));
req.admin = admin;
+1 -19
View File
@@ -2,7 +2,6 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
@@ -39,13 +38,6 @@ async function adminAuth(req, res, next) {
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject any session issued before the global cutoff (set by a .picpeak
// restore, which can reassign admin ids). Forces every pre-restore admin
// session to re-authenticate against the restored data.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Verify token type
if (decoded.type !== 'admin') {
@@ -165,12 +157,7 @@ async function galleryAuth(req, res, next) {
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Verify token type
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid access token' });
@@ -234,11 +221,6 @@ async function photoAuth(req, res, next) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
-6
View File
@@ -13,7 +13,6 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
@@ -62,11 +61,6 @@ async function customerAuth(req, res, next) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
if (decoded.type !== 'customer') {
logger.warn('[customerAuth] wrong token type', {
url: req.originalUrl,
+11 -18
View File
@@ -34,26 +34,20 @@ async function getRateLimitSettings() {
.where('setting_key', 'feedback_rate_limits')
.first();
// Defaults FIRST, stored values override: persisted rows predate newer
// action types (`reaction`, #839) — returning the stored object alone
// would silently drop their intended defaults to the generic 100/h.
const defaults = {
if (settings && settings.setting_value) {
// setting_value is already a JSON object in PostgreSQL
return typeof settings.setting_value === 'string'
? JSON.parse(settings.setting_value)
: settings.setting_value;
}
// Default settings
return {
rating: { max: 100, window: 3600 }, // 100 ratings per hour
comment: { max: 20, window: 3600 }, // 20 comments per hour
like: { max: 200, window: 3600 }, // 200 likes per hour
favorite: { max: 100, window: 3600 }, // 100 favorites per hour
reaction: { max: 200, window: 3600 } // reactions churn like likes (#839)
favorite: { max: 100, window: 3600 } // 100 favorites per hour
};
if (settings && settings.setting_value) {
// setting_value is already a JSON object in PostgreSQL
const stored = typeof settings.setting_value === 'string'
? JSON.parse(settings.setting_value)
: settings.setting_value;
return { ...defaults, ...stored };
}
return defaults;
} catch (error) {
logger.error('Error getting rate limit settings:', error);
// Return defaults on error
@@ -61,8 +55,7 @@ async function getRateLimitSettings() {
rating: { max: 100, window: 3600 },
comment: { max: 20, window: 3600 },
like: { max: 200, window: 3600 },
favorite: { max: 100, window: 3600 },
reaction: { max: 200, window: 3600 }
favorite: { max: 100, window: 3600 }
};
}
}
-5
View File
@@ -164,11 +164,6 @@ async function verifyGalleryAccess(req, res, next) {
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event;
req.accessLevel = decoded.accessLevel || 'guest';
// Customer-portal provenance (#746/#849): portal-minted tokens carry
// via:'customer' but NO accessLevel (they default to guest), while
// PIN-client logins carry accessLevel:'client' without `via`. Activity
// attribution/dedup needs the distinction, so surface it explicitly.
req.viaCustomer = decoded.via === 'customer';
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
// Create client info for logging (similar to secureImageMiddleware but simpler)
-9
View File
@@ -73,15 +73,6 @@ async function maintenanceMiddleware(req, res, next) {
// entries here matched nothing, which is exactly why the lockout happened).
const skipPaths = [
'/api/auth/admin/login',
// The second factor is part of the same login — without this, any
// MFA-enrolled admin gets a 503 on the verify step and cannot sign in
// at all while maintenance mode is on.
'/api/auth/admin/login/mfa',
// SSO variants of the admin login (#798) — same reasoning: an SSO-only
// (JIT-provisioned) admin has no password, so blocking these would make
// maintenance mode admin-proof for them.
'/api/auth/admin/sso/login',
'/api/auth/admin/sso/callback',
'/api/auth/session',
'/api/public/settings',
'/health'
+2 -2
View File
@@ -23,7 +23,7 @@ async function validateUploadedFile(filePath) {
let metadata;
try {
metadata = await sharp(filePath, {
failOn: 'none', // Don't fail on recoverable errors
failOnError: false, // Don't fail on recoverable errors
limitInputPixels: 268402689 // ~16k x 16k max
}).metadata();
} catch (metadataError) {
@@ -43,7 +43,7 @@ async function validateUploadedFile(filePath) {
// Additional check: verify we can actually decode a small portion of the image
try {
await sharp(filePath, {
failOn: 'none',
failOnError: false,
limitInputPixels: 268402689
})
.resize(10, 10) // Try to resize to very small size
+2 -11
View File
@@ -11,7 +11,6 @@ const { db, logActivity } = require('../database/db');
const { adminAuth } = require('./../middleware/auth');
const { requirePermission } = require('./../middleware/permissions');
const { generateApiToken, VALID_SCOPES } = require('./../middleware/apiTokenAuth');
const { toIso } = require('../utils/dateNormalize');
const logger = require('../utils/logger');
const router = express.Router();
@@ -34,15 +33,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
'admin_users.username as owner_username'
)
.orderBy('api_tokens.created_at', 'desc');
// toIso: last_used_at / revoked_at were written as raw Dates before
// this fix — SQLite installs hold epoch numbers in existing rows.
res.json(tokens.map((t) => ({
...t,
created_at: toIso(t.created_at),
expires_at: toIso(t.expires_at),
last_used_at: toIso(t.last_used_at),
revoked_at: toIso(t.revoked_at),
})));
res.json(tokens);
} catch (error) {
logger.error('Failed to list API tokens', { error: error.message });
res.status(500).json({ error: 'Failed to list tokens' });
@@ -112,7 +103,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
if (!row) return res.status(404).json({ error: 'Token not found' });
if (row.revoked_at) return res.status(400).json({ error: 'Token already revoked' });
await db('api_tokens').where({ id }).update({ revoked_at: new Date().toISOString() });
await db('api_tokens').where({ id }).update({ revoked_at: new Date() });
await logActivity('api_token_revoked', { name: row.name }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username
});
+1 -12
View File
@@ -9,7 +9,6 @@ const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const StreamZip = require('node-stream-zip');
const { requireEventOwnership } = require('../middleware/ownership');
const { assertZipEntriesWithin } = require('../utils/safePath');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
const router = express.Router();
@@ -184,16 +183,6 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
const entries = Object.values(await zip.entries());
logger.info(`Archive contains ${entries.length} entries`);
// Reject ZIP-slip entries before writing anything to disk — extract()
// does not neutralise `../` in entry names (GHSA-jfhw-fj23-fx6x).
try {
assertZipEntriesWithin(entries, eventDir);
} catch (slipErr) {
await zip.close();
logger.warn(`Refusing archive restore — unsafe entry path: ${slipErr.message}`);
return res.status(400).json({ error: 'Archive contains invalid entry paths' });
}
// Stream-extract everything to disk
await zip.extract(null, eventDir);
await zip.close();
@@ -293,7 +282,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
type: path.extname(filename).substring(1).toLowerCase(),
size_bytes: stats.size,
category_id: categoryId,
uploaded_at: new Date().toISOString()
uploaded_at: new Date()
});
}
} catch (statError) {
+2 -43
View File
@@ -2,8 +2,6 @@ const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
const { revokeToken } = require('../utils/tokenRevocation');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
const logger = require('../utils/logger');
const { errorResponse, getPagination } = require('../utils/routeHelpers');
@@ -31,13 +29,7 @@ router.get('/config', adminAuth, requirePermission('backup.view'), async (req, r
config[setting.setting_key] = setting.setting_value;
}
});
// Never return the stored credentials — mask like the email/WhatsApp
// config endpoints do. The PUT below skips the mask sentinel, so the
// form round-trips without clobbering the real values.
if (config.backup_s3_secret_key) config.backup_s3_secret_key = '••••••••';
if (config.backup_rsync_ssh_key) config.backup_rsync_ssh_key = '••••••••';
res.json(config);
} catch (error) {
errorResponse(res, error, 500, 'Failed to get backup configuration');
@@ -73,11 +65,6 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
// Update settings
for (const [key, value] of Object.entries(updates)) {
// An unchanged secret round-trips as the GET mask sentinel — keep the
// stored value instead of overwriting it with bullets.
if (value === '••••••••') {
continue;
}
if (key.startsWith('backup_')) {
await db('app_settings')
.insert({
@@ -191,40 +178,12 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
const picpeakPath = req.file.path;
try {
const { importFromPicpeak } = require('../services/picpeakImportService');
// adminAuth populates req.admin, not req.user. Passing req.user.id here
// left currentAdminId undefined, so reinjectCurrentAdmin() had no account
// to preserve and the admin_users table was fully replaced by the backup —
// letting a crafted .picpeak take over every admin account (GHSA-qxfx-4493-4v8f).
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.admin && req.admin.id });
// The restore rewrote admin_users, so ids may have shifted. importFromPicpeak
// already stamped a GLOBAL session cutoff (see setSessionsValidAfter), so
// every JWT issued before the restore — admin, customer, gallery — now fails
// auth. Here we additionally give the importing admin an immediate, clean
// logout: revoke this token and clear the cookie so their browser drops the
// session at once rather than on the next 401. Cookie clear is the
// unconditional guarantee; revokeToken() swallows DB errors and returns
// false, so check the result and log loudly if the denylist write didn't
// land (the operator still re-logs-in, which the cookie clear forces).
let tokenRevoked = false;
try {
if (req.token) {
tokenRevoked = await revokeToken(req.token, 'picpeak-import', { adminId: req.admin && req.admin.id });
}
} catch (revokeErr) {
logger.warn('[picpeak-import] failed to revoke session token after restore', { error: revokeErr.message });
}
if (req.token && !tokenRevoked) {
logger.warn('[picpeak-import] session token was NOT added to the revocation denylist after restore; relying on cookie clear to force re-login');
}
clearAdminAuthCookie(res);
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id });
res.json({
success: true,
tables: result.tables,
filesRestored: result.filesRestored,
usesExternalMedia: result.usesExternalMedia,
sessionInvalidated: true,
});
} catch (error) {
const status = error.statusCode || 500;
+14 -154
View File
@@ -4,8 +4,6 @@ const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const logger = require('../utils/logger');
const router = express.Router();
@@ -14,9 +12,8 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
try {
const categories = await db('photo_categories')
.where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
logger.error('Error fetching categories:', error);
@@ -24,12 +21,19 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
}
});
// Get categories for a specific event (global + event-specific), resolved to
// the event's effective order: per-event override, else global default, else
// name (#782). Each row carries `override_position` (null when not customised).
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), requireEventOwnership, async (req, res) => {
// Get categories for a specific event (global + event-specific)
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const categories = await getEventCategoriesOrdered(req.params.eventId);
const { eventId } = req.params;
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', formatBoolean(true))
.orWhere('event_id', eventId);
})
.orderBy('is_global', 'desc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
logger.error('Error fetching event categories:', error);
@@ -77,27 +81,12 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
return res.status(400).json({ error: 'Category with this slug already exists' });
}
// Append to the end of its scope so a new category doesn't jump to the
// top of an admin-defined order (#782).
const maxRow = await db('photo_categories')
.where(function() {
if (is_global) {
this.where('is_global', formatBoolean(true));
} else {
this.where('event_id', event_id);
}
})
.max('display_order as maxOrder')
.first();
const nextOrder = (maxRow?.maxOrder || 0) + 1;
// Create category
const insertResult = await db('photo_categories').insert({
name,
slug: categorySlug,
is_global,
event_id: is_global ? null : event_id,
display_order: nextOrder
event_id: is_global ? null : event_id
}).returning('id');
const categoryId = insertResult[0]?.id || insertResult[0];
@@ -265,133 +254,4 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
}
});
// Set a per-event category order override (#782). The client sends the full
// ordered id list for THIS event — globals + event-specific, interleaved — and
// we replace the event's override rows in one transaction. This overrides the
// global default order for this gallery only.
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
body('event_id').isInt().withMessage('event_id must be an integer'),
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const eventId = parseInt(req.body.event_id, 10);
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
// Event ownership (event_id comes from the body, so requireEventOwnership —
// which reads req.params — can't be used here). Mirror it: super_admins
// bypass; other admins may only reorder events they own (ownerless
// legacy/system events allowed).
if (req.admin.roleName !== 'super_admin') {
const event = await db('events').where('id', eventId).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (event.created_by && event.created_by !== req.admin.id) {
return res.status(403).json({ error: 'Access denied' });
}
}
// Every id must be a category available to this event: a shared global OR
// one of the event's own categories. Anything else is out of scope.
const available = await db('photo_categories')
.where(function() {
this.where('is_global', formatBoolean(true)).orWhere('event_id', eventId);
})
.pluck('id');
const availableSet = new Set(available);
const invalid = orderedIds.filter((id) => !availableSet.has(id));
if (invalid.length > 0) {
return res.status(400).json({ error: 'One or more categories are not available for this event' });
}
await db.transaction(async (trx) => {
await trx('event_category_order').where('event_id', eventId).del();
await trx('event_category_order').insert(
orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 }))
);
});
// Log activity after commit (avoids a SQLite in-transaction global write).
await logActivity('event_category_order_set',
{ eventId, count: orderedIds.length },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(await getEventCategoriesOrdered(eventId));
} catch (error) {
logger.error('Error reordering categories:', error);
res.status(500).json({ error: 'Failed to reorder categories' });
}
});
// Clear an event's override — revert this gallery to the global default order.
router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), requireEventOwnership, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId, 10);
await db('event_category_order').where('event_id', eventId).del();
await logActivity('event_category_order_reset',
{ eventId },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(await getEventCategoriesOrdered(eventId));
} catch (error) {
logger.error('Error resetting category order:', error);
res.status(500).json({ error: 'Failed to reset category order' });
}
});
// Set the GLOBAL default order for shared (global) categories (#782). Applies
// to every gallery that hasn't set its own override. Rewrites display_order.
router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
const globals = await db('photo_categories').where('is_global', formatBoolean(true)).pluck('id');
const globalsSet = new Set(globals);
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
if (invalid.length > 0) {
return res.status(400).json({ error: 'One or more categories are not global' });
}
await db.transaction(async (trx) => {
for (let i = 0; i < orderedIds.length; i += 1) {
await trx('photo_categories').where('id', orderedIds[i]).update({ display_order: i + 1 });
}
});
await logActivity('global_category_order_set',
{ count: orderedIds.length },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
const categories = await db('photo_categories')
.where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
logger.error('Error reordering global categories:', error);
res.status(500).json({ error: 'Failed to reorder global categories' });
}
});
module.exports = router;

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