Compare commits
123 Commits
v3.124.1-beta.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ce6dbdab56 | |||
| 7cd7654f99 | |||
| 415497ad06 | |||
| 98d25601b4 | |||
| 2a04137a75 | |||
| f094cc06a7 | |||
| ed7ddd1e60 | |||
| 70f5a8c54e | |||
| 7c0c5c1cda | |||
| 4622478e44 | |||
| f92d4bb2d9 | |||
| 1080388f28 | |||
| 316bcbd679 | |||
| ec03089d57 | |||
| b798d8e4c1 | |||
| 59ea83c84e | |||
| 38b0e1d584 | |||
| abc960170b | |||
| cdde937d7f | |||
| e3247911a0 | |||
| e290207934 | |||
| f6b81fabf0 | |||
| c4d89c9d64 | |||
| a2bf1f644c | |||
| 443ec91de9 | |||
| 15cd5ede82 | |||
| 6d717544ec | |||
| 9f4b9bab46 | |||
| de3ae1f176 | |||
| 59ef2ee9af | |||
| 539f5db2b5 | |||
| 411d459338 | |||
| ff23efec81 | |||
| fb2f8333dc | |||
| 77b4aab61a | |||
| a5f7b38e02 | |||
| 9a437ee9e1 | |||
| 81b10f4e31 | |||
| 9168bdd504 | |||
| c61a6b089e | |||
| d20f80112f | |||
| 8d0c32902d | |||
| 662516a5ad | |||
| a31a2e25e2 | |||
| 20a291eb34 | |||
| f0e6d2dfb1 | |||
| 895e5ab3cc | |||
| acb25a9a1c | |||
| c97341e454 | |||
| 0e459b3293 | |||
| f83cbe9109 | |||
| d36585379f | |||
| 7e9a61c245 | |||
| 810801a9ab | |||
| 8cc7d7d14a | |||
| 1b3f721d10 | |||
| d6a0c4aff5 | |||
| fb9da72f14 | |||
| 16b79ee119 | |||
| 5c1e38d921 | |||
| 69754f8a2c | |||
| 8017370271 | |||
| 834111fc66 | |||
| 9bbdca9fc5 | |||
| e43216311b | |||
| 79eb6d72eb | |||
| 9d18868a07 | |||
| 1f09ac2d0d | |||
| fde0558811 | |||
| 0986f7f7ac | |||
| a02fa08f69 | |||
| a16ff855dd | |||
| f63347d79a | |||
| 5b8c13feb6 | |||
| ac24319135 | |||
| 7c968e74bb | |||
| e06d0b4513 | |||
| 35cbcefed2 | |||
| f114f3e876 | |||
| 7e40579217 | |||
| aa6e5d613f | |||
| 9edce856ff | |||
| 65831785a2 | |||
| 7b2dd3fab1 | |||
| 83b74e2256 | |||
| 5306fe378c | |||
| ef8a52f02c | |||
| 3cc893126d | |||
| 6459bff50e | |||
| c75839d3ea | |||
| ac01b73f56 | |||
| c4b03a831f | |||
| b3937d0b8c | |||
| 02b353e54f | |||
| 77ae94e649 | |||
| 25c3e3d7b8 | |||
| b801f3a6b8 | |||
| 7ca783f89b | |||
| 63ab731cee | |||
| a6a1db5254 | |||
| b0bb65d0d2 | |||
| d3622de81a | |||
| c358bc65f7 | |||
| 35b42bba9d | |||
| e40bc474bc | |||
| c741dc22c5 | |||
| 1e8b6f1b0f | |||
| a7382591bf | |||
| 5d31b61c8d | |||
| e347f8f40f | |||
| cc263f2e87 | |||
| 32d745b575 | |||
| c7cedb00d6 | |||
| 75ef137b7d | |||
| a9e51d8fd7 | |||
| bb76ca5375 | |||
| 9785b636a9 | |||
| 22da018e1b | |||
| 80e238f0ad | |||
| 4944b9b3b6 | |||
| 83fbb63e13 | |||
| c043897b0e | |||
| b53e5d97b4 |
@@ -125,6 +125,11 @@ DB_NAME=picpeak_prod
|
||||
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
|
||||
# normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that.
|
||||
#
|
||||
# Webhook and email-webhook deliveries connect to the DNS answer they just
|
||||
# validated and ignore HTTP_PROXY / HTTPS_PROXY. Behind a mandatory egress
|
||||
# proxy set the *_ALLOW_PRIVATE_URLS flag, which sends through the proxy
|
||||
# without pinning.
|
||||
#
|
||||
# A mail account with its own SMTP host (Settings -> Mail accounts) keeps
|
||||
# sending through it; this replaces the global transport only.
|
||||
#
|
||||
@@ -178,6 +183,17 @@ VITE_API_URL=/api
|
||||
# lower to 1 on very small hosts. Default: 2
|
||||
# FILE_WATCHER_CONCURRENCY=2
|
||||
|
||||
# External-media folder watcher (issue 1187). Reference-mode events can opt in
|
||||
# per event (Event → Source Mode → "Watch folder for new files"); new images in
|
||||
# the folder are then imported without pressing Import. Deleted files are
|
||||
# never removed from the gallery.
|
||||
# EXTERNAL_MEDIA_WATCH=true # global kill switch
|
||||
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
|
||||
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
|
||||
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
|
||||
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
|
||||
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
|
||||
|
||||
# Release Channel
|
||||
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
|
||||
# 'stable' uses the :stable tag (same as :latest on main)
|
||||
@@ -336,3 +352,15 @@ LOGS=./logs
|
||||
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
||||
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
|
||||
# should you change VITE_API_URL at build time.
|
||||
|
||||
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
|
||||
# Keep this value stable until participation has been deleted.
|
||||
# USAGE_ENCRYPTION_KEY=
|
||||
|
||||
# Graceful shutdown budget in milliseconds. On SIGTERM the server stops
|
||||
# accepting requests, drains workers and closes the pool; whatever is still
|
||||
# running after this long is abandoned so the process exits before Docker's
|
||||
# 10 s stop grace period (raise stop_grace_period together with this value).
|
||||
#SHUTDOWN_TIMEOUT_MS=8000
|
||||
|
||||
@@ -24,17 +24,20 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- OS: [e.g. Ubuntu 22.04]
|
||||
- Browser: [e.g. Chrome 120, Safari 17]
|
||||
- PicPeak Version: [e.g. 1.0.22]
|
||||
- Deployment Method: [e.g. Docker Compose, Manual]
|
||||
- Database: [e.g. PostgreSQL 15, SQLite]
|
||||
- OS and version:
|
||||
- Browser and version:
|
||||
- PicPeak version and Docker image tag (if applicable):
|
||||
- Deployment method: [Docker Compose, all-in-one container, manual]
|
||||
- Database and version: [PostgreSQL, SQLite]
|
||||
|
||||
**Logs**
|
||||
Please include relevant logs:
|
||||
```
|
||||
# Backend logs
|
||||
docker-compose logs backend | tail -50
|
||||
# Backend logs (Docker Compose)
|
||||
docker compose logs --tail=50 backend
|
||||
|
||||
# Or all-in-one container logs (replace picpeak if your container has another name)
|
||||
docker logs --tail=50 picpeak
|
||||
|
||||
# Frontend console errors
|
||||
[paste any browser console errors]
|
||||
@@ -44,4 +47,4 @@ docker-compose logs backend | tail -50
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Possible Solution**
|
||||
If you have an idea how to fix the issue, please describe it here.
|
||||
If you have an idea how to fix the issue, please describe it here.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📚 Documentation
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
|
||||
about: Please read the documentation before opening an issue
|
||||
url: https://docs.picpeak.app
|
||||
about: Installation, configuration and feature guides
|
||||
- name: 💬 Discussions
|
||||
url: https://github.com/PicPeak/picpeak/discussions
|
||||
about: Ask questions and discuss with the community
|
||||
- name: 🔒 Security Issues
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
|
||||
@@ -9,6 +9,7 @@ assignees: ''
|
||||
|
||||
**What documentation needs improvement?**
|
||||
Please specify which document or section needs attention:
|
||||
- [ ] Documentation website (https://docs.picpeak.app)
|
||||
- [ ] README.md
|
||||
- [ ] DEPLOYMENT.md
|
||||
- [ ] CONTRIBUTING.md
|
||||
@@ -16,6 +17,8 @@ Please specify which document or section needs attention:
|
||||
- [ ] Code Comments
|
||||
- [ ] Other: ___________
|
||||
|
||||
Link to the affected page or file:
|
||||
|
||||
**Describe the issue**
|
||||
What's wrong or missing in the documentation?
|
||||
|
||||
@@ -30,4 +33,4 @@ Who is this documentation for?
|
||||
- [ ] End users (photographers/clients)
|
||||
|
||||
**Additional context**
|
||||
Add any other context, examples, or references here.
|
||||
Add any other context, examples, or references here.
|
||||
|
||||
+21
-17
@@ -6,11 +6,6 @@ name: Tests
|
||||
# calendar) plus the photo / settings / OG / auth surface — wiring them
|
||||
# into CI makes regressions visible at PR time instead of post-merge.
|
||||
#
|
||||
# Six backend suites are excluded via --testPathIgnorePatterns. They
|
||||
# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM
|
||||
# regressions). Excluding them here keeps CI green from day 1; revisit
|
||||
# each individually as its own fix.
|
||||
#
|
||||
# Triggers on any change that could affect either suite. The backend
|
||||
# job intentionally omits frontend paths and vice versa so unrelated
|
||||
# PRs don't pay both build costs.
|
||||
@@ -89,18 +84,7 @@ jobs:
|
||||
# Un-gates the real-Postgres cases in the .picpeak restore suites
|
||||
# (see the `services:` note above). Absent it they silently skip.
|
||||
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
|
||||
run: |
|
||||
# Excluded suites — fail on upstream/beta too, tracked
|
||||
# separately as test-infra debt:
|
||||
# adminSettings.logo — supertest fixture
|
||||
# integration/adminPhotos.reference — supertest fixture
|
||||
# integration/webhookDelivery — supertest fixture
|
||||
# services/backupService.enhanced — knex mock chain
|
||||
# routes/__tests__/adminAuth — supertest fixture
|
||||
# (adminNotifications was excluded; #597 fix re-enables it.)
|
||||
npx jest \
|
||||
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \
|
||||
--ci
|
||||
run: npx jest --ci
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -121,10 +105,30 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint frontend (including Rules of Hooks)
|
||||
working-directory: ./frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Run Vitest suite
|
||||
working-directory: ./frontend
|
||||
run: npm test -- --run
|
||||
|
||||
nginx:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
strategy:
|
||||
matrix:
|
||||
# Match the two shipped frontend Dockerfiles.
|
||||
image: ['nginx:1.28-alpine', 'nginx:1.30-alpine']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify token-safe nginx logging
|
||||
env:
|
||||
NGINX_TEST_IMAGE: ${{ matrix.image }}
|
||||
run: python3 tests/nginx/test_request_logging.py
|
||||
|
||||
# Optional face-detection sidecar (#1074). Runs on every PR regardless of
|
||||
# whether the feature is enabled anywhere — these tests need no model
|
||||
# weights (they stub the pipeline out) and cover the auth boundary, the
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.124.1-beta.0"
|
||||
".": "3.131.7-beta.0"
|
||||
}
|
||||
|
||||
+232
@@ -5,6 +5,238 @@ 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.131.7-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.6-beta.0...v3.131.7-beta.0) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** keep header-style tiles from overflowing their cards ([#1422](https://github.com/PicPeak/picpeak/issues/1422)) ([7cd7654](https://github.com/PicPeak/picpeak/commit/7cd7654f99967080724d7498b72bea4986ae825d))
|
||||
|
||||
## [3.131.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.5-beta.0...v3.131.6-beta.0) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** cap how many cached zips rebuild at once in the background ([#1418](https://github.com/PicPeak/picpeak/issues/1418)) ([98d2560](https://github.com/PicPeak/picpeak/commit/98d25601b465ecc44d0875b52891073720fabbbc))
|
||||
|
||||
## [3.131.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.4-beta.0...v3.131.5-beta.0) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** stop the pre-zip build leaking storage reads ([#1402](https://github.com/PicPeak/picpeak/issues/1402)) ([f094cc0](https://github.com/PicPeak/picpeak/commit/f094cc06a78e776795e60cb3b11e8653681a2f31))
|
||||
|
||||
## [3.131.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.3-beta.0...v3.131.4-beta.0) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** bump sharp, nodemailer, multer, js-yaml, joi for security fixes ([#1374](https://github.com/PicPeak/picpeak/issues/1374)) ([f6b81fa](https://github.com/PicPeak/picpeak/commit/f6b81fabf05ab0bbce48e63bbdf3812b30aa10de))
|
||||
* **backend:** contain and sanitize the SQLite restore source path ([#1384](https://github.com/PicPeak/picpeak/issues/1384)) ([316bcbd](https://github.com/PicPeak/picpeak/commit/316bcbd67965ddec74801308a722b506bb8da265))
|
||||
* **backend:** enforce event ownership on short URL deletion ([#1379](https://github.com/PicPeak/picpeak/issues/1379)) ([e290207](https://github.com/PicPeak/picpeak/commit/e290207934708f8bf41676aec39a43474f0f6172))
|
||||
* **backend:** reject a replayed TOTP code within its validity window ([#1389](https://github.com/PicPeak/picpeak/issues/1389)) ([cdde937](https://github.com/PicPeak/picpeak/commit/cdde937d7fce130d67e331bd968cb2c4734902d4))
|
||||
* **backend:** require actor to hold every permission of a role they grant ([#1378](https://github.com/PicPeak/picpeak/issues/1378)) ([59ea83c](https://github.com/PicPeak/picpeak/commit/59ea83c84efdcf6d488853a25ba05f1d15ef1150))
|
||||
* **backend:** shorten payment-check token TTL and notify admin on use ([#1385](https://github.com/PicPeak/picpeak/issues/1385)) ([e324791](https://github.com/PicPeak/picpeak/commit/e3247911a071b5277d877a1fa0a39c544d3a03e8))
|
||||
* **backend:** use the strong password generator for resets and enforce must_change_password ([#1387](https://github.com/PicPeak/picpeak/issues/1387)) ([b798d8e](https://github.com/PicPeak/picpeak/commit/b798d8e4c19541da6ee1f58c2756182817b6c716))
|
||||
* **backend:** validate business-profile logo uploads by content, not filename ([#1381](https://github.com/PicPeak/picpeak/issues/1381)) ([abc9601](https://github.com/PicPeak/picpeak/commit/abc960170b1eac0fa1f3110015e8fce671d428ab))
|
||||
* **backend:** validate event id before using it in the logo storage filename ([#1382](https://github.com/PicPeak/picpeak/issues/1382)) ([38b0e1d](https://github.com/PicPeak/picpeak/commit/38b0e1d5842217030e7dd48247e523ded4b19c58))
|
||||
* **backend:** validate the S3 endpoint host before the restore download ([#1383](https://github.com/PicPeak/picpeak/issues/1383)) ([ec03089](https://github.com/PicPeak/picpeak/commit/ec03089d57c88ff0f7c21b6a4947ffb9ef9771f9))
|
||||
* **gallery:** bound and reclaim storage reads in the remaining zip builders ([#1410](https://github.com/PicPeak/picpeak/issues/1410)) ([70f5a8c](https://github.com/PicPeak/picpeak/commit/70f5a8c54e096f70c030d63414d5954190cb68a8))
|
||||
* **gallery:** keep an admin draft preview out of the guest share-login flow ([f92d4bb](https://github.com/PicPeak/picpeak/commit/f92d4bb2d9c2ea84f59dd4cfaa3a4272f1eec56b))
|
||||
* **gallery:** keep videos playable under enhanced and maximum protection ([#1404](https://github.com/PicPeak/picpeak/issues/1404)) ([1080388](https://github.com/PicPeak/picpeak/commit/1080388f28b846553cd670c66e9632275eb9c994))
|
||||
* **gallery:** let an admin preview a draft through its short share URL ([f92d4bb](https://github.com/PicPeak/picpeak/commit/f92d4bb2d9c2ea84f59dd4cfaa3a4272f1eec56b))
|
||||
* **gallery:** let an admin preview a draft through its short share URL ([#1405](https://github.com/PicPeak/picpeak/issues/1405)) ([f92d4bb](https://github.com/PicPeak/picpeak/commit/f92d4bb2d9c2ea84f59dd4cfaa3a4272f1eec56b))
|
||||
* **upload:** let the csrf gate pass application/octet-stream chunks ([#1401](https://github.com/PicPeak/picpeak/issues/1401)) ([7c0c5c1](https://github.com/PicPeak/picpeak/commit/7c0c5c1cda3921cbd9404dd77d64bdec7a9ae2aa))
|
||||
* **upload:** stop buffering a chunk body before anything checks its size ([#1406](https://github.com/PicPeak/picpeak/issues/1406)) ([4622478](https://github.com/PicPeak/picpeak/commit/4622478e44d5e63da031a2f27c5a5c335282eacb))
|
||||
|
||||
## [3.131.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.2-beta.0...v3.131.3-beta.0) (2026-09-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **video:** try metadata extraction and thumbnail generation independently ([#1371](https://github.com/PicPeak/picpeak/issues/1371)) ([a2bf1f6](https://github.com/PicPeak/picpeak/commit/a2bf1f644c78734fc9a86a24d441b7a70bdb38fb))
|
||||
|
||||
## [3.131.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.1-beta.0...v3.131.2-beta.0) (2026-09-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backup:** honor the configured database-backup destination path ([#1366](https://github.com/PicPeak/picpeak/issues/1366)) ([15cd5ed](https://github.com/PicPeak/picpeak/commit/15cd5ede82171f5869342de869903f55c73f3871))
|
||||
|
||||
## [3.131.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.0-beta.0...v3.131.1-beta.0) (2026-09-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **usage:** explain and de-emphasize the pending-packet button lock ([#1363](https://github.com/PicPeak/picpeak/issues/1363)) ([9f4b9ba](https://github.com/PicPeak/picpeak/commit/9f4b9bab46264d83dcdf698ce5bc318703eb10ee))
|
||||
|
||||
## [3.131.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.130.2-beta.0...v3.131.0-beta.0) (2026-09-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **setup:** add anonymous usage-reporting opt-in to the first-run wizard ([8d0c329](https://github.com/PicPeak/picpeak/commit/8d0c32902dd78324286a7585188d0c292692c8ec))
|
||||
* **setup:** add product usage consent to the first-run wizard ([539f5db](https://github.com/PicPeak/picpeak/commit/539f5db2b5e28dc42742fbbbe082fc3c23a7263e))
|
||||
* **usage:** prompt existing admins once for usage reporting after an update ([59ef2ee](https://github.com/PicPeak/picpeak/commit/59ef2ee9af74e97e042934e6ea5fab3b0b687617))
|
||||
* **usage:** prompt existing admins once for usage reporting after an update ([d20f801](https://github.com/PicPeak/picpeak/commit/d20f80112f95c7d718f936ea14aadf7eb0accdea))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* complete graceful shutdown and revoke tokens without expiry ([411d459](https://github.com/PicPeak/picpeak/commit/411d459338289cae7dce8cddbe7c78dae3d4f449))
|
||||
* interrupt idle worker waits during shutdown ([a31a2e2](https://github.com/PicPeak/picpeak/commit/a31a2e25e2666989ee226c0d7884e23f50fe58da))
|
||||
* retain revocations for tokens without expiry ([662516a](https://github.com/PicPeak/picpeak/commit/662516a5ad2a0aadd87dfff3fba4f2456e88a69d))
|
||||
* **setup:** refresh usage state after accepting consent ([a5f7b38](https://github.com/PicPeak/picpeak/commit/a5f7b38e02f20e66047431c5cb04e6f34c60c689))
|
||||
* **setup:** require the full usage reporting disclosure ([9168bdd](https://github.com/PicPeak/picpeak/commit/9168bdd5048b4419db7b0f4444375f54c06f5bbb))
|
||||
* **usage:** cap the update-prompt modal height so it scrolls on short viewports ([c61a6b0](https://github.com/PicPeak/picpeak/commit/c61a6b089e56beec1de5c106440764518f507012))
|
||||
* **usage:** classify prompt acknowledgement in privacy coverage ([fb2f833](https://github.com/PicPeak/picpeak/commit/fb2f8333dca79b9cada9f349671920e34db38d5d))
|
||||
* **usage:** preserve consent choices and make the prompt accessible ([9a437ee](https://github.com/PicPeak/picpeak/commit/9a437ee9e19f6ecb8bff8747de71d3a9527d9512))
|
||||
* **usage:** synchronize setup consent and dismissal state ([77b4aab](https://github.com/PicPeak/picpeak/commit/77b4aab61a54d92b46fdc93fce5a075e3dc1d794))
|
||||
|
||||
## [3.130.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.130.1-beta.0...v3.130.2-beta.0) (2026-09-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce gallery access and consolidate gallery workflows ([#1357](https://github.com/PicPeak/picpeak/issues/1357)) ([f0e6d2d](https://github.com/PicPeak/picpeak/commit/f0e6d2dfb12460cb1d003802f346e2026fa1c016))
|
||||
|
||||
## [3.130.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.130.0-beta.0...v3.130.1-beta.0) (2026-09-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **images:** probe and clean up preview tiers under the extension the encoder actually wrote ([#1355](https://github.com/PicPeak/picpeak/issues/1355)) ([acb25a9](https://github.com/PicPeak/picpeak/commit/acb25a9a1ce9887e51ff769d98f65379a5a1b803))
|
||||
* **images:** single-flight lazy rendition generation and keep the old rendition during replacement ([#1350](https://github.com/PicPeak/picpeak/issues/1350)) ([c97341e](https://github.com/PicPeak/picpeak/commit/c97341e4547257aab57fb746bad4203a1adaf560))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* define security support across stable and main ([#1351](https://github.com/PicPeak/picpeak/issues/1351)) ([0e459b3](https://github.com/PicPeak/picpeak/commit/0e459b3293ce9132ee8bb8324c6e76692e580cc5))
|
||||
* refresh repository support and community links ([#1349](https://github.com/PicPeak/picpeak/issues/1349)) ([f83cbe9](https://github.com/PicPeak/picpeak/commit/f83cbe9109c5c7b25a0b50e0f0e3244d32421e7e))
|
||||
|
||||
## [3.130.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.129.0-beta.0...v3.130.0-beta.0) (2026-09-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **external-media:** watch reference folders and import new files automatically ([#1345](https://github.com/PicPeak/picpeak/issues/1345)) ([8cc7d7d](https://github.com/PicPeak/picpeak/commit/8cc7d7d14a93c5d4c397eaf928361c70810b7e0a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **events:** drop non-canonical keys from the event update before any check runs ([#1346](https://github.com/PicPeak/picpeak/issues/1346)) ([810801a](https://github.com/PicPeak/picpeak/commit/810801a9ab5df49cf47cd7bf9446fe6a54e6808d))
|
||||
|
||||
## [3.129.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.128.0-beta.0...v3.129.0-beta.0) (2026-09-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cms:** keep the editor toolbar in reach on long pages ([#1335](https://github.com/PicPeak/picpeak/issues/1335)) ([d6a0c4a](https://github.com/PicPeak/picpeak/commit/d6a0c4aff52bab268799e9f7c2c5b62f2410c958))
|
||||
* **security:** opt-in recoverable gallery passwords ([#1341](https://github.com/PicPeak/picpeak/issues/1341)) ([fb9da72](https://github.com/PicPeak/picpeak/commit/fb9da72f1402aa8aee7ce0e575907789ed9faf33))
|
||||
|
||||
## [3.128.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.127.2-beta.0...v3.128.0-beta.0) (2026-09-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **settings:** expose the API rate limiter in the Security tab ([#1338](https://github.com/PicPeak/picpeak/issues/1338)) ([8017370](https://github.com/PicPeak/picpeak/commit/80173702712ffd2a8150d2f8326e1455fce1036a))
|
||||
* **usage:** distinguish real edits and template delivery with v5 consent ([#1339](https://github.com/PicPeak/picpeak/issues/1339)) ([5c1e38d](https://github.com/PicPeak/picpeak/commit/5c1e38d921f7973633d6f9d12fde4c7588214d31))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **email:** scrub gallery passwords from the sent-mail archive ([#1340](https://github.com/PicPeak/picpeak/issues/1340)) ([69754f8](https://github.com/PicPeak/picpeak/commit/69754f8a2cc99eec318a310a60609894d757f515))
|
||||
|
||||
## [3.127.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.127.1-beta.0...v3.127.2-beta.0) (2026-09-07)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **security:** correct the rate limiter defaults and how they are set ([#1336](https://github.com/PicPeak/picpeak/issues/1336)) ([9bbdca9](https://github.com/PicPeak/picpeak/commit/9bbdca9fc5db1b3610b92f395f30e5545fc42995))
|
||||
|
||||
## [3.127.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.127.0-beta.0...v3.127.1-beta.0) (2026-09-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **usage:** stop WebKit collapsing the consent dialog to its header and footer ([79eb6d7](https://github.com/PicPeak/picpeak/commit/79eb6d72eb170ab4e7f4bf33d7cf2d97cd5fbaa8))
|
||||
* **usage:** stop WebKit collapsing the consent dialog to its header and footer ([9d18868](https://github.com/PicPeak/picpeak/commit/9d18868a072094ac393651abe767bc190f0b140f))
|
||||
|
||||
## [3.127.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.126.3-beta.0...v3.127.0-beta.0) (2026-09-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **usage:** open the portal signed in, and rewrite the German copy ([a02fa08](https://github.com/PicPeak/picpeak/commit/a02fa08f696f8476c07df0acc329633e489fcd30))
|
||||
* **usage:** open the portal signed in, with the credential never in a served URL ([f114f3e](https://github.com/PicPeak/picpeak/commit/f114f3e876f81a06b9ea28598f326bcbbc5901af))
|
||||
* **usage:** plain link to the public usage portal, German opt-in copy ([a16ff85](https://github.com/PicPeak/picpeak/commit/a16ff855dd4bdcefcac5b29b31a8c596df46957e))
|
||||
* **usage:** plain link to the public usage portal, German opt-in copy ([7e40579](https://github.com/PicPeak/picpeak/commit/7e4057921721919c27c40c6e46c755c5e03d6d86))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** send Umami page views through track(), not the removed trackView() ([5b8c13f](https://github.com/PicPeak/picpeak/commit/5b8c13feb635ffaf8cb6c2a20d78f4972d304539))
|
||||
* **gallery:** honor canvas settings in the Premium lightbox ([fde0558](https://github.com/PicPeak/picpeak/commit/fde055881145c937092bfa4dd7f4c0e1b19fb538))
|
||||
* **gallery:** honor canvas settings in the Premium lightbox ([9edce85](https://github.com/PicPeak/picpeak/commit/9edce856ff59802b727e2ae610d3783efea006eb))
|
||||
* **gallery:** keep canvas rendering in the lightbox, render tiles as <img> ([0986f7f](https://github.com/PicPeak/picpeak/commit/0986f7f7ac0e53b759928872cd5ffccb1b28f27d))
|
||||
* **i18n:** rewrite the German product-usage copy ([35cbcef](https://github.com/PicPeak/picpeak/commit/35cbcefed20f7d8aaf6724fce261266ef20838a4))
|
||||
* **security:** bump sanitize-html to 2.17.7 ([7c968e7](https://github.com/PicPeak/picpeak/commit/7c968e74bb339c2953ae98984e46c3d848be39cf))
|
||||
* **security:** bump sanitize-html to 2.17.7 ([6583178](https://github.com/PicPeak/picpeak/commit/65831785a2f1a52b0d3045b4a0b34fbfb37e94ca))
|
||||
* **security:** stop a gallery viewer's own image fetches spending the anonymous budget ([ac24319](https://github.com/PicPeak/picpeak/commit/ac243191351b12ab4046cf35a9caa5317f993d3d))
|
||||
* **security:** stop a gallery viewer's own image fetches spending the anonymous budget ([7b2dd3f](https://github.com/PicPeak/picpeak/commit/7b2dd3fab1b03966ca3ca1e13d0ce75b17c4bae8))
|
||||
* **setup:** require Node 22.12 for sanitize-html ([e06d0b4](https://github.com/PicPeak/picpeak/commit/e06d0b45138688d49037c65e45a1916252da9feb))
|
||||
|
||||
## [3.126.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.126.2-beta.0...v3.126.3-beta.0) (2026-09-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **usage:** introduce consented v4 download restriction reporting ([5306fe3](https://github.com/PicPeak/picpeak/commit/5306fe378c8d7787d50ba926ad80f9ca6a85d136))
|
||||
* **usage:** introduce consented v4 without changing historical reports ([ef8a52f](https://github.com/PicPeak/picpeak/commit/ef8a52f02c4afa10dbc5fccafb6030b1aedc936c))
|
||||
|
||||
## [3.126.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.126.1-beta.0...v3.126.2-beta.0) (2026-09-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** release grid tiles once they are far enough out of view ([b3937d0](https://github.com/PicPeak/picpeak/commit/b3937d0b8c54c8ddf8a428be88aa444ab2b4f2d2))
|
||||
* **gallery:** retry a failed image fetch once the tile is back on screen ([c4b03a8](https://github.com/PicPeak/picpeak/commit/c4b03a831f843ec447a54d712aefa89c9761e8e8))
|
||||
* **gallery:** retry a failed image fetch once the tile is back on screen ([77ae94e](https://github.com/PicPeak/picpeak/commit/77ae94e649f367bb0a44166d3215ce1884c660d2))
|
||||
|
||||
## [3.126.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.126.0-beta.0...v3.126.1-beta.0) (2026-09-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **usage:** preserve compatibility with old and partial reports ([b801f3a](https://github.com/PicPeak/picpeak/commit/b801f3a6b8d74ece0e6d6be136a43bc58f458e47))
|
||||
* **usage:** preserve report contracts with compatible receiver validation ([7ca783f](https://github.com/PicPeak/picpeak/commit/7ca783f89b8ed735ec3e69306a837c0f40e0670b))
|
||||
|
||||
## [3.126.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.125.0-beta.0...v3.126.0-beta.0) (2026-09-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **usage:** add beta capabilities and gallery/photo totals with explicit consent ([b0bb65d](https://github.com/PicPeak/picpeak/commit/b0bb65d0d28124548c2b746f4c52c79f1b1f7542))
|
||||
|
||||
## [3.125.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.124.1-beta.0...v3.125.0-beta.0) (2026-09-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add opt-in product usage and feedback ([#1110](https://github.com/PicPeak/picpeak/issues/1110)) ([35b42bb](https://github.com/PicPeak/picpeak/commit/35b42bba9d57e89599f8efaeb1c89a778e96d8da))
|
||||
* expand opt-in capability coverage with versioned consent ([a738259](https://github.com/PicPeak/picpeak/commit/a7382591bfd73c841ea91fe821e2ab739c2990d0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **usage:** close the QA findings on opt-in product usage ([1e8b6f1](https://github.com/PicPeak/picpeak/commit/1e8b6f1b0f98f7242de132abceae62afdb592f65))
|
||||
* **usage:** let an operator clear a participation the collector never accepted ([e40bc47](https://github.com/PicPeak/picpeak/commit/e40bc474bc65f1a167fc4012b28ce0ce65ea9575))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **usage:** state in the consent dialog that the connection only runs outwards ([c741dc2](https://github.com/PicPeak/picpeak/commit/c741dc22c579e495b4c0514e4c222f06279aaf7d))
|
||||
|
||||
## [3.124.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.124.0-beta.0...v3.124.1-beta.0) (2026-09-05)
|
||||
|
||||
|
||||
|
||||
+6
-5
@@ -59,7 +59,7 @@ Unsure where to begin? You can start by looking through these issues:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Node.js 22.12.0 or later (matches `backend/package.json`)
|
||||
- Docker & Docker Compose
|
||||
- Git
|
||||
|
||||
@@ -172,13 +172,14 @@ PicPeak runs on two long-lived branches:
|
||||
| Branch | Role | What targets it |
|
||||
|---|---|---|
|
||||
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
|
||||
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
|
||||
| **`stable`** | Curated release channel. Production-recommended. | Security fixes and regular bugfix backports, kept small and free of unrelated features. |
|
||||
|
||||
### Which branch should my PR target?
|
||||
|
||||
- **New feature** → target `main`.
|
||||
- **Bugfix that ONLY affects active dev** → target `main`.
|
||||
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
|
||||
- **Bugfix that current stable users need** → target `main`; regular bug fixes are generally backported automatically to `stable`. Maintainers handle conflicts or create a separate focused backport PR when needed.
|
||||
- **Security vulnerability** → report privately using [SECURITY.md](SECURITY.md). Security fixes are always released on both `stable` and `main`; coordinate any fix with the maintainers before opening a public PR.
|
||||
|
||||
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
|
||||
|
||||
@@ -196,6 +197,6 @@ See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteri
|
||||
|
||||
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
|
||||
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
|
||||
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
|
||||
- Security vulnerabilities: Follow the [security policy](SECURITY.md) and use [private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
Thank you for contributing! 🎉
|
||||
|
||||
@@ -149,7 +149,7 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
|
||||
| 🧾 CRM & Accounting | [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm) · [disclaimers](https://docs.picpeak.app/features/crm/disclaimers) |
|
||||
| 🗺️ Roadmap | [GitHub Issues](https://github.com/PicPeak/picpeak/issues) |
|
||||
|
||||
**Project meta:** [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
**Project meta:** [Support](SUPPORT.md) · [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
|
||||
## 📊 Comparison with Alternatives
|
||||
|
||||
|
||||
+6
-1
@@ -68,13 +68,18 @@ The actual mechanics, in order:
|
||||
|
||||
## Hotfix path (backport to current stable)
|
||||
|
||||
If a critical bug or security issue affects the current stable and `main` has moved too far for a full promotion to be appropriate, backport just the fix:
|
||||
Regular bug fixes are generally backported automatically from `main` to `stable`. Keep backports focused on the fix, without unrelated features, and resolve conflicts manually when needed.
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** Do not wait for a full promotion to deliver a security update. A fix first applied to `stable` must also be forward-ported to `main`; a fix first applied to `main` must also reach `stable`. See [SECURITY.md](SECURITY.md) for the support policy.
|
||||
|
||||
When a backport needs manual handling:
|
||||
|
||||
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
|
||||
2. Cherry-pick or hand-write the minimal fix.
|
||||
3. Open a PR to `stable` with the smallest possible diff.
|
||||
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
|
||||
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
|
||||
6. For security fixes, verify that the fix has been published through **both** release channels; merging the code is only part of delivery.
|
||||
|
||||
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
|
||||
|
||||
|
||||
+61
-68
@@ -1,88 +1,81 @@
|
||||
# Security Policy
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the PicPeak backend, frontend, all-in-one (AIO) image, optional
|
||||
ML component, and the Docker images published by the PicPeak project. Other
|
||||
PicPeak repositories define their own supported versions and release channels.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We release patches for security vulnerabilities. Currently supported versions:
|
||||
Security support follows the current release channels:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x.x | :white_check_mark: |
|
||||
| < 2.0 | :x: |
|
||||
| Version or channel | Security support |
|
||||
| --- | --- |
|
||||
| Latest stable release from `stable` | Supported; security fixes are published through this channel |
|
||||
| Latest beta release from `main` | Supported; security fixes are published through this channel |
|
||||
| Superseded stable or beta releases | Upgrade to the latest release in the same channel; older releases are not maintained separately |
|
||||
| 2.x and earlier | No longer supported |
|
||||
|
||||
See the [latest stable release](https://github.com/PicPeak/picpeak/releases/latest)
|
||||
and [all releases, including betas](https://github.com/PicPeak/picpeak/releases).
|
||||
Version numbers differ between channels; each channel receives its own updates.
|
||||
|
||||
### Security fixes and bug backports
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** A fix that
|
||||
lands on one branch must also reach the other branch and be published through
|
||||
both release channels. Security updates do not wait for the next full
|
||||
`main`-to-`stable` promotion.
|
||||
|
||||
Regular bug fixes are also generally backported automatically to `stable`.
|
||||
Backports remain focused on the fix, without pulling in unrelated features.
|
||||
Maintainers resolve conflicts or handle a backport manually when necessary.
|
||||
|
||||
The [release process](RELEASING.md) describes backports, forward-ports and
|
||||
publication. Operators must apply the published updates to their installations.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take the security of PicPeak seriously. If you have discovered a security vulnerability, please follow these steps:
|
||||
**Do not report vulnerabilities in public issues, discussions or pull requests.**
|
||||
|
||||
### 1. **Do NOT create a public GitHub issue**
|
||||
Report privately through:
|
||||
|
||||
### 2. Report the vulnerability privately by:
|
||||
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- **Alternative:** Email us at **info@picpeak.app** with the details
|
||||
- Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
- [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) (preferred).
|
||||
- Email **info@picpeak.app** if you cannot use GitHub's private reporting form.
|
||||
|
||||
### 3. You can expect:
|
||||
- Acknowledgment within 48 hours
|
||||
- Regular updates on our progress
|
||||
- Credit in the fix announcement (unless you prefer to remain anonymous)
|
||||
Include the affected component, version or image tag, deployment method,
|
||||
reproduction steps, expected impact and any suggested fix. Share only the
|
||||
information needed to reproduce the problem; remove credentials and personal
|
||||
data from logs or examples.
|
||||
|
||||
## Security Measures
|
||||
We aim to acknowledge reports within 48 hours. This is a response target, not a
|
||||
guaranteed service level or a promised resolution time. We will provide progress
|
||||
updates and coordinate disclosure with the reporter. Reporter credit is optional;
|
||||
tell us if you prefer to remain anonymous.
|
||||
|
||||
PicPeak implements several security measures:
|
||||
## Deployment Security
|
||||
|
||||
### Authentication & Authorization
|
||||
- JWT-based authentication with secure token storage
|
||||
- bcrypt password hashing with configurable rounds
|
||||
- Role-based access control for admin functions
|
||||
- Session timeout management
|
||||
Security depends on both the software and its configuration. Operators should:
|
||||
|
||||
### Input Validation
|
||||
- All user inputs are validated and sanitized
|
||||
- SQL injection prevention through parameterized queries
|
||||
- XSS protection via Content Security Policy
|
||||
- File upload restrictions and validation
|
||||
- Use HTTPS and configure the reverse proxy and trusted proxy settings correctly.
|
||||
- Use strong credentials and keep deployment secrets private.
|
||||
- Apply updates for the chosen release channel and restrict unnecessary network access.
|
||||
- Keep backups and verify that they can be restored.
|
||||
|
||||
### Rate Limiting
|
||||
- API rate limiting to prevent abuse
|
||||
- Brute force protection on authentication endpoints
|
||||
- Configurable limits per endpoint
|
||||
|
||||
### Data Protection
|
||||
- HTTPS enforcement in production
|
||||
- Secure cookie settings
|
||||
- CORS configuration
|
||||
- Sensitive data encryption
|
||||
|
||||
### Infrastructure
|
||||
- Regular dependency updates
|
||||
- Security headers (HSTS, X-Frame-Options, etc.)
|
||||
- Activity logging for audit trails
|
||||
- Automated backups
|
||||
|
||||
## Best Practices for Deployment
|
||||
|
||||
1. **Always use HTTPS** in production
|
||||
2. **Change default passwords** immediately
|
||||
3. **Keep dependencies updated** regularly
|
||||
4. **Configure firewall rules** appropriately
|
||||
5. **Monitor logs** for suspicious activity
|
||||
6. **Backup regularly** and test restoration
|
||||
See the deployment guides for [HTTPS](https://docs.picpeak.app/deployment/ssl-certificates),
|
||||
[reverse proxies](https://docs.picpeak.app/deployment/reverse-proxy),
|
||||
[security settings](https://docs.picpeak.app/guides/admin-settings/security)
|
||||
and [backup and restore](https://docs.picpeak.app/guides/backup-restore).
|
||||
|
||||
## Vulnerability Disclosure
|
||||
|
||||
We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
We coordinate disclosure with the reporter while preparing fixes. Security fixes
|
||||
are published through both supported channels. Advisories and release notes
|
||||
identify affected versions, the fixed version in each channel, the impact and
|
||||
any required mitigation or upgrade steps. Reporter credit is included with
|
||||
permission.
|
||||
|
||||
1. We'll publish a security advisory
|
||||
2. Credit researchers (with permission)
|
||||
3. Detail the impact and mitigation steps
|
||||
4. Release patches for all supported versions
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
|
||||
|
||||
Thank you for helping keep PicPeak and its users safe!
|
||||
For ordinary bugs and support requests, use
|
||||
[GitHub Issues](https://github.com/PicPeak/picpeak/issues) or
|
||||
[GitHub Discussions](https://github.com/PicPeak/picpeak/discussions).
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Getting help with PicPeak
|
||||
|
||||
## Documentation
|
||||
|
||||
Start with [docs.picpeak.app](https://docs.picpeak.app) for installation,
|
||||
configuration, gallery features and administration guides.
|
||||
|
||||
- [Getting started](https://docs.picpeak.app/getting-started)
|
||||
- [Deployment](https://docs.picpeak.app/deployment)
|
||||
- [Admin settings](https://docs.picpeak.app/guides/admin-settings)
|
||||
- [Release channels](https://docs.picpeak.app/deployment/release-channels)
|
||||
|
||||
## Questions and troubleshooting
|
||||
|
||||
Use [GitHub Discussions](https://github.com/PicPeak/picpeak/discussions) for
|
||||
setup questions, troubleshooting and advice from the community. Include your
|
||||
PicPeak version, deployment method and what you have already tried.
|
||||
|
||||
## Bugs and feature requests
|
||||
|
||||
Search [existing issues](https://github.com/PicPeak/picpeak/issues) first, then
|
||||
[choose an issue template](https://github.com/PicPeak/picpeak/issues/new/choose)
|
||||
to report a bug, suggest a feature or identify a documentation problem.
|
||||
|
||||
For bugs, include the exact version, reproduction steps and relevant logs.
|
||||
See [Contributing](CONTRIBUTING.md) for development and pull request guidance.
|
||||
|
||||
## Security vulnerabilities
|
||||
|
||||
Follow the [security policy](SECURITY.md) and use
|
||||
[private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
or email **info@picpeak.app**. Do not report vulnerabilities in public issues
|
||||
or discussions.
|
||||
+18
-1
@@ -112,6 +112,17 @@ ARCHIVE_PATH=/app/storage/events/archived
|
||||
# generation from exhausting memory on small hosts. Default: 2
|
||||
# FILE_WATCHER_CONCURRENCY=2
|
||||
|
||||
# External-media folder watcher (issue 1187). Reference-mode events can opt in
|
||||
# per event (Event → Source Mode → "Watch folder for new files"); new images in
|
||||
# the folder are then imported without pressing Import. Deleted files are
|
||||
# never removed from the gallery.
|
||||
# EXTERNAL_MEDIA_WATCH=true # global kill switch
|
||||
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
|
||||
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
|
||||
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
|
||||
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
|
||||
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
|
||||
|
||||
# Analytics Backend Configuration (OPTIONAL)
|
||||
# Used for server-side tracking only
|
||||
# Primary configuration should be done through Admin UI > Settings > Analytics
|
||||
@@ -119,4 +130,10 @@ ARCHIVE_PATH=/app/storage/events/archived
|
||||
# UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||
# Encryption material for the backend-only signing key (32+ characters).
|
||||
# Defaults to JWT_SECRET; keep it stable until participation has been deleted.
|
||||
# USAGE_ENCRYPTION_KEY=
|
||||
|
||||
@@ -23,7 +23,7 @@ const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('admin thumbnail regeneration (#1129)', () => {
|
||||
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage;
|
||||
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage; let logInfo;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
|
||||
@@ -54,6 +54,10 @@ describe('admin thumbnail regeneration (#1129)', () => {
|
||||
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Same module registry as the route, so the spy sees its calls. The
|
||||
// completion line is what drain() below waits for.
|
||||
logInfo = jest.spyOn(require('../../src/utils/logger'), 'info');
|
||||
|
||||
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
|
||||
// success, which ends the jest worker mid-suite.
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
@@ -94,8 +98,20 @@ describe('admin thumbnail regeneration (#1129)', () => {
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** The work runs in setImmediate; give it room to finish. */
|
||||
const drain = () => new Promise((resolve) => setTimeout(resolve, 150));
|
||||
/**
|
||||
* The work runs in setImmediate, after the response. Wait for the loop's
|
||||
* "regeneration complete" log line rather than a fixed 150 ms: under a
|
||||
* loaded machine (fifteen suites in parallel, each booting a migrated
|
||||
* SQLite) the loop occasionally took longer than that, and the assertions
|
||||
* then ran against a half-finished mock call list.
|
||||
*/
|
||||
const drain = async () => {
|
||||
const deadline = Date.now() + 10000;
|
||||
const done = () => logInfo.mock.calls.some((c) => /regeneration complete/.test(String(c[0])));
|
||||
while (!done() && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
|
||||
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
|
||||
const eventId = await seedEvent();
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* POST /api/admin/business-profile/logo and PUT /api/admin/business-profile
|
||||
* — GHSA-6wrv-9pr4-hhmw regression coverage.
|
||||
*
|
||||
* The upload route used to take the stored file extension straight from
|
||||
* the client-supplied filename and only checked `file.mimetype` against an
|
||||
* allowlist — a file could declare an image MIME type while carrying a
|
||||
* `.html`/`.js` extension and arbitrary content, land in the same-origin
|
||||
* `/uploads/logos` static mount, and execute as script. The mass-assignable
|
||||
* `logoPath` field on PUT compounded it: an attacker could point the
|
||||
* "logo" at any other uploaded file.
|
||||
*
|
||||
* These tests pin:
|
||||
* (a) a MIME/extension mismatch is rejected at upload,
|
||||
* (b) the extension actually written to disk always matches the
|
||||
* validated MIME type, never the client-supplied filename,
|
||||
* (c) legitimate PNG/JPEG/SVG uploads still succeed,
|
||||
* (d) `logoPath` on PUT cannot be set to an arbitrary string pointing at
|
||||
* another file, only to a path the upload route itself produced.
|
||||
*
|
||||
* Defense-in-depth (not a re-opening of the above): fileFilter only pairs
|
||||
* the claimed MIME type against the extension — it can't see the bytes,
|
||||
* since it runs before multer finishes writing the stream to disk. A file
|
||||
* whose declared MIME/extension pair is valid but whose actual content
|
||||
* doesn't match (e.g. a PNG-declared upload that isn't really a PNG) is
|
||||
* now caught by validateFileContent() (magic-number check) after multer
|
||||
* writes it, closing the gap where declared-vs-actual content diverges.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bplogo-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bplogo-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
// Real magic-number-prefixed payloads, for content-sniffing to accept.
|
||||
const REAL_PNG_BYTES = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
Buffer.from('not a real png body, but the header is real'),
|
||||
]);
|
||||
const REAL_JPEG_BYTES = Buffer.concat([
|
||||
Buffer.from([0xFF, 0xD8, 0xFF]),
|
||||
Buffer.from('not a real jpeg body, but the header is real'),
|
||||
]);
|
||||
|
||||
describe('business profile — logo upload content/extension validation', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let token;
|
||||
|
||||
const uploadLogo = (buffer, filename, mimetype) => request(app)
|
||||
.post('/api/admin/business-profile/logo')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.attach('logo', buffer, { filename, contentType: mimetype });
|
||||
|
||||
const put = (payload) => request(app)
|
||||
.put('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(payload);
|
||||
|
||||
const get = () => request(app)
|
||||
.get('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
const profileOf = (res) => (res.body.data || res.body).profile;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
// fileFilter rejections surface via Express's generic error handler
|
||||
// (the pre-existing behaviour of every sibling logo/favicon upload
|
||||
// route in this codebase — none of them special-case multer's
|
||||
// fileFilter `Error` into a 400 either), so the status code itself
|
||||
// can be 400 or 500 depending on environment. What actually matters
|
||||
// for GHSA-6wrv-9pr4-hhmw is that the request never succeeds and
|
||||
// nothing with the dangerous extension is ever written to disk.
|
||||
const logosDirFiles = () => {
|
||||
const logosDir = path.join(process.env.STORAGE_PATH, 'uploads', 'logos');
|
||||
return fs.existsSync(logosDir) ? fs.readdirSync(logosDir) : [];
|
||||
};
|
||||
|
||||
it('rejects an HTML/script payload disguised as an image via mismatched extension', async () => {
|
||||
const evil = Buffer.from('<script>alert(document.domain)</script>');
|
||||
const res = await uploadLogo(evil, 'evil.html', 'image/svg+xml');
|
||||
expect(res.status).not.toBe(200);
|
||||
expect(logosDirFiles().some((f) => f.endsWith('.html'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a .js file disguised with an image MIME type', async () => {
|
||||
const evil = Buffer.from('alert(1)');
|
||||
const res = await uploadLogo(evil, 'evil.js', 'image/png');
|
||||
expect(res.status).not.toBe(200);
|
||||
expect(logosDirFiles().some((f) => f.endsWith('.js'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a disallowed MIME type outright', async () => {
|
||||
const res = await uploadLogo(Buffer.from('whatever'), 'file.pdf', 'application/pdf');
|
||||
expect(res.status).not.toBe(200);
|
||||
expect(logosDirFiles().some((f) => f.endsWith('.pdf'))).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a legitimate PNG upload and stores it with a .png extension', async () => {
|
||||
const res = await uploadLogo(REAL_PNG_BYTES, 'logo.png', 'image/png');
|
||||
expect(res.status).toBe(200);
|
||||
const logoPath = (res.body.data || res.body).logoPath;
|
||||
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.png$/);
|
||||
|
||||
const onDisk = path.join(process.env.STORAGE_PATH, logoPath.replace(/^\//, ''));
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
expect(profileOf(await get()).logoPath).toBe(logoPath);
|
||||
});
|
||||
|
||||
it('accepts a legitimate JPEG upload and stores it with a .jpg extension', async () => {
|
||||
const res = await uploadLogo(REAL_JPEG_BYTES, 'logo.jpg', 'image/jpeg');
|
||||
expect(res.status).toBe(200);
|
||||
const logoPath = (res.body.data || res.body).logoPath;
|
||||
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.jpg$/);
|
||||
});
|
||||
|
||||
it('rejects a PNG-declared upload whose bytes are not actually a PNG, and leaves nothing on disk', async () => {
|
||||
const before = logosDirFiles();
|
||||
const res = await uploadLogo(Buffer.from('totally not a png'), 'logo.png', 'image/png');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/content does not match/i);
|
||||
|
||||
// No new file left behind: the rejected upload's own file was cleaned
|
||||
// up, and every other file on disk (if any) is unchanged.
|
||||
expect(logosDirFiles()).toEqual(before);
|
||||
});
|
||||
|
||||
it('accepts a legitimate SVG upload and always stores it with a .svg extension, even under a spoofed filename', async () => {
|
||||
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>');
|
||||
// Client-declared filename ext is .svg here to pass validateFileType
|
||||
// (mismatched ext is covered by the rejection tests above); the point
|
||||
// of this test is that the ON-DISK extension comes from the MIME type
|
||||
// lookup table, not path.extname(originalname).
|
||||
const res = await uploadLogo(svg, 'vector-logo.svg', 'image/svg+xml');
|
||||
expect(res.status).toBe(200);
|
||||
const logoPath = (res.body.data || res.body).logoPath;
|
||||
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.svg$/);
|
||||
});
|
||||
|
||||
it('rejects logoPath on PUT set to an arbitrary string pointing at another file', async () => {
|
||||
const before = profileOf(await get()).logoPath;
|
||||
|
||||
const res = await put({ logoPath: '/uploads/logos/cms-somepage-1234.png' });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
expect(profileOf(await get()).logoPath).toBe(before);
|
||||
});
|
||||
|
||||
it('rejects logoPath on PUT with a path-traversal payload', async () => {
|
||||
const res = await put({ logoPath: '/uploads/logos/../../../../etc/passwd' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts logoPath on PUT when it matches the pattern this route itself writes', async () => {
|
||||
const upload = await uploadLogo(REAL_PNG_BYTES, 'logo2.png', 'image/png');
|
||||
const uploadedPath = (upload.body.data || upload.body).logoPath;
|
||||
|
||||
// Round-trip: PUT-ing back the exact value the upload endpoint
|
||||
// returned (what the frontend's generic profile save does) must
|
||||
// keep working.
|
||||
const res = await put({ logoPath: uploadedPath });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).logoPath).toBe(uploadedPath);
|
||||
});
|
||||
|
||||
it('still allows clearing logoPath with an empty string', async () => {
|
||||
const res = await put({ logoPath: '' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).logoPath).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -129,8 +129,15 @@ describe('preview tiers (#1095)', () => {
|
||||
it('derives every non-default tier key for cleanup', () => {
|
||||
// Tiers live outside preview_path, so delete/archive/regenerate have no
|
||||
// other way to find them. 1920 is excluded because that IS preview_path.
|
||||
// Two candidates per width — the encoder picks `.jpg` or `.webp` and the
|
||||
// cleanup list cannot know which without probing the source.
|
||||
const keys = imageProcessor.previewTierKeys({ id: 5, path: 'e/a.jpg', source_origin: 'managed' });
|
||||
expect(keys).toHaveLength(imageProcessor.PREVIEW_WIDTHS.length - 1);
|
||||
const widths = imageProcessor.PREVIEW_WIDTHS.filter((w) => w !== 1920);
|
||||
expect(keys).toHaveLength(widths.length * 2);
|
||||
for (const w of widths) {
|
||||
expect(keys).toContain(`previews/preview_w${w}_p5_a.jpg`);
|
||||
expect(keys).toContain(`previews/preview_w${w}_p5_a.webp`);
|
||||
}
|
||||
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
|
||||
expect(keys.every((k) => k.includes('p5_'))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* PostgreSQL checks for product usage (#1110).
|
||||
*
|
||||
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway database, e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_usage_pg_test" \
|
||||
* npx jest __tests__/integration/productUsagePg.test.js
|
||||
*
|
||||
* What SQLite cannot answer:
|
||||
* - `cancel_seq` and `sequence` are bigint, and node-postgres returns bigint
|
||||
* as a STRING. The withdrawal guard compares that value, so a `'1' !== 1`
|
||||
* slip would let an activation complete after an opt-out — and SQLite,
|
||||
* which hands back a number, would never show it.
|
||||
* - booleans are real booleans here, not 0/1, which is what every
|
||||
* `configured` signal in a report is built from.
|
||||
* - markUsed takes SELECT ... FOR UPDATE on this engine only.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
|
||||
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('product usage on Postgres', () => {
|
||||
let db;
|
||||
let UsageService;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Its own schema, not `public`. CI hands every gated suite the same
|
||||
// PICPEAK_PG_TEST_URL and runs jest with parallel workers, and both
|
||||
// picpeakRestorePg and externalRelpathFoldPg drop and recreate `events`
|
||||
// and `app_settings` there. Sharing that would have made all three
|
||||
// intermittently destroy each other's fixtures. The service queries
|
||||
// unqualified table names, so a searchPath keeps it entirely in here.
|
||||
const bootstrap = knex({
|
||||
client: 'pg', connection: PG_URL, pool: { min: 0, max: 2 }
|
||||
});
|
||||
await bootstrap.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE');
|
||||
await bootstrap.raw('CREATE SCHEMA usage_pg_test');
|
||||
await bootstrap.destroy();
|
||||
|
||||
db = knex({
|
||||
client: 'pg',
|
||||
connection: PG_URL,
|
||||
searchPath: ['usage_pg_test'],
|
||||
pool: { min: 0, max: 10 }
|
||||
});
|
||||
// The real migrations, on the real engine.
|
||||
await require('../../migrations/core/201_product_usage').up(db);
|
||||
await require('../../migrations/core/202_product_usage_cancel_requested').up(db);
|
||||
await require('../../migrations/core/203_product_usage_cancel_seq').up(db);
|
||||
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
|
||||
await require('../../migrations/core/205_product_usage_consent_version').up(db);
|
||||
await require('../../migrations/core/206_product_usage_delivery_backoff').up(db);
|
||||
await require('../../migrations/core/212_product_usage_prompt_shown').up(db);
|
||||
|
||||
await db.schema.createTable('app_settings', (t) => {
|
||||
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
|
||||
});
|
||||
await db.schema.createTable('feature_flags', (t) => {
|
||||
t.string('key').primary(); t.boolean('value');
|
||||
});
|
||||
await db.schema.createTable('events', (t) => {
|
||||
t.increments('id'); t.text('color_theme'); t.string('external_path'); t.integer('css_template_id');
|
||||
});
|
||||
await db.schema.createTable('css_templates', (t) => {
|
||||
t.increments('id'); t.boolean('is_enabled'); t.text('css_content');
|
||||
});
|
||||
for (const table of ['email_configs', 'mail_accounts']) {
|
||||
await db.schema.createTable(table, (t) => { t.increments('id'); t.string('smtp_host'); });
|
||||
}
|
||||
await db.schema.createTable('whatsapp_configs', (t) => {
|
||||
t.increments('id'); t.boolean('enabled'); t.string('phone_number_id'); t.string('access_token');
|
||||
});
|
||||
|
||||
({ UsageService } = require('../../src/usage/UsageService'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE');
|
||||
await db.destroy();
|
||||
}
|
||||
fs.rmSync(bindingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('product_usage_markers').delete();
|
||||
await db('product_usage_state').delete();
|
||||
await db('product_usage_state').insert({ id: 1 });
|
||||
await db('events').delete();
|
||||
await db('css_templates').delete();
|
||||
await db('feature_flags').delete();
|
||||
await db('app_settings').delete();
|
||||
});
|
||||
|
||||
// The instance-binding file defaults to STORAGE_PATH, which is '/storage'
|
||||
// in a bare test process. Point it at a temp dir so the real binding code
|
||||
// runs rather than being stubbed out.
|
||||
const bindingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-usage-pg-'));
|
||||
|
||||
const service = (over = {}) =>
|
||||
new UsageService(db, {
|
||||
secret: 'p'.repeat(48),
|
||||
endpoint: 'http://127.0.0.1:9/',
|
||||
bindingPath: path.join(bindingDir, 'usage-instance.key'),
|
||||
fetch: async () => { throw new Error('collector unreachable in tests'); },
|
||||
...over,
|
||||
});
|
||||
|
||||
it('creates the columns with the types the code expects', async () => {
|
||||
const cols = await db('product_usage_state').columnInfo();
|
||||
expect(cols.cancel_seq).toBeDefined();
|
||||
expect(cols.cancel_requested).toBeUndefined(); // dropped by 203
|
||||
expect(cols.sequence).toBeDefined();
|
||||
expect(cols.privacy_receipts).toBeDefined();
|
||||
expect(cols.consent_version).toBeDefined();
|
||||
// next_attempt_at is a bigint like sequence and cancel_seq, so pg hands it
|
||||
// back as a STRING — the tick() gate compares it against a number.
|
||||
expect(cols.attempts).toBeDefined();
|
||||
expect(cols.next_attempt_at).toBeDefined();
|
||||
expect(cols.prompt_shown).toBeDefined();
|
||||
});
|
||||
|
||||
it('backfills the prompt for existing participation using PostgreSQL booleans', async () => {
|
||||
const migration = require('../../migrations/core/212_product_usage_prompt_shown');
|
||||
await migration.down(db);
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
expect(await service().status()).toMatchObject({ status: 'active', prompt_shown: true, consent_version: 'usage-consent.v2' });
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'disabled' });
|
||||
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true });
|
||||
});
|
||||
|
||||
it('persists a fresh installation declining without opting in on PostgreSQL', async () => {
|
||||
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: false });
|
||||
await service().markPromptShown();
|
||||
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
|
||||
});
|
||||
|
||||
it('reruns the backoff migration safely', async () => {
|
||||
const migration = require('../../migrations/core/206_product_usage_delivery_backoff');
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(Number(row.attempts)).toBe(0);
|
||||
expect(Number(row.next_attempt_at)).toBe(0);
|
||||
});
|
||||
|
||||
it('honours the retry gate even though pg returns next_attempt_at as a string', async () => {
|
||||
let clock = 5_000_000;
|
||||
let calls = 0;
|
||||
const identity = generateIdentity();
|
||||
const client = service({
|
||||
now: () => clock,
|
||||
fetch: async () => { calls += 1; throw new Error('collector unreachable'); },
|
||||
});
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'active',
|
||||
consent_version: 'usage-consent.v2',
|
||||
installation_id: identity.installation_id,
|
||||
public_key: identity.public_key,
|
||||
private_key_encrypted: client.encrypt(identity.private_key),
|
||||
sequence: 1,
|
||||
attempts: 0,
|
||||
next_attempt_at: 0,
|
||||
pending_packet: JSON.stringify(makePacket(identity, 'session', 2, {}, 'usage.v2')),
|
||||
});
|
||||
|
||||
await client.tick();
|
||||
expect(calls).toBe(1);
|
||||
const paced = await db('product_usage_state').where({ id: 1 }).first();
|
||||
// A '5000120000' > 5000000 string comparison would be a different answer.
|
||||
expect(typeof paced.next_attempt_at).toBe('string');
|
||||
await client.tick();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
clock = Number(paced.next_attempt_at) + 1;
|
||||
await client.tick();
|
||||
expect(calls).toBe(2);
|
||||
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'disabled', pending_packet: null, attempts: 0, next_attempt_at: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => {
|
||||
const migration = require('../../migrations/core/204_product_usage_privacy_receipts');
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
last_receipt: JSON.stringify({ status: 'accepted', session_token: 'synthetic-old-token' })
|
||||
});
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(JSON.parse(row.last_receipt)).toEqual({ status: 'accepted' });
|
||||
});
|
||||
|
||||
it('migration preserves v1 consent and v2 snapshot works with PostgreSQL booleans and optional modules', async () => {
|
||||
const migration = require('../../migrations/core/205_product_usage_consent_version');
|
||||
await migration.up(db); await migration.up(db);
|
||||
const svc = service();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
await svc.markUsed(['video_uploads']);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
|
||||
expect((await svc.status()).schema_version).toBe('usage.v1');
|
||||
await db('product_usage_state').where({ id: 1 }).update({ consent_version: 'usage-consent.v2' });
|
||||
await db('feature_flags').insert({ key: 'quotes', value: true });
|
||||
await db('app_settings').insert({ setting_key: 'general_allowed_file_types', setting_value: '"dng,mp4"' });
|
||||
await svc.markUsed(['video_uploads', 'gallery_downloads']);
|
||||
const report = await svc.snapshot();
|
||||
expect(Object.keys(report.features)).toHaveLength(73);
|
||||
expect(report.features.video_uploads).toEqual({ configured: true, used: true });
|
||||
expect(report.features.camera_raw_uploads).toEqual({ configured: true, used: false });
|
||||
expect(report.features.gallery_downloads).toEqual({ configured: false });
|
||||
expect(report.features.crm.configured).toBe(true);
|
||||
expect(report.features.api_integration.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('reads bigint cancel_seq correctly even though pg returns it as a string', async () => {
|
||||
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 5 });
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
// The thing SQLite hides: this is a string here.
|
||||
expect(typeof row.cancel_seq).toBe('string');
|
||||
expect(Number(row.cancel_seq)).toBe(5);
|
||||
});
|
||||
|
||||
it('honours a withdrawal that lands while an activation is starting', async () => {
|
||||
const svc = service();
|
||||
const realBinding = svc.binding.bind(svc);
|
||||
svc.binding = async (create = false) => {
|
||||
// The withdrawal lands inside the window where the row still reads
|
||||
// `disabled`, with the real binding write still happening.
|
||||
if (create) await svc.disable();
|
||||
return realBinding(create);
|
||||
};
|
||||
await svc.enable('usage-consent.v1');
|
||||
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(row.status).toBe('disabled');
|
||||
expect(row.installation_id).toBeNull();
|
||||
});
|
||||
|
||||
it('activates when no withdrawal arrives', async () => {
|
||||
await service().enable('usage-consent.v1');
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(row.status).toBe('activation_pending');
|
||||
expect(row.installation_id).not.toBeNull();
|
||||
});
|
||||
|
||||
it('records markers only while active, using SELECT ... FOR UPDATE', async () => {
|
||||
const svc = service();
|
||||
await svc.markUsed(['crm']);
|
||||
expect(await db('product_usage_markers').count('* as c').first()).toMatchObject({ c: '0' });
|
||||
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
await svc.markUsed(['crm', 'newsletters']);
|
||||
const rows = await db('product_usage_markers').pluck('feature');
|
||||
expect(rows.sort()).toEqual(['crm', 'newsletters']);
|
||||
|
||||
// onConflict().ignore() must not throw on a repeat.
|
||||
await svc.markUsed(['crm']);
|
||||
expect((await db('product_usage_markers').pluck('feature')).length).toBe(2);
|
||||
});
|
||||
|
||||
it('builds a report from real booleans, not 0/1', async () => {
|
||||
await db('feature_flags').insert([
|
||||
{ key: 'clients', value: true },
|
||||
{ key: 'newsletters', value: false },
|
||||
]);
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
await service().markUsed(['crm']);
|
||||
|
||||
const report = await service().snapshot();
|
||||
expect(report.features.crm.configured).toBe(true);
|
||||
expect(report.features.crm.used).toBe(true);
|
||||
expect(report.features.newsletters.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves preset layouts and template CSS on this engine too', async () => {
|
||||
const [tpl] = await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' }).returning('id');
|
||||
const templateId = typeof tpl === 'object' ? tpl.id : tpl;
|
||||
await db('events').insert([
|
||||
{ color_theme: 'modernMasonry' },
|
||||
{ color_theme: null, css_template_id: templateId },
|
||||
]);
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'theme_config',
|
||||
setting_value: JSON.stringify({ galleryLayout: 'carousel' }),
|
||||
});
|
||||
|
||||
const report = await service().snapshot();
|
||||
expect(report.gallery_layouts.sort()).toEqual(['carousel', 'masonry']);
|
||||
expect(report.features.custom_css.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
const knex = require('knex');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { randomUUID } = require('crypto');
|
||||
const migration = require('../../migrations/core/211_revocations_without_expiry');
|
||||
|
||||
for (const client of ['sqlite3', 'pg']) {
|
||||
const enabled = client !== 'pg' || process.env.PICPEAK_PG_TEST_URL;
|
||||
(enabled ? describe : describe.skip)(`token revocation expiry (${client})`, () => {
|
||||
let db, owner, schema, revocation;
|
||||
const sign = claims => jwt.sign({ id: 1, type: 'admin', jti: randomUUID(), ...claims }, process.env.JWT_SECRET);
|
||||
|
||||
beforeAll(async () => {
|
||||
if (client === 'pg') {
|
||||
schema = `revocation_${randomUUID().replace(/-/g, '')}`;
|
||||
owner = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL });
|
||||
await owner.schema.createSchema(schema);
|
||||
db = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL, searchPath: [schema] });
|
||||
} else {
|
||||
db = knex({ client, connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
}
|
||||
// Exercise the upgrade from the real legacy NOT NULL schema as well as
|
||||
// repeated migration runs, without sharing another test's database.
|
||||
await require('../../migrations/legacy/017_add_token_revocation_tables').up(db);
|
||||
await db('revoked_tokens').insert({ token_id: 'existing', expires_at: '2099-01-01T00:00:00.000Z' });
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
revocation = require('../../src/utils/tokenRevocation');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.destroy();
|
||||
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
||||
jest.dontMock('../../src/database/db');
|
||||
});
|
||||
|
||||
it('preserves existing revocations and their unique key during upgrade', async () => {
|
||||
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
|
||||
await expect(db('revoked_tokens').insert({ token_id: 'existing', expires_at: null })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each([true, false])('permanently revokes a token without exp (jti: %s)', async withJti => {
|
||||
const token = sign(withJti ? {} : { jti: undefined });
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
expect(await revocation.isTokenRevoked(payload)).toBe(false);
|
||||
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(payload)).toBe(true);
|
||||
const rows = await db('revoked_tokens').where({ token_id: revocation.buildTokenId(payload) });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].expires_at).toBeNull();
|
||||
});
|
||||
|
||||
it('cleans up expired revocations and retains future ones', async () => {
|
||||
const expired = sign({ exp: Math.floor(Date.now() / 1000) - 60 });
|
||||
const future = sign({ exp: Math.floor(Date.now() / 1000) + 3600 });
|
||||
expect(await revocation.revokeToken(expired, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(future, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(expired))).toBe(false);
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(future))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([true, false])('upgrades an expiring entry with the same key permanently (jti: %s)', async withJti => {
|
||||
const claims = { id: 99, iat: Math.floor(Date.now() / 1000), jti: withJti ? randomUUID() : undefined };
|
||||
const expiring = sign({ ...claims, exp: claims.iat - 60 });
|
||||
const permanent = sign(claims);
|
||||
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(permanent, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(permanent))).toBe(true);
|
||||
});
|
||||
|
||||
it('retains a signed token whose numeric expiry cannot fit a database timestamp', async () => {
|
||||
const token = sign({ exp: 1e100 });
|
||||
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a rollback that would remove permanent revocations', async () => {
|
||||
await expect(migration.down(db)).rejects.toThrow('permanent token revocations');
|
||||
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(true);
|
||||
// A rollback with only expiring records remains supported and reversible.
|
||||
await db('revoked_tokens').whereNull('expires_at').delete();
|
||||
await migration.down(db);
|
||||
await migration.down(db);
|
||||
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(false);
|
||||
await migration.up(db);
|
||||
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -5,9 +5,10 @@ process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
|
||||
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
|
||||
|
||||
const http = require('http');
|
||||
const { db } = require('../../src/database/db');
|
||||
const webhookService = require('../../src/services/webhookService');
|
||||
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
let db, cleanup, adminId;
|
||||
let webhookService;
|
||||
let __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker;
|
||||
|
||||
// Local-only test stub: matches what dev/webhook-receiver/server.js does
|
||||
// in the docker-compose flow but spun up inside the Jest process so the
|
||||
@@ -43,7 +44,7 @@ async function insertWebhook(url, events = ['event.published'], extras = {}) {
|
||||
secret_preview: preview,
|
||||
events: JSON.stringify(events),
|
||||
active: extras.active !== false,
|
||||
created_by: 1,
|
||||
created_by: adminId,
|
||||
}).returning('id');
|
||||
const id = insert[0]?.id || insert[0];
|
||||
return { id, secret: plaintext };
|
||||
@@ -56,16 +57,15 @@ async function clearWebhooks() {
|
||||
|
||||
describe('webhook delivery worker (#327)', () => {
|
||||
beforeAll(async () => {
|
||||
// Schema is expected to already be applied by `npm run migrate`. We
|
||||
// just verify the webhooks tables exist; if not, the test harness has
|
||||
// missed running migration 082.
|
||||
const ok = await db.schema.hasTable('webhooks');
|
||||
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
webhookService = require('../../src/services/webhookService');
|
||||
({ __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker'));
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
stopWebhookDeliveryWorker();
|
||||
await db.destroy();
|
||||
await stopWebhookDeliveryWorker();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* GHSA-h4w8-57xq-53fx enforcement half: `must_change_password` was written
|
||||
* by the admin password-reset flow (userManagementService.resetAdminPassword)
|
||||
* and returned in a few response payloads, but no route-blocking logic ever
|
||||
* checked it — a reset admin could keep using the old/weak password on every
|
||||
* protected route indefinitely. adminAuth() is now the server-side backstop:
|
||||
* a flagged admin gets 403 MUST_CHANGE_PASSWORD on everything except the
|
||||
* routes they need to clear the flag (change-password) or leave (logout).
|
||||
*
|
||||
* Mirrors the mocking shape of adminAuthRoleFallback.test.js — a stub `db`
|
||||
* chain, no real SQLite needed, so this stays a fast unit test.
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
|
||||
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) }));
|
||||
jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), info: jest.fn() }));
|
||||
|
||||
let mockMustChangePassword = false;
|
||||
const mockAdminRow = { id: 7, username: 'scoped', email: 's@example.com', password_changed_at: null, role_id: 1, role_name: 'editor' };
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({
|
||||
leftJoin() { return this; },
|
||||
where() { return this; },
|
||||
select() { return this; },
|
||||
first: () => Promise.resolve({ ...mockAdminRow, must_change_password: mockMustChangePassword }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { adminAuth } = require('../../src/middleware/auth');
|
||||
|
||||
const SECRET = 'test-secret-for-must-change-password';
|
||||
|
||||
function makeReq(originalUrl) {
|
||||
const token = jwt.sign(
|
||||
{ id: mockAdminRow.id, type: 'admin' },
|
||||
SECRET,
|
||||
{ algorithm: 'HS256', issuer: 'picpeak-auth' },
|
||||
);
|
||||
return { headers: { authorization: `Bearer ${token}` }, ip: '127.0.0.1', connection: {}, originalUrl };
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
return {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
}
|
||||
|
||||
describe('adminAuth must_change_password enforcement (GHSA-h4w8-57xq-53fx)', () => {
|
||||
const OLD_SECRET = process.env.JWT_SECRET;
|
||||
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
|
||||
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
|
||||
beforeEach(() => { mockMustChangePassword = false; });
|
||||
|
||||
it('blocks an arbitrary protected route with 403 MUST_CHANGE_PASSWORD when the flag is set', async () => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq('/api/admin/dashboard/stats');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual(expect.objectContaining({ code: 'MUST_CHANGE_PASSWORD' }));
|
||||
expect(req.admin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not block when the flag is not set', async () => {
|
||||
mockMustChangePassword = false;
|
||||
const req = makeReq('/api/admin/dashboard/stats');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.admin.mustChangePassword).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/api/admin/auth/change-password'],
|
||||
['/api/admin/auth/logout'],
|
||||
])('still allows %s through when the flag is set', async (originalUrl) => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq(originalUrl);
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.admin.mustChangePassword).toBe(true);
|
||||
expect(res.statusCode).toBeNull();
|
||||
});
|
||||
|
||||
it('allows the exempt change-password path even with a query string', async () => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq('/api/admin/auth/change-password?foo=bar');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not exempt a route that merely starts with the change-password path', async () => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq('/api/admin/auth/change-password-history');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The chunked upload client posts each chunk as application/octet-stream and
|
||||
* the route reads the raw request stream. The CSRF gate answered every such
|
||||
* request 415 before it reached the route, so the endpoint never accepted a
|
||||
* chunk (PicPeak/picpeak#1377).
|
||||
*
|
||||
* The gate's origin check is the CSRF defence. The Content-Type list only
|
||||
* has to keep out what a cross-site page can send without a preflight, and
|
||||
* application/octet-stream is not on that list: an HTML form cannot produce
|
||||
* it and fetch() with it is not CORS-safelisted.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
// Same order as server.js: scoped JSON parser, then the gate on /api.
|
||||
app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use('/api', require('../../src/middleware/csrf'));
|
||||
// Mirrors the chunk route in adminPhotos.js: consume the raw stream.
|
||||
app.post('/api/admin/photos/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
res.json({ received: Buffer.concat(chunks).toString('base64') });
|
||||
});
|
||||
app.post('/api/admin/other', (req, res) => res.json({ body: req.body }));
|
||||
return app;
|
||||
}
|
||||
|
||||
const CHUNK_PATH = '/api/admin/photos/1/chunked-upload/abc/chunk/0';
|
||||
|
||||
describe('CSRF gate and application/octet-stream', () => {
|
||||
it('lets a same-origin octet-stream chunk reach the route byte for byte', async () => {
|
||||
// Not valid UTF-8, so a text decode anywhere on the path would show up.
|
||||
const payload = Buffer.concat([Buffer.from('chunkbytes'), Buffer.from([0xff, 0x00, 0xfe])]);
|
||||
const res = await request(buildApp())
|
||||
.post(CHUNK_PATH)
|
||||
.set('sec-fetch-site', 'same-origin')
|
||||
.set('Content-Type', 'application/octet-stream')
|
||||
.send(payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Buffer.from(res.body.received, 'base64').equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it('still rejects a cross-site octet-stream post on origin', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post(CHUNK_PATH)
|
||||
.set('sec-fetch-site', 'cross-site')
|
||||
.set('Content-Type', 'application/octet-stream')
|
||||
.send(Buffer.from('chunkbytes'));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('still rejects the types a form can send', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/api/admin/other')
|
||||
.set('sec-fetch-site', 'same-origin')
|
||||
.set('Content-Type', 'text/plain')
|
||||
.send('x=1');
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
|
||||
it('leaves a JSON route with an empty body on an octet-stream post', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/api/admin/other')
|
||||
.set('sec-fetch-site', 'same-origin')
|
||||
.set('Content-Type', 'application/octet-stream')
|
||||
.send(Buffer.from('{"a":1}'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* A verified gallery viewer's own image fetches do not spend the anonymous
|
||||
* per-IP budget (#1287).
|
||||
*
|
||||
* Since gallery tokens stopped earning the authenticated skip, a guest on a
|
||||
* 546-photo grid ran out of the default 300 requests per 15 minutes
|
||||
* mid-scroll; the rest of the tiles came back 429 and rendered blank, and the
|
||||
* next refresh found the photo list limited too. Reproduced in iOS Safari
|
||||
* against a seeded gallery: loading in bursts, then nothing, no error
|
||||
* anywhere. The image routes are exempt for a token that verifies and names
|
||||
* the gallery in the path; everything else stays on the budget.
|
||||
*/
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
process.env.JWT_SECRET = 'gallery-image-skip-secret';
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn(), withRetry: (fn) => fn() }));
|
||||
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
|
||||
|
||||
const { isOwnGalleryImageRequest, shouldSkipRateLimit } = require('../../src/services/rateLimitService');
|
||||
|
||||
const iat = Math.floor(Date.now() / 1000) - 10;
|
||||
const galleryToken = (eventSlug) => jwt.sign({ type: 'gallery', eventId: 1, eventSlug, iat }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
const adminToken = () => jwt.sign({ type: 'admin', id: 1, iat }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
const req = (path, token, method = 'GET') => ({
|
||||
path, method, cookies: {}, headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
const config = { enabled: true, skipAuthenticated: true, publicEndpointsOnly: false };
|
||||
|
||||
describe('a gallery viewer fetching its own images', () => {
|
||||
it.each(['thumbnail', 'preview', 'hero', 'photo'])('skips the budget on /%s', (route) => {
|
||||
const r = req(`/api/gallery/wedding-2026/${route}/42`, galleryToken('wedding-2026'));
|
||||
expect(isOwnGalleryImageRequest(r)).toBe(true);
|
||||
expect(shouldSkipRateLimit(r, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('also reads the per-slug gallery cookie, as the browser sends it', () => {
|
||||
const r = { path: '/api/gallery/wedding-2026/thumbnail/42', method: 'GET', headers: {},
|
||||
cookies: { 'gallery_token_wedding-2026': galleryToken('wedding-2026') } };
|
||||
expect(isOwnGalleryImageRequest(r)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('everything else stays on the budget', () => {
|
||||
it('the photo list, downloads and feedback', () => {
|
||||
const token = galleryToken('wedding-2026');
|
||||
for (const path of ['/api/gallery/wedding-2026/photos', '/api/gallery/wedding-2026/download/42',
|
||||
'/api/gallery/wedding-2026/download-all', '/api/gallery/wedding-2026/info', '/api/gallery/wedding-2026/feedback/42']) {
|
||||
expect(isOwnGalleryImageRequest(req(path, token))).toBe(false);
|
||||
expect(shouldSkipRateLimit(req(path, token), config)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('a token minted for a different gallery', () => {
|
||||
const r = req('/api/gallery/wedding-2026/thumbnail/42', galleryToken('other-gallery'));
|
||||
expect(isOwnGalleryImageRequest(r)).toBe(false);
|
||||
expect(shouldSkipRateLimit(r, config)).toBe(false);
|
||||
});
|
||||
|
||||
it('no token, a garbage token, a token under another secret, a token without a slug', () => {
|
||||
const path = '/api/gallery/wedding-2026/thumbnail/42';
|
||||
const foreign = jwt.sign({ type: 'gallery', eventSlug: 'wedding-2026', iat }, 'someone-elses-secret');
|
||||
const slugless = jwt.sign({ type: 'gallery', eventId: 1, iat }, process.env.JWT_SECRET);
|
||||
for (const token of [undefined, 'not-a-jwt', foreign, slugless]) {
|
||||
expect(isOwnGalleryImageRequest(req(path, token))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('a non-GET on an image path', () => {
|
||||
expect(isOwnGalleryImageRequest(req('/api/gallery/wedding-2026/photo/42', galleryToken('wedding-2026'), 'DELETE'))).toBe(false);
|
||||
});
|
||||
|
||||
it('an admin token still skips everywhere, and is not what this checks', () => {
|
||||
const r = req('/api/gallery/wedding-2026/thumbnail/42', adminToken());
|
||||
expect(isOwnGalleryImageRequest(r)).toBe(false);
|
||||
expect(shouldSkipRateLimit(r, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('the operator switch skip_authenticated=false counts guests too', () => {
|
||||
const r = req('/api/gallery/wedding-2026/thumbnail/42', galleryToken('wedding-2026'));
|
||||
expect(shouldSkipRateLimit(r, { ...config, skipAuthenticated: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const fake = { maintenance: 'true', revoked: false, beforeCutoff: false, admin:
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const db = jest.fn((table) => {
|
||||
const q = {
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
first: jest.fn(async () => {
|
||||
@@ -27,6 +28,7 @@ jest.mock('../../src/database/db', () => {
|
||||
return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance };
|
||||
}
|
||||
if (table === 'admin_users') return fake.admin;
|
||||
if (table === 'events') return { id: 1, slug: 'preview', created_by: 1, is_active: 1 };
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
@@ -34,6 +36,7 @@ jest.mock('../../src/database/db', () => {
|
||||
});
|
||||
return { db, withRetry: (fn) => fn() };
|
||||
});
|
||||
jest.mock('../../src/middleware/permissions', () => ({ userHasAllPermissions: jest.fn().mockResolvedValue(true) }));
|
||||
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn(async () => fake.revoked) }));
|
||||
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn(async () => fake.beforeCutoff) }));
|
||||
@@ -75,7 +78,7 @@ describe('general rate limiter skip', () => {
|
||||
});
|
||||
|
||||
describe('admin preview requires a live admin session', () => {
|
||||
const req = (token) => ({ query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
|
||||
const req = (token) => ({ params: { slug: 'preview' }, query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
|
||||
beforeEach(() => { fake.revoked = false; fake.beforeCutoff = false; fake.admin = { id: 1, password_changed_at: null }; });
|
||||
|
||||
it('passes for a live session and sets req.isAdminPreview', async () => {
|
||||
@@ -108,13 +111,23 @@ describe('multipart origin gate', () => {
|
||||
const req = (headers) => ({ headers: { host: 'photos.example.com', ...headers } });
|
||||
it('accepts same-origin, same-site and non-browser requests', () => {
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-origin' }))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(false);
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'none' }))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({}))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({ origin: 'https://photos.example.com' }))).toBe(true);
|
||||
// Same-origin install without FRONTEND_URL: Origin matches the Host.
|
||||
expect(multipartOriginAllowed({ headers: { host: 'gallery.local', origin: 'http://gallery.local' } })).toBe(true);
|
||||
});
|
||||
it('trusts Fetch Metadata same-origin before the Origin/scheme comparison', () => {
|
||||
// TLS terminated upstream without X-Forwarded-Proto: req.protocol is http
|
||||
// while the browser's Origin is https. Login must still work.
|
||||
// gallery.local is not in the configured allowlist, so only the Host/scheme
|
||||
// comparison or Fetch Metadata can admit it.
|
||||
const proxied = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local', 'sec-fetch-site': 'same-origin' } };
|
||||
expect(multipartOriginAllowed(proxied)).toBe(true);
|
||||
const legacyBrowser = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local' } };
|
||||
expect(multipartOriginAllowed(legacyBrowser)).toBe(false);
|
||||
});
|
||||
it('rejects cross-site form posts', () => {
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false);
|
||||
expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const knex = require('knex');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { UsageService } = require('../../src/usage/UsageService');
|
||||
const { generateIdentity, digest, canonical } = require('../../src/usage/protocol.cjs');
|
||||
const migration = require('../../migrations/core/212_product_usage_prompt_shown');
|
||||
|
||||
let db;
|
||||
let directory;
|
||||
beforeEach(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-prompt-test-'));
|
||||
for (const name of [
|
||||
'201_product_usage', '202_product_usage_cancel_requested', '203_product_usage_cancel_seq',
|
||||
'204_product_usage_privacy_receipts', '205_product_usage_consent_version', '206_product_usage_delivery_backoff'
|
||||
]) await require(`../../migrations/core/${name}`).up(db);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.each(['active', 'activation_pending', 'deletion_pending', 'identity_conflict'])(
|
||||
'preserves an existing %s participation without altering its consent or pending packet', async (status) => {
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status, consent_version: 'usage-consent.v2', pending_packet: 'retained-packet',
|
||||
});
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
const state = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(state).toMatchObject({ status, consent_version: 'usage-consent.v2', pending_packet: 'retained-packet', prompt_shown: 1 });
|
||||
}
|
||||
);
|
||||
|
||||
test('a previously participating installation stays acknowledged after a confirmed withdrawal', async () => {
|
||||
const actions = [];
|
||||
const service = new UsageService(db, {
|
||||
secret: 'test-only-prompt-encryption-secret-32-characters',
|
||||
endpoint: 'https://collector.example.test',
|
||||
bindingPath: path.join(directory, 'instance.key'),
|
||||
fetch: async (_url, init) => {
|
||||
const { packet } = JSON.parse(init.body);
|
||||
actions.push(packet.action);
|
||||
return new Response(JSON.stringify({
|
||||
packet_id: packet.packet_id, installation_id: packet.installation_id,
|
||||
packet_digest: digest(canonical(packet)), action: packet.action,
|
||||
sequence: packet.sequence, status: 'deleted',
|
||||
}));
|
||||
},
|
||||
});
|
||||
const identity = generateIdentity();
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'active', notice_dismissed: 1, consent_version: 'usage-consent.v5', sequence: 1,
|
||||
installation_id: identity.installation_id, public_key: identity.public_key,
|
||||
private_key_encrypted: service.encrypt(identity.private_key), instance_binding: await service.binding(true),
|
||||
});
|
||||
await migration.up(db);
|
||||
const state = await service.disable();
|
||||
expect(actions).toEqual(['delete']);
|
||||
expect(state).toMatchObject({ status: 'disabled', prompt_shown: true, installation_id: null });
|
||||
expect(state.privacy_receipts.last_deletion.status).toBe('collector-confirmed');
|
||||
});
|
||||
|
||||
test('a fresh installation can decline once without changing consent or the separate banner', async () => {
|
||||
await migration.up(db);
|
||||
const fetch = jest.fn();
|
||||
const service = new UsageService(db, { fetch });
|
||||
expect(await service.status()).toMatchObject({ status: 'disabled', prompt_shown: false, notice_dismissed: false });
|
||||
await service.markPromptShown();
|
||||
await migration.up(db);
|
||||
expect(await service.status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('migration guards tolerate a missing table', async () => {
|
||||
await db.schema.dropTable('product_usage_state');
|
||||
await expect(migration.up(db)).resolves.toBeUndefined();
|
||||
await expect(migration.down(db)).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
const knex = require('knex');
|
||||
const migration = require('../../migrations/core/210_events_updated_at');
|
||||
const { toTimestamp } = require('../../src/utils/dateNormalize');
|
||||
const { randomUUID } = require('crypto');
|
||||
|
||||
const engines = [['sqlite', null], ...(process.env.PICPEAK_PG_TEST_URL ? [['pg', process.env.PICPEAK_PG_TEST_URL]] : [])];
|
||||
describe.each(engines)('event timestamp migration contract (%s)', (engine, connection) => {
|
||||
let db, owner, schema;
|
||||
beforeEach(async () => {
|
||||
if (engine === 'pg') {
|
||||
schema = `event_contract_${randomUUID().replace(/-/g, '')}`;
|
||||
owner = knex({ client: 'pg', connection });
|
||||
await owner.schema.createSchema(schema);
|
||||
db = knex({ client: 'pg', connection, searchPath: [schema] });
|
||||
} else db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
});
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
||||
});
|
||||
it('upgrades legacy data, is repeatable and preserves subsequent edits', async () => {
|
||||
await db.schema.createTable('events', table => {
|
||||
table.increments('id'); table.timestamp('created_at').defaultTo(db.fn.now()); table.boolean('is_active').defaultTo(true);
|
||||
});
|
||||
const created = '2026-01-02T03:04:05.000Z';
|
||||
await db('events').insert({ created_at: created });
|
||||
await migration.up(db); await migration.up(db);
|
||||
let row = await db('events').first();
|
||||
expect(toTimestamp(row.updated_at)).toBe(Date.parse(created));
|
||||
await db('events').where({ id: row.id }).update({ updated_at: db.fn.now(), is_active: engine === 'pg' ? false : 0 });
|
||||
const changed = (await db('events').first()).updated_at;
|
||||
await migration.up(db); row = await db('events').first();
|
||||
expect(toTimestamp(row.updated_at)).toBe(toTimestamp(changed)); expect([false, 0]).toContain(row.is_active);
|
||||
});
|
||||
it('handles a fresh table and an already present updated_at column', async () => {
|
||||
await db.schema.createTable('events', table => { table.increments('id'); table.timestamp('created_at'); table.timestamp('updated_at'); });
|
||||
await migration.up(db);
|
||||
expect(await db.schema.hasColumn('events', 'updated_at')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const knex = require('knex');
|
||||
const { randomUUID } = require('crypto');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const request = require('supertest');
|
||||
const pgUrl = process.env.PICPEAK_PG_TEST_URL;
|
||||
(pgUrl ? describe : describe.skip)('fresh PostgreSQL gallery contract', () => {
|
||||
let owner, db, schema, tmpDir, cleanup, previousClient;
|
||||
beforeAll(async () => {
|
||||
schema = `fresh_gallery_${randomUUID().replace(/-/g, '')}`;
|
||||
owner = knex({ client: 'pg', connection: pgUrl });
|
||||
await owner.schema.createSchema(schema);
|
||||
previousClient = process.env.DATABASE_CLIENT;
|
||||
process.env.DATABASE_CLIENT = 'pg';
|
||||
process.env.JWT_SECRET = 'fresh-pg-gallery-test-secret-at-least-32-characters';
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-fresh-pg-'));
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
jest.doMock('../../knexfile', () => ({ client: 'pg', connection: pgUrl, searchPath: [schema] }));
|
||||
({ db } = require('../../src/database/db'));
|
||||
// bootCrmDb runs the complete core chain against the shared db singleton.
|
||||
({ cleanup } = await require('../integration/helpers/crmDb').bootCrmDb());
|
||||
}, 120000);
|
||||
afterAll(async () => {
|
||||
await require('../../src/services/serviceShutdown').stopServices();
|
||||
if (cleanup) await cleanup(); else if (db) await db.destroy();
|
||||
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
||||
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
if (previousClient === undefined) delete process.env.DATABASE_CLIENT; else process.env.DATABASE_CLIENT = previousClient;
|
||||
jest.dontMock('../../knexfile');
|
||||
});
|
||||
it('creates through the real admin route, then toggles a typed boolean and timestamp', async () => {
|
||||
const { seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId);
|
||||
const app = buildRouteApp('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
const bearer = `Bearer ${mintAdminToken(adminId)}`;
|
||||
const created = await request(app).post('/api/admin/events').set('Authorization', bearer).send({
|
||||
event_type: 'wedding', event_name: 'Fresh PostgreSQL', event_date: '2026-10-01',
|
||||
customer_name: 'Customer', customer_email: 'customer@example.test', admin_email: 'admin@example.test',
|
||||
password: 'Strong-Test-Photo-Pass-924!', expiration_days: 30, feedback_enabled: true,
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
const event = await db('events').where({ event_name: 'Fresh PostgreSQL' }).first();
|
||||
expect(event.created_by).toBe(adminId);
|
||||
expect(event.is_active).toBe(true);
|
||||
expect(event.updated_at).toBeInstanceOf(Date);
|
||||
expect(await db('event_feedback_settings').where({ event_id: event.id }).first()).toBeTruthy();
|
||||
const toggled = await request(app).post(`/api/admin/events/${event.id}/toggle-status`).set('Authorization', bearer).send({});
|
||||
expect(toggled.status).toBe(200);
|
||||
const row = await db('events').where({ id: event.id }).first();
|
||||
expect(row.is_active).toBe(false);
|
||||
expect(row.updated_at).toBeInstanceOf(Date);
|
||||
await require('../../migrations/core/210_events_updated_at').up(db);
|
||||
expect((await db('events').where({ id: event.id }).first()).updated_at).toEqual(row.updated_at);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Same bug class as GHSA-9q5j-vqfw-32hr (fixed in adminEvents/logo.js) —
|
||||
* the signed-PDF upload's multer `filename` callback built the stored
|
||||
* path directly from `req.params.id` with no integer validation:
|
||||
*
|
||||
* filename: (req, file, cb) => {
|
||||
* cb(null, `contract-${req.params.id}-${Date.now()}${ext}`);
|
||||
* }
|
||||
*
|
||||
* `POST /:id/upload-signed-pdf` declares `param('id').isInt({ min: 1 })`,
|
||||
* but express-validator's check only runs inside the route handler via
|
||||
* validateRequest(req) — AFTER multer has already parsed the multipart
|
||||
* body and invoked the filename callback. A traversal payload in the raw
|
||||
* `:id` URL segment reaches multer completely unvalidated.
|
||||
*
|
||||
* Fixed by rejecting any non-positive-integer id before it is used to
|
||||
* build the filename, independent of the declared-but-too-late
|
||||
* express-validator check.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// ALLOWED_MEDIA_TYPES in fileSecurityUtils.js only defines image/video
|
||||
// entries, so the route's real fileFilter (validateFileType(..., ['application/pdf']))
|
||||
// rejects every PDF upload with "Only PDF files are allowed" — a
|
||||
// separate, pre-existing bug unrelated to the path-traversal fix under
|
||||
// test here (also present in publicContracts.js, which is why neither
|
||||
// suite exercises a successful upload). Stub validateFileType so this
|
||||
// suite can drive the full route, including the filename-callback fix,
|
||||
// end-to-end.
|
||||
jest.mock('../../src/utils/fileSecurityUtils', () => {
|
||||
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
|
||||
return {
|
||||
...actual,
|
||||
validateFileType: (filename, mimetype, allowedTypes) => allowedTypes.includes(mimetype),
|
||||
};
|
||||
});
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-contracts-signed-pdf-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-contracts-signed-pdf-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('POST /api/admin/contracts/:id/upload-signed-pdf — path traversal guard', () => {
|
||||
let db; let cleanup; let app; let adminId; let customerId; let token;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
// Feature flag defaults OFF on a fresh install — the contracts
|
||||
// router 403s every route until it's on.
|
||||
await db('feature_flags').where({ key: 'contracts' }).update({ value: true });
|
||||
|
||||
app = buildRouteApp('/api/admin/contracts', require('../../src/routes/adminContracts'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
const signedDir = () => path.join(process.env.STORAGE_PATH, 'uploads/contracts/signed');
|
||||
|
||||
async function insertContract(over = {}) {
|
||||
const base = {
|
||||
contract_number: `K-TEST-${Math.random().toString(16).slice(2, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
title: 'Test Contract',
|
||||
issue_date: new Date().toISOString().slice(0, 10),
|
||||
status: 'sent',
|
||||
language: 'de',
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const inserted = await db('contracts').insert(base).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
it('rejects a traversal payload in the id param instead of writing outside uploads/contracts/signed', async () => {
|
||||
// '../../../../tmp/pwned' URL-encoded so the raw request path still
|
||||
// has a single segment (matches Express's `:id`), but Express
|
||||
// decodes the param back into literal '../' sequences before the
|
||||
// route sees it.
|
||||
const traversalId = encodeURIComponent('../../../../tmp/pwned');
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/contracts/${traversalId}/upload-signed-pdf`)
|
||||
).attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.body.error).toMatch(/invalid contract id/i);
|
||||
|
||||
// No file should have been written anywhere — the filename callback
|
||||
// must error out before multer opens a write stream.
|
||||
const escapedFile = path.join(os.tmpdir(), 'pwned');
|
||||
expect(fs.existsSync(escapedFile)).toBe(false);
|
||||
if (fs.existsSync(signedDir())) {
|
||||
expect(fs.readdirSync(signedDir())).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('still accepts a normal numeric contract id', async () => {
|
||||
const id = await insertContract();
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/contracts/${id}/upload-signed-pdf`)
|
||||
).attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const files = fs.readdirSync(signedDir());
|
||||
expect(files.some((f) => f.startsWith(`contract-${id}-`))).toBe(true);
|
||||
|
||||
const row = await db('contracts').where({ id }).first();
|
||||
expect(row.status).toBe('fully_signed');
|
||||
expect(row.signed_pdf_path).toMatch(new RegExp(`contract-${id}-`));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The Messages reading pane must not serve passwords (see
|
||||
* utils/emailSecretRedaction.js). Rows sent before the processor learned to
|
||||
* scrub still carry the gallery password in email_data and rendered_html;
|
||||
* the route redacts them on read.
|
||||
*/
|
||||
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-paneredact-')), 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'paneredact-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paneredact-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
|
||||
const { MASK } = require('../../src/utils/emailSecretRedaction');
|
||||
|
||||
describe('GET /admin/email/queue/:id redacts secrets from legacy rows', () => {
|
||||
let db; let cleanup; let app; let token; let rowId;
|
||||
const PASSWORD = 'Sunset-42!'; const PIN = 'Tom & Ada\'s 7788';
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
await db('feature_flags').insert({ key: 'messaging', value: true }).onConflict('key').merge({ value: true });
|
||||
invalidateFeatureFlagCache();
|
||||
const ins = await db('email_queue').insert({
|
||||
recipient_email: 'client@example.com', email_type: 'gallery_created', status: 'sent',
|
||||
created_at: new Date().toISOString(), sent_at: new Date().toISOString(), retry_count: 0,
|
||||
email_data: JSON.stringify({ customer_name: 'Ada', gallery_password: PASSWORD, client_password: PIN, cc: ['second@example.com'] }),
|
||||
rendered_html: `<ul><li>Password: ${PASSWORD}</li><li>PIN: Tom & Ada's 7788</li></ul>`,
|
||||
}).returning('id');
|
||||
rowId = ins[0]?.id ?? ins[0];
|
||||
app = buildRouteApp('/api/admin/email', require('../../src/routes/adminEmail'));
|
||||
}, 120000);
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('masks the password and the PIN in the rendered body, keeps the rest', async () => {
|
||||
const res = await request(app).get(`/api/admin/email/queue/${rowId}`).set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.renderedHtml).not.toContain(PASSWORD);
|
||||
expect(res.body.renderedHtml).not.toContain('Ada's 7788');
|
||||
expect(res.body.renderedHtml).toContain(`Password: ${MASK}`);
|
||||
expect(res.body.renderedHtml).toContain(`PIN: ${MASK}`);
|
||||
expect(res.body.cc).toBe('second@example.com');
|
||||
expect(JSON.stringify(res.body)).not.toContain(PASSWORD);
|
||||
// the stored row is untouched by a read
|
||||
const row = await db('email_queue').where('id', rowId).first();
|
||||
expect(row.rendered_html).toContain(PASSWORD);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Opt-in recoverable gallery passwords (#1271).
|
||||
*
|
||||
* Off by default: nothing reversible is stored, the view route says so, and
|
||||
* resend falls back to the security sentinel. On: every path that hashes a
|
||||
* gallery or client password keeps an encrypted copy, the view route returns
|
||||
* it and logs the reveal, resend uses it unchanged, disabling the gallery
|
||||
* password clears it, and switching the setting off purges every copy.
|
||||
*/
|
||||
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-recover-')), 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'recover-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-recover-storage-'));
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
const vault = require('../../src/utils/galleryPasswordVault');
|
||||
|
||||
const PASSWORD = 'Meadow-Lark-77!';
|
||||
const PIN = '4321';
|
||||
|
||||
describe('recoverable gallery passwords', () => {
|
||||
let db; let cleanup; let app; let token; let adminId;
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
const setSetting = (value) => db('app_settings').insert({
|
||||
setting_key: vault.SETTING_KEY, setting_value: JSON.stringify(value), setting_type: 'security',
|
||||
}).onConflict('setting_key').merge({ setting_value: JSON.stringify(value) });
|
||||
const createEvent = (over = {}) => auth(request(app).post('/api/admin/events')).send({
|
||||
event_type: 'wedding', event_name: 'Recover Wedding', event_date: '2026-09-07',
|
||||
customer_name: 'Ada', customer_email: 'ada@example.com', admin_email: 'admin@example.com',
|
||||
require_password: true, password: PASSWORD, expiration_days: 30,
|
||||
client_access_enabled: true, client_password: PIN, ...over,
|
||||
});
|
||||
const stored = (id) => db('events').where('id', id).first('password_recoverable', 'client_password_recoverable');
|
||||
|
||||
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'));
|
||||
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => { res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code }); });
|
||||
}, 120000);
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('encrypts and decrypts, and a different ciphertext each time', () => {
|
||||
const a = vault.encryptPassword(PASSWORD); const b = vault.encryptPassword(PASSWORD);
|
||||
expect(a).not.toBe(b);
|
||||
expect(vault.decryptPassword(a)).toBe(PASSWORD);
|
||||
// flip one ciphertext character so the tamper is never a no-op
|
||||
const [iv, tag, ct] = a.split('.');
|
||||
const tampered = [iv, tag, (ct[0] === 'A' ? 'B' : 'A') + ct.slice(1)].join('.');
|
||||
expect(() => vault.decryptPassword(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it('purges before the write when turning on and after it when off, never on a same-state on save', async () => {
|
||||
// off → on: leftovers go first, so a copy stored under the new "on" is
|
||||
// never deleted by the purge; on → off and off → off: after, so a write
|
||||
// that still read "on" is caught (see purgePlanForSettingWrite).
|
||||
expect(await vault.purgePlanForSettingWrite(true)).toEqual({ before: true, after: false });
|
||||
expect(await vault.purgePlanForSettingWrite(false)).toEqual({ before: false, after: true });
|
||||
await setSetting(true);
|
||||
expect(await vault.purgePlanForSettingWrite('1')).toEqual({ before: false, after: false });
|
||||
expect(await vault.purgePlanForSettingWrite(false)).toEqual({ before: false, after: true });
|
||||
await setSetting(false);
|
||||
});
|
||||
|
||||
it('with the setting off, creation stores nothing and the view route says the feature is off', async () => {
|
||||
const res = await createEvent();
|
||||
expect([200, 201]).toContain(res.status);
|
||||
const row = await stored(res.body.id);
|
||||
expect(row.password_recoverable).toBeNull();
|
||||
expect(row.client_password_recoverable).toBeNull();
|
||||
const view = await auth(request(app).get(`/api/admin/events/${res.body.id}/password`));
|
||||
expect(view.status).toBe(200);
|
||||
expect(view.body).toEqual({ enabled: false, password: null, client_password: null });
|
||||
// the hash still works, i.e. nothing about login changed
|
||||
const ev = await db('events').where('id', res.body.id).first();
|
||||
expect(await bcrypt.compare(PASSWORD, ev.password_hash)).toBe(true);
|
||||
});
|
||||
|
||||
describe('with the setting on', () => {
|
||||
let id;
|
||||
beforeAll(async () => {
|
||||
await setSetting(true);
|
||||
const res = await createEvent();
|
||||
expect([200, 201]).toContain(res.status);
|
||||
id = res.body.id;
|
||||
});
|
||||
|
||||
it('creation keeps an encrypted copy of both passwords, never the plaintext', async () => {
|
||||
const row = await stored(id);
|
||||
expect(row.password_recoverable).toBeTruthy();
|
||||
expect(row.password_recoverable).not.toContain(PASSWORD);
|
||||
expect(vault.decryptPassword(row.password_recoverable)).toBe(PASSWORD);
|
||||
expect(vault.decryptPassword(row.client_password_recoverable)).toBe(PIN);
|
||||
});
|
||||
|
||||
it('the view route returns them and writes an activity-log entry', async () => {
|
||||
const view = await auth(request(app).get(`/api/admin/events/${id}/password`));
|
||||
expect(view.status).toBe(200);
|
||||
expect(view.body).toEqual({ enabled: true, password: PASSWORD, client_password: PIN });
|
||||
const log = await db('activity_logs').where({ activity_type: 'gallery_password_viewed' }).orderBy('id', 'desc').first();
|
||||
expect(log).toBeTruthy();
|
||||
expect(String(log.event_id)).toBe(String(id));
|
||||
});
|
||||
|
||||
it('resend uses the stored password instead of the security sentinel', async () => {
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/resend-email`)).send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.usedStoredPassword).toBe(true);
|
||||
const mail = await db('email_queue').where({ event_id: id, email_type: 'gallery_created' }).orderBy('id', 'desc').first();
|
||||
const data = JSON.parse(mail.email_data);
|
||||
expect(data.gallery_password).toBe(PASSWORD);
|
||||
// client access is on for this event: the resend carries the stored PIN
|
||||
// and the client link, as the creation mail did
|
||||
expect(data.client_password).toBe(PIN);
|
||||
expect(data.client_link).toMatch(/\/client-access\?token=[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
it('a reset replaces the stored copy', async () => {
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/reset-password`)).send({ sendEmail: false, password: 'Harbour-Light-91!' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(vault.decryptPassword((await stored(id)).password_recoverable)).toBe('Harbour-Light-91!');
|
||||
});
|
||||
|
||||
it('editing the client PIN and the gallery password updates the copies', async () => {
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({ client_password: '9999', password: 'Quiet-River-33!' });
|
||||
expect(res.status).toBe(200);
|
||||
const row = await stored(id);
|
||||
expect(vault.decryptPassword(row.client_password_recoverable)).toBe('9999');
|
||||
expect(vault.decryptPassword(row.password_recoverable)).toBe('Quiet-River-33!');
|
||||
});
|
||||
|
||||
it('turning the gallery password off clears its copy', async () => {
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({ require_password: false });
|
||||
expect(res.status).toBe(200);
|
||||
const row = await stored(id);
|
||||
expect(row.password_recoverable).toBeNull();
|
||||
expect(row.client_password_recoverable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('switching the setting on again does not resurrect leftovers', async () => {
|
||||
await setSetting(false);
|
||||
const leftover = await createEvent({ event_name: 'Leftover Wedding' });
|
||||
// a copy that survived the purge somehow (a write racing the switch-off)
|
||||
await db('events').where('id', leftover.body.id).update({ password_recoverable: vault.encryptPassword('Leftover-1!') });
|
||||
const res = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect((await stored(leftover.body.id)).password_recoverable).toBeNull();
|
||||
// and a value the API may send as 1/"1" keeps the vault (no purge on a same-state save)
|
||||
const keep = await createEvent({ event_name: 'Kept Wedding' });
|
||||
expect((await stored(keep.body.id)).password_recoverable).toBeTruthy();
|
||||
const same = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: '1' });
|
||||
expect(same.status).toBe(200);
|
||||
expect((await stored(keep.body.id)).password_recoverable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('a creation in flight while the setting is switched off leaves no copy behind', async () => {
|
||||
// The setting is read while the insert is assembled, then the client
|
||||
// PIN hash awaits (crud.js). A switch-off that lands in that gap used to
|
||||
// be overtaken by the insert; the write-site re-check clears the row.
|
||||
const realHash = bcrypt.hash;
|
||||
const spy = jest.spyOn(bcrypt, 'hash').mockImplementation(async (...args) => {
|
||||
if (args[0] === PIN) {
|
||||
const off = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: false });
|
||||
expect(off.status).toBe(200);
|
||||
}
|
||||
return realHash.apply(bcrypt, args);
|
||||
});
|
||||
try {
|
||||
const res = await createEvent({ event_name: 'Racing Wedding' });
|
||||
expect([200, 201]).toContain(res.status);
|
||||
const row = await stored(res.body.id);
|
||||
expect(row.password_recoverable).toBeNull();
|
||||
expect(row.client_password_recoverable).toBeNull();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
await setSetting(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('switching the setting off purges every stored copy', async () => {
|
||||
const other = await createEvent({ event_name: 'Second Wedding' });
|
||||
expect((await stored(other.body.id)).password_recoverable).toBeTruthy();
|
||||
const res = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: false });
|
||||
expect(res.status).toBe(200);
|
||||
for (const eid of [id, other.body.id]) {
|
||||
const row = await stored(eid);
|
||||
expect(row.password_recoverable).toBeNull();
|
||||
expect(row.client_password_recoverable).toBeNull();
|
||||
}
|
||||
const view = await auth(request(app).get(`/api/admin/events/${other.body.id}/password`));
|
||||
expect(view.body.enabled).toBe(false);
|
||||
// and resend is back to the sentinel
|
||||
const resend = await auth(request(app).post(`/api/admin/events/${other.body.id}/resend-email`)).send({});
|
||||
expect(resend.body.usedStoredPassword).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* GHSA-9q5j-vqfw-32hr — the event-logo upload's multer `filename` callback
|
||||
* built the stored path directly from `req.params.id` with no integer
|
||||
* validation:
|
||||
*
|
||||
* filename: (req, file, cb) => {
|
||||
* cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
|
||||
* }
|
||||
*
|
||||
* A traversal payload in the `:id` route param (URL-encoded so it still
|
||||
* matches a single Express path segment, then decoded back into literal
|
||||
* `../` sequences by Express before handlers see it) could escape the
|
||||
* intended uploads/logos/events/ directory. Most directly reachable via a
|
||||
* super_admin session: requireEventOwnership short-circuits with next() and
|
||||
* zero DB lookup for that role (src/middleware/ownership.js), so nothing
|
||||
* upstream of multer validates the id first.
|
||||
*
|
||||
* Fixed by rejecting any non-positive-integer id before it is used to build
|
||||
* the filename, regardless of role or ownership-check ordering.
|
||||
*/
|
||||
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-logo-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-logo-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: '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('POST /api/admin/events/:id/logo — path traversal guard', () => {
|
||||
let db; let cleanup; let app; let adminId; let token;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
// super_admin: requireEventOwnership short-circuits with no DB lookup
|
||||
// for this role, so it reaches multer with nothing upstream having
|
||||
// validated the id — the exact path GHSA-9q5j-vqfw-32hr exploited.
|
||||
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(); });
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
const logoDir = () => path.join(process.env.STORAGE_PATH, 'uploads/logos/events');
|
||||
|
||||
it('rejects a traversal payload in the id param instead of writing outside uploads/logos/events', async () => {
|
||||
// '../../../../tmp/pwned' URL-encoded so the raw request path still has
|
||||
// a single segment (matches Express's `:id`), but Express decodes the
|
||||
// param back into literal '../' sequences before the route sees it.
|
||||
const traversalId = encodeURIComponent('../../../../tmp/pwned');
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/events/${traversalId}/logo`)
|
||||
).attach('logo', Buffer.from('fake image data'), 'logo.png');
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.body.error).toMatch(/invalid event id/i);
|
||||
|
||||
// No file should have been written anywhere — the filename callback
|
||||
// must error out before multer opens a write stream.
|
||||
const escapedFile = path.join(os.tmpdir(), 'pwned');
|
||||
expect(fs.existsSync(escapedFile)).toBe(false);
|
||||
if (fs.existsSync(logoDir())) {
|
||||
expect(fs.readdirSync(logoDir())).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('still accepts a normal numeric event id', async () => {
|
||||
const id = await insertEvent(db, adminId, { event_name: 'Logo Event' });
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/events/${id}/logo`)
|
||||
).attach('logo', Buffer.from('fake image data'), 'logo.png');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hero_logo_url).toMatch(new RegExp(`^/uploads/logos/events/event-${id}-logo-`));
|
||||
|
||||
const files = fs.readdirSync(logoDir());
|
||||
expect(files.some((f) => f.startsWith(`event-${id}-logo-`))).toBe(true);
|
||||
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.hero_logo_url).toBe(res.body.hero_logo_url);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ const { authenticator } = require('otplib');
|
||||
const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
const mfaService = require('../../src/services/mfaService');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
@@ -235,6 +236,138 @@ describe('MFA disable — /api/admin/auth/mfa/disable', () => {
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
});
|
||||
|
||||
// Concurrency regression: a plain UPDATE with no conditional guard let two
|
||||
// requests carrying the same captured code both read the same
|
||||
// two_factor_last_used_step and both persist, defeating replay protection.
|
||||
// The guarded UPDATE (mfaService.persistTotpStep) makes only the first
|
||||
// writer's affected-row count > 0; the loser must be rejected.
|
||||
it('two concurrent disable requests with the SAME captured code: only one succeeds', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
]);
|
||||
|
||||
expect([r1.status, r2.status].sort()).toEqual([200, 400]);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA regenerate recovery codes — /api/admin/auth/mfa/recovery-codes', () => {
|
||||
it('a valid TOTP regenerates the recovery codes and persists the step', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.recoveryCodes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('a wrong code is rejected (400)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
const valid = authenticator.generate(secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: wrong });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
// Concurrency regression (see the disable test above for the mechanism):
|
||||
// this is the endpoint called out as the worst lost-update case, since it
|
||||
// both rotates the recovery codes and (previously) persisted the step in
|
||||
// one unconditional UPDATE.
|
||||
it('two concurrent regenerations with the SAME captured code: only one succeeds', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
]);
|
||||
|
||||
expect([r1.status, r2.status].sort()).toEqual([200, 400]);
|
||||
const winner = r1.status === 200 ? r1 : r2;
|
||||
expect(winner.body.recoveryCodes).toHaveLength(10);
|
||||
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_last_used_step).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService.persistTotpStep — atomic replay-tracking persist', () => {
|
||||
// Deterministic simulation of the race: two "concurrent" requests that
|
||||
// read the SAME two_factor_last_used_step and computed the SAME totpStep
|
||||
// from the same captured code. Calling persistTotpStep twice in a row with
|
||||
// that identical totpStep reproduces exactly the DB-level outcome of a
|
||||
// true race, without relying on event-loop timing.
|
||||
it('the second writer with the same totpStep affects 0 rows and is rejected', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
const code = authenticator.generate(secret);
|
||||
const totpStep = mfaService.verifyTotpEncryptedStep(code, row.two_factor_secret, null);
|
||||
expect(totpStep).toEqual(expect.any(Number));
|
||||
|
||||
const first = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
|
||||
expect(first).toBe(true);
|
||||
|
||||
// The row's two_factor_last_used_step has now already advanced to
|
||||
// totpStep by the time this "losing" write runs — the guard condition
|
||||
// (whereNull OR < totpStep) is false, so 0 rows are affected.
|
||||
const second = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
|
||||
expect(second).toBe(false);
|
||||
|
||||
const after = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(Number(after.two_factor_last_used_step)).toBe(totpStep);
|
||||
});
|
||||
|
||||
it('succeeds when the new step advances past the current one', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
const code = authenticator.generate(secret);
|
||||
const totpStep = mfaService.verifyTotpEncryptedStep(code, row.two_factor_secret, null);
|
||||
|
||||
const ok = await mfaService.persistTotpStep(db, admin.id, totpStep, {});
|
||||
expect(ok).toBe(true);
|
||||
|
||||
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
|
||||
const nextCode = nextStepAuthenticator.generate(secret);
|
||||
const nextStep = mfaService.verifyTotpEncryptedStep(nextCode, row.two_factor_secret, totpStep);
|
||||
expect(nextStep).toBeGreaterThan(totpStep);
|
||||
|
||||
const advanced = await mfaService.persistTotpStep(db, admin.id, nextStep, {});
|
||||
expect(advanced).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
@@ -284,6 +417,86 @@ describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
expect(res.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
// GHSA-qcwx-r25m-j869: verifyTotp() was stateless, so otplib's window:1
|
||||
// tolerance let the same 6-digit code complete two independent logins
|
||||
// within its ~90s validity window. mfaService now tracks each admin's
|
||||
// last-consumed TOTP step and rejects a code that doesn't advance past it.
|
||||
it('#GHSA-qcwx-r25m-j869 — a TOTP code cannot be replayed into a second login', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
// First use of the code completes a login.
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const first = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c1.body.mfaToken, code });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.user).toBeDefined();
|
||||
|
||||
// Replaying the SAME code for an independent second login must fail,
|
||||
// even though otplib's window:1 tolerance still considers it valid.
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const replay = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c2.body.mfaToken, code });
|
||||
expect(replay.status).toBe(401);
|
||||
expect(replay.body.code).toBe('MFA_INVALID');
|
||||
expect(replay.body.user).toBeUndefined();
|
||||
|
||||
// A freshly generated code for the NEXT TOTP step is not a replay and
|
||||
// succeeds. Generated via a cloned authenticator with a future epoch
|
||||
// rather than mocking Date.now(), so mfaService's own step computation
|
||||
// (real Date.now()) still lands the match one step ahead.
|
||||
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
|
||||
const nextCode = nextStepAuthenticator.generate(secret);
|
||||
const c3 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const third = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c3.body.mfaToken, code: nextCode });
|
||||
expect(third.status).toBe(200);
|
||||
expect(third.body.user).toBeDefined();
|
||||
expect(third.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
// Concurrency regression: verifyTotpEncryptedStep()'s "does this advance"
|
||||
// check was read against a snapshot taken earlier in the request, then a
|
||||
// PLAIN update persisted the step — two concurrent requests carrying the
|
||||
// SAME captured code could both pass the check and both complete a login
|
||||
// before either write landed. The persist is now a conditional UPDATE
|
||||
// (mfaService.persistTotpStep), so only the first writer's affected-row
|
||||
// count is > 0 and the other is correctly treated as a replay.
|
||||
it('two concurrent login/mfa requests with the SAME captured code: only one completes', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
request(authApp).post('/api/auth/admin/login/mfa').send({ mfaToken: c1.body.mfaToken, code }),
|
||||
request(authApp).post('/api/auth/admin/login/mfa').send({ mfaToken: c2.body.mfaToken, code }),
|
||||
]);
|
||||
|
||||
expect([r1.status, r2.status].sort()).toEqual([200, 401]);
|
||||
const winner = r1.status === 200 ? r1 : r2;
|
||||
const loser = r1.status === 200 ? r2 : r1;
|
||||
expect(winner.body.user).toBeDefined();
|
||||
expect(loser.body.user).toBeUndefined();
|
||||
expect(loser.body.code).toBe('MFA_INVALID');
|
||||
});
|
||||
|
||||
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* The general API rate limiter's settings, read and written by the admin
|
||||
* (#1337).
|
||||
*
|
||||
* Until now the six rate_limit_* keys had a write route and no screen, and
|
||||
* the write route used a plain UPDATE — on a fresh install, which has no
|
||||
* rows, it answered 200 and changed nothing. The settings read did not
|
||||
* mention the keys at all when they had no row, so the budget in force
|
||||
* (300 per 15 minutes per IP) was invisible. Pins: the read surfaces the
|
||||
* defaults, the write creates rows, validation holds, and the limiter picks
|
||||
* the new values up at once.
|
||||
*/
|
||||
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-ratelimit-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'ratelimit-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-ratelimit-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
const { clearPermissionCache } = require('../../src/middleware/permissions');
|
||||
const rateLimitService = require('../../src/services/rateLimitService');
|
||||
const { MemoryStore } = require('express-rate-limit');
|
||||
|
||||
describe('admin rate limiter settings', () => {
|
||||
let db; let cleanup; let app; let tok; let general;
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${tok}`);
|
||||
const rows = () => db('app_settings').where('setting_key', 'like', 'rate_limit_%').orderBy('setting_key');
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
tok = mintAdminToken(adminId);
|
||||
await db('app_settings').where('setting_key', 'like', 'rate_limit_%').delete();
|
||||
clearPermissionCache();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
|
||||
}, 120000);
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('surfaces the code defaults in the settings read when no row exists', async () => {
|
||||
expect(await rows()).toHaveLength(0);
|
||||
const res = await auth(request(app).get('/api/admin/settings'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
rate_limit_enabled: true, rate_limit_window_minutes: 15, rate_limit_max_requests: 300,
|
||||
rate_limit_auth_max_requests: 5, rate_limit_skip_authenticated: true, rate_limit_public_endpoints_only: false,
|
||||
});
|
||||
// and only when asked for, with a key filter
|
||||
const filtered = await auth(request(app).get('/api/admin/settings?keys=rate_limit_max_requests,general_site_url'));
|
||||
expect(filtered.body.rate_limit_max_requests).toBe(300);
|
||||
expect(filtered.body).not.toHaveProperty('rate_limit_window_minutes');
|
||||
});
|
||||
|
||||
it('creates the rows on a fresh install and the limiter sees the change at once', async () => {
|
||||
const before = await rateLimitService.getRateLimitSettings();
|
||||
expect(before.maxRequests).toBe(300);
|
||||
const res = await auth(request(app).put('/api/admin/settings/security/rate-limit')).send({
|
||||
rate_limit_enabled: true, rate_limit_window_minutes: 10, rate_limit_max_requests: 5000,
|
||||
rate_limit_auth_max_requests: 8, rate_limit_skip_authenticated: true, rate_limit_public_endpoints_only: false,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = await rows();
|
||||
expect(stored.map((r) => r.setting_key)).toEqual([
|
||||
'rate_limit_auth_max_requests', 'rate_limit_enabled', 'rate_limit_max_requests',
|
||||
'rate_limit_public_endpoints_only', 'rate_limit_skip_authenticated', 'rate_limit_window_minutes',
|
||||
]);
|
||||
expect(stored.every((r) => r.setting_type === 'security')).toBe(true);
|
||||
const read = await auth(request(app).get('/api/admin/settings'));
|
||||
expect(read.body.rate_limit_max_requests).toBe(5000);
|
||||
expect(read.body.rate_limit_window_minutes).toBe(10);
|
||||
// The route clears the limiter's 60-second cache, so the new budget applies now.
|
||||
// The window is fixed per limiter instance, so the route rebuilds them too.
|
||||
expect(rateLimitService.getGeneralLimiter()).toEqual(expect.any(Function));
|
||||
expect(rateLimitService.getAuthLimiter()).toEqual(expect.any(Function));
|
||||
general = rateLimitService.getGeneralLimiter();
|
||||
const after = await rateLimitService.getRateLimitSettings();
|
||||
expect(after.maxRequests).toBe(5000);
|
||||
expect(after.windowMinutes).toBe(10);
|
||||
expect(after.authMaxRequests).toBe(8);
|
||||
});
|
||||
|
||||
it('updates existing rows rather than duplicating them', async () => {
|
||||
// A rebuild must not leak the previous stores' cleanup intervals.
|
||||
const shutdown = jest.spyOn(MemoryStore.prototype, 'shutdown');
|
||||
await auth(request(app).put('/api/admin/settings/security/rate-limit')).send({
|
||||
rate_limit_enabled: false, rate_limit_window_minutes: 15, rate_limit_max_requests: 300,
|
||||
rate_limit_auth_max_requests: 5, rate_limit_skip_authenticated: false, rate_limit_public_endpoints_only: true,
|
||||
});
|
||||
expect(await rows()).toHaveLength(6);
|
||||
const after = await rateLimitService.getRateLimitSettings();
|
||||
expect(after).toMatchObject({ enabled: false, maxRequests: 300, skipAuthenticated: false, publicEndpointsOnly: true });
|
||||
// and every save hands the gates a fresh instance, shutting the old stores down
|
||||
expect(rateLimitService.getGeneralLimiter()).not.toBe(general);
|
||||
expect(shutdown).toHaveBeenCalledTimes(2);
|
||||
shutdown.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects values outside the documented ranges', async () => {
|
||||
for (const bad of [
|
||||
{ rate_limit_window_minutes: 0 }, { rate_limit_window_minutes: 61 },
|
||||
{ rate_limit_max_requests: 9 }, { rate_limit_max_requests: 10001 },
|
||||
{ rate_limit_auth_max_requests: 0 }, { rate_limit_enabled: 'yes' },
|
||||
]) {
|
||||
const res = await auth(request(app).put('/api/admin/settings/security/rate-limit')).send({
|
||||
rate_limit_enabled: true, rate_limit_window_minutes: 15, rate_limit_max_requests: 300,
|
||||
rate_limit_auth_max_requests: 5, rate_limit_skip_authenticated: true, rate_limit_public_endpoints_only: false,
|
||||
...bad,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* GHSA-9h7q-2jpf-vj85 — DELETE /api/admin/short-urls/:id only checked
|
||||
* `events.edit` permission, with no ownership scoping. GET and POST for an
|
||||
* event's short URLs both chain requireEventOwnership; DELETE takes the
|
||||
* short URL row's own :id (not :eventId), so any admin holding events.edit
|
||||
* could delete another admin's branded gallery short URL. The route now
|
||||
* resolves the short URL's event first and applies the same ownership
|
||||
* predicate requireEventOwnership uses. super_admin keeps global access.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-suown-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'suown-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-suown-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('short URL delete ownership scoping', () => {
|
||||
let db; let cleanup; let app; let service;
|
||||
let superTok; let ownerTok; let foreignTok;
|
||||
let ownerId;
|
||||
let foreignShortUrlId;
|
||||
|
||||
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
|
||||
|
||||
async function seedEvent(createdBy, slugSuffix) {
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [id] = await db('events').insert({
|
||||
slug: `suown-${slugSuffix}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Event',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@e.com',
|
||||
admin_email: 'a@e.com',
|
||||
password_hash: 'x',
|
||||
share_link: `suown-${slugSuffix}`,
|
||||
share_token: `suown-share-${slugSuffix}`,
|
||||
expires_at: farFuture,
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
created_by: createdBy,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
return db('events').where({ id }).first();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
service = require('../../src/services/galleryShortUrlService');
|
||||
|
||||
const superIns = await db('admin_users').insert({
|
||||
username: 'suown-super', email: 'suown-super@example.com',
|
||||
password_hash: 'x', must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const superId = superIns[0]?.id ?? superIns[0];
|
||||
await assignAdminRole(db, superId, 'super_admin');
|
||||
superTok = mintAdminToken(superId);
|
||||
|
||||
const ownerIns = await db('admin_users').insert({
|
||||
username: 'suown-owner', email: 'suown-owner@example.com',
|
||||
password_hash: 'x', must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
ownerId = ownerIns[0]?.id ?? ownerIns[0];
|
||||
await assignAdminRole(db, ownerId, 'editor');
|
||||
ownerTok = mintAdminToken(ownerId);
|
||||
|
||||
const foreignIns = await db('admin_users').insert({
|
||||
username: 'suown-foreign', email: 'suown-foreign@example.com',
|
||||
password_hash: 'x', must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const foreignId = foreignIns[0]?.id ?? foreignIns[0];
|
||||
await assignAdminRole(db, foreignId, 'editor');
|
||||
foreignTok = mintAdminToken(foreignId);
|
||||
|
||||
// Event owned by `owner`, NOT `foreign`.
|
||||
await seedEvent(ownerId, 'owned');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin', require('../../src/routes/adminShortUrls'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh short URL per DELETE test so earlier deletes don't interfere.
|
||||
const event = await db('events').where({ created_by: ownerId }).first();
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: `suown-target-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
createdBy: ownerId,
|
||||
});
|
||||
foreignShortUrlId = row.id;
|
||||
});
|
||||
|
||||
it('an admin who does not own the event cannot delete its short URL (403, row survives)', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
|
||||
foreignTok,
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
|
||||
expect(row).toBeDefined();
|
||||
expect(row.deleted_at).toBeFalsy();
|
||||
});
|
||||
|
||||
it('the owning admin can delete its own short URL', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
|
||||
ownerTok,
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
|
||||
expect(row.deleted_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('super_admin can delete any short URL', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
|
||||
superTok,
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
|
||||
expect(row.deleted_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deleting a nonexistent short URL id returns 404', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete('/api/admin/short-urls/9999999'),
|
||||
superTok,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('deleting a nonexistent short URL id as a non-owner also returns 404 (existence check runs first)', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete('/api/admin/short-urls/9999999'),
|
||||
foreignTok,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const mockDb = require('knex')({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: ':memory:' },
|
||||
useNullAsDefault: true
|
||||
});
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
get db() {
|
||||
return mockDb;
|
||||
}
|
||||
}));
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn().mockResolvedValue(false)
|
||||
}));
|
||||
jest.mock('../../src/utils/sessionCutoff', () => ({
|
||||
isTokenBeforeCutoff: jest.fn().mockResolvedValue(false)
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn()
|
||||
}));
|
||||
jest.mock('../../src/services/productUsageService', () =>
|
||||
Object.fromEntries(
|
||||
[
|
||||
'tick',
|
||||
'status',
|
||||
'dismiss',
|
||||
'markPromptShown',
|
||||
'enable',
|
||||
'disable',
|
||||
'abandon',
|
||||
'preview',
|
||||
'export',
|
||||
'preferences',
|
||||
'command',
|
||||
'markUsed'
|
||||
].map((key) => [key, jest.fn().mockResolvedValue({ status: 'disabled' })])
|
||||
)
|
||||
);
|
||||
const service = require('../../src/services/productUsageService');
|
||||
const { productUsage } = require('../../src/middleware/productUsage');
|
||||
const { productUsageApi } = require('../../src/middleware/productUsage');
|
||||
const SECRET = 'usage-auth-test-secret-not-a-live-credential';
|
||||
const token = (type, id = 1) =>
|
||||
jwt.sign({ type, id }, SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
algorithm: 'HS256'
|
||||
});
|
||||
let app;
|
||||
beforeAll(async () => {
|
||||
process.env.JWT_SECRET = SECRET;
|
||||
await mockDb.schema.createTable('roles', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name');
|
||||
});
|
||||
await mockDb.schema.createTable('admin_users', (t) => {
|
||||
t.increments('id');
|
||||
t.string('username');
|
||||
t.string('email');
|
||||
t.integer('role_id');
|
||||
t.boolean('is_active');
|
||||
t.timestamp('password_changed_at');
|
||||
// adminAuth() now selects this on every request (GHSA-h4w8-57xq-53fx
|
||||
// must_change_password enforcement) — without the column the join
|
||||
// throws and every route in this file 401s before reaching the
|
||||
// permission check it's meant to test.
|
||||
t.boolean('must_change_password');
|
||||
});
|
||||
await mockDb.schema.createTable('permissions', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name');
|
||||
});
|
||||
await mockDb.schema.createTable('role_permissions', (t) => {
|
||||
t.integer('role_id');
|
||||
t.integer('permission_id');
|
||||
});
|
||||
await mockDb('roles').insert([
|
||||
{ id: 1, name: 'super_admin' },
|
||||
{ id: 2, name: 'viewer' }
|
||||
]);
|
||||
await mockDb('admin_users').insert([
|
||||
{
|
||||
id: 1,
|
||||
username: 'owner',
|
||||
email: 'owner@example.test',
|
||||
role_id: 1,
|
||||
is_active: 1
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'viewer',
|
||||
email: 'viewer@example.test',
|
||||
role_id: 2,
|
||||
is_active: 1
|
||||
}
|
||||
]);
|
||||
await mockDb('permissions').insert({ id: 1, name: 'settings.edit' });
|
||||
await mockDb('role_permissions').insert({ role_id: 1, permission_id: 1 });
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/usage', require('../../src/routes/adminUsage'));
|
||||
app.use((err, _req, res, _next) =>
|
||||
res.status(err.statusCode || 500).json({ code: err.code })
|
||||
);
|
||||
});
|
||||
afterAll(() => mockDb.destroy());
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('scoped API use records only its fixed v2 capability and never triggers a report', () => {
|
||||
const simulate = (admin, apiToken, statusCode) => {
|
||||
const res = new (require('events').EventEmitter)(); res.statusCode = statusCode;
|
||||
productUsageApi({ admin, apiToken, body: { user: 'PRIVATE@example.test' } }, res, () => {});
|
||||
res.emit('finish');
|
||||
};
|
||||
simulate(null, { id: 99 }, 200);
|
||||
simulate({ id: 42 }, null, 200);
|
||||
simulate({ id: 42 }, { id: 99 }, 403);
|
||||
expect(service.markUsed).not.toHaveBeenCalled();
|
||||
simulate({ id: 42 }, { id: 99 }, 200);
|
||||
expect(service.markUsed).toHaveBeenCalledWith(['api_integration'], { legacyFeatures: [] });
|
||||
expect(service.tick).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(service.markUsed.mock.calls)).not.toMatch(/PRIVATE|42|99/);
|
||||
});
|
||||
|
||||
const ROUTES = [
|
||||
['get', '/'],
|
||||
['post', '/activity'],
|
||||
['post', '/enable'],
|
||||
['post', '/consent'],
|
||||
['post', '/disable'],
|
||||
['post', '/abandon'],
|
||||
['post', '/retry'],
|
||||
['post', '/dismiss'],
|
||||
['post', '/prompt-seen'],
|
||||
['get', '/preview'],
|
||||
['get', '/export'],
|
||||
['put', '/feedback-preferences'],
|
||||
['post', '/feedback'],
|
||||
['post', '/vote'],
|
||||
['post', '/portal-session']
|
||||
];
|
||||
test.each(ROUTES)(
|
||||
'%s %s rejects unauthenticated and gallery tokens',
|
||||
async (method, route) => {
|
||||
await request(app)[method](`/api/admin/usage${route}`).send({}).expect(401);
|
||||
await request(app)[method](`/api/admin/usage${route}`)
|
||||
.set('Authorization', `Bearer ${token('gallery')}`)
|
||||
.send({})
|
||||
.expect(403);
|
||||
expect(service.tick).not.toHaveBeenCalled();
|
||||
expect(service.enable).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
test.each(ROUTES.filter(([, route]) => route !== '/activity'))(
|
||||
'%s %s requires settings.edit',
|
||||
async (method, route) => {
|
||||
await request(app)[method](`/api/admin/usage${route}`)
|
||||
.set('Authorization', `Bearer ${token('admin', 2)}`)
|
||||
.send({})
|
||||
.expect(403);
|
||||
}
|
||||
);
|
||||
test('an authenticated admin can trigger cadence without seeing identity or packet data', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/admin/usage/activity')
|
||||
.set('Authorization', `Bearer ${token('admin', 2)}`)
|
||||
.expect(200);
|
||||
expect(response.body).toEqual({ ok: true });
|
||||
expect(service.tick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('owner sees no-store status and supplies consent to the service', async () => {
|
||||
await request(app)
|
||||
.get('/api/admin/usage')
|
||||
.set('Authorization', `Bearer ${token('admin')}`)
|
||||
.expect('Cache-Control', 'no-store')
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.post('/api/admin/usage/enable')
|
||||
.set('Authorization', `Bearer ${token('admin')}`)
|
||||
.send({ consent_version: 'usage-consent.v1' })
|
||||
.expect(200);
|
||||
expect(service.enable).toHaveBeenCalledWith('usage-consent.v1');
|
||||
});
|
||||
test('only a settings editor can acknowledge the prompt without opting in', async () => {
|
||||
await request(app)
|
||||
.post('/api/admin/usage/prompt-seen')
|
||||
.set('Authorization', `Bearer ${token('admin')}`)
|
||||
.expect('Cache-Control', 'no-store')
|
||||
.expect(200);
|
||||
expect(service.markPromptShown).toHaveBeenCalledTimes(1);
|
||||
expect(service.enable).not.toHaveBeenCalled();
|
||||
});
|
||||
test('public/gallery paths and failed/unauthenticated admin operations never set feature markers', async () => {
|
||||
const { EventEmitter } = require('events');
|
||||
const simulate = (path, admin, statusCode) => {
|
||||
const res = new EventEmitter();
|
||||
res.locals = {};
|
||||
res.statusCode = statusCode;
|
||||
productUsage({ path, method: 'POST', admin }, res, () => {});
|
||||
res.emit('finish');
|
||||
};
|
||||
simulate('/gallery/example', null, 200);
|
||||
simulate('/customers', null, 200);
|
||||
simulate('/quotes', { id: 1 }, 403);
|
||||
expect(service.markUsed).not.toHaveBeenCalled();
|
||||
simulate('/customers/42/hour-entries', { id: 1 }, 200);
|
||||
// The second argument tells markUsed whether this operation writes to the
|
||||
// configured backup destination; a CRM route never does.
|
||||
expect(service.markUsed).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['crm', 'crm_hours']),
|
||||
expect.objectContaining({ destinationBackup: false })
|
||||
);
|
||||
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
|
||||
});
|
||||
|
||||
test.each(['usage-consent.v2', 'usage-consent.v3', 'usage-consent.v4', 'usage-consent.v5'])('consent upgrade accepts exactly the explicit %s choice, never extra fields', async (consent_version) => {
|
||||
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v6' }, { consent_version, user: 'PRIVATE' }])
|
||||
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`).send(data).expect(400);
|
||||
expect(service.command).not.toHaveBeenCalled();
|
||||
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`)
|
||||
.send({ consent_version }).expect(200);
|
||||
expect(service.command).toHaveBeenCalledWith('consent', { consent_version });
|
||||
});
|
||||
|
||||
test('only a backup that writes to the configured destination flags S3', () => {
|
||||
// /database-backup/* and /backup/picpeak/export produce a local file, so
|
||||
// they must not imply S3 use just because S3 is the configured destination.
|
||||
const seen = [];
|
||||
const simulate = (pathname) => {
|
||||
service.markUsed.mockClear();
|
||||
const res = new (require('events').EventEmitter)();
|
||||
res.locals = {};
|
||||
res.statusCode = 200;
|
||||
productUsage({ path: pathname, method: pathname.endsWith('/export') ? 'GET' : 'POST', admin: { id: 1 } }, res, () => {});
|
||||
res.emit('finish');
|
||||
seen.push([pathname, service.markUsed.mock.calls[0]?.[1]?.destinationBackup]);
|
||||
};
|
||||
simulate('/backup/run');
|
||||
simulate('/database-backup/backup');
|
||||
simulate('/backup/picpeak/export');
|
||||
expect(seen).toEqual([
|
||||
['/backup/run', true],
|
||||
['/database-backup/backup', false],
|
||||
['/backup/picpeak/export', false],
|
||||
]);
|
||||
});
|
||||
|
||||
// The route allowlist and the packet schema have to agree. The allowlist used
|
||||
// to let `name`, `allow_public` and `allow_marketing` be omitted while the
|
||||
// schema requires all three, so an API caller got a bare INVALID_PACKET from
|
||||
// deep inside signing instead of being told which field was missing.
|
||||
const VALID_FEEDBACK = {
|
||||
kind: 'feedback',
|
||||
title: 'Title',
|
||||
body: 'Body',
|
||||
name: '',
|
||||
allow_public: false,
|
||||
allow_marketing: false
|
||||
};
|
||||
test.each([
|
||||
['no body at all', {}],
|
||||
['missing name', { ...VALID_FEEDBACK, name: undefined }],
|
||||
['missing allow_public', { ...VALID_FEEDBACK, allow_public: undefined }],
|
||||
['missing allow_marketing', { ...VALID_FEEDBACK, allow_marketing: undefined }],
|
||||
['a boolean sent as a string', { ...VALID_FEEDBACK, allow_public: 'true' }],
|
||||
['a title of only whitespace', { ...VALID_FEEDBACK, title: ' ' }],
|
||||
['an unknown field', { ...VALID_FEEDBACK, ownerId: 7 }]
|
||||
])('feedback rejects %s before anything is signed', async (_label, data) => {
|
||||
const response = await request(app)
|
||||
.post('/api/admin/usage/feedback')
|
||||
.set('Authorization', `Bearer ${token('admin')}`)
|
||||
.send(JSON.parse(JSON.stringify(data)))
|
||||
.expect(400);
|
||||
// Named, not a bare protocol failure the caller cannot act on.
|
||||
expect(response.body.code).toBe('VALIDATION_ERROR');
|
||||
expect(service.command).not.toHaveBeenCalled();
|
||||
});
|
||||
test('feedback accepts the complete payload and mints the id server-side', async () => {
|
||||
await request(app)
|
||||
.post('/api/admin/usage/feedback')
|
||||
.set('Authorization', `Bearer ${token('admin')}`)
|
||||
.send({ ...VALID_FEEDBACK, name: 'QA' })
|
||||
.expect(200);
|
||||
expect(service.command).toHaveBeenCalledWith(
|
||||
'feedback',
|
||||
expect.objectContaining({ name: 'QA', feedback_id: expect.any(String) })
|
||||
);
|
||||
});
|
||||
|
||||
// Runs last on purpose: the limiter's budget is per-process and shared with
|
||||
// every test above that reaches an outbound route, so consuming it here cannot
|
||||
// starve them. The assertion is deliberately about the property — some request
|
||||
// is refused and the service stops being called — rather than an exact count,
|
||||
// which would depend on how much budget earlier tests used.
|
||||
test('the outbound routes are throttled so an admin session cannot flood the collector', async () => {
|
||||
const codes = [];
|
||||
for (let i = 0; i < 45; i += 1) {
|
||||
const response = await request(app)
|
||||
.post('/api/admin/usage/feedback')
|
||||
.set('Authorization', `Bearer ${token('admin')}`)
|
||||
.send({ ...VALID_FEEDBACK, title: `flood ${i}` });
|
||||
codes.push(response.status);
|
||||
if (response.status === 429) {
|
||||
expect(response.body.code).toBe('USAGE_RATE_LIMITED');
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(codes).toContain(429);
|
||||
expect(service.command.mock.calls.length).toBeLessThan(codes.length);
|
||||
|
||||
// The same budget covers the other two routes that relay to the collector.
|
||||
await request(app)
|
||||
.post('/api/admin/usage/vote')
|
||||
.set('Authorization', `Bearer ${token('admin')}`)
|
||||
.send({ feedback_id: '11111111-1111-4111-8111-111111111111', voted: true })
|
||||
.expect(429);
|
||||
await request(app)
|
||||
.post('/api/admin/usage/portal-session')
|
||||
.set('Authorization', `Bearer ${token('admin')}`)
|
||||
.expect(429);
|
||||
|
||||
// Reading status and withdrawing must never be throttled: those are how an
|
||||
// operator sees what is happening and how they get out.
|
||||
await request(app)
|
||||
.get('/api/admin/usage')
|
||||
.set('Authorization', `Bearer ${token('admin')}`)
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.post('/api/admin/usage/disable')
|
||||
.set('Authorization', `Bearer ${token('admin')}`)
|
||||
.expect(200);
|
||||
});
|
||||
@@ -1,460 +1,82 @@
|
||||
/**
|
||||
* Regression test for the /admin/login → /admin/dashboard → /admin/login
|
||||
* redirect loop reported on v3.32.4-beta.0.
|
||||
*
|
||||
* Cause: GET /auth/session was less strict than the adminAuth middleware.
|
||||
* The session endpoint accepted tokens that the protected endpoints
|
||||
* subsequently rejected with 401, which the frontend's interceptor
|
||||
* translated into a hard redirect to /admin/login. /auth/session then
|
||||
* said "valid: true" again on the next page load and the cycle closed.
|
||||
*
|
||||
* /auth/session must reject the same admin tokens adminAuth would
|
||||
* reject, specifically: deactivated admin user, deleted admin user,
|
||||
* password changed since iat. Same for gallery: archived event.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
/** Session restoration uses the same live policy as protected routes. */
|
||||
const request = require('supertest');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
process.env.JWT_SECRET = 'session-symmetry-test-secret';
|
||||
|
||||
const fakeDb = {
|
||||
adminUsers: [],
|
||||
events: [],
|
||||
revokedTokens: [],
|
||||
};
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const formatBoolean = (v) => (v ? 1 : 0);
|
||||
void formatBoolean;
|
||||
function dbFn(table) {
|
||||
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\./, '');
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
};
|
||||
return this;
|
||||
},
|
||||
select(...cols) {
|
||||
this._cols = cols;
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
const row = fakeDb.adminUsers.find(rowFilter);
|
||||
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];
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === 'events') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
rowFilter = (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 fakeDb.events.find(rowFilter);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`);
|
||||
}
|
||||
return { db: dbFn, formatBoolean: () => 1 };
|
||||
const crypto = require('crypto');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
process.env.JWT_SECRET = 'session-symmetry-test-secret-with-at-least-32-characters';
|
||||
let db, cleanup, app, adminId, customerId, eventId, cutoff;
|
||||
const slug = 'session-symmetry';
|
||||
const sign = (claims = {}) => jwt.sign({ type: 'admin', id: adminId, username: 'tester',
|
||||
iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims },
|
||||
process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '4h' });
|
||||
const gallery = (claims = {}) => sign({ type: 'gallery', eventId, eventSlug: slug, ...claims });
|
||||
const session = bearer => request(app).get(`/api/auth/session?slug=${slug}`).set('Authorization', `Bearer ${bearer}`);
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId);
|
||||
const row = await require('../../src/services/eventCreationService').createEvent({
|
||||
event_type: 'wedding', event_name: 'Session symmetry', event_date: '2026-10-01',
|
||||
slug, password: 'Session-Strong-Password-924!', expiration_days: 30,
|
||||
customer_email: 'customer@example.test', admin_email: 'admin@example.test',
|
||||
}, { actor: { id: adminId }, source: 'v1' });
|
||||
eventId = row.id;
|
||||
await db('events').where({ id: eventId }).update({ slug });
|
||||
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||
cutoff = require('../../src/utils/sessionCutoff');
|
||||
app = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 120000);
|
||||
beforeEach(async () => {
|
||||
await db('admin_users').where({ id: adminId }).update({ is_active: 1, password_changed_at: null });
|
||||
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
|
||||
await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0,
|
||||
expires_at: new Date(Date.now() + 86400000).toISOString() });
|
||||
await cutoff.setSessionsValidAfter(0);
|
||||
});
|
||||
|
||||
jest.mock('../../src/utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
|
||||
revokeToken: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenUtils', () => ({
|
||||
getAdminTokenFromRequest: (req) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
|
||||
return null;
|
||||
},
|
||||
getGalleryTokenFromRequest: () => null,
|
||||
setAdminAuthCookie: jest.fn(),
|
||||
setGalleryAuthCookies: jest.fn(),
|
||||
clearAdminAuthCookie: jest.fn(),
|
||||
clearGalleryAuthCookies: jest.fn(),
|
||||
buildCookieOptionsWithExpiry: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
|
||||
// Mock sessionTimeout's isSessionExpired so each test controls the return.
|
||||
// Default: not expired (so existing tests keep passing without setup).
|
||||
jest.mock('../../src/middleware/sessionTimeout', () => ({
|
||||
endSession: jest.fn(),
|
||||
isSessionExpired: jest.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth', authRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
|
||||
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
|
||||
// Note: do NOT pass noTimestamp:true here — that strips iat from the
|
||||
// payload entirely, defeating the password-change comparison. Provide
|
||||
// iat (and exp) via the payload directly instead.
|
||||
return jwt.sign(
|
||||
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
|
||||
process.env.JWT_SECRET,
|
||||
{ issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) {
|
||||
return jwt.sign(
|
||||
{ eventId, eventSlug, type: 'gallery', ...extra },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
describe('GET /auth/session — symmetry with protected middleware', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.adminUsers = [];
|
||||
fakeDb.events = [];
|
||||
fakeDb.revokedTokens = [];
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.type).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user has been deactivated', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: false,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user no longer exists', async () => {
|
||||
// adminUsers is empty
|
||||
const token = signAdminToken({ id: 999 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when password was changed after the token was issued', async () => {
|
||||
// iat must be in the past, exp must be in the future so jwt.verify
|
||||
// doesn't reject the token before /auth/session even gets to look
|
||||
// at password_changed_at.
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true when password was changed BEFORE the token was issued', async () => {
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60;
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is archived', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: true,
|
||||
expires_at: null,
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is expired', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() - 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true for an active gallery token', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* What KIND of gallery session this is (#1149).
|
||||
*
|
||||
* The frontend used to keep this in sessionStorage, which is per-TAB while
|
||||
* the cookie is per-browser: a gallery reopened in a second tab lost
|
||||
* 'client' even though the backend still served it as one, and the UI hid
|
||||
* the only control that clears the privileged cookie. Reported from the
|
||||
* token so a restored session knows what it actually is.
|
||||
*/
|
||||
describe('gallery session kind', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a PIN-client session as client', async () => {
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken({ accessLevel: 'client' })}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('client');
|
||||
expect(res.body.viaCustomer).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a customer-portal session, which looks like a guest', async () => {
|
||||
// via:'customer' runs at accessLevel 'guest' but bypasses reveal mode,
|
||||
// so it is a credential that does not look like one.
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken({ via: 'customer', customerId: 7 })}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('guest');
|
||||
expect(res.body.viaCustomer).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a plain guest as neither', async () => {
|
||||
// The flags have to discriminate, or they would just hand every visitor
|
||||
// a Logout button back.
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken()}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('guest');
|
||||
expect(res.body.viaCustomer).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns valid:false when the token is revoked', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
fakeDb.revokedTokens.push(1);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
// Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0.
|
||||
// sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat
|
||||
// tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't.
|
||||
// The new isSessionExpired helper closes that asymmetry.
|
||||
describe('session-timeout symmetry', () => {
|
||||
const { isSessionExpired } = require('../../src/middleware/sessionTimeout');
|
||||
|
||||
beforeEach(() => {
|
||||
isSessionExpired.mockReset();
|
||||
// Default to "active session" so the other admin checks above also
|
||||
// pass when this branch runs.
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when isSessionExpired reports the token has timed out', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(true);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.error).toBe('Session expired');
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token (helper says not expired)', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call isSessionExpired for gallery tokens', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through (treats as valid) if the helper itself throws', async () => {
|
||||
// Defensive: the require() in auth.js is wrapped in try/catch so a
|
||||
// missing/broken helper doesn't fail-closed during early bootstrap.
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockRejectedValue(new Error('boom'));
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await require('../../src/services/serviceShutdown').stopServices();
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
it('hydrates an active admin and its role', async () => {
|
||||
const res = await session(sign());
|
||||
expect(res.body).toMatchObject({ valid: true, type: 'admin', adminUser: { id: adminId, role: { name: 'super_admin' } } });
|
||||
});
|
||||
it.each(['disabled', 'password', 'deleted', 'idle'])('rejects an admin after %s', async reason => {
|
||||
let bearer = sign();
|
||||
if (reason === 'disabled') await db('admin_users').where({ id: adminId }).update({ is_active: 0 });
|
||||
if (reason === 'password') await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date().toISOString() });
|
||||
if (reason === 'deleted') bearer = sign({ id: 999999 });
|
||||
if (reason === 'idle') bearer = sign({ iat: Math.floor(Date.now() / 1000) - 7200 });
|
||||
expect((await session(bearer)).body.valid).toBe(false);
|
||||
});
|
||||
it('accepts a session issued after a previous password change', async () => {
|
||||
await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date(Date.now() - 120000).toISOString() });
|
||||
expect((await session(sign())).body.valid).toBe(true);
|
||||
});
|
||||
it.each(['archived', 'expired', 'draft', 'inactive'])('rejects a gallery that is %s', async reason => {
|
||||
await db('events').where({ id: eventId }).update({
|
||||
...(reason === 'archived' && { is_archived: 1 }), ...(reason === 'draft' && { is_draft: 1 }),
|
||||
...(reason === 'inactive' && { is_active: 0 }), ...(reason === 'expired' && { expires_at: new Date(Date.now() - 1000).toISOString() }),
|
||||
});
|
||||
expect((await session(gallery())).body.valid).toBe(false);
|
||||
});
|
||||
it.each(['guest', 'client', 'customer'])('restores the %s gallery session kind', async kind => {
|
||||
const res = await session(gallery(kind === 'customer' ? { via: 'customer', customerId } : { accessLevel: kind }));
|
||||
expect(res.body).toMatchObject({ valid: true, accessLevel: kind === 'client' ? 'client' : 'guest', viaCustomer: kind === 'customer' });
|
||||
});
|
||||
it.each(['revoked', 'restore'])('invalidates both admin and gallery sessions after %s', async reason => {
|
||||
const tokens = [sign(), gallery()];
|
||||
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||
else for (const bearer of tokens) await require('../../src/utils/tokenRevocation').revokeToken(bearer, 'test');
|
||||
for (const bearer of tokens) expect((await session(bearer)).body.valid).toBe(false);
|
||||
});
|
||||
it('refuses a deactivated customer gallery session', async () => {
|
||||
const bearer = gallery({ via: 'customer', customerId });
|
||||
expect((await session(bearer)).body.valid).toBe(true);
|
||||
await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
|
||||
expect((await session(bearer)).body.valid).toBe(false);
|
||||
});
|
||||
it('refuses an unrelated JWT type', async () => {
|
||||
const res = await session(sign({ type: 'password-reset' }));
|
||||
expect(res.status).toBe(403); expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
@@ -137,11 +137,17 @@ describe('authorization / ownership gaps', () => {
|
||||
// Case-variant keys — SQLite matches columns case-insensitively.
|
||||
Password_Hash: 'case-hijack-hash',
|
||||
Created_By: 88888,
|
||||
// A case variant of an ORDINARY column must not reach the UPDATE
|
||||
// either: field-level guards in the handler key on the exact name,
|
||||
// and on SQLite the variant would still land on the real column.
|
||||
Event_Name: 'case-variant-name',
|
||||
Welcome_Message: 'case-variant-welcome',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('events').where({ id: eventId }).first();
|
||||
expect(row.event_name).toBe('After'); // legit field applied
|
||||
expect(row.event_name).toBe('After'); // legit field applied; Event_Name variant dropped
|
||||
expect(row.welcome_message).toBeFalsy(); // case variant of an ordinary column dropped
|
||||
expect(row.created_by).toBe(superId); // ownership untouched (+ case-variant)
|
||||
expect(row.slug).toBe('authz-mass-assign'); // routing identity untouched
|
||||
expect(row.share_token).toBe(seedShareToken); // secret untouched
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* PUT /api/admin/database-backup/config must reject a
|
||||
* database_backup_destination_path that resolves inside a publicly served
|
||||
* directory (GHSA-jw8m-43r2-jqrm class, #1365).
|
||||
*
|
||||
* Before #1365, database_backup_destination_path was silently ignored by
|
||||
* databaseBackupService.backup() (a destructuring bug always fell back to
|
||||
* the hardcoded /backup/database), so this setting being freely writable by
|
||||
* any backup.create holder — the built-in `admin` role has it without
|
||||
* settings.edit or backup.restore — was harmless. Making the setting
|
||||
* actually take effect reopens the exact exfiltration path GHSA-jw8m fixed
|
||||
* for the per-request override, through the persisted setting instead.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-config-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-config-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('database backup destination-path config guard (GHSA-jw8m class, #1365)', () => {
|
||||
let db; let cleanup; let app; let adminToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const role = await db('roles').where({ name: 'admin' }).first();
|
||||
const r = await db('admin_users').insert({
|
||||
username: 'limited-admin',
|
||||
email: 'limited-admin-config@example.com',
|
||||
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const id = r[0]?.id ?? r[0];
|
||||
adminToken = jwt.sign(
|
||||
{ id, username: 'limited-admin', type: 'admin', role: 'admin', loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' },
|
||||
);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/database-backup', require('../../src/routes/adminDatabaseBackup'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('rejects a destination inside the public uploads/logos mount', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'uploads', 'logos') });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
// The seeded default must survive untouched — the rejected value never lands.
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe('/backup/database');
|
||||
});
|
||||
|
||||
it('rejects a destination inside the public fonts mount', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'fonts') });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts a destination outside any public mount', async () => {
|
||||
const safePath = path.join(process.env.STORAGE_PATH, 'db-backups');
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: safePath });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe(safePath);
|
||||
});
|
||||
|
||||
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
|
||||
// the future, deleting every completed backup on the next scheduled run
|
||||
// — a backup.create holder achieving what backup.delete gates on /cleanup.
|
||||
it.each([-1, 0])('rejects database_backup_retention_days=%s', async (bad) => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_retention_days: bad });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts a positive database_backup_retention_days', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_retention_days: 90 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_retention_days' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe(90);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/** Real routes + migrated SQLite: the same session policy protects lists and media. */
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
process.env.JWT_SECRET = 'gallery-policy-regression-secret-at-least-32-characters';
|
||||
|
||||
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
|
||||
secureImageAccess: (req, _res, next) => {
|
||||
req.clientInfo = { fingerprint: 'policy-test', ip: '127.0.0.1', userAgent: 'jest' };
|
||||
next();
|
||||
},
|
||||
getSecurityStatus: (_req, res) => res.json({}),
|
||||
}));
|
||||
|
||||
let db, cleanup, app, adminId, customerId, foreignId, event, secure, cutoff, revokeToken;
|
||||
const eventId = 70001, photoId = 70002, slug = 'policy-test';
|
||||
const token = (claims = {}) => jwt.sign({ type: 'gallery', eventId, eventSlug: slug,
|
||||
iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims },
|
||||
process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '1h' });
|
||||
const get = (url, bearer) => {
|
||||
const req = request(app).get(url);
|
||||
return bearer ? req.set('Authorization', `Bearer ${bearer}`) : req;
|
||||
};
|
||||
const endpoints = [`/api/gallery/${slug}/photos`, `/api/gallery/${slug}/photo/${photoId}`,
|
||||
`/api/gallery/${slug}/thumbnail/${photoId}`, `/api/gallery/${slug}/download/${photoId}`];
|
||||
const expectDirect = async (bearer, status, suffix = '') => {
|
||||
for (const url of endpoints) expect((await get(url + suffix, bearer)).status).toBe(status);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId);
|
||||
const [row] = await db('admin_users').insert({ username: 'foreign', email: 'foreign@example.test', password_hash: 'unused', is_active: 1 }).returning('id');
|
||||
foreignId = row.id ?? row;
|
||||
await assignAdminRole(db, foreignId, 'viewer');
|
||||
await db('events').insert({ id: eventId, slug, event_type: 'wedding', event_name: 'Policy test',
|
||||
event_date: '2026-01-01', host_email: 'h@example.test', admin_email: 'a@example.test', password_hash: 'unused',
|
||||
share_link: '/gallery/policy-test', created_by: adminId });
|
||||
const file = path.join(process.env.STORAGE_PATH, `events/active/${slug}/individual/fixture.jpg`);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await require('sharp')({ create: { width: 8, height: 8, channels: 3, background: '#228844' } }).jpeg().toFile(file);
|
||||
await db('photos').insert({ id: photoId, event_id: eventId, filename: 'fixture.jpg', path: `${slug}/individual/fixture.jpg`,
|
||||
type: 'individual', mime_type: 'image/jpeg', processing_status: 'complete', size_bytes: (await fs.stat(file)).size });
|
||||
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||
secure = require('../../src/services/secureImageService');
|
||||
jest.spyOn(secure, 'createClientFingerprint').mockReturnValue('policy-test');
|
||||
cutoff = require('../../src/utils/sessionCutoff');
|
||||
({ revokeToken } = require('../../src/utils/tokenRevocation'));
|
||||
app = express(); app.use(express.json()); app.use(cookieParser());
|
||||
app.use('/api', require('../../src/middleware/csrf'));
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/images', require('../../src/routes/protectedImages'));
|
||||
app.use('/api/secure-images', require('../../src/routes/secureImages'));
|
||||
}, 120000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0, require_password: 1,
|
||||
expires_at: new Date(Date.now() + 86400000).toISOString(), reveal_mode: 0 });
|
||||
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
|
||||
if (!await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).first()) {
|
||||
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||
}
|
||||
await cutoff.setSessionsValidAfter(0);
|
||||
event = await db('events').where({ id: eventId }).first();
|
||||
});
|
||||
afterAll(async () => { secure?.dispose(); if (cleanup) await cleanup(); });
|
||||
|
||||
it('serves a valid session as a real list and JPEG', async () => {
|
||||
const bearer = token();
|
||||
const list = await get(endpoints[0], bearer);
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.photos).toEqual(expect.arrayContaining([expect.objectContaining({ id: photoId })]));
|
||||
const image = await get(endpoints[1], bearer);
|
||||
expect(image.status).toBe(200); expect(image.headers['content-type']).toMatch(/image\/jpeg/);
|
||||
expect(image.body.length).toBeGreaterThan(100);
|
||||
});
|
||||
it('scopes draft previews to the owner and current read permissions', async () => {
|
||||
await db('events').where({ id: eventId }).update({ is_draft: 1 });
|
||||
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
|
||||
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
|
||||
// Ownership alone does not grant a user without a role read access.
|
||||
const role = (await db('admin_users').where({ id: adminId }).first()).role_id;
|
||||
await db('admin_users').where({ id: adminId }).update({ role_id: null });
|
||||
try { await expectDirect(mintAdminToken(adminId), 403, '?admin_preview=1'); }
|
||||
finally { await db('admin_users').where({ id: adminId }).update({ role_id: role }); }
|
||||
});
|
||||
it.each(['revocation', 'restore'])('rejects gallery sessions after %s', async (reason) => {
|
||||
const bearer = token();
|
||||
if (reason === 'revocation') expect(await revokeToken(bearer, 'test')).toBe(true);
|
||||
else await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||
await expectDirect(bearer, 401);
|
||||
});
|
||||
it.each(['deactivated', 'password changed'])('rejects an assigned customer when %s', async (reason) => {
|
||||
const bearer = token({ via: 'customer', customerId });
|
||||
await expectDirect(bearer, 200);
|
||||
await db('customer_accounts').where({ id: customerId }).update(reason === 'deactivated'
|
||||
? { is_active: 0 } : { password_changed_at: new Date().toISOString() });
|
||||
await expectDirect(bearer, 401);
|
||||
});
|
||||
it.each(['ISO', 'epoch'])('enforces expiry immediately for public and JWT access (%s)', async (format) => {
|
||||
const expiry = Date.now() - 1000;
|
||||
await db('events').where({ id: eventId }).update({ require_password: 0, expires_at: format === 'ISO' ? new Date(expiry).toISOString() : expiry });
|
||||
await expectDirect(undefined, 404); await expectDirect(token(), 404);
|
||||
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
|
||||
});
|
||||
it.each(['revocation', 'restore', 'expiry', 'customer'])('rechecks signed and secure image grants after %s', async (reason) => {
|
||||
const bearer = token(reason === 'customer' ? { via: 'customer', customerId } : {});
|
||||
const signed = await request(app).post(`/api/images/${slug}/photo/${photoId}/generate-url`).set('Authorization', `Bearer ${bearer}`).send({});
|
||||
expect(signed.status).toBe(200);
|
||||
const minted = await request(app).post(`/api/secure-images/${slug}/generate-token`).set('Authorization', `Bearer ${bearer}`).send({ photoId });
|
||||
expect(minted.status).toBe(200);
|
||||
const secureUrl = `/api/secure-images/${slug}/secure/${photoId}/${minted.body.token}`;
|
||||
expect((await get(signed.body.url)).status).toBe(200);
|
||||
expect((await get(secureUrl)).status).toBe(200);
|
||||
if (reason === 'revocation') await revokeToken(bearer, 'test');
|
||||
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||
if (reason === 'expiry') await db('events').where({ id: eventId }).update({ expires_at: new Date(Date.now() - 1000).toISOString() });
|
||||
if (reason === 'customer') await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
|
||||
const status = reason === 'expiry' ? 404 : 401;
|
||||
expect((await get(signed.body.url)).status).toBe(status);
|
||||
expect((await get(secureUrl)).status).toBe(status);
|
||||
});
|
||||
|
||||
it('blocks an empty cross-site cookie POST before the reveal state changes', async () => {
|
||||
await db('events').where({ id: eventId }).update({ reveal_mode: 1, revealed_at: null });
|
||||
const cookie = `admin_token=${mintAdminToken(adminId)}`;
|
||||
const url = `/api/admin/events/${eventId}/reveal`;
|
||||
const blocked = await request(app).post(url).set('Cookie', cookie).set('Origin', 'https://attacker.example')
|
||||
.set('Sec-Fetch-Site', 'cross-site').set('Content-Type', 'application/x-www-form-urlencoded').send('');
|
||||
expect(blocked.status).toBe(403);
|
||||
expect((await db('events').where({ id: eventId }).first()).revealed_at).toBeNull();
|
||||
process.env.ADMIN_URL = 'https://admin.example.test';
|
||||
try {
|
||||
const allowed = await request(app).post(url).set('Cookie', cookie).set('Origin', process.env.ADMIN_URL)
|
||||
.set('Sec-Fetch-Site', 'cross-site').send({});
|
||||
expect(allowed.status).toBe(200);
|
||||
expect((await db('events').where({ id: eventId }).first()).revealed_at).not.toBeNull();
|
||||
} finally { delete process.env.ADMIN_URL; }
|
||||
});
|
||||
|
||||
it('toggles status on a fully migrated fresh database and records updated_at', async () => {
|
||||
const response = await request(app).post(`/api/admin/events/${eventId}/toggle-status`)
|
||||
.set('Authorization', `Bearer ${mintAdminToken(adminId)}`).send({});
|
||||
expect(response.status).toBe(200);
|
||||
const row = await db('events').where({ id: eventId }).first();
|
||||
expect([false, 0]).toContain(row.is_active);
|
||||
expect(Number.isFinite(require('../../src/utils/dateNormalize').toTimestamp(row.updated_at))).toBe(true);
|
||||
});
|
||||
it('paginates after feedback filtering, with a total independent of page size', async () => {
|
||||
const ids = [70003, 70004, 70005];
|
||||
await db('photos').insert(ids.map(id => ({ id, event_id: eventId, filename: `${id}.jpg`, path: 'unused',
|
||||
type: 'individual', like_count: 1, processing_status: 'complete' })));
|
||||
await db('event_feedback_settings').insert({ event_id: eventId, feedback_enabled: 1, show_feedback_to_guests: 1 });
|
||||
try {
|
||||
const bearer = token();
|
||||
const first = await get(`${endpoints[0]}?filter=liked&limit=2&page=1&sort=filename&order=asc`, bearer);
|
||||
const second = await get(`${endpoints[0]}?filter=liked&limit=2&page=2&sort=filename&order=asc`, bearer);
|
||||
expect(first.status).toBe(200); expect(second.status).toBe(200);
|
||||
expect(first.body.pagination).toMatchObject({ total: 3, has_more: true });
|
||||
expect(second.body.pagination).toMatchObject({ total: 3, has_more: false });
|
||||
expect([...first.body.photos, ...second.body.photos].map(photo => photo.id)).toEqual(ids);
|
||||
} finally {
|
||||
await db('photos').whereIn('id', ids).del();
|
||||
await db('event_feedback_settings').where({ event_id: eventId }).del();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['assignment removed', 'anonymized'])('rejects an existing customer grant after %s', async reason => {
|
||||
const bearer = token({ via: 'customer', customerId });
|
||||
await expectDirect(bearer, 200);
|
||||
if (reason === 'assignment removed') await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).del();
|
||||
else await require('../../src/services/customerAccountsService').eraseCustomer(customerId, adminId);
|
||||
await expectDirect(bearer, reason === 'assignment removed' ? 403 : 401);
|
||||
});
|
||||
it('denies foreign editors and allows the editor who owns the gallery', async () => {
|
||||
await assignAdminRole(db, foreignId, 'editor');
|
||||
try {
|
||||
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
|
||||
await db('events').where({ id: eventId }).update({ created_by: foreignId });
|
||||
await expectDirect(mintAdminToken(foreignId), 200, '?admin_preview=1');
|
||||
} finally {
|
||||
await db('events').where({ id: eventId }).update({ created_by: adminId });
|
||||
await assignAdminRole(db, foreignId, 'viewer');
|
||||
}
|
||||
});
|
||||
it('bounds a large gallery response while retaining the complete count', async () => {
|
||||
const rows = Array.from({ length: 5000 }, (_, index) => ({ id: 80000 + index, event_id: eventId,
|
||||
filename: `large-${index}.jpg`, path: 'unused', type: 'individual', processing_status: 'complete' }));
|
||||
try {
|
||||
await db.batchInsert('photos', rows, 100);
|
||||
const response = await get(`${endpoints[0]}?limit=999999&page=1`, token());
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.photos).toHaveLength(250);
|
||||
expect(response.body.pagination).toMatchObject({ total: 5001, limit: 250, has_more: true });
|
||||
} finally { await db('photos').where('id', '>=', 80000).where({ event_id: eventId }).del(); }
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Previewing an unpublished gallery through its SHORT share URL (#1386).
|
||||
*
|
||||
* /info has honoured admin_preview since #868, but two sibling routes never
|
||||
* did, and both sit on the short-URL path:
|
||||
*
|
||||
* GET /resolve/:identifier — filtered drafts out via ACTIVE_EVENT_FILTER
|
||||
* GET /:slug/verify-token/:token — same, inline
|
||||
*
|
||||
* With "use short gallery URLs" OFF the admin's View Gallery link carries the
|
||||
* slug, GalleryPage never calls /resolve, and the preview worked. With it ON
|
||||
* the link is the token form, GalleryPage resolves it first, and the draft
|
||||
* 404'd as "Gallery Not Found" — which is exactly what was reported.
|
||||
*
|
||||
* The relaxation is admin-preview-only, so the other half of these tests is
|
||||
* the part that must NOT move: anonymous callers still get 404 for a draft,
|
||||
* and GHSA-rh8r's rule (never hand a share_token back on a bare slug lookup)
|
||||
* has to survive the new path too.
|
||||
*/
|
||||
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-draft-preview-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'draft-preview-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-draft-preview-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
// Share-token fixtures, deliberately low-entropy and obviously fake. They
|
||||
// have to satisfy SHARE_TOKEN_REGEX (32 hex chars), and random-looking hex of
|
||||
// that shape is exactly what secret scanners flag — GitGuardian raised two
|
||||
// "Generic High Entropy Secret" findings on the first version of this file.
|
||||
const DRAFT_SLUG = 'draft-preview-event';
|
||||
const DRAFT_TOKEN = 'deadbeefdeadbeefdeadbeefdeadbeef';
|
||||
const LIVE_SLUG = 'published-event';
|
||||
const LIVE_TOKEN = 'feedfacefeedfacefeedfacefeedface';
|
||||
|
||||
describe('draft preview through the short share URL (#1386)', () => {
|
||||
let db; let cleanup; let app; let adminId; let foreignId;
|
||||
|
||||
const asAdmin = (req, id = adminId) => req.set('Authorization', `Bearer ${mintAdminToken(id)}`);
|
||||
|
||||
async function insertEvent({ slug, token, isDraft }) {
|
||||
await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-09-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${slug}/${token}`,
|
||||
share_token: token,
|
||||
require_password: 0,
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: isDraft ? 1 : 0,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId);
|
||||
const [row] = await db('admin_users').insert({
|
||||
username: 'foreign', email: 'foreign@example.test', password_hash: 'unused', is_active: 1,
|
||||
}).returning('id');
|
||||
foreignId = row?.id ?? row;
|
||||
await assignAdminRole(db, foreignId, 'viewer');
|
||||
|
||||
await insertEvent({ slug: DRAFT_SLUG, token: DRAFT_TOKEN, isDraft: true });
|
||||
await insertEvent({ slug: LIVE_SLUG, token: LIVE_TOKEN, isDraft: false });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('the reported case — admin previewing a draft', () => {
|
||||
it('resolves the draft by share token (was 404 "Gallery Not Found")', async () => {
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.slug).toBe(DRAFT_SLUG);
|
||||
expect(res.body.matchType).toBe('token');
|
||||
});
|
||||
|
||||
it('resolves the draft by full share link', async () => {
|
||||
const identifier = encodeURIComponent(`/gallery/${DRAFT_SLUG}/${DRAFT_TOKEN}`);
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${identifier}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.slug).toBe(DRAFT_SLUG);
|
||||
});
|
||||
|
||||
it('clears verify-token for the draft, the next step of the same flow', async () => {
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what must not move', () => {
|
||||
it('404s an anonymous resolve of the draft token', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s even with admin_preview=1 but no admin token', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s for an admin who cannot access this event', async () => {
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
foreignId,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s an anonymous verify-token for the draft', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('still withholds the share_token on a bare slug lookup (GHSA-rh8r)', async () => {
|
||||
// The draft path must not become a way around the token-withholding rule.
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${DRAFT_SLUG}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.matchType).toBe('slug');
|
||||
expect(res.body.token).toBeUndefined();
|
||||
expect(res.body.share_link).toBeUndefined();
|
||||
expect(res.body.share_url).toBeUndefined();
|
||||
expect(JSON.stringify(res.body)).not.toContain(DRAFT_TOKEN);
|
||||
});
|
||||
|
||||
it('leaves the published gallery resolving anonymously, as before', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${LIVE_TOKEN}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.slug).toBe(LIVE_SLUG);
|
||||
expect(res.body.token).toBe(LIVE_TOKEN);
|
||||
});
|
||||
|
||||
it('still 404s an identifier that matches nothing', async () => {
|
||||
const res = await asAdmin(
|
||||
request(app).get('/api/gallery/resolve/no-such-gallery?admin_preview=1'),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Videos under enhanced/maximum image protection (#1370).
|
||||
*
|
||||
* Both halves of the video path used to be routed through /api/secure-images
|
||||
* once an event left `standard` protection, and neither half could carry a
|
||||
* video:
|
||||
*
|
||||
* 1. galleryQueryService emitted `/api/secure-images/{slug}/secure/{id}/{{token}}`
|
||||
* as the video's `url`. The lightbox drops that straight into a <video>
|
||||
* element, nothing substitutes `{{token}}` (the helper that could is
|
||||
* unreferenced), and the route answers 403 "Invalid or expired token".
|
||||
* 2. Even with a valid token it would still fail: the secure-images route
|
||||
* pipes every byte through secureImageService.processProtectedImage,
|
||||
* which calls sharp() and throws on an mp4 → 404.
|
||||
*
|
||||
* The guest saw a poster frozen at 0:00 with no error of any kind.
|
||||
*
|
||||
* Videos now keep the JWT route at every protection level. That is not a new
|
||||
* exposure — thumbnails of those same videos have always been served from it —
|
||||
* so these tests also pin the inverse: still images must keep bouncing to the
|
||||
* secure endpoint. Every assertion here fails on the unfixed code except the
|
||||
* two guarding images.
|
||||
*/
|
||||
|
||||
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-video-urls-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'video-urls-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-video-urls-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const SLUG = 'protected-video-gallery';
|
||||
const VIDEO_BYTES = Buffer.from('not really an mp4, but the route only streams bytes');
|
||||
|
||||
describe('videos stay playable under enhanced/maximum protection (#1370)', () => {
|
||||
let db; let cleanup; let app; let eventId; let videoId; let imageId;
|
||||
|
||||
async function setProtection(level) {
|
||||
await db('events').where('id', eventId).update({ protection_level: level });
|
||||
}
|
||||
|
||||
async function photoPayload(id) {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
|
||||
expect(res.status).toBe(200);
|
||||
const photo = res.body.photos.find((p) => p.id === id);
|
||||
expect(photo).toBeDefined();
|
||||
return photo;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const ev = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Protected Video',
|
||||
event_date: '2026-09-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/s`,
|
||||
share_token: 'protected-video-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
// Password-free so verifyGalleryAccess takes the public path, same as
|
||||
// the sibling gallery suites.
|
||||
require_password: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
const mediaDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG, 'individual');
|
||||
fs.mkdirSync(mediaDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(mediaDir, 'clip.mp4'), VIDEO_BYTES);
|
||||
fs.writeFileSync(path.join(mediaDir, 'still.jpg'), Buffer.from('jpeg-ish'));
|
||||
|
||||
const vid = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'clip.mp4',
|
||||
path: `${SLUG}/individual/clip.mp4`,
|
||||
type: 'individual',
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
duration: 43,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
videoId = vid[0]?.id ?? vid[0];
|
||||
|
||||
const img = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'still.jpg',
|
||||
path: `${SLUG}/individual/still.jpg`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
imageId = img[0]?.id ?? img[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe.each(['enhanced', 'maximum'])('protection_level = %s', (level) => {
|
||||
beforeAll(async () => { await setProtection(level); });
|
||||
|
||||
test('the video url is the JWT route, not a {{token}} template', async () => {
|
||||
const photo = await photoPayload(videoId);
|
||||
expect(photo.url).toBe(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect(photo.url).not.toContain('{{token}}');
|
||||
expect(photo.requires_token).toBe(false);
|
||||
});
|
||||
|
||||
test('the video streams instead of bouncing to the secure endpoint', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('video/mp4');
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(Buffer.from(res.body)).toEqual(VIDEO_BYTES);
|
||||
});
|
||||
|
||||
test('range requests still work, so seeking is possible', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photo/${videoId}`)
|
||||
.set('Range', 'bytes=0-9');
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers['content-range']).toBe(`bytes 0-9/${VIDEO_BYTES.length}`);
|
||||
});
|
||||
|
||||
test('still images keep bouncing to the secure endpoint', async () => {
|
||||
const photo = await photoPayload(imageId);
|
||||
expect(photo.url).toBe(`/api/secure-images/${SLUG}/secure/${imageId}/{{token}}`);
|
||||
expect(photo.requires_token).toBe(true);
|
||||
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photo/${imageId}`);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.body.error).toBe('Secure access required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('protection_level = standard', () => {
|
||||
beforeAll(async () => { await setProtection('standard'); });
|
||||
|
||||
test('both media types take the JWT route, as before', async () => {
|
||||
expect((await photoPayload(videoId)).url).toBe(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect((await photoPayload(imageId)).url).toBe(`/api/gallery/${SLUG}/photo/${imageId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Gallery tokens must carry a per-token `jti`. tokenRevocation falls back to
|
||||
* `${eventId}-${iat}-gallery` without one, so a guest logging out would revoke
|
||||
* every other guest whose token was minted for the same event in the same
|
||||
* second (QR-code share links at an event make that routine).
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const mintSites = [
|
||||
'src/routes/auth.js',
|
||||
'src/routes/customer.js',
|
||||
'src/routes/gallery/slideshow.js',
|
||||
];
|
||||
|
||||
describe('gallery token mint sites', () => {
|
||||
it.each(mintSites)('%s sets a unique jti on every gallery token', (file) => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../', file), 'utf8');
|
||||
const payloads = source.split('jwt.sign(').slice(1)
|
||||
.map((chunk) => chunk.split('process.env.JWT_SECRET')[0])
|
||||
.filter((payload) => payload.includes("type: 'gallery'"));
|
||||
expect(payloads.length).toBeGreaterThan(0);
|
||||
for (const payload of payloads) expect(payload).toContain('jti: crypto.randomUUID()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revocation key', () => {
|
||||
beforeAll(() => { process.env.JWT_SECRET = process.env.JWT_SECRET || 'jti-regression-secret-at-least-32-characters-long'; });
|
||||
it('is distinct for two same-second gallery logins of the same event', () => {
|
||||
const { buildTokenId } = require('../../src/utils/tokenRevocation');
|
||||
const crypto = require('crypto');
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const mint = () => jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat, jti: crypto.randomUUID() }, process.env.JWT_SECRET));
|
||||
expect(buildTokenId(mint())).not.toBe(buildTokenId(mint()));
|
||||
// Without a jti the key collapses to eventId + login second.
|
||||
const bare = jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat }, process.env.JWT_SECRET));
|
||||
expect(buildTokenId(bare)).toBe(buildTokenId({ ...bare }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
const request = require('supertest');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { randomUUID } = require('crypto');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
|
||||
let db, cleanup, adminId, customerId, eventId, apps, revocation;
|
||||
const slug = 'logout-revocation';
|
||||
const cases = [
|
||||
['auth', '/logout', 'admin', 'admin_token'],
|
||||
['auth', '/gallery/logout', 'gallery', `gallery_token_${slug}`],
|
||||
['customerAuth', '/logout', 'customer', 'customer_token'],
|
||||
['adminAuth', '/logout', 'admin', 'admin_token'],
|
||||
];
|
||||
const sign = type => jwt.sign({
|
||||
type, ...(type === 'admin' ? { id: adminId } : type === 'customer' ? { customerId } : { eventId, eventSlug: slug }),
|
||||
jti: randomUUID(),
|
||||
}, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId);
|
||||
const event = await require('../../src/services/eventCreationService').createEvent({
|
||||
event_type: 'wedding', event_name: 'Logout revocation', event_date: '2026-10-01',
|
||||
slug, password: 'Logout-Strong-Password-924!', expiration_days: 30,
|
||||
customer_email: 'customer@example.test', admin_email: 'admin@example.test',
|
||||
}, { actor: { id: adminId }, source: 'v1' });
|
||||
eventId = event.id;
|
||||
await db('events').where({ id: eventId }).update({ slug });
|
||||
revocation = require('../../src/utils/tokenRevocation');
|
||||
apps = Object.fromEntries(['auth', 'adminAuth', 'customerAuth'].map(name => [
|
||||
name, buildRouteApp('/', require(`../../src/routes/${name}`)),
|
||||
]));
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
afterAll(async () => {
|
||||
await require('../../src/services/serviceShutdown').stopServices();
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it.each(cases)('%s%s revokes a no-expiry %s cookie session', async (route, path, type, cookie) => {
|
||||
const token = sign(type);
|
||||
const sessionApp = type === 'customer' ? apps.customerAuth : apps.auth;
|
||||
const sessionPath = type === 'gallery' ? `/session?slug=${slug}` : '/session';
|
||||
const session = () => request(sessionApp).get(sessionPath).set('Cookie', `${cookie}=${token}`);
|
||||
const before = await session();
|
||||
expect(before.status).toBe(200);
|
||||
if (type !== 'customer') expect(before.body.valid).toBe(true);
|
||||
|
||||
const res = await request(apps[route]).post(path).set('Cookie', `${cookie}=${token}`).send({ slug });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['set-cookie'].some(value => value.startsWith(`${cookie}=;`))).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(true);
|
||||
const after = await session();
|
||||
if (type === 'customer') expect(after.status).toBe(401);
|
||||
else expect(after.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it.each(cases)('%s%s reports failed persistence for %s logout and clears its cookie', async (route, path, type, cookie) => {
|
||||
const token = sign(type);
|
||||
const realQuery = db.client.query;
|
||||
jest.spyOn(db.client, 'query').mockImplementation(function (connection, query) {
|
||||
if (/^insert into [`"]revoked_tokens[`"]/.test(query.sql)) {
|
||||
return Promise.reject(new Error('simulated revocation write failure'));
|
||||
}
|
||||
return realQuery.call(this, connection, query);
|
||||
});
|
||||
const res = await request(apps[route]).post(path).set('Cookie', `${cookie}=${token}`).send({ slug });
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBeTruthy();
|
||||
expect(res.body.message).not.toBe('Logged out successfully');
|
||||
expect(res.headers['set-cookie'].some(value => value.startsWith(`${cookie}=;`))).toBe(true);
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(false);
|
||||
});
|
||||
@@ -25,14 +25,18 @@ process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const tokenGuards = require('../../src/utils/publicTokenGuards');
|
||||
const { errorHandler } = require('../../src/middleware/errorHandler');
|
||||
|
||||
describe('publicContracts routes', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let appWithErrorHandler;
|
||||
let customerId;
|
||||
let contractId;
|
||||
|
||||
@@ -51,6 +55,17 @@ describe('publicContracts routes', () => {
|
||||
contractId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
|
||||
|
||||
// A second app instance wired to the REAL production error handler
|
||||
// (buildRouteApp's is a simplified stand-in that only reads
|
||||
// err.statusCode/err.status, which a bare MulterError doesn't set).
|
||||
// Used below to verify the actual 4xx contract end-to-end, not just
|
||||
// that multer aborted the request.
|
||||
appWithErrorHandler = express();
|
||||
appWithErrorHandler.use(express.json());
|
||||
appWithErrorHandler.use(cookieParser());
|
||||
appWithErrorHandler.use('/api/public/contracts', require('../../src/routes/publicContracts'));
|
||||
appWithErrorHandler.use(errorHandler);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -131,6 +146,40 @@ describe('publicContracts routes', () => {
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// CVE-2026-82333 regression (#1374 follow-up): multer 2.3.0 added an
|
||||
// opt-in `fieldArrayIndexLimit` that must be set to actually close the
|
||||
// field-parser DoS — the version bump alone does nothing. This route is
|
||||
// unauthenticated (token-in-URL only), so it's the sharpest place to
|
||||
// prove a crafted request with an oversized array-index field name
|
||||
// (`evil[999999999]`) is rejected rather than accepted or left to hang.
|
||||
it('rejects a multipart request with an oversized array-index field name', async () => {
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId,
|
||||
});
|
||||
const res = await request(app)
|
||||
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
|
||||
.field('evil[999999999]', 'x')
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
// multer aborts the request before the handler runs; buildRouteApp's
|
||||
// generic error handler falls back to 500 for a bare MulterError
|
||||
// (see appWithErrorHandler test below for the real 4xx contract), so
|
||||
// here we only assert the upload was NOT accepted/processed.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.body.error).not.toBe(undefined);
|
||||
});
|
||||
|
||||
it('maps the oversized array-index rejection to a 400 through the real error handler', async () => {
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId,
|
||||
});
|
||||
const res = await request(appWithErrorHandler)
|
||||
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
|
||||
.field('evil[999999999]', 'x')
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:token/pdf', () => {
|
||||
|
||||
@@ -22,29 +22,38 @@ process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
// `bootCrmDb()` hands back the process-wide `db` singleton (module cache —
|
||||
// see its own comment), so it must only be called ONCE per test file: a
|
||||
// second call re-runs migrations against the same connection, and the first
|
||||
// call's `cleanup()` (db.destroy()) would tear down the connection both
|
||||
// describe blocks below share. Boot once at file scope; each describe below
|
||||
// only touches app_settings / env vars, never the connection lifecycle.
|
||||
let db; let cleanup; let checkRestorePathsAllowed;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
async function setBackupSetting(key, value) {
|
||||
const existing = await db('app_settings').where({ setting_key: key }).first();
|
||||
if (existing) {
|
||||
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
|
||||
} else {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('restore path allowlist (GHSA-fw4c)', () => {
|
||||
let db; let cleanup; let checkRestorePathsAllowed;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
// Configure a backup root so the allowlist is actually active.
|
||||
for (const [key, value] of [['backup_destination_path', '/backup']]) {
|
||||
const existing = await db('app_settings').where({ setting_key: key }).first();
|
||||
if (existing) {
|
||||
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
|
||||
} else {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
await setBackupSetting('backup_destination_path', '/backup');
|
||||
});
|
||||
|
||||
it('allows the wizard\'s source TYPE tokens', async () => {
|
||||
for (const source of ['local', 's3', 'upload']) {
|
||||
@@ -84,3 +93,89 @@ describe('restore path allowlist (GHSA-fw4c)', () => {
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GHSA-xfvx-j447-732c: `checkRestorePathsAllowed` constrained the top-level
|
||||
* `source`/`manifestPath` request fields (GHSA-fw4c above), but never looked
|
||||
* INSIDE the manifest itself. `manifest.database.backup_file` — handed
|
||||
* straight to restoreService's candidate resolution and eventually
|
||||
* interpolated into `sqlite3 .restore '<path>'` — was unchecked, so an
|
||||
* absolute path there could point the restore at an arbitrary file even
|
||||
* though `source`/`manifestPath` both passed containment.
|
||||
*/
|
||||
describe('restore path allowlist — manifest database.backup_file containment (GHSA-xfvx)', () => {
|
||||
let tmpRoot;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setBackupSetting('backup_destination_path', '/backup');
|
||||
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-xfvx-manifest-'));
|
||||
// Additional allowed root via the documented escape hatch — keeps this
|
||||
// describe block's fixtures out of the shared '/backup' root above.
|
||||
process.env.RESTORE_ALLOWED_ROOTS = tmpRoot;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.RESTORE_ALLOWED_ROOTS;
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const writeManifest = (name, databaseSection) => {
|
||||
const manifestPath = path.join(tmpRoot, name);
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
manifest: { version: '1.0', id: 'test' },
|
||||
backup: { type: 'full' },
|
||||
system: { platform: 'linux' },
|
||||
application: { version: '1.0.0' },
|
||||
files: { count: 0, manifest: [] },
|
||||
database: databaseSection,
|
||||
verification: { total_checksum: null, checksum_algorithm: null },
|
||||
}));
|
||||
return manifestPath;
|
||||
};
|
||||
|
||||
it('rejects a manifest whose database.backup_file is an absolute path outside every configured root', async () => {
|
||||
const manifestPath = writeManifest('evil-1.json', { backup_file: '/etc/passwd' });
|
||||
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
|
||||
expect(err).toMatch(/database\.backup_file must be inside a configured backup location/i);
|
||||
});
|
||||
|
||||
it('accepts a manifest whose database.backup_file is an absolute path inside a configured root', async () => {
|
||||
const dbFile = path.join(tmpRoot, 'database', 'picpeak-db-sqlite-1.sql.gz');
|
||||
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
|
||||
fs.writeFileSync(dbFile, 'not a real sqlite dump, just a fixture');
|
||||
const manifestPath = writeManifest('legit-1.json', { backup_file: dbFile });
|
||||
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it('does not choke on a manifest whose database.backup_file is a legitimate relative path', async () => {
|
||||
// Relative candidates are resolved against restoreService's own
|
||||
// `backupPath` (which this route-level pre-check doesn't have — it only
|
||||
// sees `source`/`manifestPath`), so this layer intentionally defers
|
||||
// relative-path containment to restoreService.performDatabaseRestore
|
||||
// and must not false-positive here.
|
||||
const manifestPath = writeManifest('legit-2.json', { backup_file: 'database/picpeak-db-sqlite-1.sql.gz' });
|
||||
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects everything when no backup location is configured at all (fail closed, not fail open)', async () => {
|
||||
// Simulate an install that never had backup_destination_path /
|
||||
// backup_manifest_path seeded/configured, and isn't using the
|
||||
// RESTORE_ALLOWED_ROOTS escape hatch either.
|
||||
const savedRoots = process.env.RESTORE_ALLOWED_ROOTS;
|
||||
delete process.env.RESTORE_ALLOWED_ROOTS;
|
||||
await db('app_settings').whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path']).del();
|
||||
|
||||
try {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
|
||||
});
|
||||
expect(err).toMatch(/no backup location is configured/i);
|
||||
} finally {
|
||||
process.env.RESTORE_ALLOWED_ROOTS = savedRoots;
|
||||
await setBackupSetting('backup_destination_path', '/backup');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,8 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
|
||||
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
|
||||
photoId,
|
||||
`gallery_public_${eventId}_${Date.now()}`,
|
||||
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
|
||||
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600,
|
||||
galleryAccess: require('../../src/services/galleryAccessService').grant({ id: eventId }, 'public') },
|
||||
);
|
||||
|
||||
const view = (slug, photoId, token) => request(app)
|
||||
@@ -114,7 +115,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
|
||||
const token = mint(photoA, galleryA);
|
||||
const res = await view('secimg-private-b', photoB, token);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/not valid for this photo/i);
|
||||
expect(res.body.code).toBe('INVALID_GALLERY_GRANT');
|
||||
});
|
||||
|
||||
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
|
||||
@@ -123,7 +124,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
|
||||
// check (sessionId gallery A != URL gallery B) must catch it.
|
||||
const res = await view('secimg-private-b', photoA, token);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/not valid for this gallery/i);
|
||||
expect(res.body.code).toBe('INVALID_GALLERY_GRANT');
|
||||
});
|
||||
|
||||
it('lets a token read its own gallery + photo (binding passes)', async () => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const knex = require('knex');
|
||||
const { changedFields, settingsChanged } = require('../../src/usage/adoptionEvidence');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({ adminAuth: (req, res, next) => { req.admin = { id: 1 }; next(); } }));
|
||||
jest.mock('../../src/middleware/permissions', () => ({ requirePermission: () => (req, res, next) => next() }));
|
||||
jest.mock('../../src/middleware/requireFeatureFlag', () => ({ requireFeatureFlag: () => (req, res, next) => next() }));
|
||||
jest.mock('../../src/services/productUsageService', () => ({ markUsed: jest.fn().mockResolvedValue() }));
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
htmlToText: body => body, wrapEmailHtml: jest.fn(async body => body), buildSignatureTextFor: jest.fn(async () => ''),
|
||||
}));
|
||||
jest.mock('../../src/services/businessProfileService', () => ({ getEmailSignature: jest.fn(async () => null) }));
|
||||
|
||||
describe('v5 evidence comes from real edits, not the generic successful-route marker', () => {
|
||||
let db, app;
|
||||
const marker = require('../../src/services/productUsageService').markUsed;
|
||||
const recorded = () => marker.mock.calls.flatMap(([keys]) => keys).filter(key => /_editing$/.test(key));
|
||||
beforeAll(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
jest.doMock('../../src/database/db', () => ({ db, logActivity: jest.fn(async () => {}) }));
|
||||
await db.schema.createTable('cms_pages', t => {
|
||||
t.increments('id'); t.string('slug');
|
||||
for (const key of ['title_en', 'title_de', 'content_en', 'content_de', 'logo_url', 'external_url']) t.text(key);
|
||||
t.boolean('use_external_url').defaultTo(false); t.boolean('show_in_footer').defaultTo(true); t.timestamp('updated_at');
|
||||
});
|
||||
await db.schema.createTable('email_templates', t => { t.increments('id'); t.string('template_key'); t.timestamp('updated_at'); });
|
||||
await db.schema.createTable('email_template_translations', t => {
|
||||
t.increments('id'); t.integer('template_id'); t.string('language');
|
||||
for (const key of ['subject', 'body_html', 'body_text']) t.text(key);
|
||||
t.timestamp('updated_at'); t.timestamp('created_at');
|
||||
});
|
||||
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
|
||||
await db.schema.createTable('event_types', t => {
|
||||
t.increments('id'); for (const key of ['name', 'slug_prefix', 'emoji', 'theme_preset', 'theme_config']) t.text(key);
|
||||
t.integer('display_order'); t.boolean('is_active'); t.boolean('is_system'); t.timestamp('updated_at'); t.timestamp('created_at');
|
||||
});
|
||||
await db.schema.createTable('events', t => { t.increments('id'); t.integer('created_by'); });
|
||||
await db.schema.createTable('event_category_order', t => { t.integer('event_id'); t.integer('category_id'); t.integer('position'); });
|
||||
await db.schema.createTable('photo_categories', t => {
|
||||
t.increments('id'); t.text('name'); t.text('slug'); t.integer('hero_photo_id'); t.integer('event_id');
|
||||
t.integer('display_order'); t.boolean('allow_downloads'); t.boolean('is_folder'); t.boolean('is_global');
|
||||
});
|
||||
app = express(); app.use(express.json());
|
||||
app.use(require('../../src/middleware/productUsage').productUsage);
|
||||
app.use('/cms', require('../../src/routes/adminCMS'));
|
||||
app.use('/email', require('../../src/routes/adminEmail'));
|
||||
app.use('/event-types', require('../../src/routes/adminEventTypes'));
|
||||
app.use('/categories', require('../../src/routes/adminCategories'));
|
||||
});
|
||||
beforeEach(async () => {
|
||||
marker.mockClear();
|
||||
for (const table of ['cms_pages', 'email_templates', 'email_template_translations', 'app_settings', 'event_types', 'photo_categories', 'events', 'event_category_order']) await db(table).delete();
|
||||
await db('cms_pages').insert({ slug: 'privacy', title_en: 'Privacy', content_en: 'Seeded content' });
|
||||
await db('email_templates').insert({ id: 1, template_key: 'PRIVATE-template' });
|
||||
await db('email_template_translations').insert({ template_id: 1, language: 'en', subject: 'Seeded subject', body_html: 'Seeded body', body_text: '' });
|
||||
});
|
||||
afterAll(() => db.destroy());
|
||||
test('CMS reads, unchanged saves and external content do not imply internal content editing', async () => {
|
||||
await request(app).get('/cms/pages').expect(200);
|
||||
await request(app).put('/cms/pages/privacy').send({ content_en: 'Seeded content' }).expect(200);
|
||||
await request(app).put('/cms/pages/privacy').send({ use_external_url: true, external_url: 'https://example.test/privacy', content_en: 'Other' }).expect(200);
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).put('/cms/pages/privacy').send({ use_external_url: false, content_en: 'PRIVATE real content' }).expect(200);
|
||||
expect(recorded()).toEqual(['cms_content_editing']);
|
||||
expect(JSON.stringify(marker.mock.calls)).not.toMatch(/PRIVATE|example\.test|privacy/);
|
||||
});
|
||||
test('template preview, empty and unchanged saves are excluded; actual content changes count', async () => {
|
||||
await request(app).post('/email/templates/PRIVATE-template/preview').send({}).expect(200);
|
||||
await request(app).put('/email/templates/PRIVATE-template').send({ translations: {} }).expect(200);
|
||||
const translation = { subject: 'Seeded subject', body_html: 'Seeded body', body_text: '' };
|
||||
await request(app).put('/email/templates/PRIVATE-template').send({ translations: { en: translation } }).expect(200);
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).put('/email/templates/PRIVATE-template').send({ translations: { en: { ...translation, subject: 'PRIVATE custom subject' } } }).expect(200);
|
||||
expect(recorded()).toEqual(['email_template_editing']);
|
||||
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
|
||||
});
|
||||
test('failed saves do not count; new nonempty templates do', async () => {
|
||||
await request(app).put('/email/templates/missing').send({ translations: {} }).expect(404);
|
||||
await request(app).post('/email/templates').send({ template_key: 'empty', translations: {} }).expect(201);
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).post('/email/templates').send({ template_key: 'custom', translations: { de: { subject: 'Privat' } } }).expect(201);
|
||||
expect(recorded()).toEqual(['email_template_editing']);
|
||||
});
|
||||
test('seeded event types and categories count only after a real edit, not identical saves', async () => {
|
||||
await db('event_types').insert({ id: 1, name: 'Wedding', slug_prefix: 'wedding', is_active: true, is_system: true });
|
||||
await db('photo_categories').insert({ id: 1, name: 'All', slug: 'all', is_global: true, is_folder: false });
|
||||
await request(app).get('/event-types').expect(200);
|
||||
await request(app).get('/categories/global').expect(200);
|
||||
await request(app).put('/event-types/1').send({ name: 'Wedding', is_active: true }).expect(200);
|
||||
await request(app).put('/categories/1').send({ name: 'All', is_folder: false }).expect(200);
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).put('/event-types/1').send({ name: 'PRIVATE event type' }).expect(200);
|
||||
await request(app).put('/categories/1').send({ name: 'PRIVATE category' }).expect(200);
|
||||
expect(recorded()).toEqual(['event_type_editing', 'category_editing']);
|
||||
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
|
||||
});
|
||||
test('reordering event types and global categories counts only when the order changes', async () => {
|
||||
await db('event_types').insert([
|
||||
{ id: 1, name: 'A', slug_prefix: 'a', display_order: 1, is_active: true, is_system: true },
|
||||
{ id: 2, name: 'B', slug_prefix: 'b', display_order: 2, is_active: true, is_system: true },
|
||||
]);
|
||||
await db('photo_categories').insert([
|
||||
{ id: 1, name: 'A', slug: 'a', is_global: true, is_folder: false, display_order: 1 },
|
||||
{ id: 2, name: 'B', slug: 'b', is_global: true, is_folder: false, display_order: 2 },
|
||||
]);
|
||||
await db('events').insert({ id: 1 });
|
||||
await request(app).post('/event-types/reorder').send({ orderedIds: [1, 2] }).expect(200);
|
||||
await request(app).post('/categories/reorder-global').send({ orderedIds: [1, 2] }).expect(200);
|
||||
await request(app).delete('/categories/reorder/1').expect(200); // no override to reset
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).post('/event-types/reorder').send({ orderedIds: [2, 1] }).expect(200);
|
||||
await request(app).post('/categories/reorder-global').send({ orderedIds: [2, 1] }).expect(200);
|
||||
await request(app).post('/categories/reorder').send({ event_id: 1, orderedIds: [2, 1] }).expect(200);
|
||||
expect(recorded()).toEqual(['event_type_editing', 'category_editing', 'category_editing']);
|
||||
marker.mockClear();
|
||||
await request(app).post('/categories/reorder').send({ event_id: 1, orderedIds: [2, 1] }).expect(200); // same override again
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).delete('/categories/reorder/1').expect(200);
|
||||
expect(recorded()).toEqual(['category_editing']);
|
||||
});
|
||||
test('settings compare persisted values, not timestamps, JSON order or defaults materialized as rows', async () => {
|
||||
await db('app_settings').insert({ setting_key: 'theme_config', setting_value: JSON.stringify({ a: 1, b: 2 }) });
|
||||
expect(await settingsChanged(db, { theme_config: { b: 2, a: 1 } }, ['theme_config'])).toBe(false);
|
||||
expect(await settingsChanged(db, { theme_config: { b: 2, a: 2 } }, ['theme_config'])).toBe(true);
|
||||
expect(await settingsChanged(db, { missing: 'fallback' }, ['missing'])).toBe(false);
|
||||
expect(await settingsChanged(db, { secret: 'PRIVATE' }, ['theme_config'])).toBe(false);
|
||||
expect(changedFields({ enabled: 1, updated_at: 'old' }, { enabled: true, updated_at: 'new' }, ['enabled'])).toBe(false);
|
||||
expect(changedFields({ body: '' }, { body: null }, ['body'])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const mockExport = jest.fn();
|
||||
const mockMarkUsed = jest.fn().mockResolvedValue();
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: jest.fn(() => ({ where: jest.fn().mockReturnThis(), first: jest.fn().mockResolvedValue({ id: 1 }) })),
|
||||
withRetry: fn => fn()
|
||||
}));
|
||||
jest.mock('../../src/middleware/auth', () => ({ adminAuth: (req, res, next) => {
|
||||
if (!req.headers.authorization) return res.sendStatus(401);
|
||||
req.admin = { id: 1 }; next();
|
||||
} }));
|
||||
jest.mock('../../src/middleware/permissions', () => ({ requirePermission: () => (_req, _res, next) => next() }));
|
||||
jest.mock('../../src/middleware/ownership', () => ({ requireEventOwnership: (_req, _res, next) => next() }));
|
||||
jest.mock('../../src/services/photoExportService', () => ({ PhotoExportService: jest.fn().mockImplementation(() => ({ exportPhotos: mockExport })) }));
|
||||
jest.mock('../../src/services/photoAdminMarksService', () => ({}));
|
||||
jest.mock('../../src/services/feedbackService', () => ({}));
|
||||
jest.mock('../../src/services/productUsageService', () => ({ markUsed: (...args) => mockMarkUsed(...args) }));
|
||||
const { productUsage } = require('../../src/middleware/productUsage');
|
||||
const router = require('../../src/routes/adminPhotoExport');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin', productUsage);
|
||||
app.use('/admin/photo-export', router);
|
||||
|
||||
beforeEach(() => { mockMarkUsed.mockClear(); mockExport.mockReset(); });
|
||||
test('only a successful authenticated XMP export produces the new bit, without request or exported content', async () => {
|
||||
mockExport.mockResolvedValue({ type: 'content', contentType: 'text/plain', filename: 'PRIVATE-export.txt', content: 'PRIVATE-content' });
|
||||
await request(app).post('/admin/photo-export/1/export').set('Authorization', 'test').send({ photo_ids: [9], format: 'xmp' }).expect(200);
|
||||
expect(mockMarkUsed).toHaveBeenCalledWith(['photo_exports', 'photo_xmp_export'], { legacyFeatures: [], destinationBackup: false });
|
||||
expect(JSON.stringify(mockMarkUsed.mock.calls)).not.toContain('PRIVATE');
|
||||
mockMarkUsed.mockClear();
|
||||
await request(app).post('/admin/photo-export/1/export').set('Authorization', 'test').send({ photo_ids: [9], format: 'csv' }).expect(200);
|
||||
expect(mockMarkUsed.mock.calls[0][0]).toEqual(['photo_exports']);
|
||||
});
|
||||
test('failed, invalid and unauthenticated exports never produce an XMP-use marker', async () => {
|
||||
mockExport.mockRejectedValue(new Error('Synthetic export failure'));
|
||||
await request(app).post('/admin/photo-export/1/export').set('Authorization', 'test').send({ photo_ids: [9], format: 'xmp' }).expect(500);
|
||||
await request(app).post('/admin/photo-export/1/export').set('Authorization', 'test').send({ photo_ids: [9], format: 'unknown' }).expect(400);
|
||||
await request(app).post('/admin/photo-export/1/export').send({ photo_ids: [9], format: 'xmp' }).expect(401);
|
||||
expect(mockMarkUsed).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* A rejected chunk must not cost its own size in memory (#1403).
|
||||
*
|
||||
* The route used to drain the whole request into an array and `Buffer.concat`
|
||||
* it before calling uploadChunk, which is where every check lives — the
|
||||
* per-file cap, the chunk index, and even "does this upload id exist". So a
|
||||
* 300MB body against an unknown upload id was read in full, added ~300MB to
|
||||
* RSS, and was then answered with an error. The size cap was real but only
|
||||
* applied after the damage.
|
||||
*
|
||||
* The contract these tests pin: uploadChunk consumes NOTHING until every check
|
||||
* has passed, and once it does start reading it stops at the remaining
|
||||
* allowance rather than trusting the sender.
|
||||
*/
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs').promises;
|
||||
const { Readable } = require('stream');
|
||||
|
||||
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-stream-test-${process.pid}`);
|
||||
|
||||
const chunkedUpload = require('../../src/services/chunkedUploadService');
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
const init = (overrides = {}) => chunkedUpload.initializeUpload({
|
||||
filename: 'clip.mp4',
|
||||
fileSize: 1,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 2,
|
||||
maxFileSizeBytes: 1 * MB,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A readable that reports how much of it was actually pulled. Bytes are
|
||||
* generated lazily, so "never read" really means the body never materialized.
|
||||
*/
|
||||
function countingSource(totalBytes, sliceSize = 64 * 1024) {
|
||||
let remaining = totalBytes;
|
||||
const source = new Readable({
|
||||
read() {
|
||||
if (remaining <= 0) return this.push(null);
|
||||
const n = Math.min(sliceSize, remaining);
|
||||
remaining -= n;
|
||||
source.bytesRead += n;
|
||||
this.push(Buffer.alloc(n));
|
||||
},
|
||||
});
|
||||
source.bytesRead = 0;
|
||||
return source;
|
||||
}
|
||||
|
||||
describe('chunked upload streams the body under a cap (#1403)', () => {
|
||||
afterAll(async () => {
|
||||
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('refused before the body is read', () => {
|
||||
it('reads nothing for an unknown upload id', async () => {
|
||||
const source = countingSource(8 * MB);
|
||||
await expect(chunkedUpload.uploadChunk('does-not-exist', 0, source, { declaredBytes: 8 * MB }))
|
||||
.rejects.toThrow('Upload not found or expired');
|
||||
expect(source.bytesRead).toBe(0);
|
||||
});
|
||||
|
||||
it('reads nothing for an out-of-range chunk index', async () => {
|
||||
const { uploadId } = await init();
|
||||
const source = countingSource(8 * MB);
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 99, source, { declaredBytes: 8 * MB }))
|
||||
.rejects.toMatchObject({ code: 'INVALID_CHUNK', statusCode: 400 });
|
||||
expect(source.bytesRead).toBe(0);
|
||||
});
|
||||
|
||||
it('reads nothing when Content-Length already exceeds the cap', async () => {
|
||||
const { uploadId } = await init();
|
||||
const source = countingSource(8 * MB);
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, source, { declaredBytes: 8 * MB }))
|
||||
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
|
||||
expect(source.bytesRead).toBe(0);
|
||||
expect(chunkedUpload.getUploadStatus(uploadId)).toBeNull();
|
||||
});
|
||||
|
||||
it('counts what earlier chunks already banked when checking Content-Length', async () => {
|
||||
const { uploadId } = await init();
|
||||
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.75 * MB));
|
||||
const source = countingSource(0.5 * MB);
|
||||
// 0.75MB banked + 0.5MB declared > the 1MB cap.
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 1, source, { declaredBytes: 0.5 * MB }))
|
||||
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
|
||||
expect(source.bytesRead).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a sender that lies, or says nothing', () => {
|
||||
it('stops at the allowance instead of reading the whole body', async () => {
|
||||
const { uploadId } = await init();
|
||||
// No declaredBytes at all — the Transfer-Encoding: chunked case.
|
||||
const source = countingSource(8 * MB);
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, source))
|
||||
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
|
||||
// The overshoot is whatever the readable had already buffered ahead when
|
||||
// the cap tripped — a small constant tied to highWaterMark, NOT a
|
||||
// function of the body size. That is the whole claim: 8MB offered, ~1MB
|
||||
// read. The slack is deliberately loose so this doesn't turn into a
|
||||
// Node-version canary.
|
||||
expect(source.bytesRead).toBeLessThan(2 * MB);
|
||||
});
|
||||
|
||||
it('leaves no partial chunk file behind when it cuts a body off', async () => {
|
||||
const { uploadId } = await init();
|
||||
const meta = chunkedUpload.getUploadStatus(uploadId);
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, countingSource(8 * MB)))
|
||||
.rejects.toMatchObject({ statusCode: 413 });
|
||||
// abortUpload removes the whole directory; assert nothing survived it.
|
||||
await expect(fs.readdir(path.join(process.env.STORAGE_PATH, 'chunks', uploadId)))
|
||||
.rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(meta).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Every case here was found by an external review of the first cut of this
|
||||
// fix. All three are regressions the buffered version did not have: the
|
||||
// async iterator it replaced rejected a dead request on its own, and never
|
||||
// opened the chunk file at all until it had the whole body in hand.
|
||||
describe('failure paths the streaming rewrite introduced', () => {
|
||||
it('rejects an already-destroyed request instead of hanging forever', async () => {
|
||||
const { uploadId } = await init();
|
||||
const source = countingSource(1024);
|
||||
source.destroy();
|
||||
// pipe() on a dead stream emits neither `end` nor `error`, so without an
|
||||
// explicit check this promise never settles and the write fd leaks.
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, source))
|
||||
.rejects.toMatchObject({ code: 'CHUNK_PREMATURE_CLOSE', statusCode: 400 });
|
||||
});
|
||||
|
||||
it('leaves a previously banked chunk intact when a re-send fails', async () => {
|
||||
const { uploadId } = await init();
|
||||
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(1000));
|
||||
const chunkPath = path.join(process.env.STORAGE_PATH, 'chunks', uploadId, 'chunk_000000');
|
||||
expect((await fs.stat(chunkPath)).size).toBe(1000);
|
||||
|
||||
// Re-send the same index, then fail it mid-flight.
|
||||
const source = new Readable({ read() {} });
|
||||
const pending = chunkedUpload.uploadChunk(uploadId, 0, source);
|
||||
source.push(Buffer.alloc(10));
|
||||
source.destroy(new Error('client went away'));
|
||||
await expect(pending).rejects.toThrow();
|
||||
|
||||
// The banked copy must still be there: receivedChunks/chunkSizes still
|
||||
// count it, so a truncated file here means status reports 100% and
|
||||
// completeUpload dies on ENOENT.
|
||||
expect((await fs.stat(chunkPath)).size).toBe(1000);
|
||||
// Still counted as received — which is exactly why the file has to still
|
||||
// be there and be the full 1000 bytes.
|
||||
expect(chunkedUpload.getUploadStatus(uploadId).receivedChunks).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps two in-flight sends of the same chunk off each other\'s staging file', async () => {
|
||||
const { uploadId } = await init();
|
||||
const chunkPath = path.join(process.env.STORAGE_PATH, 'chunks', uploadId, 'chunk_000000');
|
||||
|
||||
// Two requests for the same index, overlapping. A shared .part path let
|
||||
// whichever renamed first publish bytes the other had already truncated.
|
||||
const slow = new Readable({ read() {} });
|
||||
const doomed = new Readable({ read() {} });
|
||||
const slowDone = chunkedUpload.uploadChunk(uploadId, 0, slow);
|
||||
const doomedDone = chunkedUpload.uploadChunk(uploadId, 0, doomed);
|
||||
|
||||
doomed.push(Buffer.alloc(2));
|
||||
doomed.destroy(new Error('retry gave up'));
|
||||
await expect(doomedDone).rejects.toThrow();
|
||||
|
||||
slow.push(Buffer.alloc(10));
|
||||
slow.push(null);
|
||||
await expect(slowDone).resolves.toBeTruthy();
|
||||
|
||||
// The surviving attempt's 10 bytes, not the failed one's 2.
|
||||
expect((await fs.stat(chunkPath)).size).toBe(10);
|
||||
});
|
||||
|
||||
it('leaves no staging files behind after a failure', async () => {
|
||||
const { uploadId } = await init();
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, countingSource(8 * MB)))
|
||||
.rejects.toMatchObject({ statusCode: 413 });
|
||||
// The cap path aborts the whole upload, so the directory is gone; what
|
||||
// must not happen is a .part file reappearing after cleanup because the
|
||||
// write stream's open() was still pending when the unlink ran.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await expect(fs.readdir(path.join(process.env.STORAGE_PATH, 'chunks', uploadId)))
|
||||
.rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('counts chunks that landed while another was still streaming', async () => {
|
||||
const { uploadId } = await init({ totalChunks: 3 });
|
||||
|
||||
// Start a slow 0.75MB chunk. Its allowance is computed now, when nothing
|
||||
// else is banked.
|
||||
const slow = new Readable({ read() {} });
|
||||
const slowDone = chunkedUpload.uploadChunk(uploadId, 0, slow);
|
||||
|
||||
// A second 0.75MB chunk completes in the meantime.
|
||||
await chunkedUpload.uploadChunk(uploadId, 1, Buffer.alloc(0.75 * MB));
|
||||
|
||||
// Finishing the first must not publish: 1.5MB against a 1MB cap.
|
||||
slow.push(Buffer.alloc(0.75 * MB));
|
||||
slow.push(null);
|
||||
await expect(slowDone).rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
|
||||
expect(chunkedUpload.getUploadStatus(uploadId)).toBeNull();
|
||||
});
|
||||
|
||||
it('removes the staging file when publishing it fails', async () => {
|
||||
const { uploadId } = await init();
|
||||
const dir = path.join(process.env.STORAGE_PATH, 'chunks', uploadId);
|
||||
// Make the rename fail by putting a directory where the chunk goes.
|
||||
await fs.mkdir(path.join(dir, 'chunk_000000'), { recursive: true });
|
||||
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, countingSource(1024)))
|
||||
.rejects.toThrow();
|
||||
|
||||
// The fully written .part must not survive a failed publish — its name is
|
||||
// per-attempt, so retries would otherwise pile them up until expiry.
|
||||
const leftovers = (await fs.readdir(dir)).filter((f) => f.endsWith('.part'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not destroy the request stream when it trips the cap', async () => {
|
||||
const { uploadId } = await init();
|
||||
const source = countingSource(8 * MB);
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, source))
|
||||
.rejects.toMatchObject({ statusCode: 413 });
|
||||
// `source` stands in for the IncomingMessage. Destroying it would take
|
||||
// the socket down before the route could send its 413 JSON, so the client
|
||||
// would see a connection reset instead of the error.
|
||||
expect(source.destroyed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// These were plain Errors, so the routes answered 500 for what are plainly
|
||||
// client mistakes — a backend fault in monitoring, and an invitation to
|
||||
// retry something that can never succeed.
|
||||
describe('client-caused states carry their own status code', () => {
|
||||
it('404s an unknown upload id rather than 500', async () => {
|
||||
await expect(chunkedUpload.uploadChunk('does-not-exist', 0, Buffer.alloc(10)))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// 409 (wrong status) and 410 (expired) share uploadStateError with the two
|
||||
// covered here. Reaching them from the public surface needs either a clock
|
||||
// or a setter the service does not expose, and a test that pretends to
|
||||
// exercise them while actually hitting the 404 path is worse than none.
|
||||
|
||||
it('404s completing an unknown upload rather than 500', async () => {
|
||||
await expect(chunkedUpload.completeUpload('does-not-exist'))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('400s completing an upload that is missing chunks', async () => {
|
||||
const { uploadId } = await init();
|
||||
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(10));
|
||||
await expect(chunkedUpload.completeUpload(uploadId))
|
||||
.rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the happy path still works', () => {
|
||||
it('writes a streamed chunk and reports progress', async () => {
|
||||
const { uploadId } = await init();
|
||||
const result = await chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.25 * MB), {
|
||||
declaredBytes: 0.25 * MB,
|
||||
});
|
||||
expect(result).toMatchObject({ chunkIndex: 0, received: 1, expected: 2, complete: false });
|
||||
|
||||
const chunkPath = path.join(process.env.STORAGE_PATH, 'chunks', uploadId, 'chunk_000000');
|
||||
expect((await fs.stat(chunkPath)).size).toBe(0.25 * MB);
|
||||
});
|
||||
|
||||
it('still accepts a Buffer, the shape the service was written for', async () => {
|
||||
const { uploadId } = await init();
|
||||
const result = await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.25 * MB));
|
||||
expect(result).toMatchObject({ chunkIndex: 0, received: 1 });
|
||||
});
|
||||
|
||||
it('lets a re-sent chunk replace itself without double-counting', async () => {
|
||||
const { uploadId } = await init();
|
||||
await chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.6 * MB), { declaredBytes: 0.6 * MB });
|
||||
// Same index again: the first copy's 0.6MB must not count toward the cap.
|
||||
await expect(
|
||||
chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.6 * MB), { declaredBytes: 0.6 * MB }),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Background zip rebuilds are capped (#1399).
|
||||
*
|
||||
* invalidateAll() invalidates every event holding a cached zip, and each
|
||||
* invalidate() arms its own debounce timer in the same tick — so they all fire
|
||||
* together. Every build opens its own storage reads, so a settings change
|
||||
* across 25 events was enough to exhaust the S3 agent pool and stall uploads,
|
||||
* thumbnails and gallery reads until the burst drained.
|
||||
*
|
||||
* The cap is on the BACKGROUND path only: a guest waiting on a download must
|
||||
* not be queued behind a settings-change burst.
|
||||
*/
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const { db } = require('../../src/database/db');
|
||||
const service = require('../../src/services/downloadZipService');
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r));
|
||||
|
||||
describe('downloadZipService background regen concurrency (#1399)', () => {
|
||||
let peak;
|
||||
let inFlight;
|
||||
let release;
|
||||
|
||||
beforeEach(() => {
|
||||
// setImmediate must stay real: the flush() helper below rides on it, and
|
||||
// jest's modern fake timers mock it too.
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
|
||||
peak = 0;
|
||||
inFlight = 0;
|
||||
release = [];
|
||||
service.stopped = false;
|
||||
service.regenActive = 0;
|
||||
service.regenWaiters = [];
|
||||
service.debounceTimers.clear();
|
||||
service.activeBuilds.clear();
|
||||
|
||||
jest.spyOn(service, 'generateZip').mockImplementation(() => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
return new Promise((resolve) => {
|
||||
release.push(() => { inFlight -= 1; resolve(); });
|
||||
});
|
||||
});
|
||||
jest.spyOn(service, '_cleanup').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('never runs more than two rebuilds at once, however many fire together', async () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
// Every debounce timer was armed in the same tick — fire them all.
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
|
||||
expect(peak).toBe(2);
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('starts the next rebuild as each one finishes', async () => {
|
||||
const rows = Array.from({ length: 5 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
|
||||
release.shift()();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(3);
|
||||
expect(peak).toBe(2);
|
||||
|
||||
while (release.length) { release.shift()(); await flush(); }
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(5);
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
|
||||
it('does not queue a foreground download behind the burst', async () => {
|
||||
const rows = Array.from({ length: 6 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
|
||||
// A guest asking for a zip right now calls generateZip directly. It must
|
||||
// not park behind the two rebuilds already holding the slots.
|
||||
service.generateZip(999);
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledWith(999);
|
||||
expect(inFlight).toBe(3);
|
||||
});
|
||||
|
||||
it('releases anything parked for a slot on shutdown', async () => {
|
||||
const rows = Array.from({ length: 6 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.regenWaiters.length).toBeGreaterThan(0);
|
||||
|
||||
// stop() must not hang on a queue that will never drain.
|
||||
const stopping = service.stop();
|
||||
release.forEach((fn) => fn());
|
||||
await expect(stopping).resolves.toBeUndefined();
|
||||
expect(service.regenWaiters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* A failed pre-zip build must not leave storage reads open.
|
||||
*
|
||||
* The builder opened one storage read per photo and handed the raw stream to
|
||||
* archiver. archiver drains its queue one entry at a time, so on an S3 backend
|
||||
* every photo beyond the one being written parked a socket with a full receive
|
||||
* buffer, and the error path (a source stream dying, or a photo upload
|
||||
* invalidating the build) walked away from all of them. archiver's abort()
|
||||
* does not touch the source streams, and the AWS SDK arms its socket timeout
|
||||
* on a 3s delay then clears it once the response headers arrive, so nothing
|
||||
* ever reclaimed those sockets. On a live server 43 of the 50 pooled sockets
|
||||
* ended up stuck for days and photo uploads stopped completing.
|
||||
*/
|
||||
|
||||
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-zipleak-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'zipleak-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-zipleak-storage-'));
|
||||
|
||||
const { Readable } = require('stream');
|
||||
|
||||
const PHOTO_COUNT = 6;
|
||||
const MAX_INFLIGHT_READS = 2;
|
||||
|
||||
// One storage read. It never ends on its own, which is what a large photo
|
||||
// looks like to the builder: the bytes only move while archiver pulls them.
|
||||
class StoredObject extends Readable {
|
||||
constructor(key, failAfterReads, chunks) {
|
||||
super();
|
||||
this.key = key;
|
||||
this.failAfterReads = failAfterReads;
|
||||
this.chunks = chunks;
|
||||
this.reads = 0;
|
||||
}
|
||||
|
||||
_read() {
|
||||
this.reads += 1;
|
||||
if (this.failAfterReads && this.reads > this.failAfterReads) {
|
||||
// What a dropped connection to S3 looks like in Node.
|
||||
this.destroy(new Error('aborted'));
|
||||
return;
|
||||
}
|
||||
this.push(this.reads > this.chunks ? null : Buffer.alloc(4096, 1));
|
||||
}
|
||||
}
|
||||
|
||||
const reads = { opened: [], live: 0, peak: 0 };
|
||||
const failingKey = { value: null };
|
||||
const onOpen = { fn: null };
|
||||
// A read only finishes when the build pulls the whole object. Photos big
|
||||
// enough to matter never finish inside one archiver turn, and a stream that
|
||||
// ends on its own would be auto-destroyed and hide the leak.
|
||||
const objectChunks = { value: Number.POSITIVE_INFINITY };
|
||||
|
||||
function openStoredObject(key) {
|
||||
const stream = new StoredObject(key, key === failingKey.value ? 1 : 0, objectChunks.value);
|
||||
reads.opened.push(stream);
|
||||
reads.live += 1;
|
||||
if (reads.live > reads.peak) reads.peak = reads.live;
|
||||
let settled = false;
|
||||
const settle = () => { if (!settled) { settled = true; reads.live -= 1; } };
|
||||
stream.once('end', settle);
|
||||
stream.once('close', settle);
|
||||
if (onOpen.fn) onOpen.fn(reads.opened.length);
|
||||
return stream;
|
||||
}
|
||||
|
||||
const mockStorage = {
|
||||
kind: () => 's3',
|
||||
get: jest.fn(async (key) => openStoredObject(key)),
|
||||
getToFile: jest.fn(async () => undefined),
|
||||
putFromFile: jest.fn(async () => undefined),
|
||||
stat: jest.fn(async () => ({ size: 1234, mtime: new Date() })),
|
||||
delete: jest.fn(async () => undefined),
|
||||
exists: jest.fn(async () => true),
|
||||
};
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({
|
||||
getStorage: () => mockStorage,
|
||||
initStorage: async () => mockStorage,
|
||||
}));
|
||||
|
||||
// Nothing to resize or watermark, so the builder takes the stream-from-storage
|
||||
// branch, which is the one that holds sockets.
|
||||
jest.mock('../../src/services/downloadRendition', () => ({
|
||||
renderPhotoForDownload: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const downloadZipService = require('../../src/services/downloadZipService');
|
||||
|
||||
describe('pre-zip build releases its storage reads', () => {
|
||||
let db; let cleanup; let eventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const ev = await db('events').insert({
|
||||
slug: 'zipleak',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Zip Leak',
|
||||
event_date: '2026-09-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/zipleak/s',
|
||||
share_token: 'zipleak-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
require_password: 0,
|
||||
allow_downloads: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
for (let i = 0; i < PHOTO_COUNT; i += 1) {
|
||||
await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `photo-${i}.jpg`,
|
||||
path: `zipleak/photo-${i}.jpg`,
|
||||
type: 'individual',
|
||||
source_origin: 'managed',
|
||||
mime_type: 'image/jpeg',
|
||||
visibility: 'visible',
|
||||
uploaded_at: new Date(Date.now() - i * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(() => {
|
||||
reads.opened = [];
|
||||
reads.live = 0;
|
||||
reads.peak = 0;
|
||||
failingKey.value = null;
|
||||
onOpen.fn = null;
|
||||
objectChunks.value = Number.POSITIVE_INFINITY;
|
||||
mockStorage.get.mockClear();
|
||||
downloadZipService.versions.clear();
|
||||
downloadZipService.activeBuilds.clear();
|
||||
});
|
||||
|
||||
it('destroys every open read when a source stream dies mid-build', async () => {
|
||||
// The oldest photo is written first, so failing it strands the rest.
|
||||
failingKey.value = 'events/active/zipleak/photo-0.jpg';
|
||||
|
||||
const result = await downloadZipService.generateZip(eventId);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(reads.opened.length).toBeGreaterThan(1);
|
||||
const stranded = reads.opened.filter((s) => !s.destroyed);
|
||||
expect(stranded.map((s) => s.key)).toEqual([]);
|
||||
});
|
||||
|
||||
it('destroys every open read when an upload invalidates the build', async () => {
|
||||
// What adminPhotos does on every upload, delete and bulk edit, landing
|
||||
// while the archive is half built.
|
||||
onOpen.fn = (count) => {
|
||||
if (count !== 2) return;
|
||||
downloadZipService.invalidate(eventId);
|
||||
// invalidate() also schedules a rebuild; this test is not about that.
|
||||
clearTimeout(downloadZipService.debounceTimers.get(eventId));
|
||||
downloadZipService.debounceTimers.delete(eventId);
|
||||
};
|
||||
|
||||
const result = await downloadZipService.generateZip(eventId);
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Build invalidated' });
|
||||
expect(reads.opened.filter((s) => !s.destroyed).map((s) => s.key)).toEqual([]);
|
||||
});
|
||||
|
||||
it('never holds more storage reads open than the build needs', async () => {
|
||||
objectChunks.value = 8;
|
||||
|
||||
const result = await downloadZipService.generateZip(eventId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockStorage.get).toHaveBeenCalledTimes(PHOTO_COUNT);
|
||||
expect(reads.peak).toBeLessThanOrEqual(MAX_INFLIGHT_READS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Passwords leave the email archive once a row is final (see
|
||||
* utils/emailSecretRedaction.js). The gallery-created email carries the
|
||||
* gallery password and the client PIN; after the mail is out — or after the
|
||||
* row is out of retries — neither survives in email_data or rendered_html.
|
||||
*/
|
||||
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-mailredact-')), 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mailredact-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mailredact-storage-'));
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const { MASK } = require('../../src/utils/emailSecretRedaction');
|
||||
|
||||
function stubWebhookTransport(impl) {
|
||||
const transport = require('../../src/services/emailWebhookTransport');
|
||||
const savedFrom = process.env.EMAIL_FROM;
|
||||
process.env.EMAIL_FROM = 'noreply@example.com';
|
||||
const mails = [];
|
||||
const enabled = jest.spyOn(transport, 'isEnabled').mockReturnValue(true);
|
||||
// mockRestore() wipes mock.calls, so keep our own copy of what went out
|
||||
const send = jest.spyOn(transport, 'send').mockImplementation(async (mail) => { mails.push(mail); return impl(mail); });
|
||||
return { mails, restore() { enabled.mockRestore(); send.mockRestore(); if (savedFrom === undefined) delete process.env.EMAIL_FROM; else process.env.EMAIL_FROM = savedFrom; } };
|
||||
}
|
||||
|
||||
describe('email archive redaction', () => {
|
||||
let db; let cleanup; let eventId;
|
||||
const PASSWORD = 'Sunset-42!'; const PIN = '7788';
|
||||
const queue = (extra = {}) => db('email_queue').insert({
|
||||
event_id: eventId, recipient_email: 'client@example.com', email_type: 'gallery_created',
|
||||
email_data: JSON.stringify({
|
||||
customer_name: 'Ada', host_name: 'Ada', event_name: 'Redaction Wedding', event_date: '2026-09-07',
|
||||
gallery_link: 'https://photos.example/gallery/redaction-wedding/tok', gallery_password: PASSWORD,
|
||||
client_link: 'https://photos.example/gallery/redaction-wedding/client-access?token=abc', client_password: PIN,
|
||||
expiry_date: null, welcome_message: '',
|
||||
}),
|
||||
status: 'pending', created_at: new Date().toISOString(), scheduled_at: new Date().toISOString(), retry_count: 0,
|
||||
...extra,
|
||||
}).returning('id').then((r) => r[0]?.id ?? r[0]);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
const ins = await db('events').insert({
|
||||
slug: 'redaction-wedding', event_type: 'wedding', event_name: 'Redaction Wedding', event_date: '2026-09-07',
|
||||
customer_email: 'client@example.com', customer_name: 'Ada', password_hash: 'x', share_link: '/gallery/redaction-wedding/tok',
|
||||
share_token: 'tok', expires_at: new Date(Date.now() + 86400000).toISOString(), is_active: true, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ins[0]?.id ?? ins[0];
|
||||
}, 120000);
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('scrubs the variables and the rendered body once the mail is out', async () => {
|
||||
const id = await queue();
|
||||
const stub = stubWebhookTransport(async () => ({ messageId: 'sent-1' }));
|
||||
try {
|
||||
const { processEmailQueue } = require('../../src/services/emailProcessor');
|
||||
await processEmailQueue({ ignoreSchedule: true, onlyId: id });
|
||||
} finally { stub.restore(); }
|
||||
const row = await db('email_queue').where('id', id).first();
|
||||
expect(row.status).toBe('sent');
|
||||
const data = JSON.parse(row.email_data);
|
||||
expect(data.gallery_password).toBe(MASK);
|
||||
expect(data.client_password).toBe(MASK);
|
||||
expect(data.customer_name).toBe('Ada');
|
||||
expect(row.rendered_html).toBeTruthy();
|
||||
expect(row.rendered_html).not.toContain(PASSWORD);
|
||||
expect(row.rendered_html).not.toContain(PIN);
|
||||
expect(row.rendered_html).toContain(MASK);
|
||||
// the mail itself went out with the real password; only the archive lost it
|
||||
expect(stub.mails).toHaveLength(1);
|
||||
expect(String(stub.mails[0].html)).toContain(PASSWORD);
|
||||
expect(String(stub.mails[0].html)).not.toContain(MASK);
|
||||
|
||||
// Messages "resend" copies the archived variables into a new pending
|
||||
// row and "retry" re-queues the row itself: both mails must say the
|
||||
// password is not shown rather than print the mask (or the password).
|
||||
const { resendEmail, retryEmail } = require('../../src/services/projectService');
|
||||
const resent = await resendEmail(id);
|
||||
await retryEmail(id);
|
||||
for (const rowId of [resent.id, id]) {
|
||||
const stub2 = stubWebhookTransport(async () => ({ messageId: `sent-again-${rowId}` }));
|
||||
try {
|
||||
const { processEmailQueue } = require('../../src/services/emailProcessor');
|
||||
await processEmailQueue({ ignoreSchedule: true, onlyId: rowId });
|
||||
} finally { stub2.restore(); }
|
||||
expect(stub2.mails).toHaveLength(1);
|
||||
const html = String(stub2.mails[0].html);
|
||||
expect(html).not.toContain(MASK);
|
||||
expect(html).not.toContain(PASSWORD);
|
||||
expect(html).not.toContain(PIN);
|
||||
expect(html).not.toContain('{{password_security_message}}');
|
||||
expect(html).toContain('security');
|
||||
// archived again without the password (mask or sentinel, never the value)
|
||||
expect(JSON.parse((await db('email_queue').where('id', rowId).first()).email_data).gallery_password).not.toBe(PASSWORD);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the variables in the clear while the row can still be retried', async () => {
|
||||
const id = await queue({ retry_count: 1 });
|
||||
const stub = stubWebhookTransport(async () => { throw new Error('transport down'); });
|
||||
try {
|
||||
const { processEmailQueue } = require('../../src/services/emailProcessor');
|
||||
await processEmailQueue({ ignoreSchedule: true, onlyId: id });
|
||||
let row = await db('email_queue').where('id', id).first();
|
||||
expect(row.retry_count).toBe(2);
|
||||
expect(JSON.parse(row.email_data).gallery_password).toBe(PASSWORD);
|
||||
// out of retries — a Messages "retry" resets the counter and this row
|
||||
// must still be able to send the real password
|
||||
await processEmailQueue({ ignoreSchedule: true, onlyId: id });
|
||||
row = await db('email_queue').where('id', id).first();
|
||||
expect(row.retry_count).toBe(3);
|
||||
expect(row.status).not.toBe('sent');
|
||||
expect(JSON.parse(row.email_data).gallery_password).toBe(PASSWORD);
|
||||
} finally { stub.restore(); }
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
jest.mock('axios', () => ({ post: jest.fn() }));
|
||||
jest.mock('../../src/utils/networkValidation', () => ({
|
||||
validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok' })),
|
||||
validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] })),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
@@ -48,7 +48,7 @@ beforeEach(() => {
|
||||
process.env.EMAIL_WEBHOOK_SECRET = SECRET;
|
||||
transport.__testing.setAllowPrivateUrls(false);
|
||||
transport.__testing.resetSecretWarning();
|
||||
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok' });
|
||||
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] });
|
||||
axios.post.mockResolvedValue({ status: 200, data: streamOf('') });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* External-media folder watcher (issue 1187).
|
||||
*
|
||||
* Drives the real service against a real temp folder and the real import
|
||||
* pass (sharp and thumbnail generation mocked, as in the other external
|
||||
* import suites). Covers the contracts the feature rests on:
|
||||
*
|
||||
* - only events that opted in, are in reference mode, live and not
|
||||
* archived get a watcher, and reconcile() follows the row both ways;
|
||||
* - the timer sweep imports what appeared since the last pass, through the
|
||||
* same code path as the Import button, and never deletes;
|
||||
* - a change in the folder triggers a debounced import on its own;
|
||||
* - a claim held elsewhere makes the watcher stand down rather than walk
|
||||
* the tree a second time;
|
||||
* - quiet system passes stay out of the activity log, real imports go in.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Fixture files are written "in the past": an automatic pass leaves a file
|
||||
// modified inside the settle window for the next pass (that is the point of
|
||||
// the check), so anything a test expects to be imported straight away must
|
||||
// not look like a copy still in flight.
|
||||
const writeOld = async (file, content) => {
|
||||
await fs.promises.writeFile(file, content);
|
||||
const old = new Date(Date.now() - 60000);
|
||||
await fs.promises.utimes(file, old, old);
|
||||
};
|
||||
|
||||
const waitFor = async (predicate, { timeoutMs = 8000, stepMs = 50 } = {}) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) return true;
|
||||
await new Promise((r) => setTimeout(r, stepMs));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
describe('externalMediaWatcher (issue 1187)', () => {
|
||||
let tmpDir; let mediaRoot; let db; let watcher; let jobState;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extwatch-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(mediaRoot, 'other'), { recursive: true });
|
||||
await writeOld(path.join(mediaRoot, 'nas', 'individual', 'a.jpg'), 'not-a-real-jpeg');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extwatch-secret';
|
||||
// Short timers, and stat-polling so the change test does not depend on
|
||||
// the host's inotify/FSEvents behaviour for a temp directory.
|
||||
process.env.EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS = '100';
|
||||
process.env.EXTERNAL_MEDIA_WATCH_STABILITY_MS = '150';
|
||||
process.env.EXTERNAL_MEDIA_WATCH_POLLING = 'true';
|
||||
process.env.EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS = '100';
|
||||
process.env.EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS = '0';
|
||||
process.env.EXTERNAL_MEDIA_WATCH_RECONCILE_INTERVAL_MS = '3600000';
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('sharp', () => () => ({ metadata: async () => ({ width: 100, height: 200 }) }));
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'),
|
||||
extractCaptureDate: jest.fn(async () => null),
|
||||
orientedDimensions: (m) => ({ width: m.width, height: m.height }),
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('../integration/helpers/crmDb').bootCrmDb());
|
||||
watcher = require('../../src/services/externalMediaWatcher');
|
||||
jobState = require('../../src/services/maintenanceJobState');
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
await watcher.stopExternalMediaWatcher();
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await watcher.stopExternalMediaWatcher();
|
||||
await db('activity_logs').del();
|
||||
await db('photos').del();
|
||||
await db('external_import_exclusions').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
async function seedEvent(overrides = {}) {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `extwatch-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'extwatch',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `extwatch-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference',
|
||||
external_path: 'nas',
|
||||
external_watch: 1,
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
const relpaths = async (eventId) => (await db('photos').where({ event_id: eventId }).select('external_relpath'))
|
||||
.map((r) => r.external_relpath).sort();
|
||||
|
||||
it('lists only reference events that opted in, are active and not archived', async () => {
|
||||
const watched = await seedEvent();
|
||||
await seedEvent({ external_watch: 0 });
|
||||
await seedEvent({ source_mode: 'managed', external_path: null });
|
||||
await seedEvent({ is_archived: 1 });
|
||||
await seedEvent({ is_active: 0 });
|
||||
|
||||
const rows = await watcher.listWatchedEvents();
|
||||
expect(rows.map((r) => r.id)).toEqual([watched]);
|
||||
});
|
||||
|
||||
it('reconcile starts a watcher for an opted-in event, imports once, and stops it when the row changes', async () => {
|
||||
const eventId = await seedEvent();
|
||||
|
||||
await watcher.reconcile();
|
||||
expect(watcher.watchedEventIds()).toEqual([eventId]);
|
||||
// Ticking the box is enough: what is already in the folder comes in now,
|
||||
// not at the next sweep.
|
||||
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
|
||||
|
||||
// A folder that does not exist is not watched — and is retried, not failed.
|
||||
await db('events').where('id', eventId).update({ external_path: 'does-not-exist' });
|
||||
await watcher.reconcile();
|
||||
expect(watcher.watchedEventIds()).toEqual([]);
|
||||
|
||||
await db('events').where('id', eventId).update({ external_path: 'nas' });
|
||||
await watcher.reconcile();
|
||||
expect(watcher.watchedEventIds()).toEqual([eventId]);
|
||||
|
||||
await db('events').where('id', eventId).update({ external_watch: 0 });
|
||||
await watcher.reconcile();
|
||||
expect(watcher.watchedEventIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sweep imports new files through the shared import pass and never deletes', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await watcher.reconcile();
|
||||
|
||||
await watcher.sweep();
|
||||
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
|
||||
|
||||
// An import that changed something is logged, with the system actor ...
|
||||
expect(await db('activity_logs').where({ activity_type: 'external_import_completed' }).count('* as n').first())
|
||||
.toMatchObject({ n: 1 });
|
||||
|
||||
await writeOld(path.join(mediaRoot, 'nas', 'individual', 'b.jpg'), 'also-not-a-jpeg');
|
||||
await fs.promises.unlink(path.join(mediaRoot, 'nas', 'individual', 'a.jpg'));
|
||||
await watcher.sweep();
|
||||
|
||||
// ... b.jpg is in, a.jpg's row is kept although the file is gone.
|
||||
expect(await relpaths(eventId)).toEqual([
|
||||
path.join('nas', 'individual', 'a.jpg'),
|
||||
path.join('nas', 'individual', 'b.jpg'),
|
||||
]);
|
||||
|
||||
const logs = await db('activity_logs').where({ activity_type: 'external_import_completed' });
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs.every((l) => l.actor_type === 'system')).toBe(true);
|
||||
|
||||
// ... a quiet pass is not: no third entry.
|
||||
await watcher.sweep();
|
||||
expect(await db('activity_logs').where({ activity_type: 'external_import_completed' }).count('* as n').first())
|
||||
.toMatchObject({ n: 2 });
|
||||
|
||||
// Leave the folder as the next test expects it.
|
||||
await fs.promises.unlink(path.join(mediaRoot, 'nas', 'individual', 'b.jpg'));
|
||||
await writeOld(path.join(mediaRoot, 'nas', 'individual', 'a.jpg'), 'not-a-real-jpeg');
|
||||
});
|
||||
|
||||
it('a file appearing in the folder triggers a debounced import on its own', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await watcher.reconcile();
|
||||
await watcher.sweep();
|
||||
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
|
||||
|
||||
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', 'c.jpg'), 'new-arrival');
|
||||
|
||||
const arrived = await waitFor(async () => (await relpaths(eventId)).includes(path.join('nas', 'individual', 'c.jpg')));
|
||||
expect(arrived).toBe(true);
|
||||
|
||||
await fs.promises.unlink(path.join(mediaRoot, 'nas', 'individual', 'c.jpg'));
|
||||
}, 20000);
|
||||
|
||||
it('stands down while another runner holds the claim for the event', async () => {
|
||||
const eventId = await seedEvent();
|
||||
|
||||
const { jobNameFor } = require('../../src/services/externalImportService');
|
||||
await jobState.ensure(jobNameFor(eventId));
|
||||
const token = await jobState.claim(jobNameFor(eventId));
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
expect(await watcher.runImport(eventId, 'sweep')).toBeNull();
|
||||
expect(await relpaths(eventId)).toEqual([]);
|
||||
|
||||
await jobState.release(jobNameFor(eventId), token);
|
||||
const result = await watcher.runImport(eventId, 'sweep');
|
||||
expect(result).toMatchObject({ imported: 1 });
|
||||
});
|
||||
|
||||
it('does not bring back a photo an admin deleted, until the manual Import asks for it', async () => {
|
||||
const eventId = await seedEvent();
|
||||
const { importExternalFolder, recordExclusions } = require('../../src/services/externalImportService');
|
||||
|
||||
await watcher.reconcile();
|
||||
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
|
||||
|
||||
// The delete routes record the exclusion before removing the row.
|
||||
const [row] = await db('photos').where({ event_id: eventId });
|
||||
await recordExclusions(eventId, [row]);
|
||||
await db('photos').where({ id: row.id }).del();
|
||||
|
||||
const swept = await watcher.runImport(eventId, 'sweep');
|
||||
expect(swept).toMatchObject({ imported: 0, excluded: 1 });
|
||||
expect(await relpaths(eventId)).toEqual([]);
|
||||
|
||||
// Pressing Import is the explicit intent: the file comes back and the
|
||||
// exclusion is cleared, so later automatic passes keep it.
|
||||
const manual = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'admin' } });
|
||||
expect(manual).toMatchObject({ imported: 1 });
|
||||
expect(await db('external_import_exclusions').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves a file that is still changing for the next pass', async () => {
|
||||
const eventId = await seedEvent();
|
||||
const { importExternalFolder } = require('../../src/services/externalImportService');
|
||||
const growing = path.join(mediaRoot, 'nas', 'individual', 'growing.jpg');
|
||||
await fs.promises.writeFile(growing, 'part-one');
|
||||
|
||||
// mtime is "now", inside the settle window: deferred, not inserted.
|
||||
const first = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true, settleMs: 300 });
|
||||
expect(first).toMatchObject({ imported: 1, deferred: 1 });
|
||||
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
|
||||
|
||||
// Old mtime but the size moves during the wait: still deferred.
|
||||
const old = new Date(Date.now() - 60000);
|
||||
await fs.promises.utimes(growing, old, old);
|
||||
const grow = setTimeout(() => fs.promises.appendFile(growing, '-part-two').then(() => fs.promises.utimes(growing, old, old)), 100);
|
||||
const second = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true, settleMs: 300 });
|
||||
clearTimeout(grow);
|
||||
expect(second).toMatchObject({ imported: 0, deferred: 1 });
|
||||
|
||||
// Quiet now: imported.
|
||||
await fs.promises.utimes(growing, old, old);
|
||||
const third = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true, settleMs: 300 });
|
||||
expect(third).toMatchObject({ imported: 1, deferred: 0 });
|
||||
|
||||
await fs.promises.unlink(growing);
|
||||
});
|
||||
|
||||
it('a photo deleted while the pass is settling stays deleted', async () => {
|
||||
const eventId = await seedEvent();
|
||||
const { importExternalFolder, recordExclusions } = require('../../src/services/externalImportService');
|
||||
await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'admin' } });
|
||||
const [row] = await db('photos').where({ event_id: eventId });
|
||||
|
||||
// A new file makes the pass wait for the settle window; inside that
|
||||
// window the admin deletes a.jpg. The snapshot taken before the wait
|
||||
// saw a.jpg as present, so only a per-file check can keep it out.
|
||||
await writeOld(path.join(mediaRoot, 'nas', 'individual', 'd.jpg'), 'new-file');
|
||||
setTimeout(async () => {
|
||||
await recordExclusions(eventId, [row]);
|
||||
await db('photos').where({ id: row.id }).del();
|
||||
}, 100);
|
||||
const result = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true, settleMs: 400 });
|
||||
|
||||
expect(result).toMatchObject({ imported: 1, excluded: 1 });
|
||||
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'd.jpg')]);
|
||||
await fs.promises.unlink(path.join(mediaRoot, 'nas', 'individual', 'd.jpg'));
|
||||
});
|
||||
|
||||
it('records an exclusion for a replaced external photo too', async () => {
|
||||
const eventId = await seedEvent();
|
||||
const { importExternalFolder, recordExclusions } = require('../../src/services/externalImportService');
|
||||
await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'admin' } });
|
||||
const [row] = await db('photos').where({ event_id: eventId });
|
||||
|
||||
// photoReplacementService flips the row to managed but keeps the relpath.
|
||||
await db('photos').where({ id: row.id }).update({ source_origin: 'managed' });
|
||||
const replaced = await db('photos').where({ id: row.id }).first();
|
||||
await recordExclusions(eventId, [replaced]);
|
||||
await db('photos').where({ id: row.id }).del();
|
||||
|
||||
expect(await watcher.runImport(eventId, 'sweep')).toMatchObject({ imported: 0, excluded: 1 });
|
||||
expect(await relpaths(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('an automatic pass stops when the event stopped qualifying since it was scheduled', async () => {
|
||||
const eventId = await seedEvent();
|
||||
const { importExternalFolder } = require('../../src/services/externalImportService');
|
||||
await db('events').where('id', eventId).update({ external_watch: 0 });
|
||||
expect(await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true }))
|
||||
.toMatchObject({ imported: 0 });
|
||||
expect(await relpaths(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('an automatic pass never rewrites the event folder, and stops if it moved', async () => {
|
||||
const eventId = await seedEvent();
|
||||
const { importExternalFolder } = require('../../src/services/externalImportService');
|
||||
|
||||
// The pass was started for 'nas' but the admin has since pointed the
|
||||
// event at 'other': nothing imported, row untouched.
|
||||
await db('events').where('id', eventId).update({ external_path: 'other' });
|
||||
const result = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true });
|
||||
expect(result).toMatchObject({ imported: 0 });
|
||||
expect(await relpaths(eventId)).toEqual([]);
|
||||
expect((await db('events').where('id', eventId).first()).external_path).toBe('other');
|
||||
|
||||
// The manual Import is what writes the folder onto the event.
|
||||
await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'admin' } });
|
||||
expect((await db('events').where('id', eventId).first()).external_path).toBe('nas');
|
||||
});
|
||||
|
||||
it('re-arms itself for a file that was deferred, so a disabled sweep is not needed', async () => {
|
||||
const eventId = await seedEvent();
|
||||
// Written just now: the pass that starts with the watcher defers it.
|
||||
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', 'e.jpg'), 'fresh');
|
||||
await watcher.reconcile();
|
||||
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
|
||||
|
||||
const arrived = await waitFor(async () => (await relpaths(eventId)).includes(path.join('nas', 'individual', 'e.jpg')));
|
||||
expect(arrived).toBe(true);
|
||||
await fs.promises.unlink(path.join(mediaRoot, 'nas', 'individual', 'e.jpg'));
|
||||
}, 20000);
|
||||
|
||||
it('skips an event that was archived or deactivated after it was scheduled', async () => {
|
||||
const archived = await seedEvent();
|
||||
await db('events').where('id', archived).update({ is_archived: 1 });
|
||||
expect(await watcher.runImport(archived, 'change')).toBeNull();
|
||||
|
||||
const inactive = await seedEvent();
|
||||
await db('events').where('id', inactive).update({ is_active: 0 });
|
||||
expect(await watcher.runImport(inactive, 'sweep')).toBeNull();
|
||||
|
||||
expect(await db('photos').count('* as n').first()).toMatchObject({ n: 0 });
|
||||
});
|
||||
|
||||
it('follows the row at run time: an event that opted out since scheduling is skipped', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await db('events').where('id', eventId).update({ external_watch: 0 });
|
||||
|
||||
expect(await watcher.runImport(eventId, 'change')).toBeNull();
|
||||
expect(await relpaths(eventId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Lazy rendition generation is single-flight per photo and rendition (#1020).
|
||||
*
|
||||
* ensureThumbnail / ensureHeroImage / ensurePreviewImage and the tier
|
||||
* variants are check-then-generate, and the check reads the path off the row
|
||||
* the caller already fetched. N concurrent cold requests for one photo all
|
||||
* missed and all ran the same Sharp pass; worse, the hero and preview
|
||||
* generators deleted the existing object before writing its replacement, so
|
||||
* a reader landing between B's delete and B's write was redirected to the
|
||||
* full original, and a regeneration whose source could not be read left the
|
||||
* old rendition gone with the row still pointing at it.
|
||||
*
|
||||
* Driven against real Sharp output and the real LocalFsStorage, plus an
|
||||
* in-memory backend with the S3 contract (no local paths, download on read),
|
||||
* because the single-flight sits around withLocalCopy and the difference
|
||||
* between one download and eight is the whole point.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const EXTERNAL_ROOT = path.join(os.tmpdir(), `picpeak-sf-ext-${process.pid}`);
|
||||
process.env.EXTERNAL_MEDIA_ROOT = EXTERNAL_ROOT;
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const state = { events: {}, updates: [] };
|
||||
const api = (table) => {
|
||||
if (table === 'events') {
|
||||
return { where: (_col, id) => ({ first: async () => state.events[id] || null }) };
|
||||
}
|
||||
if (table === 'photos') {
|
||||
return {
|
||||
where: (criteria) => ({
|
||||
update: async (values) => { state.updates.push({ criteria, values }); return 1; },
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === 'app_settings') {
|
||||
return { whereIn: () => ({ select: async () => [] }) };
|
||||
}
|
||||
throw new Error(`unexpected table in test: ${table}`);
|
||||
};
|
||||
api.__state = state;
|
||||
return { db: api };
|
||||
});
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const storageModule = require('../../src/services/storage');
|
||||
const { db } = require('../../src/database/db');
|
||||
|
||||
const MANAGED_EVENT = { id: 11, slug: 'managed-ev', source_mode: 'managed' };
|
||||
const EXTERNAL_EVENT = { id: 7, slug: 'nas-ev', source_mode: 'reference', external_path: 'weddings/sf' };
|
||||
|
||||
let nextId = 1000;
|
||||
|
||||
async function writeJpeg(absPath, { width = 2400, height = 1600 } = {}) {
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await sharp({ create: { width, height, channels: 3, background: { r: 30, g: 120, b: 200 } } })
|
||||
.jpeg({ quality: 85 }).toFile(absPath);
|
||||
}
|
||||
|
||||
async function jpegBuffer({ width = 2400, height = 1600 } = {}) {
|
||||
return sharp({ create: { width, height, channels: 3, background: { r: 200, g: 60, b: 30 } } })
|
||||
.jpeg({ quality: 85 }).toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* The S3 contract as imageProcessor sees it: kind() !== 'local', so validity
|
||||
* is a stat only, and withLocalCopy has to download the source through
|
||||
* getToFile before Sharp can open it.
|
||||
*/
|
||||
class MemoryObjectStore {
|
||||
constructor() { this.objects = new Map(); this.puts = []; this.downloads = []; this.failNextPut = false; }
|
||||
kind() { return 's3'; }
|
||||
async init() {}
|
||||
async put(key, body) {
|
||||
if (this.failNextPut) { this.failNextPut = false; throw new Error('simulated upload failure'); }
|
||||
this.puts.push(key);
|
||||
this.objects.set(key, Buffer.from(body));
|
||||
}
|
||||
async stat(key) {
|
||||
const b = this.objects.get(key);
|
||||
return b ? { size: b.length, mtime: new Date() } : null;
|
||||
}
|
||||
async exists(key) { return this.objects.has(key); }
|
||||
async getToFile(key, localPath) {
|
||||
this.downloads.push(key);
|
||||
const b = this.objects.get(key);
|
||||
if (!b) throw new Error(`NoSuchKey: ${key}`);
|
||||
await fs.mkdir(path.dirname(localPath), { recursive: true });
|
||||
await fs.writeFile(localPath, b);
|
||||
}
|
||||
async delete(key) { this.objects.delete(key); }
|
||||
}
|
||||
|
||||
describe('single-flight rendition generation (#1020)', () => {
|
||||
let imageProcessor;
|
||||
|
||||
beforeAll(() => {
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.__state.events = { [MANAGED_EVENT.id]: MANAGED_EVENT, [EXTERNAL_EVENT.id]: EXTERNAL_EVENT };
|
||||
db.__state.updates = [];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
await fs.rm(EXTERNAL_ROOT, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('local storage', () => {
|
||||
let storage; let storageRoot; let puts;
|
||||
|
||||
beforeAll(async () => {
|
||||
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-sf-store-'));
|
||||
storage = new LocalFsStorage({ root: storageRoot });
|
||||
await storage.init();
|
||||
const origPut = storage.put.bind(storage);
|
||||
storage.put = async (key, ...rest) => {
|
||||
if (storage.failNextPut) { storage.failNextPut = false; throw new Error('simulated write failure'); }
|
||||
if (storage.holdNextPut) { const gate = storage.holdNextPut; storage.holdNextPut = null; await gate; }
|
||||
puts.push(key);
|
||||
return origPut(key, ...rest);
|
||||
};
|
||||
storageModule.setStorageForTesting(storage);
|
||||
}, 30000);
|
||||
|
||||
beforeEach(() => { puts = []; storage.failNextPut = false; storage.holdNextPut = null; });
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function managedPhoto() {
|
||||
const id = nextId++;
|
||||
const name = `managed-${id}.jpg`;
|
||||
const rel = `${MANAGED_EVENT.slug}/${name}`;
|
||||
await writeJpeg(path.join(storageRoot, 'events/active', rel));
|
||||
return { id, event_id: MANAGED_EVENT.id, source_origin: 'managed', path: rel, filename: name };
|
||||
}
|
||||
|
||||
async function externalPhoto({ write = true } = {}) {
|
||||
const id = nextId++;
|
||||
const name = `external-${id}.jpg`;
|
||||
const relpath = path.join(EXTERNAL_EVENT.external_path, name);
|
||||
if (write) await writeJpeg(path.join(EXTERNAL_ROOT, relpath));
|
||||
return { id, event_id: EXTERNAL_EVENT.id, source_origin: 'external', external_relpath: relpath, filename: name };
|
||||
}
|
||||
|
||||
const putsUnder = (prefix) => puts.filter((k) => k.startsWith(prefix));
|
||||
|
||||
it('ensurePreviewImage: eight concurrent cold requests share one generation', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 8 }, () => imageProcessor.ensurePreviewImage(photo)));
|
||||
|
||||
expect(results[0]).toMatch(/^previews\/preview_/);
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(putsUnder('previews/')).toHaveLength(1);
|
||||
// One flight, one row write — not eight identical updates.
|
||||
expect(db.__state.updates).toHaveLength(1);
|
||||
expect(await storage.stat(results[0])).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ensureHeroImage: eight concurrent cold requests share one generation', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 8 }, () => imageProcessor.ensureHeroImage(photo)));
|
||||
|
||||
expect(results[0]).toMatch(/^heroes\/hero_/);
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(putsUnder('heroes/')).toHaveLength(1);
|
||||
expect(db.__state.updates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ensureThumbnail: eight concurrent cold requests for an external photo share one generation', async () => {
|
||||
const photo = await externalPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 8 }, () => imageProcessor.ensureThumbnail(photo)));
|
||||
|
||||
expect(results[0]).toBe(`thumbnails/thumb_ext${photo.id}_${photo.filename}`);
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(putsUnder('thumbnails/')).toHaveLength(1);
|
||||
expect(db.__state.updates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a tier request that resolves to the canonical thumbnail shares the canonical flight', async () => {
|
||||
// ensureThumbnailAtWidth hands the canonical width, and every video, to
|
||||
// ensureThumbnail. That used to be the one unguarded path a guarded
|
||||
// request could fall through into.
|
||||
const photo = await managedPhoto();
|
||||
const canonical = 300; // DEFAULT_THUMBNAIL_WIDTH; the settings mock returns no override
|
||||
const results = await Promise.all([
|
||||
imageProcessor.ensureThumbnailAtWidth(photo, canonical),
|
||||
imageProcessor.ensureThumbnailAtWidth(photo, canonical),
|
||||
imageProcessor.ensureThumbnail(photo),
|
||||
imageProcessor.ensureThumbnail(photo),
|
||||
]);
|
||||
|
||||
expect(results[0]).toMatch(/^thumbnails\/thumb_/);
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(putsUnder('thumbnails/')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ensureThumbnail', 'thumbnail_path', 'thumbnails/'],
|
||||
['ensureHeroImage', 'hero_path', 'heroes/'],
|
||||
['ensurePreviewImage', 'preview_path', 'previews/'],
|
||||
])('%s: a forced rebuild is never satisfied by joining a viewer\'s hot-path check', async (fn, column, prefix) => {
|
||||
// adminThumbnails.js forces a rebuild by passing the row with the path
|
||||
// nulled. If the validity check ran inside the flight, that call could
|
||||
// join a viewer's flight for the same photo — one that was merely
|
||||
// stat-ing an already good rendition — and be handed back the very
|
||||
// file it was asked to replace, with the endpoint counting a success.
|
||||
const photo = await managedPhoto();
|
||||
const existing = await imageProcessor[fn](photo);
|
||||
expect(existing).toMatch(new RegExp(`^${prefix}`));
|
||||
expect(putsUnder(prefix)).toHaveLength(1);
|
||||
|
||||
const [viewer, forced] = await Promise.all([
|
||||
imageProcessor[fn]({ ...photo, [column]: existing }),
|
||||
imageProcessor[fn]({ ...photo, [column]: null }),
|
||||
]);
|
||||
|
||||
expect(viewer).toBe(existing);
|
||||
expect(forced).toBe(existing);
|
||||
// The forced call wrote a fresh rendition; the viewer's did not.
|
||||
expect(putsUnder(prefix)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ensureThumbnail', 'thumbnail_path', 'thumbnails/'],
|
||||
['ensurePreviewImage', 'preview_path', 'previews/'],
|
||||
])('%s: a forced rebuild runs after a lazy generation already in flight instead of adopting it', async (fn, column, prefix) => {
|
||||
// The lazy flight read the settings when it started; after a settings
|
||||
// change it is producing exactly what the admin's regenerate exists to
|
||||
// replace. Joining it would count a success and leave the old size
|
||||
// cached — the validity check only asks whether the file parses.
|
||||
const photo = await managedPhoto();
|
||||
let release;
|
||||
storage.holdNextPut = new Promise((r) => { release = r; });
|
||||
|
||||
const lazy = imageProcessor[fn](photo); // blocks inside put
|
||||
const forced = imageProcessor[fn]({ ...photo, [column]: null }, { force: true });
|
||||
const joiner = imageProcessor[fn](photo); // lazy miss after the forced call
|
||||
let forcedSettled = false;
|
||||
forced.then(() => { forcedSettled = true; });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
expect(putsUnder(prefix)).toHaveLength(0);
|
||||
expect(forcedSettled).toBe(false);
|
||||
|
||||
release();
|
||||
const results = await Promise.all([lazy, forced, joiner]);
|
||||
expect(results.every((k) => k === results[0])).toBe(true);
|
||||
expect(results[0]).toMatch(new RegExp(`^${prefix}`));
|
||||
// Lazy wrote once, the forced rebuild wrote once more after it; the
|
||||
// later lazy miss joined the forced flight rather than starting a third.
|
||||
expect(putsUnder(prefix)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('a replaced photo (same id, new path) does not join a flight still rendering the old source', async () => {
|
||||
// replacePhoto keeps the id and changes path/filename. Keyed by id and
|
||||
// width alone, a request carrying the replacement row would join the
|
||||
// old flight, be handed the old image, and the gallery would cache it.
|
||||
const before = await managedPhoto();
|
||||
const after = await managedPhoto();
|
||||
const replacement = { ...after, id: before.id };
|
||||
|
||||
let release;
|
||||
storage.holdNextPut = new Promise((r) => { release = r; });
|
||||
const stale = imageProcessor.ensureThumbnailAtWidth(before, 600); // blocks inside put
|
||||
const fresh = imageProcessor.ensureThumbnailAtWidth(replacement, 600);
|
||||
release();
|
||||
|
||||
const [oldKey, newKey] = await Promise.all([stale, fresh]);
|
||||
expect(oldKey).toBe(`thumbnails/thumb_w600_p${before.id}_${before.filename}`);
|
||||
expect(newKey).toBe(`thumbnails/thumb_w600_p${before.id}_${after.filename}`);
|
||||
expect(putsUnder('thumbnails/').sort()).toEqual([oldKey, newKey].sort());
|
||||
|
||||
// Same shape for the canonical preview, which had no guard at all on
|
||||
// base and must not gain a cross-source one now.
|
||||
storage.holdNextPut = new Promise((r) => { release = r; });
|
||||
const staleP = imageProcessor.ensurePreviewImage(before);
|
||||
const freshP = imageProcessor.ensurePreviewImage(replacement);
|
||||
release();
|
||||
const [oldP, newP] = await Promise.all([staleP, freshP]);
|
||||
expect(oldP).not.toBe(newP);
|
||||
expect(putsUnder('previews/')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('different photos and different widths are separate flights', async () => {
|
||||
const a = await managedPhoto();
|
||||
const b = await externalPhoto();
|
||||
const calls = [
|
||||
...Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImageAtWidth(a, 640)),
|
||||
...Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImageAtWidth(a, 1280)),
|
||||
...Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImageAtWidth(b, 640)),
|
||||
...Array.from({ length: 4 }, () => imageProcessor.ensureThumbnailAtWidth(a, 600)),
|
||||
...Array.from({ length: 4 }, () => imageProcessor.ensureThumbnailAtWidth(b, 600)),
|
||||
];
|
||||
const results = await Promise.all(calls);
|
||||
|
||||
expect(results.every(Boolean)).toBe(true);
|
||||
expect(new Set(results).size).toBe(5);
|
||||
expect(results.slice(0, 4).every((k) => k === results[0])).toBe(true);
|
||||
expect(results.slice(4, 8).every((k) => k === results[4])).toBe(true);
|
||||
expect(putsUnder('previews/')).toHaveLength(3);
|
||||
expect(putsUnder('thumbnails/')).toHaveLength(2);
|
||||
// Tiers are pure cache: never written to the row.
|
||||
expect(db.__state.updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a warm tier is served from storage without a second generation', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
||||
const again = await Promise.all([
|
||||
imageProcessor.ensurePreviewImageAtWidth(photo, 640),
|
||||
imageProcessor.ensurePreviewImageAtWidth(photo, 640),
|
||||
]);
|
||||
|
||||
expect(again).toEqual([first, first]);
|
||||
expect(putsUnder('previews/')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a failed flight is cleared so the next request retries instead of adopting the failure', async () => {
|
||||
const photo = await externalPhoto({ write: false });
|
||||
|
||||
const cold = await Promise.all(Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImage(photo)));
|
||||
expect(cold).toEqual([null, null, null, null]);
|
||||
expect(putsUnder('previews/')).toHaveLength(0);
|
||||
|
||||
// The source appears (mount came back, file finished copying).
|
||||
await writeJpeg(path.join(EXTERNAL_ROOT, photo.external_relpath));
|
||||
const warm = await imageProcessor.ensurePreviewImage(photo);
|
||||
expect(warm).toBe(`previews/preview_ext${photo.id}_external-${photo.id}.jpg`);
|
||||
expect(putsUnder('previews/')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a flight that returns null is shared by every waiter and cleared afterwards', async () => {
|
||||
const photo = await managedPhoto();
|
||||
db.__state.events = {}; // the event lookup inside the flight finds nothing
|
||||
const results = await Promise.all(Array.from({ length: 3 }, () => imageProcessor.ensureThumbnail(photo)));
|
||||
expect(results).toEqual([null, null, null]);
|
||||
|
||||
db.__state.events = { [MANAGED_EVENT.id]: MANAGED_EVENT };
|
||||
const key = await imageProcessor.ensureThumbnail(photo);
|
||||
expect(key).toMatch(/^thumbnails\/thumb_/);
|
||||
expect(putsUnder('thumbnails/')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a flight that throws rejects every waiter identically and is cleared afterwards', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const boom = new Error('db down');
|
||||
const realEvents = db.__state.events;
|
||||
db.__state.events = new Proxy({}, { get: () => { throw boom; } });
|
||||
|
||||
// ensureThumbnail's event lookup is not wrapped in try/catch, so this
|
||||
// propagates — to every caller of the shared flight, not just the first.
|
||||
const settled = await Promise.allSettled(Array.from({ length: 3 }, () => imageProcessor.ensureThumbnail(photo)));
|
||||
expect(settled.map((s) => s.status)).toEqual(['rejected', 'rejected', 'rejected']);
|
||||
expect(settled.every((s) => s.reason === boom)).toBe(true);
|
||||
|
||||
db.__state.events = realEvents;
|
||||
const key = await imageProcessor.ensureThumbnail(photo);
|
||||
expect(key).toMatch(/^thumbnails\/thumb_/);
|
||||
});
|
||||
|
||||
it('the existing hero survives a regeneration whose source cannot be read', async () => {
|
||||
// generateHeroImage used to delete the target before Sharp had opened
|
||||
// the source, so a corrupt file or a blipped mount stripped the old
|
||||
// hero and returned null with the row still pointing at it.
|
||||
const src = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'hero-src.jpg');
|
||||
await writeJpeg(src);
|
||||
const key = await imageProcessor.generateHeroImage(src, { outputBasename: 'survive.jpg' });
|
||||
expect(key).toBe('heroes/hero_survive.jpg');
|
||||
const before = await storage.stat(key);
|
||||
|
||||
const junk = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'hero-junk.jpg');
|
||||
await fs.writeFile(junk, Buffer.from('this is not a jpeg'));
|
||||
const result = await imageProcessor.generateHeroImage(junk, { regenerate: true, outputBasename: 'survive.jpg' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
const after = await storage.stat(key);
|
||||
expect(after).toBeTruthy();
|
||||
expect(after.size).toBe(before.size);
|
||||
await expect(sharp(storage.resolveLocalPath(key)).metadata()).resolves.toMatchObject({ width: 1920 });
|
||||
});
|
||||
|
||||
it('the existing preview survives a regeneration whose write fails', async () => {
|
||||
// Probe succeeds, the pipeline runs, the put throws: the catch used to
|
||||
// delete the key, which by then only ever held the PREVIOUS good file.
|
||||
const src = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'preview-src.jpg');
|
||||
await writeJpeg(src);
|
||||
const key = await imageProcessor.generatePreviewImage(src, { outputBasename: 'survive.jpg' });
|
||||
expect(key).toBe('previews/preview_survive.jpg');
|
||||
const before = await storage.stat(key);
|
||||
|
||||
storage.failNextPut = true;
|
||||
const result = await imageProcessor.generatePreviewImage(src, { regenerate: true, outputBasename: 'survive.jpg' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
const after = await storage.stat(key);
|
||||
expect(after).toBeTruthy();
|
||||
expect(after.size).toBe(before.size);
|
||||
});
|
||||
|
||||
it('the existing hero survives a regeneration whose write fails', async () => {
|
||||
const src = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'hero-src2.jpg');
|
||||
await writeJpeg(src);
|
||||
const key = await imageProcessor.generateHeroImage(src, { outputBasename: 'survive2.jpg' });
|
||||
const before = await storage.stat(key);
|
||||
|
||||
storage.failNextPut = true;
|
||||
const result = await imageProcessor.generateHeroImage(src, { regenerate: true, outputBasename: 'survive2.jpg' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect((await storage.stat(key)).size).toBe(before.size);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 contract', () => {
|
||||
let store;
|
||||
|
||||
beforeAll(() => {
|
||||
store = new MemoryObjectStore();
|
||||
storageModule.setStorageForTesting(store);
|
||||
});
|
||||
|
||||
beforeEach(() => { store.puts = []; store.downloads = []; store.failNextPut = false; });
|
||||
|
||||
async function managedPhoto() {
|
||||
const id = nextId++;
|
||||
const name = `s3-${id}.jpg`;
|
||||
const rel = `${MANAGED_EVENT.slug}/${name}`;
|
||||
store.objects.set(`events/active/${rel}`, await jpegBuffer());
|
||||
return { id, event_id: MANAGED_EVENT.id, source_origin: 'managed', path: rel, filename: name };
|
||||
}
|
||||
|
||||
it('ensurePreviewImage: concurrent cold requests download the source once and upload once', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 6 }, () => imageProcessor.ensurePreviewImage(photo)));
|
||||
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(results[0]).toMatch(/^previews\/preview_/);
|
||||
expect(store.downloads).toEqual([`events/active/${photo.path}`]);
|
||||
expect(store.puts).toHaveLength(1);
|
||||
expect(await store.stat(results[0])).toBeTruthy();
|
||||
expect(db.__state.updates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ensureHeroImage: concurrent cold requests download the source once and upload once', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 6 }, () => imageProcessor.ensureHeroImage(photo)));
|
||||
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(store.downloads).toHaveLength(1);
|
||||
expect(store.puts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ensureThumbnailAtWidth: concurrent cold tier requests download the source once and upload once', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 6 }, () => imageProcessor.ensureThumbnailAtWidth(photo, 600)));
|
||||
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(results[0]).toBe(`thumbnails/thumb_w600_p${photo.id}_${photo.filename}`);
|
||||
expect(store.downloads).toHaveLength(1);
|
||||
expect(store.puts).toEqual([results[0]]);
|
||||
});
|
||||
|
||||
it('the existing preview object survives a regeneration whose upload fails', async () => {
|
||||
// Fixed outputBasename, as the external and RAW branches pass: the key
|
||||
// is the same on both runs, so the pre-fix delete would have hit the
|
||||
// good object. (Through withLocalCopy the basename carries a random
|
||||
// temp prefix and the two runs never share a key, which is why this
|
||||
// drives the generator directly.)
|
||||
const srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-sf-s3src-'));
|
||||
const src = path.join(srcDir, 'src.jpg');
|
||||
await writeJpeg(src);
|
||||
try {
|
||||
const key = await imageProcessor.generatePreviewImage(src, { outputBasename: 's3-survive.jpg' });
|
||||
expect(key).toBe('previews/preview_s3-survive.jpg');
|
||||
const before = store.objects.get(key);
|
||||
expect(before).toBeTruthy();
|
||||
|
||||
store.failNextPut = true;
|
||||
const result = await imageProcessor.generatePreviewImage(src, { regenerate: true, outputBasename: 's3-survive.jpg' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(store.objects.get(key)).toBe(before);
|
||||
} finally {
|
||||
await fs.rm(srcDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,7 @@ jest.mock('../../src/utils/logger', () => ({
|
||||
}));
|
||||
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
const emailProcessor = require('../../src/services/emailProcessor');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
@@ -261,7 +262,10 @@ describe('invoiceService.releaseForDelivery', () => {
|
||||
});
|
||||
|
||||
describe('invoiceService.recordPaymentCheckAction', () => {
|
||||
beforeEach(() => resetChains());
|
||||
beforeEach(() => {
|
||||
resetChains();
|
||||
emailProcessor.queueEmail.mockClear();
|
||||
});
|
||||
|
||||
it('rejects invalid actions', async () => {
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
@@ -321,6 +325,71 @@ describe('invoiceService.recordPaymentCheckAction', () => {
|
||||
token: 'a'.repeat(64), action: 'partial', amountMinor: 9999,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
// GHSA-wg94-f86h-vq68 hardening: every write via this unauthenticated
|
||||
// route notifies the admin. Uses 'paid_full' as the exercised action —
|
||||
// it stays inside markPaid (no workflow-engine / PDF-rendering
|
||||
// dependencies to stub) while still going through the full
|
||||
// recordPaymentCheckAction write path.
|
||||
it('queues an admin notification email after a successful action', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, invoice_number: 'INV-0005', status: 'overdue',
|
||||
total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
customer_account_id: 7, created_by_admin_id: 42,
|
||||
currency: 'CHF', language: 'de', event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 7, email: 'c@example.com', display_name: 'Test Customer' };
|
||||
|
||||
const result = await invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'paid_full', ip: '203.0.113.7',
|
||||
});
|
||||
expect(result).toEqual({ applied: 'paid_full' });
|
||||
|
||||
expect(emailProcessor.queueEmail).toHaveBeenCalledTimes(1);
|
||||
const [, recipientEmail, templateKey, data] = emailProcessor.queueEmail.mock.calls[0];
|
||||
expect(recipientEmail).toBe('admin@example.com');
|
||||
expect(templateKey).toBe('invoice_payment_check_action_recorded');
|
||||
expect(data.invoice_number).toBe('INV-0005');
|
||||
expect(data.action).toBe('paid_full');
|
||||
expect(data.ip).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('does not fail (or roll back) the ledger write when the admin notification fails to send', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, invoice_number: 'INV-0005', status: 'overdue',
|
||||
total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
customer_account_id: 7, created_by_admin_id: 42,
|
||||
currency: 'CHF', language: 'de', event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 7, email: 'c@example.com', display_name: 'Test Customer' };
|
||||
emailProcessor.queueEmail.mockRejectedValueOnce(new Error('smtp down'));
|
||||
|
||||
// The write itself (token consumption + markPaid) must still
|
||||
// succeed — the notification is best-effort only.
|
||||
const result = await invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'paid_full', ip: '203.0.113.7',
|
||||
});
|
||||
expect(result).toEqual({ applied: 'paid_full' });
|
||||
|
||||
// Token was actually consumed (the real assertion that the write
|
||||
// committed): the mock chain's .update() ran with used_at set.
|
||||
const tokenChain = pickChainFor('invoice_payment_check_tokens');
|
||||
expect(tokenChain.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ used_at: expect.any(Date), used_action: 'paid_full' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.queuePaymentCheckEmail', () => {
|
||||
@@ -371,4 +440,35 @@ describe('invoiceService.queuePaymentCheckEmail', () => {
|
||||
expect(res.sent).toBe(true);
|
||||
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
// GHSA-wg94-f86h-vq68 hardening: token TTL shortened from 30 days to 72h.
|
||||
it('mints a token with a ~72h TTL, not the old 30-day window', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'overdue',
|
||||
customer_account_id: 5,
|
||||
created_by_admin_id: 42,
|
||||
total_amount_minor: 10000,
|
||||
currency: 'CHF',
|
||||
language: 'de',
|
||||
reminder_level: 0,
|
||||
due_date: '2026-05-01',
|
||||
last_payment_check_at: null,
|
||||
event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, email: 'c@example.com', display_name: 'Test' };
|
||||
|
||||
const before = Date.now();
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res.sent).toBe(true);
|
||||
|
||||
const tokenChain = pickChainFor('invoice_payment_check_tokens');
|
||||
const insertedRow = tokenChain.insert.mock.calls[0][0];
|
||||
const ttlMs = new Date(insertedRow.expires_at).getTime() - before;
|
||||
expect(ttlMs).toBeGreaterThan(71 * 60 * 60 * 1000);
|
||||
expect(ttlMs).toBeLessThanOrEqual(72 * 60 * 60 * 1000 + 5000);
|
||||
// Well under the old 30-day TTL — the actual regression guard.
|
||||
expect(ttlMs).toBeLessThan(24 * 60 * 60 * 1000 * 30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,52 @@ describe('mfaService — TOTP verification', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — replay protection (GHSA-qcwx-r25m-j869)', () => {
|
||||
it('verifyTotp accepts a code once and rejects the same code as a replay', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
// First use: no lastUsedStep yet, so it's accepted.
|
||||
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
||||
|
||||
// Simulate persisting the matched step and replaying the same code: the
|
||||
// matched step must strictly advance past lastUsedStep, so this fails.
|
||||
const step = mfaService.currentTotpStep();
|
||||
expect(mfaService.verifyTotp(code, secret, step)).toBe(false);
|
||||
// A lastUsedStep the code hasn't caught up to yet also rejects it.
|
||||
expect(mfaService.verifyTotp(code, secret, step + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifyTotpEncryptedStep returns the matched step on success and null on replay', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const stored = mfaService.encryptSecret(secret);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const step = mfaService.verifyTotpEncryptedStep(code, stored, null);
|
||||
expect(step).toEqual(expect.any(Number));
|
||||
expect(step).toBeGreaterThan(0);
|
||||
|
||||
// Replaying the same code against the just-persisted step is rejected.
|
||||
expect(mfaService.verifyTotpEncryptedStep(code, stored, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('a freshly generated code for the next TOTP step is accepted after a replay is rejected', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
const step = mfaService.verifyTotpEncryptedStep(code, mfaService.encryptSecret(secret), null)
|
||||
|| mfaService.currentTotpStep();
|
||||
|
||||
// Same-step replay: rejected.
|
||||
expect(mfaService.verifyTotp(code, secret, step)).toBe(false);
|
||||
|
||||
// A code minted for the next step (via a cloned authenticator with a
|
||||
// future epoch, not by mocking Date.now()) advances past last_used_step.
|
||||
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
|
||||
const nextCode = nextStepAuthenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(nextCode, secret, step)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — otpauth URI / QR', () => {
|
||||
it('builds an otpauth:// URI containing issuer, account and secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Preview tier lookup and cleanup must agree with what the generator writes
|
||||
* (#1020 follow-up).
|
||||
*
|
||||
* generatePreviewImage rewrites the extension to match the encoding (`.jpg`,
|
||||
* or `.webp` for alpha / multi-frame sources). ensurePreviewImageAtWidth and
|
||||
* previewTierKeys kept the SOURCE extension, so for a `.png`, `.JPG`, `.heic`
|
||||
* or RAW source the tier was generated on every single request — the stat
|
||||
* never matched — and cleanup never found the files, which piled up in
|
||||
* storage for the life of the install.
|
||||
*
|
||||
* Driven against real Sharp output, because the whole question is which
|
||||
* extension the encoder actually chose.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const state = { event: null };
|
||||
const api = (table) => {
|
||||
if (table === 'events') return { where: () => ({ first: async () => state.event }) };
|
||||
if (table === 'photos') return { where: () => ({ update: async () => 1 }) };
|
||||
if (table === 'app_settings') return { whereIn: () => ({ select: async () => [] }) };
|
||||
throw new Error(`unexpected table in test: ${table}`);
|
||||
};
|
||||
api.__state = state;
|
||||
return { db: api };
|
||||
});
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const storageModule = require('../../src/services/storage');
|
||||
const { db } = require('../../src/database/db');
|
||||
|
||||
const EVENT = { id: 21, slug: 'fmt-ev', source_mode: 'managed' };
|
||||
let nextId = 5000;
|
||||
|
||||
describe('preview tier keys follow the encoded extension', () => {
|
||||
let storage; let storageRoot; let imageProcessor; let puts;
|
||||
|
||||
beforeAll(async () => {
|
||||
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-tierkeys-'));
|
||||
storage = new LocalFsStorage({ root: storageRoot });
|
||||
await storage.init();
|
||||
const origPut = storage.put.bind(storage);
|
||||
storage.put = async (key, ...rest) => { puts.push(key); return origPut(key, ...rest); };
|
||||
storageModule.setStorageForTesting(storage);
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
}, 30000);
|
||||
|
||||
beforeEach(() => { puts = []; db.__state.event = EVENT; });
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function photoWith(name, { alpha = false } = {}) {
|
||||
const id = nextId++;
|
||||
const rel = `${EVENT.slug}/${name}`;
|
||||
const abs = path.join(storageRoot, 'events/active', rel);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
const pipeline = sharp({
|
||||
create: {
|
||||
width: 2000, height: 1400, channels: alpha ? 4 : 3,
|
||||
background: alpha ? { r: 10, g: 20, b: 30, alpha: 0.5 } : { r: 10, g: 20, b: 30 },
|
||||
},
|
||||
});
|
||||
if (/\.png$/i.test(name)) await pipeline.png().toFile(abs);
|
||||
else if (/\.webp$/i.test(name)) await pipeline.webp().toFile(abs);
|
||||
else await pipeline.jpeg().toFile(abs);
|
||||
return { id, event_id: EVENT.id, source_origin: 'managed', path: rel, filename: name };
|
||||
}
|
||||
|
||||
it.each([
|
||||
['opaque PNG', 'photo.png', false, '.jpg'],
|
||||
['transparent PNG', 'photo-alpha.png', true, '.webp'],
|
||||
['uppercase JPG', 'IMG_0001.JPG', false, '.jpg'],
|
||||
['jpeg spelled out', 'photo.jpeg', false, '.jpg'],
|
||||
['lowercase jpg', 'photo.jpg', false, '.jpg'],
|
||||
])('%s: the second request is a cache hit, not a second generation', async (_label, name, alpha, ext) => {
|
||||
const photo = await photoWith(name, { alpha });
|
||||
|
||||
const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
||||
expect(first).toBe(`previews/preview_w640_p${photo.id}_${name.replace(/\.[^.]+$/, '')}${ext}`);
|
||||
expect(puts).toEqual([first]);
|
||||
|
||||
const second = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
||||
expect(second).toBe(first);
|
||||
expect(puts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['opaque PNG', 'cleanup.png', false],
|
||||
['transparent PNG', 'cleanup-alpha.png', true],
|
||||
['uppercase JPG', 'CLEANUP.JPG', false],
|
||||
])('%s: previewTierKeys covers the generated key, so deletePreviewTiers removes it', async (_label, name, alpha) => {
|
||||
const photo = await photoWith(name, { alpha });
|
||||
const k640 = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
||||
const k1280 = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
|
||||
expect(await storage.stat(k640)).toBeTruthy();
|
||||
expect(await storage.stat(k1280)).toBeTruthy();
|
||||
|
||||
const keys = imageProcessor.previewTierKeys(photo);
|
||||
expect(keys).toEqual(expect.arrayContaining([k640, k1280]));
|
||||
|
||||
await imageProcessor.deletePreviewTiers(photo);
|
||||
expect(await storage.stat(k640)).toBeNull();
|
||||
expect(await storage.stat(k1280)).toBeNull();
|
||||
});
|
||||
|
||||
it('a tier written before the extension rewrite is still found, by lookup and by cleanup', async () => {
|
||||
// JPEG bytes under the source's `.png` name — what generatePreviewImage
|
||||
// produced before it started rewriting the extension. Served as JPEG by
|
||||
// the route (the Content-Type comes from the `.webp` suffix only).
|
||||
const photo = await photoWith('legacy.png');
|
||||
const legacyKey = `previews/preview_w640_p${photo.id}_legacy.png`;
|
||||
await storage.put(legacyKey, await sharp({ create: { width: 640, height: 448, channels: 3, background: '#123' } }).jpeg().toBuffer());
|
||||
puts = [];
|
||||
|
||||
expect(await imageProcessor.ensurePreviewImageAtWidth(photo, 640)).toBe(legacyKey);
|
||||
expect(puts).toHaveLength(0);
|
||||
|
||||
expect(imageProcessor.previewTierKeys(photo)).toContain(legacyKey);
|
||||
await imageProcessor.deletePreviewTiers(photo);
|
||||
expect(await storage.stat(legacyKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('never lists the canonical 1920 rendition, which preview_path owns', () => {
|
||||
const keys = imageProcessor.previewTierKeys({ id: 9, path: 'e/a.png', source_origin: 'managed' });
|
||||
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
|
||||
expect(keys.every((k) => k.includes('p9_'))).toBe(true);
|
||||
// Both encodings plus the legacy source-extension key, per width.
|
||||
expect(keys).toEqual([
|
||||
'previews/preview_w640_p9_a.jpg', 'previews/preview_w640_p9_a.webp', 'previews/preview_w640_p9_a.png',
|
||||
'previews/preview_w1280_p9_a.jpg', 'previews/preview_w1280_p9_a.webp', 'previews/preview_w1280_p9_a.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate the legacy key when the source already is a lowercase .jpg', () => {
|
||||
const keys = imageProcessor.previewTierKeys({ id: 9, path: 'e/a.jpg', source_origin: 'managed' });
|
||||
expect(keys.filter((k) => k.includes('w640'))).toEqual([
|
||||
'previews/preview_w640_p9_a.jpg', 'previews/preview_w640_p9_a.webp',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* GHSA-xfvx-j447-732c: the SQLite restore path let an attacker-influenced
|
||||
* `manifest.database.backup_file` replace the live database.
|
||||
*
|
||||
* Two independent bugs, both fixed here:
|
||||
*
|
||||
* 1. Candidate resolution (restoreService.js's performDatabaseRestore,
|
||||
* ~L1000) tried an absolute `dbBackupFile` and a
|
||||
* `path.join(backupPath, dbBackupFile)` candidate with NO check that
|
||||
* the resolved path actually stayed inside the configured backup
|
||||
* root — a manifest could point `.restore` at any file on disk.
|
||||
*
|
||||
* 2. The resolved path was interpolated unescaped into a
|
||||
* `sqlite3 .restore '<path>'` dot-command string. sqlite3's CLI
|
||||
* parses that string itself (not the shell), so a single quote in
|
||||
* the path breaks out of the quoted argument regardless of
|
||||
* spawn()'s `shell: false` argv separation.
|
||||
*
|
||||
* These tests pin the fix directly against the exported helpers
|
||||
* (`resolveContainedDbBackupCandidates`, `assertSafeSqlitePath`,
|
||||
* `isContainedInRoots`, `getConfiguredBackupRoots`) — the exact functions
|
||||
* `performDatabaseRestore` calls before ever running `sqlite3 .restore` —
|
||||
* rather than driving the full restore (which does a real `db.destroy()` +
|
||||
* live-file swap against the shared app db and isn't worth the added
|
||||
* fragility for what's fundamentally a path-validation contract).
|
||||
*/
|
||||
|
||||
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-restoresvc-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restoresvc-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('restoreService — sqlite restore path safety (GHSA-xfvx)', () => {
|
||||
let db; let cleanup; let _internal;
|
||||
let backupPath;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
// The restore run's resolved local backup root — analogous to
|
||||
// `localBackupPath` in restoreService.restore(). Real directory with a
|
||||
// real database/ subfolder, matching what a genuine backup run leaves
|
||||
// on disk.
|
||||
backupPath = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-xfvx-backuproot-'));
|
||||
fs.mkdirSync(path.join(backupPath, 'database'), { recursive: true });
|
||||
|
||||
({ _internal } = require('../../src/services/restoreService'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('assertSafeSqlitePath — the sqlite3 dot-command injection gate', () => {
|
||||
it.each([
|
||||
['/backup/database/picpeak-db-sqlite-1.sql'],
|
||||
[`${backupPath || '/backup'}/database/picpeak-db-sqlite-2024-01-01.sql.gz`],
|
||||
])('accepts a normal backup path: %s', (p) => {
|
||||
expect(() => _internal.assertSafeSqlitePath(p)).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/backup/database/x\'; DROP TABLE admin_users; --.sql'],
|
||||
['/backup/database/x\' .restore \'/etc/passwd'],
|
||||
['/backup/database/x\n.shell rm -rf /'],
|
||||
['/backup/database/has space.sql'],
|
||||
['/backup/database/semi;colon.sql'],
|
||||
[null],
|
||||
[undefined],
|
||||
[42],
|
||||
])('rejects an unsafe/non-string path: %j', (p) => {
|
||||
expect(() => _internal.assertSafeSqlitePath(p)).toThrow(/unsafe path/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isContainedInRoots', () => {
|
||||
it('accepts a path inside a root', () => {
|
||||
expect(_internal.isContainedInRoots('/backup/database/x.sql', ['/backup'])).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a root path equal to the root itself', () => {
|
||||
expect(_internal.isContainedInRoots('/backup', ['/backup'])).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a path outside every root', () => {
|
||||
expect(_internal.isContainedInRoots('/etc/passwd', ['/backup'])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a sibling directory that merely shares a prefix', () => {
|
||||
// '/backup-evil' starts with the string '/backup' but is NOT inside it.
|
||||
expect(_internal.isContainedInRoots('/backup-evil/x.sql', ['/backup'])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a `..`-traversal path that resolves outside the root', () => {
|
||||
expect(_internal.isContainedInRoots('/backup/../etc/passwd', ['/backup'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfiguredBackupRoots', () => {
|
||||
afterEach(async () => {
|
||||
delete process.env.RESTORE_ALLOWED_ROOTS;
|
||||
await db('app_settings').whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path']).del();
|
||||
});
|
||||
|
||||
it('always includes the trusted root even with nothing else configured', async () => {
|
||||
const roots = await _internal.getConfiguredBackupRoots('/some/trusted/backup-path');
|
||||
expect(roots).toContain(path.resolve('/some/trusted/backup-path'));
|
||||
});
|
||||
|
||||
it('adds configured backup_destination_path / backup_manifest_path and RESTORE_ALLOWED_ROOTS', async () => {
|
||||
await db('app_settings').insert([
|
||||
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify('/backup/dest'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_manifest_path', setting_value: JSON.stringify('/backup/manifests'), setting_type: 'backup' },
|
||||
]);
|
||||
process.env.RESTORE_ALLOWED_ROOTS = '/extra/root';
|
||||
|
||||
const roots = await _internal.getConfiguredBackupRoots('/trusted');
|
||||
expect(roots).toEqual(expect.arrayContaining([
|
||||
path.resolve('/trusted'),
|
||||
path.resolve('/backup/dest'),
|
||||
path.resolve('/backup/manifests'),
|
||||
path.resolve('/extra/root'),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveContainedDbBackupCandidates — the manifest.database.backup_file gate', () => {
|
||||
it('rejects an absolute backup_file outside every configured root, but still offers the safe legacy basename candidate', async () => {
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, '/etc/passwd', () => {}
|
||||
);
|
||||
// The raw absolute escape must NOT be present.
|
||||
expect(candidates).not.toContain('/etc/passwd');
|
||||
// Candidate (3), the basename-only legacy reconstruct, is inherently
|
||||
// safe (can't escape backupPath) and stays available as a fallback.
|
||||
expect(candidates).toContain(path.join(backupPath, 'database', 'passwd'));
|
||||
});
|
||||
|
||||
it('rejects a `..`-traversal relative backup_file, keeping only the contained legacy candidate', async () => {
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, '../../../../etc/passwd', () => {}
|
||||
);
|
||||
const escaped = candidates.some((c) => !_internal.isContainedInRoots(c, [path.resolve(backupPath)]));
|
||||
expect(escaped).toBe(false);
|
||||
expect(candidates).toContain(path.join(backupPath, 'database', 'passwd'));
|
||||
});
|
||||
|
||||
it('accepts a legitimate relative backup_file recorded by a real backup run', async () => {
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, 'database/picpeak-db-sqlite-2024-01-01.sql.gz', () => {}
|
||||
);
|
||||
expect(candidates).toContain(path.join(backupPath, 'database', 'picpeak-db-sqlite-2024-01-01.sql.gz'));
|
||||
// Every returned candidate must actually be safe to use.
|
||||
for (const c of candidates) {
|
||||
expect(_internal.isContainedInRoots(c, [path.resolve(backupPath)])).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a legitimate absolute backup_file that IS inside backupPath (the real dumper shape)', async () => {
|
||||
const absFile = path.join(backupPath, 'database', 'picpeak-db-sqlite-2024-02-02.sql.gz');
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, absFile, () => {}
|
||||
);
|
||||
expect(candidates).toContain(absFile);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* DNS-rebinding follow-up to the blind-SSRF fix in restoreServiceS3Ssrf.test.js
|
||||
* (GHSA-vm2x-c628-3cx5).
|
||||
*
|
||||
* isHostAllowed()/validateExternalUrlAsync() are check-then-connect on their
|
||||
* own: they resolve the S3 endpoint hostname once to vet it, then hand a
|
||||
* bare hostname to the AWS SDK, which resolves it AGAIN when it actually
|
||||
* connects. An attacker who controls DNS for the endpoint hostname (or an
|
||||
* infra DNS-rebinding condition) can answer the first lookup with a public
|
||||
* IP and the second with a private/metadata one.
|
||||
*
|
||||
* downloadFileFromS3() now builds pinned http/https agents (pinnedRequest.js
|
||||
* — the same primitive webhookDeliveryWorker.js and emailWebhookTransport.js
|
||||
* use for outbound HTTP) from the validated address and passes them into
|
||||
* S3StorageAdapter, which threads them into the S3Client's NodeHttpHandler
|
||||
* requestHandler. This asserts that wiring: the agents S3StorageAdapter
|
||||
* receives resolve the endpoint hostname to ONLY the address vetted during
|
||||
* validation, and never fall through to a second, real DNS lookup that a
|
||||
* rebinding attacker could answer differently.
|
||||
*/
|
||||
|
||||
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-restores3pin-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restores3pin-test-secret';
|
||||
|
||||
jest.mock('dns', () => {
|
||||
const actual = jest.requireActual('dns');
|
||||
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() }, lookup: jest.fn() };
|
||||
});
|
||||
|
||||
let capturedConfig;
|
||||
jest.mock('../../src/services/storage/s3Storage', () =>
|
||||
jest.fn().mockImplementation((config) => {
|
||||
capturedConfig = config;
|
||||
return { download: jest.fn().mockResolvedValue(undefined) };
|
||||
})
|
||||
);
|
||||
|
||||
const dns = require('dns');
|
||||
const promiseLookup = dns.promises.lookup;
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { RestoreService } = require('../../src/services/restoreService');
|
||||
|
||||
describe('downloadFileFromS3 DNS-rebinding pinning', () => {
|
||||
let restoreService;
|
||||
let originalNodeEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreService = new RestoreService();
|
||||
capturedConfig = undefined;
|
||||
promiseLookup.mockReset();
|
||||
dns.lookup.mockReset();
|
||||
S3StorageAdapter.mockClear();
|
||||
originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
capturedConfig?.httpAgent?.destroy();
|
||||
capturedConfig?.httpsAgent?.destroy();
|
||||
});
|
||||
|
||||
it('passes pinned http/https agents into S3StorageAdapter built from the validated address', async () => {
|
||||
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
|
||||
expect(capturedConfig.httpAgent).toBeInstanceOf(require('http').Agent);
|
||||
expect(capturedConfig.httpsAgent).toBeInstanceOf(require('https').Agent);
|
||||
});
|
||||
|
||||
it('the pinned agent never performs a second DNS lookup — rebinding to a private IP on the real resolver is ignored', async () => {
|
||||
// First (validation) lookup: public IP, passes the preflight.
|
||||
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
// If the pinned agent ever fell through to a real lookup, this would
|
||||
// hand back a private/metadata address — simulating the rebind.
|
||||
dns.lookup.mockImplementation((_hostname, options, callback) => {
|
||||
if (typeof options === 'function') { callback = options; options = {}; }
|
||||
callback(null, ...(options?.all ? [[{ address: '169.254.169.254', family: 4 }]] : ['169.254.169.254', 4]));
|
||||
});
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
const pinnedLookup = capturedConfig.httpAgent.options.lookup;
|
||||
expect(typeof pinnedLookup).toBe('function');
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
pinnedLookup('rebind.example.com', {}, (err, address, family) => {
|
||||
if (err) return reject(err);
|
||||
resolve({ address, family });
|
||||
});
|
||||
});
|
||||
|
||||
// Only the address vetted during validation is ever handed back —
|
||||
// never the private address the real resolver would now answer with.
|
||||
expect(result).toEqual({ address: '93.184.216.34', family: 4 });
|
||||
expect(dns.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a lookup for any hostname other than the one that was validated', async () => {
|
||||
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
const pinnedLookup = capturedConfig.httpAgent.options.lookup;
|
||||
|
||||
await expect(new Promise((resolve, reject) => {
|
||||
pinnedLookup('attacker-controlled.example', {}, (err, address) => {
|
||||
if (err) return reject(err);
|
||||
resolve(address);
|
||||
});
|
||||
})).rejects.toThrow(/hostname changed/i);
|
||||
});
|
||||
|
||||
it('does not pin agents when no custom endpoint is configured (default AWS, no rebinding surface)', async () => {
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(promiseLookup).not.toHaveBeenCalled();
|
||||
expect(capturedConfig.httpAgent).toBeUndefined();
|
||||
expect(capturedConfig.httpsAgent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Blind SSRF via the restore S3 download path (GHSA-vm2x-c628-3cx5).
|
||||
*
|
||||
* downloadFileFromS3() built a bare S3StorageAdapter and called .download()
|
||||
* directly, never running the DNS-resolving isHostAllowed() guard that
|
||||
* testConnection() applies elsewhere — so an admin with backup.restore could
|
||||
* point the request-supplied S3 endpoint at an internal/metadata address for
|
||||
* unauthenticated egress via the server. `s3Config` here is fully attacker
|
||||
* controlled (POST /api/admin/restore/validate and /restore/start take it
|
||||
* straight from the request body — see routes/adminRestore.js), unlike the
|
||||
* scheduled-backup S3 endpoint, which is vetted at settings-save time.
|
||||
*/
|
||||
|
||||
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-restores3ssrf-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restores3ssrf-test-secret';
|
||||
|
||||
jest.mock('dns', () => {
|
||||
const actual = jest.requireActual('dns');
|
||||
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() } };
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/storage/s3Storage', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
download: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
);
|
||||
|
||||
const dns = require('dns');
|
||||
const lookup = dns.promises.lookup;
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { RestoreService } = require('../../src/services/restoreService');
|
||||
|
||||
describe('downloadFileFromS3 SSRF guard (GHSA-vm2x-c628-3cx5)', () => {
|
||||
let restoreService;
|
||||
let originalNodeEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreService = new RestoreService();
|
||||
lookup.mockReset();
|
||||
S3StorageAdapter.mockClear();
|
||||
originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
});
|
||||
|
||||
it('rejects an endpoint hostname that resolves to a private/internal address before any network call', async () => {
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
|
||||
|
||||
await expect(
|
||||
restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'evil-rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
)
|
||||
).rejects.toThrow(/private or internal network address/i);
|
||||
|
||||
expect(S3StorageAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an endpoint hostname that resolves to the cloud metadata address', async () => {
|
||||
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
||||
|
||||
await expect(
|
||||
restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'metadata-rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
)
|
||||
).rejects.toThrow(/private or internal network address/i);
|
||||
|
||||
expect(S3StorageAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a legitimate public S3 endpoint through to download()', async () => {
|
||||
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 's3.example-cdn.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not require the guard outside production (dev MinIO stays usable), but still downloads', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]); // would be rejected in prod
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'localhost:9000', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
jest.mock('../../src/utils/logger', () => ({ error: jest.fn() }));
|
||||
const { scheduledTask } = require('../../src/services/scheduledTask');
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
it('starts once, skips overlap and drains the accepted run on stop', async () => {
|
||||
let finish;
|
||||
const work = jest.fn(() => new Promise(resolve => { finish = resolve; }));
|
||||
const task = scheduledTask(work, { interval: 100 });
|
||||
task.start(); task.start();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
await jest.advanceTimersByTimeAsync(300);
|
||||
expect(work).toHaveBeenCalledTimes(1);
|
||||
let stopped = false;
|
||||
const stop = task.stop().then(() => { stopped = true; });
|
||||
await Promise.resolve(); expect(stopped).toBe(false);
|
||||
finish(); await stop; expect(stopped).toBe(true);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
await jest.advanceTimersByTimeAsync(1000); expect(work).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('cancels a delayed first run and can restart cleanly', async () => {
|
||||
const work = jest.fn(); const task = scheduledTask(work, { interval: 100, initialDelay: 10 });
|
||||
task.start(); await task.stop(); await jest.advanceTimersByTimeAsync(200); expect(work).not.toHaveBeenCalled();
|
||||
task.start(); await jest.advanceTimersByTimeAsync(10); expect(work).toHaveBeenCalledTimes(1);
|
||||
await task.stop();
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
const secure = require('../../src/services/secureImageService');
|
||||
beforeEach(() => { jest.useFakeTimers(); secure.dispose(); });
|
||||
afterEach(() => { secure.dispose(); jest.useRealTimers(); });
|
||||
it('owns one timer for many tokens and sweeps expired capabilities', () => {
|
||||
for (let i = 0; i < 100; i++) secure.generateSecureToken(i, 'gallery_public_1_1', { expiresIn: 1 });
|
||||
expect(jest.getTimerCount()).toBe(1); expect(secure.tokenCache.size).toBe(100);
|
||||
jest.advanceTimersByTime(60000); expect(secure.tokenCache.size).toBe(0);
|
||||
secure.dispose(); expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
it('disposes all session/rate caches and restarts on demand', () => {
|
||||
secure.generateSecureToken(1, 'session'); secure.sessionTokens.set('a', 'b'); secure.rateLimitCache.set('a', 'b');
|
||||
secure.dispose(); expect(secure.sessionTokens.size + secure.rateLimitCache.size + secure.tokenCache.size).toBe(0);
|
||||
secure.generateSecureToken(1, 'session'); expect(jest.getTimerCount()).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const catalog = require('../../src/usage/features.v5.json');
|
||||
const inventory = require('../../../docs/usage-coverage.v5.json');
|
||||
const protocol = require('../../src/usage/schema.cjs');
|
||||
const { RULES_V2, capabilityKeys } = require('../../src/usage/capabilityRules');
|
||||
const { acceptedUpload, capabilityEvidence } = require('../../src/usage/capabilityEvidence');
|
||||
|
||||
test('every route family and literal route declaration has an explicit privacy decision', () => {
|
||||
const root = path.resolve(__dirname, '../../src/routes');
|
||||
const actual = {};
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === '__tests__') continue;
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (entry.name.endsWith('.js')) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
actual[path.relative(root, file)] = [...source.matchAll(/router\.(get|post|put|patch|delete)\(\s*(['"])([^'"]+)\2/g)]
|
||||
.map((m) => `${m[1].toUpperCase()} ${m[3]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
expect(Object.keys(inventory.route_families).sort()).toEqual(Object.keys(actual).sort());
|
||||
for (const [file, decision] of Object.entries(inventory.route_families)) {
|
||||
expect(decision.reason.length).toBeGreaterThan(30);
|
||||
expect(decision.route_signatures).toEqual(actual[file]);
|
||||
for (const signal of decision.signals) expect(catalog.features[signal]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('all flags and catalog capabilities have a documented decision', () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../src/routes/adminFeatureFlags.js'), 'utf8');
|
||||
const array = source.match(/const KNOWN_FLAGS = \[([\s\S]*?)\];/)[1].replace(/\/\/[^\n]*/g, '');
|
||||
const flags = [...array.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
expect(Object.keys(inventory.feature_flags).sort()).toEqual(flags.sort());
|
||||
for (const key of protocol.FEATURE_KEYS)
|
||||
expect(Object.values(inventory.route_families).some((family) => family.signals.includes(key))).toBe(true);
|
||||
expect(inventory.configuration_only.sort()).toEqual(protocol.FEATURE_KEYS.filter((key) => !protocol.observesUse(key)).sort());
|
||||
});
|
||||
|
||||
test('all current settings tabs have an explicit scope decision', () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../../frontend/src/pages/admin/SettingsPage.tsx'), 'utf8');
|
||||
const union = source.match(/type TabType =([\s\S]*?);/)[1].replace(/\/\/[^\n]*/g, '');
|
||||
const tabs = [...union.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
expect(Object.keys(inventory.settings_tabs).sort()).toEqual(tabs.sort());
|
||||
for (const entry of Object.values(inventory.settings_tabs)) {
|
||||
expect(entry.reason.length).toBeGreaterThan(20);
|
||||
for (const key of entry.signals) expect(catalog.features[key]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('v1/v2/v3 wire validation is immutable; v5 catalog, UI and translated descriptions agree', () => {
|
||||
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v1'].properties)).digest('hex'))
|
||||
.toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922');
|
||||
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v2'].properties)).digest('hex'))
|
||||
.toBe('159821cf45c1951016d33a4ed9ca55a0a7ee1b60dd715b803fcfed33e5c8a846');
|
||||
expect(protocol.FEATURE_KEYS).toHaveLength(87);
|
||||
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v3'].properties)).digest('hex'))
|
||||
.toBe('93214702c79f47823f154544ebad6612dd313604f69e60b86de4c0e4c904571a');
|
||||
expect(protocol.FEATURE_KEYS).toContain('gallery_downloads_restricted');
|
||||
expect(protocol.FEATURE_KEYS).not.toContain('gallery_downloads');
|
||||
expect(protocol.ALL_FEATURE_KEYS).toHaveLength(94);
|
||||
expect(protocol.ALL_FEATURE_KEYS).toContain('gallery_downloads');
|
||||
expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19);
|
||||
expect(inventory.configuration_only).toHaveLength(23);
|
||||
const frontend = path.resolve(__dirname, '../../../frontend');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v5.json')))).toEqual(catalog);
|
||||
// The catalog is source, and source is English only: its strings are the
|
||||
// en locale verbatim. Every other language lives in its locale file and
|
||||
// must cover every key and field, but says whatever its translator chose.
|
||||
const english = JSON.parse(fs.readFileSync(path.join(frontend, 'src/i18n/locales/en.json'))).productUsage.catalog;
|
||||
for (const [key, value] of Object.entries(catalog.features)) {
|
||||
expect(Object.keys(value.name)).toEqual(['en']);
|
||||
expect(english[key]).toEqual({ name: value.name.en, configured: value.configured.en, ...(value.used ? { used: value.used.en } : {}) });
|
||||
}
|
||||
const german = JSON.parse(fs.readFileSync(path.join(frontend, 'src/i18n/locales/de.json'))).productUsage.catalog;
|
||||
for (const [key, value] of Object.entries(catalog.features)) {
|
||||
expect(Object.keys(german[key] || {}).sort()).toEqual(Object.keys(english[key]).sort());
|
||||
for (const field of Object.keys(english[key])) expect(typeof german[key][field]).toBe('string');
|
||||
void value;
|
||||
}
|
||||
});
|
||||
|
||||
test('every used field has either a fixed route rule or explicit trusted success evidence', () => {
|
||||
const explicit = ['cms_content_editing', 'email_template_editing', 'email_template_delivery', 'branding_editing', 'seo_editing', 'event_type_editing', 'category_editing', 'custom_css', 'oauth', 'smtp', 'email_webhook', 'whatsapp', 'incoming_mail',
|
||||
'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage', 's3_backups', 'api_integration', 'photo_xmp_export', 'photo_replacement', 'photo_admin_marks', 'crm_invoice_import', 'crm_combined_billing', 'crm_monthly_billing_manual', 'crm_document_conversion'];
|
||||
const covered = new Set([...explicit, ...RULES_V2.flatMap(([, , keys]) => keys)]);
|
||||
expect(protocol.FEATURE_KEYS.filter((key) => protocol.observesUse(key)).filter((key) => !covered.has(key))).toEqual([]);
|
||||
for (const key of covered) expect(protocol.ALL_FEATURES[key].used).toBeTruthy();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['POST', '/events', 'galleries'], ['POST', '/events/123/publish', 'galleries'],
|
||||
['POST', '/photos/repair-dimensions', 'photo_processing'], ['GET', '/events/123/photos/456/download', 'photo_exports'],
|
||||
['PUT', '/events/123/slideshow', 'slideshow'], ['POST', '/expenses/inbound', 'accounting_incoming_invoices'],
|
||||
['POST', '/expenses', 'accounting_expenses'], ['GET', '/tax-report/csv', 'accounting_tax_report'],
|
||||
['POST', '/deals/123/installment-plan', 'crm_installments'], ['GET', '/ledger/export', 'accounting_ledger'],
|
||||
['POST', '/quotes/presets', 'document_templates'], ['PUT', '/cms/pages/home', 'cms'],
|
||||
['POST', '/webhooks/123/test', 'webhooks'], ['POST', '/webhooks/123/deliveries/456/replay', 'webhooks'],
|
||||
['POST', '/email/send', 'messaging'], ['PUT', '/feedback/feedback/123/approve', 'feedback_moderation'],
|
||||
['GET', '/events/123/guests/export-all', 'guest_management'], ['POST', '/backup/picpeak/import', 'portable_backup'],
|
||||
['PUT', '/roles/123', 'admin_management'], ['POST', '/newsletters/123/queue', 'newsletters']
|
||||
])('fixed allowlist recognizes %s %s', (method, url, expected) => {
|
||||
expect(capabilityKeys(method, url)).toContain(expected);
|
||||
expect(JSON.stringify(capabilityKeys(method, url))).not.toContain('123');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['GET', '/events/faces/health'], ['GET', '/photos/repair-dimensions/status'],
|
||||
['POST', '/events/123/validate-rename'], ['POST', '/photos/123/chunked-upload/init'],
|
||||
['POST', '/photos/123/chunked-upload/456/chunk/0'], ['GET', '/dashboard/health'],
|
||||
['GET', '/customers'], ['GET', '/email/queue'], ['POST', '/email/flush-queue'],
|
||||
['POST', '/newsletters/123/recipients/resolve'], ['POST', '/newsletters/123/preview'],
|
||||
['POST', '/users/123/reset-password'], ['PUT', '/settings/security'],
|
||||
['POST', '/gallery/a/feedback'], ['POST', '/public/newsletter/unsubscribe'],
|
||||
['POST', '/customer/quotes/123/accept'], ['POST', '/usage/consent']
|
||||
])('no v2 observation for excluded %s %s', (method, url) => expect(capabilityKeys(method, url)).toEqual([]));
|
||||
|
||||
test('trusted upload evidence retains only constant keys and configuration-only use cannot be recorded', () => {
|
||||
const res = { locals: {} };
|
||||
acceptedUpload(res, { video: true, raw: true, s3: true });
|
||||
capabilityEvidence(res, 'PRIVATE-user@example.test', 'gallery_feedback_likes');
|
||||
expect(res.locals.productUsageFeatures.sort()).toEqual(['photo_management', 'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage'].sort());
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* /disable overlapping an in-flight /enable (#1110).
|
||||
*
|
||||
* While activation generates an identity and writes its binding file the row
|
||||
* still reads `disabled`, so disable()'s conditional update matched nothing
|
||||
* and the lease conflict from its tick() was swallowed. The admin was told
|
||||
* participation was off, and the activation then completed and left it on —
|
||||
* an opt-out silently ignored, which is the one thing this feature cannot do.
|
||||
*
|
||||
* enable() now claims its state with a single conditional UPDATE that also
|
||||
* tests the cancellation flag, so whichever lands first wins outright.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
const { UsageService } = require('../../src/usage/UsageService');
|
||||
|
||||
const SECRET = 'z'.repeat(48);
|
||||
|
||||
// A report the envelope schema accepts. An empty payload fails validation
|
||||
// during signing, so the packet would never reach the collector for reasons
|
||||
// unrelated to what the test is checking.
|
||||
function validReport() {
|
||||
const { LEGACY_FEATURE_KEYS: FEATURE_KEYS } = require('../../src/usage/protocol.cjs');
|
||||
return {
|
||||
picpeak_version: '3.0.0',
|
||||
report_date: '2026-09-05',
|
||||
generated_at: '2026-09-05T00:00:00.000Z',
|
||||
features: Object.fromEntries(
|
||||
FEATURE_KEYS.map((k) => [k, { configured: false, used: false }])
|
||||
),
|
||||
gallery_layouts: ['grid'],
|
||||
};
|
||||
}
|
||||
|
||||
async function bootDb() {
|
||||
const db = knex({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: ':memory:' },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
await db.schema.createTable('product_usage_state', (t) => {
|
||||
t.integer('id').primary();
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.boolean('prompt_shown').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
t.text('private_key_encrypted');
|
||||
t.string('instance_binding', 64);
|
||||
t.bigInteger('sequence').notNullable().defaultTo(0);
|
||||
t.text('pending_packet');
|
||||
t.text('last_packet');
|
||||
t.text('last_receipt');
|
||||
t.text('privacy_receipts');
|
||||
t.string('last_report_date', 10);
|
||||
t.string('last_error', 80);
|
||||
t.text('feedback_preferences');
|
||||
t.string('lease_token', 36);
|
||||
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
|
||||
t.integer('attempts').notNullable().defaultTo(0);
|
||||
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||
});
|
||||
await db.schema.createTable('product_usage_markers', (t) => {
|
||||
t.string('feature', 60).primary();
|
||||
});
|
||||
await db('product_usage_state').insert({ id: 1 });
|
||||
return db;
|
||||
}
|
||||
|
||||
/** A service whose binding() is slow, so the race window is controllable. */
|
||||
function makeService(db, { onBinding } = {}) {
|
||||
const service = new UsageService(db, {
|
||||
secret: SECRET,
|
||||
endpoint: 'http://127.0.0.1:9/',
|
||||
fetch: async () => { throw new Error('collector must not be reached'); },
|
||||
});
|
||||
const realBinding = service.binding.bind(service);
|
||||
service.binding = async (create = false) => {
|
||||
if (create && onBinding) await onBinding();
|
||||
return realBinding === undefined ? 'x'.repeat(64) : 'b'.repeat(64);
|
||||
};
|
||||
return service;
|
||||
}
|
||||
|
||||
describe('withdrawal during an in-flight activation', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
it('honours a /disable that lands while /enable is still generating its identity', async () => {
|
||||
db = await bootDb();
|
||||
let disableDone;
|
||||
const service = makeService(db, {
|
||||
// Fires inside enable(), before it claims the row — exactly the window
|
||||
// where the status still reads `disabled`.
|
||||
onBinding: async () => { disableDone = await service.disable(); },
|
||||
});
|
||||
|
||||
await service.enable('usage-consent.v1');
|
||||
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(row.status).toBe('disabled');
|
||||
// Nothing was registered, so there is no identity and nothing to delete.
|
||||
expect(row.installation_id).toBeNull();
|
||||
expect(row.pending_packet).toBeNull();
|
||||
expect(disableDone.status).toBe('disabled');
|
||||
});
|
||||
|
||||
it('activates normally when no withdrawal arrives', async () => {
|
||||
db = await bootDb();
|
||||
const service = makeService(db);
|
||||
await service.enable('usage-consent.v1');
|
||||
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
// The collector is unreachable here, so it stops at activation_pending —
|
||||
// the point is that the claim succeeded and an identity exists.
|
||||
expect(row.status).toBe('activation_pending');
|
||||
expect(row.installation_id).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not let a stale cancellation veto a later deliberate opt-in', async () => {
|
||||
db = await bootDb();
|
||||
// A withdrawal from an earlier participation is already reflected in the
|
||||
// counter when this activation reads it, so it cannot veto anything.
|
||||
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 7 });
|
||||
|
||||
const service = makeService(db);
|
||||
await service.enable('usage-consent.v1');
|
||||
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(row.status).toBe('activation_pending');
|
||||
expect(row.installation_id).not.toBeNull();
|
||||
});
|
||||
|
||||
it('honours a withdrawal even when an earlier one was never cleared', async () => {
|
||||
// The case a boolean could not express: a stale cancellation is already
|
||||
// set, and a fresh one lands mid-activation. With a flag both look the
|
||||
// same; with a counter the second increment is visible.
|
||||
db = await bootDb();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 3 });
|
||||
|
||||
const service = makeService(db, {
|
||||
onBinding: async () => { await service.disable(); },
|
||||
});
|
||||
await service.enable('usage-consent.v1');
|
||||
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(row.status).toBe('disabled');
|
||||
expect(row.installation_id).toBeNull();
|
||||
});
|
||||
|
||||
it('does not dispatch a report when the withdrawal completes during preparation', async () => {
|
||||
// deliver() checks for a withdrawal before the binding lookup, which is
|
||||
// asynchronous. A /disable that COMPLETED during it used to have the
|
||||
// report sent anyway — not an already-in-flight request, but a new one
|
||||
// started after the operator had withdrawn.
|
||||
db = await bootDb();
|
||||
const posted = [];
|
||||
const service = new UsageService(db, {
|
||||
secret: SECRET,
|
||||
endpoint: 'http://127.0.0.1:9/',
|
||||
fetch: async (_url, init) => {
|
||||
posted.push(JSON.parse(init.body).packet.action);
|
||||
throw new Error('collector unreachable');
|
||||
},
|
||||
});
|
||||
const identity = require('../../src/usage/protocol.cjs').generateIdentity();
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'active',
|
||||
installation_id: identity.installation_id,
|
||||
public_key: identity.public_key,
|
||||
private_key_encrypted: service.encrypt(identity.private_key),
|
||||
instance_binding: 'b'.repeat(64),
|
||||
sequence: 1,
|
||||
pending_packet: JSON.stringify(
|
||||
require('../../src/usage/protocol.cjs').makePacket(
|
||||
{ installation_id: identity.installation_id },
|
||||
'report',
|
||||
2,
|
||||
validReport(),
|
||||
'usage.v1'
|
||||
)
|
||||
),
|
||||
});
|
||||
// The withdrawal lands while the binding lookup is awaited.
|
||||
service.binding = async () => {
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'deletion_pending', pending_packet: null,
|
||||
});
|
||||
return 'b'.repeat(64);
|
||||
};
|
||||
|
||||
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
|
||||
|
||||
expect(posted).not.toContain('report');
|
||||
});
|
||||
|
||||
it('honours a withdrawal that lands between the lease claim and the state read', async () => {
|
||||
// locked() claims the lease and reads the row in two statements. A
|
||||
// /disable completing in that gap used to be adopted as this
|
||||
// activation's own baseline and absorbed, so registration went ahead
|
||||
// after the operator had withdrawn.
|
||||
db = await bootDb();
|
||||
const service = makeService(db);
|
||||
const realState = service.state.bind(service);
|
||||
let fired = false;
|
||||
service.state = async () => {
|
||||
// The withdrawal must land BEFORE this read returns, so the row carries
|
||||
// the incremented counter. Incrementing afterwards would hand back the
|
||||
// old value and both the broken and fixed versions would behave the
|
||||
// same — which is exactly how an earlier version of this test passed
|
||||
// against the bug it was meant to catch.
|
||||
const first = await realState();
|
||||
if (!fired && first.lease_token) {
|
||||
fired = true;
|
||||
await db('product_usage_state').where({ id: 1 }).increment('cancel_seq', 1);
|
||||
return realState();
|
||||
}
|
||||
return first;
|
||||
};
|
||||
|
||||
await service.enable('usage-consent.v1');
|
||||
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(row.status).toBe('disabled');
|
||||
expect(row.installation_id).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Two things the participant is entitled to have stated exactly.
|
||||
*
|
||||
* The export receipt is a privacy document — the artefact an operator shows a
|
||||
* third party — so a count in it has to mean what its label says. It counted
|
||||
* every packet in the participation (feedback, votes, portal sessions, the
|
||||
* registration) and called the total "usage reports": an install that had sent
|
||||
* one report and twenty feedback items reported twenty-one reports.
|
||||
*
|
||||
* The delete packet's sequence is the other: it reuses the last ACCEPTED
|
||||
* sequence rather than taking the next one, unlike every other action. That is
|
||||
* a contract with the collector, not an implementation detail — if the
|
||||
* collector ever enforced strictly increasing sequences per installation, the
|
||||
* withdrawal would be rejected forever and the operator could never leave. It
|
||||
* is pinned here so the assumption is written down and a change to it has to
|
||||
* be deliberate.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
const { UsageService } = require('../../src/usage/UsageService');
|
||||
const { generateIdentity, verifyEnvelope } = require('../../src/usage/protocol.cjs');
|
||||
|
||||
const SECRET = 's'.repeat(48);
|
||||
|
||||
async function bootDb() {
|
||||
const db = knex({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: ':memory:' },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
await db.schema.createTable('product_usage_state', (t) => {
|
||||
t.integer('id').primary();
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v2');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.boolean('prompt_shown').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
t.text('private_key_encrypted');
|
||||
t.string('instance_binding', 64);
|
||||
t.bigInteger('sequence').notNullable().defaultTo(0);
|
||||
t.text('pending_packet');
|
||||
t.text('last_packet');
|
||||
t.text('last_receipt');
|
||||
t.text('privacy_receipts');
|
||||
t.string('last_report_date', 10);
|
||||
t.string('last_error', 80);
|
||||
t.text('feedback_preferences');
|
||||
t.string('lease_token', 36);
|
||||
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
|
||||
t.integer('attempts').notNullable().defaultTo(0);
|
||||
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||
});
|
||||
await db('product_usage_state').insert({ id: 1 });
|
||||
await db.schema.createTable('product_usage_markers', (t) => t.string('feature', 60).primary());
|
||||
return db;
|
||||
}
|
||||
|
||||
const envelope = (action) => ({ packet: { action, installation_id: 'a'.repeat(64) } });
|
||||
|
||||
describe('the export receipt states what it actually counted', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
const exportWith = async (packets) => {
|
||||
db = await bootDb();
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'active',
|
||||
installation_id: 'a'.repeat(64),
|
||||
});
|
||||
const service = new UsageService(db, {
|
||||
secret: SECRET,
|
||||
endpoint: 'https://usage.example.test',
|
||||
now: () => Date.parse('2026-09-06T12:00:00.000Z'),
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
headers: { get: () => null },
|
||||
body: (async function* () {
|
||||
yield Buffer.from(JSON.stringify({ installation_id: 'a'.repeat(64), packets }));
|
||||
})(),
|
||||
}),
|
||||
});
|
||||
await service.export();
|
||||
return JSON.parse((await db('product_usage_state').where({ id: 1 }).first()).privacy_receipts)
|
||||
.last_export;
|
||||
};
|
||||
|
||||
it('counts reports as reports and everything else separately', async () => {
|
||||
const receipt = await exportWith([
|
||||
envelope('register'),
|
||||
envelope('report'),
|
||||
envelope('consent'),
|
||||
envelope('feedback'),
|
||||
envelope('feedback'),
|
||||
envelope('vote'),
|
||||
envelope('session'),
|
||||
]);
|
||||
expect(receipt.report_count).toBe(1);
|
||||
expect(receipt.packet_count).toBe(7);
|
||||
expect(receipt.scope).toEqual([
|
||||
'accepted usage reports',
|
||||
'accepted participant operations',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports zero rather than a total when no report was ever accepted', async () => {
|
||||
const receipt = await exportWith([envelope('register'), envelope('feedback')]);
|
||||
expect(receipt.report_count).toBe(0);
|
||||
expect(receipt.packet_count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the delete packet reuses the last accepted sequence', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
it('sends the accepted sequence, not the next one', async () => {
|
||||
db = await bootDb();
|
||||
const identity = generateIdentity();
|
||||
const sent = [];
|
||||
const service = new UsageService(db, {
|
||||
secret: SECRET,
|
||||
endpoint: 'https://usage.example.test',
|
||||
now: () => Date.parse('2026-09-06T12:00:00.000Z'),
|
||||
bindingPath: `${require('os').tmpdir()}/usage-delete-seq-${Date.now()}.key`,
|
||||
fetch: async (_url, options) => {
|
||||
const body = JSON.parse(options.body);
|
||||
sent.push(verifyEnvelope(body, Date.parse('2026-09-06T12:00:00.000Z')));
|
||||
// Deliberately not a sequence-enforcing collector: this test pins what
|
||||
// PicPeak sends, and the collector contract is what must match it.
|
||||
throw new Error('stop after capturing the packet');
|
||||
},
|
||||
});
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'deletion_pending',
|
||||
installation_id: identity.installation_id,
|
||||
public_key: identity.public_key,
|
||||
private_key_encrypted: service.encrypt(identity.private_key),
|
||||
sequence: 7,
|
||||
});
|
||||
|
||||
await service.tick({ force: true });
|
||||
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0].action).toBe('delete');
|
||||
expect(sent[0].sequence).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const crypto = require('crypto');
|
||||
const p = require('../../src/usage/protocol.cjs');
|
||||
const now = Date.parse('2026-09-06T12:00:00.000Z');
|
||||
|
||||
const signHistorical = (packet, id) => {
|
||||
const signed = { packet, public_key: id.public_key, issued_at: new Date(now).toISOString(), nonce: crypto.randomUUID() };
|
||||
return { ...signed, signature: crypto.sign(null, Buffer.from(p.canonical(signed)), id.private_key).toString('base64url') };
|
||||
};
|
||||
|
||||
describe.each(['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4', 'usage.v5'])('%s receiver compatibility never loosens the PicPeak sender', version => {
|
||||
test('complete original reports still sign and verify', () => {
|
||||
const id = p.generateIdentity();
|
||||
const packet = p.makePacket(id, 'report', 1, {
|
||||
picpeak_version: '1.0.0', report_date: '2026-09-06', generated_at: new Date(now).toISOString(),
|
||||
features: p.emptyFeatures(version), gallery_layouts: [],
|
||||
...(['usage.v3', 'usage.v4', 'usage.v5'].includes(version) ? { inventory: { galleries: 0, photos: 0 } } : {}),
|
||||
}, version);
|
||||
const envelope = p.signPacket(packet, id, new Date(now));
|
||||
expect(p.verifyEnvelope(envelope, now)).toEqual(packet);
|
||||
expect(p.verifyReceivedEnvelope(envelope, now)).toEqual(packet);
|
||||
});
|
||||
|
||||
test.each([undefined, null, {}, { crm: { used: true, configured: null } }])('accepts partial incoming measurements %p without rewriting them', features => {
|
||||
const id = p.generateIdentity();
|
||||
const packet = p.makePacket(id, 'report', 1, {
|
||||
report_date: '2020-01-02', generated_at: '2020-01-02T12:00:00.000Z',
|
||||
...(features === undefined ? {} : { features }),
|
||||
}, version);
|
||||
expect(() => p.signPacket(packet, id, new Date(now))).toThrow('INVALID_PACKET');
|
||||
const envelope = signHistorical(packet, id), original = JSON.stringify(envelope);
|
||||
expect(() => p.verifyEnvelope(envelope, now)).toThrow('INVALID_PACKET');
|
||||
expect(p.verifyReceivedEnvelope(envelope, now)).toEqual(packet);
|
||||
expect(JSON.stringify(envelope)).toBe(original);
|
||||
});
|
||||
|
||||
test('registration still requires exact explicit consent', () => {
|
||||
const id = p.generateIdentity();
|
||||
const packet = p.makePacket(id, 'register', 0, {}, version);
|
||||
expect(() => p.verifyReceivedEnvelope(signHistorical(packet, id), now)).toThrow('INVALID_PACKET');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The consent dialog tells the operator that this connection only ever runs
|
||||
* outwards: PicPeak sends, and reads nothing back but the acknowledgement for
|
||||
* the packet it just sent. That is a security claim — it is the reason a
|
||||
* compromised collector cannot use this path to push code, configuration or
|
||||
* content into an installation — so it is guarded here rather than left to
|
||||
* review.
|
||||
*
|
||||
* These are source-inspection assertions on purpose. A behavioural test only
|
||||
* proves the calls that exist today behave; this fails the moment someone adds
|
||||
* a "check the collector for messages" fetch, a polling job, or an endpoint the
|
||||
* collector could call.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = path.resolve(__dirname, '../../src');
|
||||
const service = fs.readFileSync(path.join(SRC, 'usage/UsageService.js'), 'utf8');
|
||||
const route = fs.readFileSync(path.join(SRC, 'routes/adminUsage.js'), 'utf8');
|
||||
const server = fs.readFileSync(path.resolve(__dirname, '../../server.js'), 'utf8');
|
||||
|
||||
test('the collector is contacted from exactly one place, and only by POST', () => {
|
||||
// One transport helper. Anything else reaching for the network here would
|
||||
// bypass the size cap, the redirect ban and the timeout as well.
|
||||
const callSites = service.match(/this\.fetch\(/g) || [];
|
||||
expect(callSites).toHaveLength(1);
|
||||
|
||||
const post = service.slice(service.indexOf('async post('));
|
||||
expect(post).toContain('method: \'POST\'');
|
||||
// A redirect is an instruction from the collector about where to go next.
|
||||
expect(post).toContain('redirect: \'error\'');
|
||||
expect(post).toContain('AbortSignal.timeout(');
|
||||
});
|
||||
|
||||
test('only the two known collector paths are ever requested', () => {
|
||||
const paths = [...service.matchAll(/this\.post\(\s*'([^']+)'/g)].map((m) => m[1]);
|
||||
expect(paths.sort()).toEqual(['/api/envelopes', '/api/participant/lookup']);
|
||||
});
|
||||
|
||||
test('nothing is read from a reply except the acknowledgement, checked field by field', () => {
|
||||
// Every field of the receipt is compared against the packet that was sent.
|
||||
for (const field of ['packet_id', 'installation_id', 'packet_digest', 'action', 'sequence', 'status'])
|
||||
expect(service).toMatch(new RegExp(`receipt\\.${field} !==`));
|
||||
expect(service).toContain('throw new Error(\'Invalid collector receipt\')');
|
||||
|
||||
// The stored copy drops the one value that is not an echo of what we sent,
|
||||
// and no read path hands it back out again.
|
||||
expect(service).toContain('delete storedReceipt.session_token');
|
||||
expect(service).not.toMatch(/last_receipt:\s*state\.last_receipt/);
|
||||
const status = service.slice(service.indexOf('async status()'), service.indexOf('async locked('));
|
||||
expect(status).not.toContain('last_receipt');
|
||||
});
|
||||
|
||||
test('the collector has no way in: no inbound route and no scheduled pull', () => {
|
||||
// Every usage route is mounted behind adminAuth on the admin surface.
|
||||
expect(server).toContain('app.use(\'/api/admin/usage\', require(\'./src/routes/adminUsage\'))');
|
||||
expect(route).toContain('router.use(adminAuth)');
|
||||
// No public/gallery/webhook mount for anything usage-related.
|
||||
const publicMounts = [...server.matchAll(/app\.use\('\/api\/(public|gallery|customer|invite)[^']*',[^\n]*\)/g)]
|
||||
.map((m) => m[0]);
|
||||
for (const mount of publicMounts) expect(mount).not.toMatch(/[Uu]sage/);
|
||||
|
||||
// Nothing schedules a collector call; the daily rollup is driven only by an
|
||||
// authenticated admin hitting /activity.
|
||||
expect(service).not.toMatch(/setInterval|setTimeout\s*\(\s*\(\)\s*=>\s*this\.tick/);
|
||||
const dir = path.join(SRC, 'services');
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
|
||||
if (entry.name === 'productUsageService.js') continue;
|
||||
if (entry.name === 'emailProcessor.js') {
|
||||
// v5 explicitly consents to one local background-mail bit, never a send
|
||||
// to the collector. No mail details may be passed into the usage API.
|
||||
const email = fs.readFileSync(path.join(dir, entry.name), 'utf8');
|
||||
expect(email).toContain(".markUsed(['email_template_delivery'])");
|
||||
expect(email).not.toMatch(/productUsageService'\)\.(?:tick|enable|command|deliver)/);
|
||||
continue;
|
||||
}
|
||||
expect(fs.readFileSync(path.join(dir, entry.name), 'utf8'))
|
||||
.not.toContain('productUsageService');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* The signing key is encrypted with USAGE_ENCRYPTION_KEY, which defaults to
|
||||
* JWT_SECRET. Rotating JWT_SECRET — the correct response to a suspected
|
||||
* compromise — makes that key unreadable.
|
||||
*
|
||||
* Before this was named, the failure surfaced as a generic DELIVERY_FAILED
|
||||
* that retried forever, and it silently blocked the DELETE packet as well:
|
||||
* an operator who asked to withdraw had their local state cleared while the
|
||||
* collector kept its copy, with nothing in the UI explaining why.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
const { UsageService } = require('../../src/usage/UsageService');
|
||||
const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
|
||||
|
||||
const SECRET_A = 'a'.repeat(48);
|
||||
const SECRET_B = 'b'.repeat(48);
|
||||
|
||||
async function bootDb() {
|
||||
const db = knex({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: ':memory:' },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
await db.schema.createTable('product_usage_state', (t) => {
|
||||
t.integer('id').primary();
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.boolean('prompt_shown').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
t.text('private_key_encrypted');
|
||||
t.string('instance_binding', 64);
|
||||
t.bigInteger('sequence').notNullable().defaultTo(0);
|
||||
t.text('pending_packet');
|
||||
t.text('last_packet');
|
||||
t.text('last_receipt');
|
||||
t.text('privacy_receipts');
|
||||
t.string('last_report_date', 10);
|
||||
t.string('last_error', 80);
|
||||
t.text('feedback_preferences');
|
||||
t.string('lease_token', 36);
|
||||
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||
t.integer('attempts').notNullable().defaultTo(0);
|
||||
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||
});
|
||||
await db('product_usage_state').insert({ id: 1 });
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('usage signing key becomes unreadable after secret rotation', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
it('names the failure instead of reporting a generic decrypt error', async () => {
|
||||
db = await bootDb();
|
||||
const before = new UsageService(db, { secret: SECRET_A });
|
||||
const sealed = before.encrypt('the-signing-key');
|
||||
|
||||
// Same value, different secret — exactly what rotating JWT_SECRET does.
|
||||
const after = new UsageService(db, { secret: SECRET_B });
|
||||
expect(() => after.decrypt(sealed)).toThrow(
|
||||
expect.objectContaining({ code: 'SIGNING_KEY_UNREADABLE' })
|
||||
);
|
||||
});
|
||||
|
||||
it('still round-trips under the unrotated secret', async () => {
|
||||
db = await bootDb();
|
||||
const service = new UsageService(db, { secret: SECRET_A });
|
||||
expect(service.decrypt(service.encrypt('the-signing-key'))).toBe('the-signing-key');
|
||||
});
|
||||
|
||||
it('records SIGNING_KEY_UNREADABLE rather than DELIVERY_FAILED, and does not flag an identity conflict', async () => {
|
||||
db = await bootDb();
|
||||
const sealed = new UsageService(db, { secret: SECRET_A }).encrypt('key');
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'active',
|
||||
installation_id: 'a'.repeat(64),
|
||||
public_key: 'p'.repeat(59),
|
||||
private_key_encrypted: sealed,
|
||||
sequence: 1,
|
||||
pending_packet: JSON.stringify({
|
||||
action: 'delete', packet_id: 'x', installation_id: 'a'.repeat(64), sequence: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const service = new UsageService(db, {
|
||||
secret: SECRET_B,
|
||||
endpoint: 'http://127.0.0.1:9/',
|
||||
// A delivery must never be attempted: signing fails first.
|
||||
fetch: () => { throw new Error('network must not be reached'); },
|
||||
});
|
||||
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
|
||||
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(row.last_error).toBe('SIGNING_KEY_UNREADABLE');
|
||||
// A key we cannot read is not evidence of a cloned installation.
|
||||
expect(row.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Naming the failure told the operator what happened but left them nowhere to
|
||||
* go: the delete packet can never be signed, so the row stays in
|
||||
* deletion_pending forever, and enable() refuses because it is not `disabled`.
|
||||
* An operator who rotated the secret precisely because it was compromised
|
||||
* cannot restore it, so without an exit the feature is bricked.
|
||||
*/
|
||||
describe('abandoning a withdrawal that can never be signed', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
const stuck = async () => {
|
||||
db = await bootDb();
|
||||
await db.schema.createTable('product_usage_markers', (t) => {
|
||||
t.string('feature', 60).primary();
|
||||
});
|
||||
await db('product_usage_markers').insert({ feature: 'crm' });
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'deletion_pending',
|
||||
installation_id: 'a'.repeat(64),
|
||||
public_key: 'p'.repeat(59),
|
||||
private_key_encrypted: new UsageService(db, { secret: SECRET_A }).encrypt('key'),
|
||||
sequence: 4,
|
||||
last_error: 'SIGNING_KEY_UNREADABLE',
|
||||
});
|
||||
return new UsageService(db, {
|
||||
secret: SECRET_B,
|
||||
endpoint: 'https://usage.example.test',
|
||||
bindingPath: `${require('os').tmpdir()}/usage-abandon-${Date.now()}.key`,
|
||||
fetch: () => { throw new Error('network must not be reached'); },
|
||||
});
|
||||
};
|
||||
|
||||
it('clears the local identity and records the deletion as unconfirmed', async () => {
|
||||
const service = await stuck();
|
||||
const status = await service.abandon();
|
||||
|
||||
expect(status.status).toBe('disabled');
|
||||
expect(status.installation_id).toBeNull();
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(row.private_key_encrypted).toBeNull();
|
||||
expect(row.public_key).toBeNull();
|
||||
expect(row.last_error).toBeNull();
|
||||
expect(await db('product_usage_markers').count('* as c').first()).toEqual({ c: 0 });
|
||||
|
||||
// The receipt must not claim a deletion the collector never confirmed.
|
||||
const receipt = JSON.parse(row.privacy_receipts).last_abandonment;
|
||||
expect(receipt.status).toBe('collector-unconfirmed');
|
||||
expect(receipt.reason).toBe('SIGNING_KEY_UNREADABLE');
|
||||
expect(receipt.installation_id).toBe('a'.repeat(64));
|
||||
});
|
||||
|
||||
it('lets the operator rejoin afterwards', async () => {
|
||||
const service = await stuck();
|
||||
await service.abandon();
|
||||
expect((await service.state()).status).toBe('disabled');
|
||||
});
|
||||
|
||||
it('refuses on a withdrawal that is merely undelivered', async () => {
|
||||
const service = await stuck();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ last_error: 'DELIVERY_FAILED' });
|
||||
await expect(service.abandon()).rejects.toThrow(/abandoned/);
|
||||
expect((await service.state()).installation_id).toBe('a'.repeat(64));
|
||||
});
|
||||
|
||||
it('refuses while participation is active', async () => {
|
||||
const service = await stuck();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
await expect(service.abandon()).rejects.toThrow(/abandoned/);
|
||||
expect((await service.state()).installation_id).toBe('a'.repeat(64));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every failed delivery used to be retried on the next admin request, and
|
||||
* /activity is open to any authenticated admin while the settings ticker fires
|
||||
* it every five minutes per open tab. A permanently rejected packet therefore
|
||||
* produced one collector request per admin action, indefinitely.
|
||||
*/
|
||||
describe('delivery backoff', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
// A real identity and a schema-valid packet, so the failure happens where
|
||||
// this test claims it does — at the network — rather than in signPacket.
|
||||
const activeWithPendingPacket = async (fetchImpl, now) => {
|
||||
db = await bootDb();
|
||||
await db.schema.createTable('product_usage_markers', (t) => {
|
||||
t.string('feature', 60).primary();
|
||||
});
|
||||
const service = new UsageService(db, {
|
||||
secret: SECRET_A,
|
||||
endpoint: 'https://usage.example.test',
|
||||
now: () => now(),
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
const identity = generateIdentity();
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'active',
|
||||
consent_version: 'usage-consent.v2',
|
||||
installation_id: identity.installation_id,
|
||||
public_key: identity.public_key,
|
||||
private_key_encrypted: service.encrypt(identity.private_key),
|
||||
sequence: 1,
|
||||
pending_packet: JSON.stringify(
|
||||
makePacket(identity, 'session', 2, {}, 'usage.v2')
|
||||
),
|
||||
});
|
||||
return service;
|
||||
};
|
||||
|
||||
it('paces the next unattended attempt after a failure, and lets Retry skip it', async () => {
|
||||
let clock = 1_000_000;
|
||||
let calls = 0;
|
||||
const service = await activeWithPendingPacket(() => {
|
||||
calls += 1;
|
||||
throw new Error('collector unreachable');
|
||||
}, () => clock);
|
||||
|
||||
await service.tick();
|
||||
expect(calls).toBe(1);
|
||||
const paced = await service.state();
|
||||
expect(Number(paced.attempts)).toBe(1);
|
||||
expect(Number(paced.next_attempt_at)).toBeGreaterThan(clock);
|
||||
|
||||
// The unattended callers — /activity and the settings ticker — wait.
|
||||
await service.tick();
|
||||
await service.tick();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
// The operator pressing Retry does not.
|
||||
await service.tick({ force: true });
|
||||
expect(calls).toBe(2);
|
||||
expect(Number((await service.state()).attempts)).toBe(2);
|
||||
|
||||
// Once the window passes, the automatic sender tries again on its own.
|
||||
clock = Number((await service.state()).next_attempt_at) + 1;
|
||||
await service.tick();
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it('grows the wait with consecutive failures and caps it at an hour', () => {
|
||||
const service = new UsageService(null, { secret: SECRET_A, endpoint: 'https://usage.example.test' });
|
||||
expect(service.backoffMs(1)).toBe(2 * 60000);
|
||||
expect(service.backoffMs(3)).toBe(8 * 60000);
|
||||
expect(service.backoffMs(20)).toBe(60 * 60000);
|
||||
});
|
||||
|
||||
it('clears the pacing once a packet is accepted', async () => {
|
||||
const clock = 1_000_000;
|
||||
const service = await activeWithPendingPacket(async () => {
|
||||
throw new Error('collector unreachable');
|
||||
}, () => clock);
|
||||
await service.tick();
|
||||
expect(Number((await service.state()).attempts)).toBe(1);
|
||||
|
||||
await service.clearDeliveryBackoff();
|
||||
const cleared = await service.state();
|
||||
expect(Number(cleared.attempts)).toBe(0);
|
||||
expect(Number(cleared.next_attempt_at)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The same dead end, reached the ordinary way. If an installation opts in to
|
||||
* usage.v2 while the collector still only speaks usage.v1 — the deployment
|
||||
* order the docs warn about — the registration is rejected outright. Nothing
|
||||
* exists at the collector, and yet the operator could not clear the tab:
|
||||
* disable moved to deletion_pending, retry was futile, enable refused, and the
|
||||
* abandon hatch was gated on SIGNING_KEY_UNREADABLE, which this is not.
|
||||
*
|
||||
* Verified against the live collector before this was written: a valid v2
|
||||
* register is answered with INVALID_PACKET while the identical v1 flow is
|
||||
* accepted.
|
||||
*/
|
||||
describe('a participation the collector never accepted', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
const rejectingCollector = async (status) => {
|
||||
db = await bootDb();
|
||||
await db.schema.createTable('product_usage_markers', (t) => {
|
||||
t.string('feature', 60).primary();
|
||||
});
|
||||
const identity = generateIdentity();
|
||||
const service = new UsageService(db, {
|
||||
secret: SECRET_A,
|
||||
endpoint: 'https://usage.example.test',
|
||||
bindingPath: `${require('os').tmpdir()}/usage-unreg-${Date.now()}-${Math.random()}.key`,
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 400,
|
||||
headers: { get: () => null },
|
||||
body: (async function* () { yield Buffer.from(JSON.stringify({ error: 'INVALID_PACKET' })); })(),
|
||||
}),
|
||||
});
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status,
|
||||
consent_version: 'usage-consent.v2',
|
||||
installation_id: identity.installation_id,
|
||||
public_key: identity.public_key,
|
||||
private_key_encrypted: service.encrypt(identity.private_key),
|
||||
sequence: 0,
|
||||
pending_packet: JSON.stringify(
|
||||
makePacket(identity, status === 'deletion_pending' ? 'delete' : 'register', 0,
|
||||
status === 'deletion_pending' ? {} : { consent_version: 'usage-consent.v2' }, 'usage.v2')
|
||||
),
|
||||
});
|
||||
return service;
|
||||
};
|
||||
|
||||
it('names the rejection instead of blaming the network', async () => {
|
||||
const service = await rejectingCollector('activation_pending');
|
||||
await service.tick({ force: true });
|
||||
expect((await service.state()).last_error).toBe('SCHEMA_NOT_ACCEPTED');
|
||||
});
|
||||
|
||||
it('offers the exit straight from activation_pending', async () => {
|
||||
const service = await rejectingCollector('activation_pending');
|
||||
await service.tick({ force: true });
|
||||
const status = await service.status();
|
||||
expect(status.can_abandon).toBe(true);
|
||||
expect(status.abandon_never_registered).toBe(true);
|
||||
|
||||
await service.abandon();
|
||||
const after = await service.state();
|
||||
expect(after.status).toBe('disabled');
|
||||
expect(after.installation_id).toBeNull();
|
||||
// Provably nothing remote, so the receipt must not hedge.
|
||||
expect(JSON.parse(after.privacy_receipts).last_abandonment.status)
|
||||
.toBe('never-registered');
|
||||
});
|
||||
|
||||
it('offers it from deletion_pending too, once the withdrawal is also undeliverable', async () => {
|
||||
const service = await rejectingCollector('deletion_pending');
|
||||
await service.tick({ force: true });
|
||||
expect((await service.status()).can_abandon).toBe(true);
|
||||
await service.abandon();
|
||||
expect((await service.state()).status).toBe('disabled');
|
||||
});
|
||||
|
||||
it('never offers it while a registered participation could still be deleted remotely', async () => {
|
||||
const service = await rejectingCollector('deletion_pending');
|
||||
// Something WAS accepted once: the collector may still hold reports, so
|
||||
// clearing local state silently would be a lie.
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
sequence: 3,
|
||||
last_receipt: JSON.stringify({ status: 'accepted' }),
|
||||
last_error: 'DELIVERY_FAILED',
|
||||
});
|
||||
expect((await service.status()).can_abandon).toBe(false);
|
||||
await expect(service.abandon()).rejects.toThrow(/cannot be completed/);
|
||||
});
|
||||
|
||||
it('does not offer it before a delivery has actually failed', async () => {
|
||||
const service = await rejectingCollector('activation_pending');
|
||||
expect((await service.status()).can_abandon).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Report accuracy (#1110).
|
||||
*
|
||||
* Two signals were wrong in ways that only show up in the aggregate, where
|
||||
* nobody can tell the number is wrong: preset-themed installs all reported
|
||||
* `grid`, and CSS applied through a template reported no custom CSS at all.
|
||||
*
|
||||
* Also covers status() surviving a misconfigured collector URL — it used to
|
||||
* throw, which took down the settings tab that is the only way to withdraw.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
const { UsageService } = require('../../src/usage/UsageService');
|
||||
const { featureKeysFor, CATALOGS, generateIdentity, makePacket, signPacket, verifyEnvelope } = require('../../src/usage/protocol.cjs');
|
||||
const FEATURE_KEYS = featureKeysFor('usage.v2');
|
||||
const CATALOG = CATALOGS['usage.v2'];
|
||||
|
||||
async function bootDb() {
|
||||
const db = knex({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: ':memory:' },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
await db.schema.createTable('product_usage_state', (t) => {
|
||||
t.integer('id').primary();
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.boolean('prompt_shown').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
t.text('private_key_encrypted');
|
||||
t.string('instance_binding', 64);
|
||||
t.bigInteger('sequence').notNullable().defaultTo(0);
|
||||
t.text('pending_packet'); t.text('last_packet'); t.text('last_receipt');
|
||||
t.text('privacy_receipts');
|
||||
t.string('last_report_date', 10); t.string('last_error', 80);
|
||||
t.text('feedback_preferences'); t.string('lease_token', 36);
|
||||
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
|
||||
t.integer('attempts').notNullable().defaultTo(0);
|
||||
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||
});
|
||||
await db('product_usage_state').insert({ id: 1 });
|
||||
await db.schema.createTable('product_usage_markers', (t) => t.string('feature', 60).primary());
|
||||
await db.schema.createTable('app_settings', (t) => {
|
||||
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
|
||||
});
|
||||
await db.schema.createTable('feature_flags', (t) => {
|
||||
t.string('key').primary(); t.boolean('value');
|
||||
});
|
||||
await db.schema.createTable('events', (t) => {
|
||||
t.increments('id'); t.text('color_theme'); t.string('external_path');
|
||||
t.integer('css_template_id');
|
||||
});
|
||||
await db.schema.createTable('css_templates', (t) => {
|
||||
t.increments('id'); t.boolean('is_enabled'); t.text('css_content');
|
||||
});
|
||||
for (const table of ['email_configs', 'mail_accounts']) {
|
||||
await db.schema.createTable(table, (t) => { t.increments('id'); t.string('smtp_host'); });
|
||||
}
|
||||
await db.schema.createTable('whatsapp_configs', (t) => {
|
||||
t.increments('id'); t.boolean('enabled'); t.string('phone_number_id'); t.string('access_token');
|
||||
});
|
||||
return db;
|
||||
}
|
||||
|
||||
const service = (db, over = {}) =>
|
||||
new UsageService(db, { secret: 'q'.repeat(48), ...over });
|
||||
|
||||
describe('gallery_layouts resolves what the gallery actually renders', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
it('maps preset NAMES to their layouts instead of calling them all grid', async () => {
|
||||
db = await bootDb();
|
||||
await db('events').insert([
|
||||
{ color_theme: 'modernMasonry' },
|
||||
{ color_theme: 'corporateTimeline' },
|
||||
{ color_theme: 'galleryStory' },
|
||||
]);
|
||||
const report = await service(db).snapshot();
|
||||
expect(report.gallery_layouts.sort()).toEqual(
|
||||
['gallery-story', 'masonry', 'timeline'].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('still reads a theme object', async () => {
|
||||
db = await bootDb();
|
||||
await db('events').insert([{ color_theme: JSON.stringify({ galleryLayout: 'mosaic' }) }]);
|
||||
expect((await service(db).snapshot()).gallery_layouts).toEqual(['mosaic']);
|
||||
});
|
||||
|
||||
it('reports an unknown preset as other, not as grid', async () => {
|
||||
// A preset added on the frontend must not silently inflate the grid count.
|
||||
db = await bootDb();
|
||||
await db('events').insert([{ color_theme: 'somePresetAddedLater' }]);
|
||||
expect((await service(db).snapshot()).gallery_layouts).toEqual(['other']);
|
||||
});
|
||||
|
||||
it('uses the global theme for an event that has none of its own', async () => {
|
||||
db = await bootDb();
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'theme_config',
|
||||
setting_value: JSON.stringify({ galleryLayout: 'carousel' }),
|
||||
});
|
||||
await db('events').insert([{ color_theme: null }]);
|
||||
expect((await service(db).snapshot()).gallery_layouts).toEqual(['carousel']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom_css counts CSS applied through a template', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
it('is configured when an enabled template is applied to an event', async () => {
|
||||
db = await bootDb();
|
||||
const [id] = await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' });
|
||||
await db('events').insert([{ color_theme: null, css_template_id: id }]);
|
||||
expect((await service(db).snapshot()).features.custom_css.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('is not configured when the applied template is disabled', async () => {
|
||||
db = await bootDb();
|
||||
const [id] = await db('css_templates').insert({ is_enabled: false, css_content: '.a{}' });
|
||||
await db('events').insert([{ color_theme: null, css_template_id: id }]);
|
||||
expect((await service(db).snapshot()).features.custom_css.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('is not configured when an enabled template is applied to nothing', async () => {
|
||||
db = await bootDb();
|
||||
await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' });
|
||||
await db('events').insert([{ color_theme: null }]);
|
||||
expect((await service(db).snapshot()).features.custom_css.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('status survives a misconfigured collector URL', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
it.each([
|
||||
['a bare hostname', 'usage.picpeak.app'],
|
||||
['a URL with a path', 'https://usage.picpeak.app/collect'],
|
||||
['a URL with a query', 'https://usage.picpeak.app/?x=1'],
|
||||
])('reports %s as a configuration error rather than failing the request', async (_l, endpoint) => {
|
||||
db = await bootDb();
|
||||
const status = await service(db, { endpoint }).status();
|
||||
expect(status.collector_error).toBe('INVALID_COLLECTOR_URL');
|
||||
expect(status.collector_url).toBeNull();
|
||||
// The operator can still read their state — and therefore still withdraw.
|
||||
expect(status.status).toBe('disabled');
|
||||
});
|
||||
|
||||
it('reports no error for a valid collector', async () => {
|
||||
db = await bootDb();
|
||||
const status = await service(db, { endpoint: 'https://usage.picpeak.app' }).status();
|
||||
expect(status.collector_error).toBeNull();
|
||||
expect(status.collector_url).toBe('https://usage.picpeak.app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 use is only implied by backups that write to the destination', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
const withS3Destination = async (database) => {
|
||||
await database('app_settings').insert({
|
||||
setting_key: 'backup_destination_type',
|
||||
setting_value: JSON.stringify('s3'),
|
||||
});
|
||||
await database('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
};
|
||||
|
||||
it('marks S3 for a backup that uses the configured destination', async () => {
|
||||
db = await bootDb();
|
||||
await withS3Destination(db);
|
||||
await service(db).markUsed(['backup'], { destinationBackup: true });
|
||||
expect((await db('product_usage_markers').pluck('feature')).sort())
|
||||
.toEqual(['backup', 's3_storage']);
|
||||
});
|
||||
|
||||
it('does NOT mark S3 for a local backup, even with S3 configured', async () => {
|
||||
// /database-backup/* and /backup/picpeak/export produce a local file. They
|
||||
// count as `backup`, but claiming S3 was used for them made merely
|
||||
// configuring S3 and downloading an export report s3_storage.used.
|
||||
db = await bootDb();
|
||||
await withS3Destination(db);
|
||||
await service(db).markUsed(['backup']);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual(['backup']);
|
||||
});
|
||||
|
||||
it('does not mark S3 when the destination is not S3', async () => {
|
||||
db = await bootDb();
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_destination_type',
|
||||
setting_value: JSON.stringify('local'),
|
||||
});
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
await service(db).markUsed(['backup'], { destinationBackup: true });
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual(['backup']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A signal whose answer is fixed by the shipped defaults is not a signal.
|
||||
* PicPeak ships default_protection_level='standard' and
|
||||
* enable_devtools_protection=true, so accepting either as evidence made
|
||||
* gallery_image_protection true on a bare install with no galleries — a
|
||||
* fleet-wide 100% that cannot separate a decision from an untouched default.
|
||||
*/
|
||||
describe('gallery_image_protection reports decisions, not shipped defaults', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
const v2 = async () => {
|
||||
db = await bootDb();
|
||||
await db.schema.alterTable('events', (t) => {
|
||||
for (const column of ['disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering']) t.boolean(column);
|
||||
t.string('protection_level');
|
||||
});
|
||||
await db('product_usage_state').where({ id: 1 })
|
||||
.update({ status: 'active', consent_version: 'usage-consent.v2' });
|
||||
return service(db);
|
||||
};
|
||||
const shipped = async () => {
|
||||
// Exactly what migration 038 seeds, plus an event carrying the column
|
||||
// defaults from the same migration.
|
||||
await db('app_settings').insert([
|
||||
{ setting_key: 'default_protection_level', setting_value: '"standard"' },
|
||||
{ setting_key: 'enable_devtools_protection', setting_value: 'true' },
|
||||
{ setting_key: 'enable_canvas_rendering', setting_value: 'false' },
|
||||
]);
|
||||
await db('events').insert({
|
||||
protection_level: 'standard',
|
||||
enable_devtools_protection: true,
|
||||
use_canvas_rendering: false,
|
||||
disable_right_click: false,
|
||||
});
|
||||
};
|
||||
|
||||
it('is false on a bare install with no galleries at all', async () => {
|
||||
const client = await v2();
|
||||
expect((await client.snapshot()).features.gallery_image_protection)
|
||||
.toEqual({ configured: false });
|
||||
});
|
||||
|
||||
it('is false when every value is still the shipped default', async () => {
|
||||
const client = await v2();
|
||||
await shipped();
|
||||
expect((await client.snapshot()).features.gallery_image_protection)
|
||||
.toEqual({ configured: false });
|
||||
});
|
||||
|
||||
it('ignores the devtools flag entirely, since it ships on', async () => {
|
||||
const client = await v2();
|
||||
await shipped();
|
||||
// Turning it OFF is the only informative state it has, and that is the
|
||||
// opposite of what this key claims — so neither state may set it.
|
||||
await db('app_settings').where({ setting_key: 'enable_devtools_protection' })
|
||||
.update({ setting_value: 'false' });
|
||||
await db('events').update({ enable_devtools_protection: false });
|
||||
expect((await client.snapshot()).features.gallery_image_protection)
|
||||
.toEqual({ configured: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a stronger global level', async (db) => db('app_settings').where({ setting_key: 'default_protection_level' }).update({ setting_value: '"maximum"' })],
|
||||
['global canvas rendering', async (db) => db('app_settings').where({ setting_key: 'enable_canvas_rendering' }).update({ setting_value: 'true' })],
|
||||
['a stronger level on one gallery', async (db) => db('events').update({ protection_level: 'enhanced' })],
|
||||
['canvas rendering on one gallery', async (db) => db('events').update({ use_canvas_rendering: true })],
|
||||
['right-click disabled on one gallery', async (db) => db('events').update({ disable_right_click: true })],
|
||||
])('is true for %s', async (_label, change) => {
|
||||
const client = await v2();
|
||||
await shipped();
|
||||
await change(db);
|
||||
expect((await client.snapshot()).features.gallery_image_protection)
|
||||
.toEqual({ configured: true });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The settings preview is the "see exactly what would be sent" view. It shared
|
||||
* snapshot() with the real sender, and snapshot() records applied custom CSS
|
||||
* as a lifetime marker — so reading the transparency view wrote a marker.
|
||||
*/
|
||||
describe('preview does not change what will be sent', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
const withAppliedCss = async () => {
|
||||
db = await bootDb();
|
||||
await db('product_usage_state').where({ id: 1 })
|
||||
.update({ status: 'active', consent_version: 'usage-consent.v2' });
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'general_custom_css', setting_value: '".x{}"'
|
||||
});
|
||||
return service(db);
|
||||
};
|
||||
|
||||
it('reports custom_css as used without persisting the marker', async () => {
|
||||
const client = await withAppliedCss();
|
||||
const preview = await client.preview();
|
||||
expect(preview.features.custom_css).toEqual({ configured: true, used: true });
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
|
||||
});
|
||||
|
||||
it('still persists it when the sender builds the real report', async () => {
|
||||
const client = await withAppliedCss();
|
||||
await client.snapshot();
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual(['custom_css']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v2 technical configuration and privacy boundaries', () => {
|
||||
let db;
|
||||
let savedEnv;
|
||||
beforeEach(() => { savedEnv = { ...process.env }; });
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; process.env = savedEnv; });
|
||||
async function expandedDb() {
|
||||
db = await bootDb();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
|
||||
await db.schema.alterTable('events', (t) => {
|
||||
for (const column of ['allow_user_uploads', 'allow_downloads', 'client_access_enabled', 'watermark_downloads', 'reveal_mode', 'download_resolution_picker_enabled', 'disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering']) t.boolean(column);
|
||||
t.string('protection_level'); t.timestamp('expires_at'); t.string('event_name'); t.string('customer_email');
|
||||
});
|
||||
for (const table of ['email_configs', 'mail_accounts']) await db.schema.alterTable(table, (t) => {
|
||||
t.boolean('enabled'); t.string('imap_host'); t.string('imap_user'); t.string('imap_pass');
|
||||
});
|
||||
await db.schema.createTable('event_feedback_settings', (t) => {
|
||||
t.increments('id'); t.boolean('feedback_enabled'); t.string('identity_mode');
|
||||
for (const col of ['allow_likes', 'allow_ratings', 'allow_comments', 'allow_favorites', 'allow_reactions', 'allow_color_labels']) t.boolean(col);
|
||||
});
|
||||
await db.schema.createTable('api_tokens', (t) => { t.increments('id'); t.timestamp('revoked_at'); t.timestamp('expires_at'); t.string('token_hash'); });
|
||||
await db.schema.createTable('webhooks', (t) => { t.increments('id'); t.boolean('active'); t.string('url'); t.string('secret'); });
|
||||
return service(db, { now: () => Date.parse('2026-09-06T12:00:00.000Z'), version: '3.124.1-beta.0' });
|
||||
}
|
||||
|
||||
it('produces all 73 closed booleans, never exposing sensitive values or configuration-only used', async () => {
|
||||
const client = await expandedDb();
|
||||
const flags = [...new Set(Object.values(CATALOG.features).map((f) => f.flag).filter(Boolean)), 'incomingMail', 'whatsapp'];
|
||||
await db('feature_flags').insert([...new Set(flags)].map((key) => ({ key, value: true })));
|
||||
const settings = {
|
||||
general_allowed_file_types: 'jpg,dng,mp4', general_public_site_enabled: true,
|
||||
database_backup_enabled: true, backup_destination_type: 's3', backup_s3_bucket: 'PRIVATE-bucket',
|
||||
oidc_enabled: true, oidc_issuer_url: 'https://PRIVATE.example.test', oidc_client_id: 'PRIVATE-client',
|
||||
general_custom_css: '.PRIVATE { color:red; }'
|
||||
};
|
||||
await db('app_settings').insert(Object.entries(settings).map(([setting_key, value]) => ({ setting_key, setting_value: JSON.stringify(value) })));
|
||||
await db('events').insert({
|
||||
event_name: 'PRIVATE PERSON', customer_email: 'PRIVATE@example.test', external_path: '/PRIVATE/path',
|
||||
color_theme: JSON.stringify({ galleryLayout: 'gallery-story', privateName: 'PRIVATE' }),
|
||||
allow_user_uploads: true, allow_downloads: true, client_access_enabled: true, watermark_downloads: true,
|
||||
reveal_mode: true, download_resolution_picker_enabled: true, disable_right_click: true,
|
||||
expires_at: '2028-01-01T00:00:00.000Z'
|
||||
});
|
||||
await db('event_feedback_settings').insert({ feedback_enabled: true, identity_mode: 'guest',
|
||||
allow_likes: true, allow_ratings: true, allow_comments: true, allow_favorites: true, allow_reactions: true, allow_color_labels: true });
|
||||
await db('email_configs').insert({ smtp_host: 'PRIVATE-host', imap_host: 'PRIVATE-host', imap_user: 'PRIVATE-user', imap_pass: 'PRIVATE-secret' });
|
||||
await db('whatsapp_configs').insert({ enabled: true, phone_number_id: 'PRIVATE-phone', access_token: 'PRIVATE-token' });
|
||||
await db('api_tokens').insert({ token_hash: 'PRIVATE-token', expires_at: '2028-01-01T00:00:00.000Z' });
|
||||
await db('webhooks').insert({ active: true, url: 'https://PRIVATE.example.test', secret: 'PRIVATE-secret' });
|
||||
Object.assign(process.env, { STORAGE_BACKEND: 's3', STORAGE_S3_BUCKET: 'PRIVATE', STORAGE_S3_ACCESS_KEY: 'PRIVATE', STORAGE_S3_SECRET_KEY: 'PRIVATE', EMAIL_WEBHOOK_URL: 'https://PRIVATE.example.test', EMAIL_WEBHOOK_SECRET: 'PRIVATE' });
|
||||
delete process.env.PICPEAK_SINGLE_CONTAINER;
|
||||
await client.markUsed([...FEATURE_KEYS, 'PRIVATE@example.test']);
|
||||
const report = await client.snapshot();
|
||||
expect(Object.keys(report.features)).toEqual(FEATURE_KEYS);
|
||||
for (const [key, definition] of Object.entries(CATALOG.features)) {
|
||||
expect(report.features[key].configured).toBe(true);
|
||||
if (definition.used) expect(report.features[key].used).toBe(true);
|
||||
else expect(report.features[key]).toEqual({ configured: true });
|
||||
}
|
||||
expect(await db('product_usage_markers').pluck('feature')).toHaveLength(56);
|
||||
expect(JSON.stringify(report)).not.toContain('PRIVATE');
|
||||
const identity = generateIdentity();
|
||||
const envelope = signPacket(makePacket(identity, 'report', 1, report, 'usage.v2'), identity, new Date(report.generated_at));
|
||||
expect(verifyEnvelope(envelope, Date.parse(report.generated_at))).toEqual(envelope.packet);
|
||||
});
|
||||
|
||||
it('applies parent/AIO gates and does not confuse disabled or expired config with availability', async () => {
|
||||
const client = await expandedDb();
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = 'yes';
|
||||
await db('feature_flags').insert(['bills', 'incomingInvoices', 'expenses', 'taxReport', 'faces', 'incomingMail'].map((key) => ({ key, value: true })));
|
||||
await db('api_tokens').insert([
|
||||
{ revoked_at: '2026-01-01', expires_at: null },
|
||||
{ revoked_at: null, expires_at: '2026-01-01' }
|
||||
]);
|
||||
await db('webhooks').insert({ active: false });
|
||||
await db('mail_accounts').insert({ enabled: false, imap_host: 'PRIVATE', imap_user: 'PRIVATE', imap_pass: 'PRIVATE' });
|
||||
await db('event_feedback_settings').insert({ feedback_enabled: false, identity_mode: 'guest', allow_likes: true });
|
||||
await db('events').insert({ allow_user_uploads: false, reveal_mode: true });
|
||||
const report = await client.snapshot();
|
||||
for (const key of ['crm_invoices', 'accounting_incoming_invoices', 'accounting_expenses', 'accounting_tax_report', 'face_recognition', 'api_integration', 'webhooks', 'incoming_mail', 'gallery_feedback_likes', 'gallery_guest_accounts', 'gallery_reveal']) expect(report.features[key].configured).toBe(false);
|
||||
expect(report.features.galleries).toEqual({ configured: true, used: false });
|
||||
expect(report.features.admin_management.configured).toBe(true);
|
||||
expect(report.features.analytics_dashboard.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('handles missing optional tables, global protection defaults and durable consent boundaries', async () => {
|
||||
db = await bootDb();
|
||||
const client = service(db);
|
||||
await db('app_settings').insert({ setting_key: 'default_protection_level', setting_value: '"enhanced"' });
|
||||
await client.markUsed(FEATURE_KEYS);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
|
||||
await db('product_usage_state').update({ status: 'active' });
|
||||
await client.markUsed(['video_uploads', 'api_integration']);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
|
||||
expect(Object.keys((await client.snapshot()).features)).toHaveLength(19);
|
||||
await db('product_usage_state').update({ consent_version: 'usage-consent.v2' });
|
||||
const report = await client.snapshot();
|
||||
expect(report.features.gallery_image_protection).toEqual({ configured: true });
|
||||
expect(report.features.api_integration).toEqual({ configured: false, used: false });
|
||||
expect(report.features.document_templates).toEqual({ configured: false, used: false });
|
||||
await client.markUsed(['video_uploads', 'gallery_downloads']);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual(['video_uploads']);
|
||||
await db('product_usage_state').update({ status: 'deletion_pending' });
|
||||
await client.markUsed(['api_integration']);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual(['video_uploads']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
const knex = require('knex');
|
||||
jest.mock('nodemailer', () => ({ createTransport: jest.fn() }));
|
||||
jest.mock('../../src/services/emailWebhookTransport', () => ({ isEnabled: jest.fn(), send: jest.fn() }));
|
||||
jest.mock('../../src/services/productUsageService', () => ({ markUsed: jest.fn().mockResolvedValue() }));
|
||||
jest.mock('../../src/services/businessProfileService', () => ({ getEmailSignature: jest.fn(async () => null) }));
|
||||
|
||||
describe('template delivery is a coarse transport-acceptance bit', () => {
|
||||
let db, sendTemplateEmail, queueEmail, processEmailQueue;
|
||||
const sendMail = jest.fn();
|
||||
const webhook = require('../../src/services/emailWebhookTransport');
|
||||
const marker = require('../../src/services/productUsageService').markUsed;
|
||||
beforeAll(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
await db.schema.createTable('email_configs', t => {
|
||||
t.increments('id'); for (const key of ['smtp_host', 'smtp_user', 'smtp_pass', 'from_name', 'from_email']) t.string(key);
|
||||
t.integer('smtp_port'); t.boolean('smtp_secure'); t.boolean('tls_reject_unauthorized');
|
||||
});
|
||||
await db('email_configs').insert({ smtp_host: 'smtp.example.test', smtp_port: 587, from_email: 'sender@example.test' });
|
||||
await db.schema.createTable('email_templates', t => { t.increments('id'); t.string('template_key'); t.text('subject'); t.text('body_html'); });
|
||||
await db('email_templates').insert({ template_key: 'PRIVATE-template', subject: 'PRIVATE subject', body_html: '<p>PRIVATE body</p>' });
|
||||
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
|
||||
await db.schema.createTable('email_queue', t => {
|
||||
t.increments('id'); t.integer('event_id'); t.integer('campaign_id'); t.string('recipient_email'); t.string('email_type');
|
||||
t.text('email_data'); t.string('status'); t.integer('retry_count'); t.text('error_message'); t.text('rendered_html');
|
||||
t.timestamp('scheduled_at'); t.timestamp('created_at'); t.timestamp('sent_at');
|
||||
});
|
||||
require('nodemailer').createTransport.mockReturnValue({ sendMail, verify: jest.fn(async () => true) });
|
||||
({ sendTemplateEmail, queueEmail, processEmailQueue } = require('../../src/services/emailProcessor'));
|
||||
});
|
||||
beforeEach(() => {
|
||||
marker.mockClear(); marker.mockResolvedValue(); webhook.isEnabled.mockReturnValue(false);
|
||||
sendMail.mockReset(); sendMail.mockResolvedValue({ messageId: 'PRIVATE-message', accepted: ['PRIVATE@example.test'] });
|
||||
});
|
||||
afterAll(() => db.destroy());
|
||||
const send = options => sendTemplateEmail('PRIVATE@example.test', 'PRIVATE-template', { __language: 'en' }, options);
|
||||
test('successful real SMTP send transmits only the fixed capability key', async () => {
|
||||
await send();
|
||||
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
|
||||
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
|
||||
});
|
||||
test('rejected recipients, failed sends, missing templates and explicit tests do not count', async () => {
|
||||
await send({ usageEligible: false });
|
||||
sendMail.mockResolvedValueOnce({ messageId: 'private', accepted: [] }); await send();
|
||||
sendMail.mockRejectedValueOnce(new Error('mail failed')); await expect(send()).rejects.toThrow('mail failed');
|
||||
await expect(sendTemplateEmail('private@example.test', 'missing', { __language: 'en' })).rejects.toThrow('not found');
|
||||
expect(marker).not.toHaveBeenCalled();
|
||||
});
|
||||
test('a queued test message keeps its exclusion through the queue processor; a queued real one counts', async () => {
|
||||
await queueEmail(null, 'PRIVATE@example.test', 'PRIVATE-template', { __language: 'en' }, { usageEligible: false });
|
||||
await processEmailQueue();
|
||||
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(marker).not.toHaveBeenCalled();
|
||||
await queueEmail(null, 'PRIVATE@example.test', 'PRIVATE-template', { __language: 'en' });
|
||||
await processEmailQueue();
|
||||
expect(sendMail).toHaveBeenCalledTimes(2);
|
||||
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
|
||||
expect(await db('email_queue').where('status', 'sent').count({ n: '*' }).first()).toMatchObject({ n: 2 });
|
||||
});
|
||||
test('a workflow test run (engine.testRun, __test) queues a test message; a real run counts', async () => {
|
||||
require('../../src/services/workflows/actions');
|
||||
const sendEmail = require('../../src/services/workflows/registry').getAction('send_email');
|
||||
const ctx = (vars) => ({ node: { config: { to: 'PRIVATE@example.test', emailType: 'PRIVATE-template', recipientClass: 'admin' } }, vars });
|
||||
await sendEmail(ctx({ __test: true, emailData: { __language: 'en' } }));
|
||||
await processEmailQueue();
|
||||
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(marker).not.toHaveBeenCalled();
|
||||
await sendEmail(ctx({ emailData: { __language: 'en' } }));
|
||||
await processEmailQueue();
|
||||
expect(sendMail).toHaveBeenCalledTimes(2);
|
||||
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
|
||||
});
|
||||
test('webhook success counts; failure does not; marker failure never retries successful mail', async () => {
|
||||
webhook.isEnabled.mockReturnValue(true); webhook.send.mockResolvedValue({ messageId: 'private' });
|
||||
marker.mockRejectedValueOnce(new Error('usage unavailable'));
|
||||
await expect(send()).resolves.toMatchObject({ success: true });
|
||||
expect(webhook.send).toHaveBeenCalledTimes(1);
|
||||
marker.mockClear(); webhook.send.mockRejectedValueOnce(new Error('webhook failed'));
|
||||
await expect(send()).rejects.toThrow('webhook failed');
|
||||
expect(marker).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const knex = require('knex');
|
||||
const { UsageService } = require('../../src/usage/UsageService');
|
||||
const p = require('../../src/usage/protocol.cjs');
|
||||
const { expandSnapshot } = require('../../src/usage/expandedSnapshot');
|
||||
const { capabilityEvidence } = require('../../src/usage/capabilityEvidence');
|
||||
|
||||
for (const engine of ['sqlite3', ...(process.env.PICPEAK_PG_TEST_URL ? ['pg'] : [])]) {
|
||||
describe(`usage.v3 on ${engine}`, () => {
|
||||
let db, admin, schema, client;
|
||||
const now = Date.parse('2026-09-06T12:00:00.000Z');
|
||||
const savedEnv = { ...process.env };
|
||||
beforeEach(async () => {
|
||||
if (engine === 'pg') {
|
||||
admin = knex({ client: 'pg', connection: process.env.PICPEAK_PG_TEST_URL });
|
||||
schema = `usage_v3_${crypto.randomUUID().replaceAll('-', '')}`;
|
||||
await admin.schema.createSchema(schema);
|
||||
db = knex({ client: 'pg', connection: process.env.PICPEAK_PG_TEST_URL, searchPath: [schema] });
|
||||
} else db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
const migrations = path.resolve(__dirname, '../../migrations/core');
|
||||
for (const file of fs.readdirSync(migrations).filter(name => /^20[1-6]_product_usage/.test(name)).sort())
|
||||
await require(path.join(migrations, file)).up(db);
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v3' });
|
||||
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
|
||||
await db.schema.createTable('feature_flags', t => { t.string('key').primary(); t.boolean('value'); });
|
||||
await db.schema.createTable('events', t => {
|
||||
t.increments('id'); t.text('color_theme'); t.string('external_path'); t.integer('css_template_id');
|
||||
t.string('default_photo_sort'); t.boolean('is_archived'); t.boolean('is_draft'); t.boolean('allow_downloads');
|
||||
});
|
||||
await db.schema.createTable('photos', t => { t.increments('id'); t.integer('event_id'); t.string('media_type'); t.string('filename'); });
|
||||
await db.schema.createTable('css_templates', t => { t.increments('id'); t.boolean('is_enabled'); t.text('css_content'); });
|
||||
await db.schema.createTable('photo_categories', t => { t.increments('id'); t.integer('event_id'); t.boolean('is_folder'); });
|
||||
await db.schema.createTable('workflows', t => { t.increments('id'); t.boolean('enabled'); });
|
||||
await db.schema.createTable('transfers', t => {
|
||||
t.increments('id'); t.string('upload_token'); t.boolean('allow_uploads'); t.timestamp('deleted_at'); t.timestamp('upload_expires_at'); t.timestamp('expires_at');
|
||||
});
|
||||
for (const table of ['email_configs', 'mail_accounts'])
|
||||
await db.schema.createTable(table, t => { t.increments('id'); t.string('smtp_host'); });
|
||||
await db.schema.createTable('whatsapp_configs', t => { t.increments('id'); t.boolean('enabled'); t.string('phone_number_id'); t.string('access_token'); });
|
||||
client = new UsageService(db, { now: () => now, secret: 'v3-test-only-secret'.repeat(3) });
|
||||
});
|
||||
afterEach(async () => {
|
||||
process.env = { ...savedEnv };
|
||||
await db?.destroy();
|
||||
if (admin) { await admin.schema.dropSchema(schema, true); await admin.destroy(); admin = null; }
|
||||
});
|
||||
|
||||
test('counts retained gallery/photo records, excluding videos, without loading entities', async () => {
|
||||
await db('events').insert([{ is_draft: true }, { is_archived: true }, { is_archived: false }]);
|
||||
await db('photos').insert([
|
||||
{ event_id: 1, media_type: 'image', filename: 'PRIVATE-original.dng' },
|
||||
{ event_id: 2, media_type: null, filename: 'PRIVATE-archive.jpg' },
|
||||
{ event_id: 3, media_type: 'video', filename: 'PRIVATE-video.mov' },
|
||||
]);
|
||||
const queries = [];
|
||||
db.on('query', q => queries.push(q.sql));
|
||||
const report = await client.snapshot();
|
||||
expect(report.inventory).toEqual({ galleries: 3, photos: 2 });
|
||||
expect(Object.keys(report.features)).toHaveLength(86);
|
||||
expect(queries.filter(sql => /from ["`]photos["`]/.test(sql))).toEqual([expect.stringMatching(/select count\(\*\)/)]);
|
||||
expect(JSON.stringify(report)).not.toContain('PRIVATE');
|
||||
const identity = p.generateIdentity();
|
||||
const envelope = p.signPacket(p.makePacket(identity, 'report', 1, report, 'usage.v3'), identity, new Date(now));
|
||||
expect(p.verifyEnvelope(envelope, now).payload).toEqual(report);
|
||||
await db('photos').where({ id: 1 }).delete();
|
||||
await db('events').where({ id: 1 }).delete();
|
||||
expect((await client.snapshot()).inventory).toEqual({ galleries: 2, photos: 1 });
|
||||
});
|
||||
|
||||
test.each(['usage.v1', 'usage.v2'])('%s consent never collects v3 markers or counts', async (version) => {
|
||||
await db('product_usage_state').where({ id: 1 }).update({ consent_version: p.CONSENT_VERSIONS[version] });
|
||||
const queries = [];
|
||||
db.on('query', q => queries.push(q.sql));
|
||||
await client.markUsed(['crm_invoice_import', 'photo_admin_marks', 'face_recognition']);
|
||||
const report = await client.preview();
|
||||
expect(report).not.toHaveProperty('inventory');
|
||||
expect(report.features).not.toHaveProperty('crm_invoice_import');
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual(['face_recognition']);
|
||||
expect(queries.filter(sql => /from ["`]photos["`]/.test(sql))).toEqual([]);
|
||||
expect(queries.some(sql => /count\(\*\)/.test(sql))).toBe(false);
|
||||
expect((await client.status()).consent_update_available).toBe(true);
|
||||
});
|
||||
|
||||
test('only allowed successful-capability bits survive and preview is read-only', async () => {
|
||||
const res = { locals: {} };
|
||||
capabilityEvidence(res, 'photo_xmp_export', 'photo_replacement', 'photo_admin_marks', 'crm_invoice_import',
|
||||
'crm_combined_billing', 'crm_monthly_billing_manual', 'crm_document_conversion', 'PRIVATE@example.test', 'gallery_folders');
|
||||
await client.markUsed(res.locals.productUsageFeatures);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toHaveLength(7);
|
||||
const before = await db('product_usage_markers').orderBy('feature');
|
||||
const report = await client.preview();
|
||||
expect(report.inventory).toEqual({ galleries: 0, photos: 0 });
|
||||
expect(report.features.crm_invoice_import.used).toBe(true);
|
||||
expect(report.features.gallery_folders).not.toHaveProperty('used');
|
||||
expect(await db('product_usage_markers').orderBy('feature')).toEqual(before);
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'deletion_pending' });
|
||||
await client.markUsed(['face_recognition']);
|
||||
expect(await db('product_usage_markers').where({ feature: 'face_recognition' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('configuration reflects effective modules, applicable folders and unexpired upload permission', async () => {
|
||||
await db('events').insert({ default_photo_sort: 'capture_date_asc' });
|
||||
await db('photo_categories').insert({ event_id: 1, is_folder: true });
|
||||
await db('workflows').insert({ enabled: true });
|
||||
await db('transfers').insert({ upload_token: 'PRIVATE', allow_uploads: true, upload_expires_at: '2026-09-07T00:00:00.000Z' });
|
||||
await db('app_settings').insert({ setting_key: 'general_use_original_filenames_for_downloads', setting_value: 'true' });
|
||||
process.env.STORAGE_BACKEND = 's3'; process.env.STORAGE_AUTO_IMPORT = 'true';
|
||||
process.env.STORAGE_S3_BUCKET = 'PRIVATE'; process.env.STORAGE_S3_ACCESS_KEY = 'PRIVATE'; process.env.STORAGE_S3_SECRET_KEY = 'PRIVATE';
|
||||
const snap = flags => expandSnapshot(db, { features: p.emptyFeatures('usage.v1'), flags, used: new Set(), now, version: 'usage.v3' });
|
||||
const enabled = await snap({ transfers: true, workflows: true, quotes: true, bills: true, incomingInvoices: true });
|
||||
for (const key of ['gallery_folders', 'transfer_upload_links', 'workflow_automation_enabled', 's3_auto_import', 'gallery_capture_date_sort', 'download_original_filenames', 'crm_invoice_import', 'crm_combined_billing'])
|
||||
expect(enabled[key].configured).toBe(true);
|
||||
expect(JSON.stringify(enabled)).not.toContain('PRIVATE');
|
||||
const disabled = await snap({ transfers: false, workflows: false, quotes: false, bills: true });
|
||||
for (const key of ['transfer_upload_links', 'workflow_automation_enabled', 'crm_invoice_import', 'crm_combined_billing'])
|
||||
expect(disabled[key].configured).toBe(false);
|
||||
await db('transfers').update({ upload_expires_at: '2026-09-06T12:00:00.000Z' });
|
||||
expect((await snap({ transfers: true })).transfer_upload_links.configured).toBe(false);
|
||||
await db('transfers').update({ upload_expires_at: null, expires_at: '2026-09-07T00:00:00.000Z' });
|
||||
expect((await snap({ transfers: true })).transfer_upload_links.configured).toBe(true);
|
||||
await db('transfers').update({ deleted_at: '2026-09-06T11:00:00.000Z' });
|
||||
expect((await snap({ transfers: true })).transfer_upload_links.configured).toBe(false);
|
||||
await db('photo_categories').update({ event_id: 999 });
|
||||
expect((await snap({})).gallery_folders.configured).toBe(false);
|
||||
});
|
||||
|
||||
test('ML recognition is already represented without querying faces or results', async () => {
|
||||
await db('feature_flags').insert({ key: 'faces', value: true });
|
||||
await client.markUsed(['face_recognition']);
|
||||
expect((await client.snapshot()).features.face_recognition).toEqual({ configured: true, used: true });
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
|
||||
expect((await client.snapshot()).features.face_recognition).toEqual({ configured: false, used: true });
|
||||
// No faces, people, embeddings or recognition-result tables exist in this fixture.
|
||||
});
|
||||
|
||||
test.each([
|
||||
[[], false, false], [[true], true, false], [[false], false, true],
|
||||
[[true, false], true, true], [[null], false, false],
|
||||
])('v4 measures explicit restrictions independently from legacy allowed downloads: %p', async (values, allowed, restricted) => {
|
||||
if (values.length) await db('events').insert(values.map(allow_downloads => ({ allow_downloads })));
|
||||
const queries = [];
|
||||
db.on('query', q => queries.push(q));
|
||||
for (const version of ['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4']) {
|
||||
await db('product_usage_state').where({ id: 1 }).update({ consent_version: p.CONSENT_VERSIONS[version] });
|
||||
queries.length = 0;
|
||||
const report = await client.preview();
|
||||
const downloadQueries = queries.filter(q => /where ["`]allow_downloads["`] =/.test(q.sql));
|
||||
if (version === 'usage.v4') {
|
||||
expect(report.features.gallery_downloads_restricted).toEqual({ configured: restricted });
|
||||
expect(report.features).not.toHaveProperty('gallery_downloads');
|
||||
expect(report.inventory).toEqual({ galleries: values.length, photos: 0 });
|
||||
expect(downloadQueries).toHaveLength(1);
|
||||
expect(downloadQueries[0].sql).toMatch(/select 1 as present/);
|
||||
expect(Number(downloadQueries[0].bindings[0])).toBe(0);
|
||||
} else {
|
||||
expect(report.features).not.toHaveProperty('gallery_downloads_restricted');
|
||||
if (version === 'usage.v1') expect(downloadQueries).toHaveLength(0);
|
||||
else {
|
||||
expect(report.features.gallery_downloads).toEqual({ configured: allowed });
|
||||
expect(downloadQueries).toHaveLength(1);
|
||||
expect(Number(downloadQueries[0].bindings[0])).toBe(1);
|
||||
}
|
||||
}
|
||||
const identity = p.generateIdentity();
|
||||
const envelope = p.signPacket(p.makePacket(identity, 'report', 1, report, version), identity, new Date(now));
|
||||
expect(p.verifyEnvelope(envelope, now).payload).toEqual(report);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* GHSA-h4w8-57xq-53fx entropy half: resetAdminPassword used to mint the
|
||||
* emailed temp password with generateReadablePassword() — 10 adjectives x
|
||||
* 10 nouns x crypto.randomInt(1000,9999) x 5 specials, ~2^21 possibilities,
|
||||
* brute-forceable. It now uses generateSecurePassword(16) (90-char charset),
|
||||
* same as every other security-sensitive password path in this file.
|
||||
*
|
||||
* Verified against a real SQLite DB (full core-migration set) so the
|
||||
* emailed plaintext, the stored hash, and must_change_password are all
|
||||
* checked end to end rather than against a mock.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// bootCrmDb() sets TEST_DATABASE_PATH itself, but only in time for requires
|
||||
// that happen AFTER it runs (inside beforeAll). userManagementService.js
|
||||
// requires database/db.js at module load — i.e. before beforeAll — so that
|
||||
// connection has to be pointed at a fresh, unused test DB up front, or it
|
||||
// falls back to the shared default path and collides with whatever another
|
||||
// test file already migrated onto it. Same workaround as
|
||||
// userManagementService.activateDelete.test.js.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-reset-pw-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'reset-pw-test-secret';
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
|
||||
const userManagementService = require('../../src/services/userManagementService');
|
||||
|
||||
// The wordlist generateReadablePassword() used to produce:
|
||||
// <Adjective><Noun><4 digits><1 special>, e.g. "SwiftEagle4821!"
|
||||
const READABLE_WORDLIST_PATTERN = /^(Swift|Bright|Strong|Happy|Clever|Brave|Noble|Quick|Sharp|Bold)(Eagle|Mountain|River|Thunder|Forest|Ocean|Falcon|Dragon|Phoenix|Tiger)\d{4}[!@#$%]$/;
|
||||
|
||||
describe('userManagementService.resetAdminPassword (GHSA-h4w8-57xq-53fx)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let actorId;
|
||||
let targetId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId: actorId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, actorId, 'super_admin');
|
||||
|
||||
const editor = await db('roles').where({ name: 'editor' }).first();
|
||||
const targetInsert = await db('admin_users').insert({
|
||||
username: 'reset-target', email: 'reset-target@example.com',
|
||||
password_hash: await bcrypt.hash('old-password', 4),
|
||||
role_id: editor?.id || null,
|
||||
is_active: 1, must_change_password: false, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
targetId = targetInsert[0]?.id ?? targetInsert[0];
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('generates a high-entropy password, not one drawn from the adjective/noun wordlist', async () => {
|
||||
const before = await db('admin_users').where({ id: targetId }).first();
|
||||
|
||||
await userManagementService.resetAdminPassword(targetId, actorId);
|
||||
|
||||
const emailRow = await db('email_queue')
|
||||
.where({ recipient_email: 'reset-target@example.com', email_type: 'admin_password_reset' })
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
expect(emailRow).toBeDefined();
|
||||
const emailData = JSON.parse(emailRow.email_data);
|
||||
const newPassword = emailData.new_password;
|
||||
|
||||
// generateSecurePassword(16): fixed 16-char length, not the wordlist's
|
||||
// variable-length "WordWord####!" shape.
|
||||
expect(newPassword).toHaveLength(16);
|
||||
expect(newPassword).not.toMatch(READABLE_WORDLIST_PATTERN);
|
||||
// generateSecurePassword guarantees at least one of each character class.
|
||||
expect(newPassword).toMatch(/[a-z]/);
|
||||
expect(newPassword).toMatch(/[A-Z]/);
|
||||
expect(newPassword).toMatch(/[0-9]/);
|
||||
expect(newPassword).toMatch(/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/);
|
||||
|
||||
// The emailed plaintext actually matches what got persisted.
|
||||
const after = await db('admin_users').where({ id: targetId }).first();
|
||||
expect(after.password_hash).not.toBe(before.password_hash);
|
||||
await expect(bcrypt.compare(newPassword, after.password_hash)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('sets must_change_password so the enforcement backstop kicks in on next login', async () => {
|
||||
await db('admin_users').where({ id: targetId }).update({ must_change_password: false });
|
||||
|
||||
await userManagementService.resetAdminPassword(targetId, actorId);
|
||||
|
||||
const after = await db('admin_users').where({ id: targetId }).first();
|
||||
expect(after.must_change_password === true || after.must_change_password === 1).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Privilege-escalation guard for PUT /api/admin/users/:id and
|
||||
* POST /api/admin/users/invite (GHSA-rv8w-m6mx-7j4q).
|
||||
*
|
||||
* updateAdminUser's role-change path previously enforced only:
|
||||
* (a) non-super_admin actors can't grant the super_admin role
|
||||
* (b) no self-role-update / demoting the last super_admin
|
||||
* It never checked whether the ACTOR's own permission set covers the
|
||||
* permissions carried by the role being granted — so an admin holding
|
||||
* only `users.edit` could hand any other admin a role (including the
|
||||
* built-in `admin` role) carrying far more permissions than the actor
|
||||
* itself held.
|
||||
*
|
||||
* createInvitation() had the identical gap: it only ever blocked
|
||||
* granting super_admin, so an admin holding only `users.create` could
|
||||
* invite a brand-new admin into any other role — including one carrying
|
||||
* far more permissions than the inviter itself held — via
|
||||
* POST /admin/users/invite.
|
||||
*
|
||||
* The fix reuses assertActorMayGrant() — the same containment already
|
||||
* applied to roles.manage (see adminRolesGuards.test.js) — inside both
|
||||
* updateAdminUser's role_id branch and createInvitation().
|
||||
*
|
||||
* Both describe blocks below share a single bootCrmDb() call: the
|
||||
* `db` module (`src/database/db.js`) is a singleton keyed off
|
||||
* TEST_DATABASE_PATH at first require, and bootCrmDb's own comment
|
||||
* warns that a second call after the first's cleanup() destroys the
|
||||
* pool, leaving "Unable to acquire a connection" for every later query.
|
||||
*/
|
||||
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-rolegrantguard-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'rolegrantguard-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-rolegrantguard-storage-'));
|
||||
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
|
||||
const svc = require('../../src/services/userManagementService');
|
||||
const { clearPermissionCache } = require('../../src/middleware/permissions');
|
||||
|
||||
let db; let cleanup;
|
||||
let superId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId: superId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, superId, 'super_admin');
|
||||
clearPermissionCache();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('updateAdminUser — role-grant privilege-escalation guard (GHSA-rv8w-m6mx-7j4q)', () => {
|
||||
let limitedRoleId; let limitedId; // holds only users.edit + events.view
|
||||
let powerfulRoleId; // carries settings.banking, which limitedId does NOT hold
|
||||
let modestRoleId; // carries only events.view, a subset of what limitedId holds
|
||||
let targetId; // account whose role limitedId will try to change
|
||||
|
||||
beforeAll(async () => {
|
||||
// The attacker in GHSA-rv8w-m6mx-7j4q: users.edit only, nothing else.
|
||||
const limitedRole = await svc.createRole(
|
||||
{ name: 'limited_user_editor', permissions: ['users.edit', 'events.view'] },
|
||||
superId,
|
||||
);
|
||||
limitedRoleId = limitedRole.id;
|
||||
const limitedIns = await db('admin_users').insert({
|
||||
username: 'limited', email: 'limited@example.com', password_hash: 'x',
|
||||
role_id: limitedRoleId, must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedId = limitedIns[0]?.id ?? limitedIns[0];
|
||||
|
||||
// A role carrying a permission the limited actor does not hold.
|
||||
const powerfulRole = await svc.createRole(
|
||||
{ name: 'powerful_role', permissions: ['users.edit', 'settings.banking'] },
|
||||
superId,
|
||||
);
|
||||
powerfulRoleId = powerfulRole.id;
|
||||
|
||||
// A role whose permissions ARE a subset of what the limited actor holds.
|
||||
const modestRole = await svc.createRole(
|
||||
{ name: 'modest_role', permissions: ['events.view'] },
|
||||
superId,
|
||||
);
|
||||
modestRoleId = modestRole.id;
|
||||
|
||||
clearPermissionCache();
|
||||
}, 120000);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh target for every test, role reset to modestRole so role-change
|
||||
// assertions always start from a known baseline.
|
||||
const existing = await db('admin_users').where({ username: 'target' }).first();
|
||||
if (existing) {
|
||||
targetId = existing.id;
|
||||
await db('admin_users').where({ id: targetId }).update({ role_id: modestRoleId });
|
||||
} else {
|
||||
const ins = await db('admin_users').insert({
|
||||
username: 'target', email: 'target@example.com', password_hash: 'x',
|
||||
role_id: modestRoleId, must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
targetId = ins[0]?.id ?? ins[0];
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to let an admin grant a role carrying permissions the admin lacks', async () => {
|
||||
await expect(
|
||||
svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: powerfulRoleId },
|
||||
limitedId,
|
||||
{ roleName: 'limited_user_editor' },
|
||||
),
|
||||
).rejects.toThrow(/only grant permissions your own role/i);
|
||||
|
||||
// Target's role must be unchanged.
|
||||
const row = await db('admin_users').where({ id: targetId }).first();
|
||||
expect(row.role_id).toBe(modestRoleId);
|
||||
});
|
||||
|
||||
it('refuses to let an admin grant the built-in admin role beyond its own permissions', async () => {
|
||||
const adminRole = await db('roles').where({ name: 'admin' }).first();
|
||||
await expect(
|
||||
svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: adminRole.id },
|
||||
limitedId,
|
||||
{ roleName: 'limited_user_editor' },
|
||||
),
|
||||
).rejects.toThrow(/only grant permissions your own role/i);
|
||||
});
|
||||
|
||||
it('allows an admin to grant a role whose permissions it already holds', async () => {
|
||||
const updated = await svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: limitedRoleId },
|
||||
limitedId,
|
||||
{ roleName: 'limited_user_editor' },
|
||||
);
|
||||
expect(updated.role_id).toBe(limitedRoleId);
|
||||
});
|
||||
|
||||
it('super_admin can still grant any role, including one carrying more permissions than a limited actor holds', async () => {
|
||||
const updated = await svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: powerfulRoleId },
|
||||
superId,
|
||||
{ roleName: 'super_admin' },
|
||||
);
|
||||
expect(updated.role_id).toBe(powerfulRoleId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInvitation — role-grant privilege-escalation guard (GHSA-rv8w-m6mx-7j4q)', () => {
|
||||
let limitedRoleId; let limitedId; // holds only users.create + events.view
|
||||
let powerfulRoleId; // carries settings.banking, which limitedId does NOT hold
|
||||
let modestRoleId; // carries only events.view, a subset of what limitedId holds
|
||||
let inviteCounter = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
const limitedRole = await svc.createRole(
|
||||
{ name: 'limited_inviter', permissions: ['users.create', 'events.view'] },
|
||||
superId,
|
||||
);
|
||||
limitedRoleId = limitedRole.id;
|
||||
const limitedIns = await db('admin_users').insert({
|
||||
username: 'limited_inviter', email: 'limited_inviter@example.com', password_hash: 'x',
|
||||
role_id: limitedRoleId, must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedId = limitedIns[0]?.id ?? limitedIns[0];
|
||||
|
||||
const powerfulRole = await svc.createRole(
|
||||
{ name: 'powerful_invite_role', permissions: ['users.create', 'settings.banking'] },
|
||||
superId,
|
||||
);
|
||||
powerfulRoleId = powerfulRole.id;
|
||||
|
||||
const modestRole = await svc.createRole(
|
||||
{ name: 'modest_invite_role', permissions: ['events.view'] },
|
||||
superId,
|
||||
);
|
||||
modestRoleId = modestRole.id;
|
||||
|
||||
clearPermissionCache();
|
||||
}, 120000);
|
||||
|
||||
function nextEmail() {
|
||||
inviteCounter += 1;
|
||||
return `invitee-${inviteCounter}@example.com`;
|
||||
}
|
||||
|
||||
it('refuses to let an admin invite someone into a role carrying permissions the admin lacks', async () => {
|
||||
await expect(
|
||||
svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: powerfulRoleId,
|
||||
invitedById: limitedId,
|
||||
inviterRoleName: 'limited_inviter',
|
||||
}),
|
||||
).rejects.toThrow(/only grant permissions your own role/i);
|
||||
});
|
||||
|
||||
it('allows an admin to invite someone into a role whose permissions it already holds', async () => {
|
||||
const invitation = await svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: limitedRoleId,
|
||||
invitedById: limitedId,
|
||||
inviterRoleName: 'limited_inviter',
|
||||
});
|
||||
expect(invitation.role).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows an admin to invite someone into a role that is a subset of its own permissions', async () => {
|
||||
const invitation = await svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: modestRoleId,
|
||||
invitedById: limitedId,
|
||||
inviterRoleName: 'limited_inviter',
|
||||
});
|
||||
expect(invitation.role).toBeTruthy();
|
||||
});
|
||||
|
||||
it('super_admin can still invite into any role, including one carrying more permissions than a limited actor holds', async () => {
|
||||
const invitation = await svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: powerfulRoleId,
|
||||
invitedById: superId,
|
||||
inviterRoleName: 'super_admin',
|
||||
});
|
||||
expect(invitation.role).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* generateVideoPlaceholder() must not touch the database when the caller
|
||||
* already supplies width/height (videoProcessor.js's thumbnail-generation
|
||||
* fallback does exactly this).
|
||||
*
|
||||
* Why it matters: processUploadedPhotos() (chunked video upload) holds a
|
||||
* per-file SQLite transaction open across thumbnail generation. SQLite's
|
||||
* knex pool defaults to a single connection, so any second, un-transacted
|
||||
* db() query made while that transaction is open blocks until
|
||||
* acquireConnectionTimeout (60s in production) — verified directly against
|
||||
* an isolated SQLite db (codex review of #1371/#1372). Passing explicit
|
||||
* dimensions must skip getThumbnailSettings()'s db() call entirely, not
|
||||
* just tolerate its failure.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const os = require('os');
|
||||
|
||||
const mockDbSpy = jest.fn(() => {
|
||||
throw new Error('db() must not be called when width/height are supplied');
|
||||
});
|
||||
jest.mock('../../src/database/db', () => ({ db: (...args) => mockDbSpy(...args) }));
|
||||
|
||||
const storageModule = require('../../src/services/storage');
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
|
||||
describe('generateVideoPlaceholder skips the settings DB lookup given explicit dimensions', () => {
|
||||
let storage;
|
||||
let root;
|
||||
let imageProcessor;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-vidplaceholder-'));
|
||||
storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
storageModule.setStorageForTesting(storage);
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => mockDbSpy.mockClear());
|
||||
|
||||
it('never calls db() when width/height are provided', async () => {
|
||||
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4', { width: 300, height: 300 });
|
||||
|
||||
expect(key).toBe('thumbnails/thumb_demo.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
expect(mockDbSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through to defaults (not a throw) when db() fails and no dimensions were given', async () => {
|
||||
const key = await imageProcessor.generateVideoPlaceholder('demo2.mp4');
|
||||
|
||||
expect(key).toBe('thumbnails/thumb_demo2.jpg');
|
||||
expect(mockDbSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
jest.mock('../../src/utils/logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }));
|
||||
|
||||
describe.each(['backgroundProcessor', 'faceQueue'])('%s shutdown', name => {
|
||||
let worker, db, processPhoto, featureEnabled, janitorUpdate, releases;
|
||||
const prefix = name === 'faceQueue' ? 'FACE_PROCESSOR' : 'UPLOAD_PROCESSOR';
|
||||
let previousEnv;
|
||||
class SidecarUnavailableError extends Error {}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
releases.push(resolve);
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.useFakeTimers();
|
||||
releases = [];
|
||||
previousEnv = { ...process.env };
|
||||
delete process.env[`${prefix}_DISABLED`];
|
||||
process.env[`${prefix}_CONCURRENCY`] = '2';
|
||||
process.env[`${prefix}_POLL_MS`] = '2000';
|
||||
process.env.FACE_PROCESSOR_BACKOFF_MS = '30000';
|
||||
processPhoto = jest.fn().mockResolvedValue({ status: 'skipped' });
|
||||
featureEnabled = jest.fn().mockResolvedValue(true);
|
||||
janitorUpdate = jest.fn().mockResolvedValue(0);
|
||||
const chain = { where: jest.fn().mockReturnThis(), update: janitorUpdate };
|
||||
db = jest.fn(() => chain);
|
||||
db.client = { config: { client: 'pg' } };
|
||||
// Claims and processing are controlled at the I/O boundary; the real
|
||||
// workers, janitors, idle sleeps and stopServices run in every test.
|
||||
db.transaction = jest.fn().mockResolvedValue(null);
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
jest.doMock('../../src/services/photoProcessor', () => ({ processPhoto }));
|
||||
jest.doMock('../../src/services/faceProcessor', () => ({
|
||||
processPhotoFaces: processPhoto, TransientSourceError: class extends Error {},
|
||||
}));
|
||||
jest.doMock('../../src/services/faceClient', () => ({ SidecarUnavailableError }));
|
||||
jest.doMock('../../src/services/faceSettings', () => ({
|
||||
isFeatureEnabled: featureEnabled, isEnabledForEvent: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
worker = require(`../../src/services/${name}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
releases.forEach(resolve => resolve());
|
||||
const stopped = worker.stop();
|
||||
// Also cleans up the original, non-interruptible implementation when a
|
||||
// regression assertion fails; the test never needs to wait a real minute.
|
||||
await jest.advanceTimersByTimeAsync(60000);
|
||||
await stopped;
|
||||
jest.useRealTimers();
|
||||
process.env = previousEnv;
|
||||
});
|
||||
|
||||
async function expectPromptStop(stop = () => worker.stop()) {
|
||||
let done = false;
|
||||
const stopped = stop().then(() => { done = true; });
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(done).toBe(true);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
await stopped;
|
||||
}
|
||||
|
||||
it('wakes all idle workers and the minute-long janitor through stopServices', async () => {
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(jest.getTimerCount()).toBe(3);
|
||||
// Confirm normal polling still runs before shutdown.
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
expect(db.transaction).toHaveBeenCalledTimes(4);
|
||||
await expectPromptStop(() => require('../../src/services/serviceShutdown').stopServices());
|
||||
});
|
||||
|
||||
it('wakes claim-error backoff and can start a fresh run after stopping', async () => {
|
||||
db.transaction.mockRejectedValue(new Error('database unavailable'));
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
await expectPromptStop();
|
||||
db.transaction.mockResolvedValue(null);
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(jest.getTimerCount()).toBe(3);
|
||||
await expectPromptStop();
|
||||
});
|
||||
|
||||
it('drains active processing for every stop caller and prevents overlapping restarts', async () => {
|
||||
const processing = deferred();
|
||||
processPhoto.mockReturnValue(processing.promise);
|
||||
db.transaction.mockResolvedValueOnce({ id: 1 });
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(processPhoto).toHaveBeenCalledWith(1);
|
||||
let done = false;
|
||||
const first = worker.stop();
|
||||
expect(worker.stop()).toBe(first);
|
||||
first.then(() => { done = true; });
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(done).toBe(false);
|
||||
expect(db.transaction).toHaveBeenCalledTimes(2);
|
||||
processing.resolve({ status: 'skipped' });
|
||||
await expectPromptStop();
|
||||
expect(done).toBe(true);
|
||||
expect(db.transaction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('drains a claim already in flight without starting another poll', async () => {
|
||||
const claim = deferred();
|
||||
db.transaction.mockReturnValueOnce(claim.promise);
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const stopped = worker.stop();
|
||||
claim.resolve({ id: 2 });
|
||||
await expectPromptStop(() => stopped);
|
||||
expect(processPhoto).toHaveBeenCalledWith(2);
|
||||
expect(db.transaction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not schedule new waits when pending database work finishes after stop', async () => {
|
||||
const claim = deferred();
|
||||
const janitor = deferred();
|
||||
db.transaction.mockReturnValue(claim.promise);
|
||||
janitorUpdate.mockReturnValue(janitor.promise);
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
let done = false;
|
||||
const stopped = worker.stop().then(() => { done = true; });
|
||||
claim.resolve(null);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(done).toBe(false);
|
||||
janitor.resolve(0);
|
||||
await expectPromptStop(() => stopped);
|
||||
});
|
||||
|
||||
if (name === 'faceQueue') {
|
||||
it('interrupts the ten-second sleep with faces disabled by default', async () => {
|
||||
featureEnabled.mockResolvedValue(false);
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(3);
|
||||
await expectPromptStop();
|
||||
});
|
||||
|
||||
it('releases a claimed photo and interrupts the sidecar outage backoff', async () => {
|
||||
db.transaction.mockResolvedValueOnce({ id: 3, event_id: 5 });
|
||||
processPhoto.mockRejectedValue(new SidecarUnavailableError('offline'));
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(janitorUpdate).toHaveBeenCalledWith({ face_status: 'pending', face_started_at: null });
|
||||
await expectPromptStop();
|
||||
expect(worker.inFlightByEvent.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not claim new work after a pending feature check resolves during shutdown', async () => {
|
||||
const feature = deferred();
|
||||
featureEnabled.mockReturnValue(feature.promise);
|
||||
worker.start();
|
||||
const stopped = worker.stop();
|
||||
feature.resolve(true);
|
||||
await expectPromptStop(() => stopped);
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Bounded, reclaimable storage reads for archiver downloads (#1399 follow-up).
|
||||
*
|
||||
* archiver drains the sources it is handed one at a time, so appending a
|
||||
* storage read per photo opens N and drains one. Every other read parks its
|
||||
* socket holding unread bytes, and nothing reclaims them: archiver's abort()
|
||||
* does not touch source streams, and the S3 SDK clears its socket timeout as
|
||||
* soon as response headers land. That is the mechanism behind the incident in
|
||||
* PR #1402 — 43 of 50 pooled sockets held, uploads starved, restart required.
|
||||
*
|
||||
* #1402 fixes the cached-zip builder. These are the guarantees the same guard
|
||||
* has to give the three remaining call sites, two of which need no admin
|
||||
* credentials to reach.
|
||||
*/
|
||||
const { Readable } = require('stream');
|
||||
const { createArchiveStreamGuard } = require('../../src/utils/archiveStreamGuard');
|
||||
|
||||
const makeStream = () => new Readable({ read() {} });
|
||||
|
||||
describe('archiveStreamGuard (#1399 follow-up)', () => {
|
||||
it('lets the configured number of reads run at once', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 2 });
|
||||
expect(await guard.acquire()).toBe(true);
|
||||
guard.track(makeStream());
|
||||
expect(await guard.acquire()).toBe(true);
|
||||
guard.track(makeStream());
|
||||
expect(guard.openCount).toBe(2);
|
||||
});
|
||||
|
||||
it('parks the next acquire until a read finishes', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||
await guard.acquire();
|
||||
const first = guard.track(makeStream());
|
||||
|
||||
let resumed = false;
|
||||
const pending = guard.acquire().then((ok) => { resumed = ok; });
|
||||
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(resumed).toBe(false); // still parked — this is the cap doing its job
|
||||
|
||||
first.push(null);
|
||||
first.resume();
|
||||
await pending;
|
||||
expect(resumed).toBe(true);
|
||||
});
|
||||
|
||||
it('releases a slot when a read errors, not just when it ends', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||
await guard.acquire();
|
||||
const stream = guard.track(makeStream());
|
||||
stream.on('error', () => {});
|
||||
stream.destroy(new Error('socket died'));
|
||||
// Without the error listener the slot would never come back and the next
|
||||
// photo would park forever.
|
||||
expect(await guard.acquire()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a failed read so the caller can abort the archive', async () => {
|
||||
// A stream that errors while still QUEUED has no archiver listener on it
|
||||
// yet. Releasing its slot and saying nothing leaves a dead stream in the
|
||||
// queue, and the archive hangs when it reaches it.
|
||||
const seen = [];
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 2, onFatalError: (e) => seen.push(e) });
|
||||
await guard.acquire();
|
||||
const queued = guard.track(makeStream());
|
||||
queued.on('error', () => {});
|
||||
queued.destroy(new Error('socket died'));
|
||||
await new Promise((r) => setImmediate(r)); // 'error' lands on the next tick
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].message).toBe('socket died');
|
||||
});
|
||||
|
||||
it('stays quiet about reads it destroyed itself', async () => {
|
||||
// destroyAll is the caller's own teardown; reporting those back as fatal
|
||||
// would re-enter the abort path it is already running.
|
||||
const seen = [];
|
||||
const guard = createArchiveStreamGuard({ onFatalError: (e) => seen.push(e) });
|
||||
await guard.acquire();
|
||||
const s1 = guard.track(makeStream());
|
||||
s1.on('error', () => {});
|
||||
guard.destroyAll();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('destroys every read still holding bytes', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 5 });
|
||||
const streams = [makeStream(), makeStream(), makeStream()];
|
||||
for (const s of streams) { await guard.acquire(); guard.track(s); }
|
||||
expect(guard.openCount).toBe(3);
|
||||
|
||||
guard.destroyAll();
|
||||
expect(streams.every((s) => s.destroyed)).toBe(true);
|
||||
expect(guard.openCount).toBe(0);
|
||||
});
|
||||
|
||||
it('wakes a parked acquire on destroyAll so the loop can exit', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||
await guard.acquire();
|
||||
guard.track(makeStream());
|
||||
|
||||
const pending = guard.acquire();
|
||||
guard.destroyAll();
|
||||
// false, so the caller breaks out instead of appending to a dead archive.
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it('destroys a stream tracked after shutdown rather than leaking it', () => {
|
||||
const guard = createArchiveStreamGuard();
|
||||
guard.destroyAll();
|
||||
const late = guard.track(makeStream());
|
||||
expect(late.destroyed).toBe(true);
|
||||
expect(guard.openCount).toBe(0);
|
||||
});
|
||||
|
||||
it('tolerates destroyAll twice — exit paths overlap', () => {
|
||||
const guard = createArchiveStreamGuard();
|
||||
guard.track(makeStream());
|
||||
guard.destroyAll();
|
||||
expect(() => guard.destroyAll()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Passwords must not survive in the email archive (see the module header of
|
||||
* utils/emailSecretRedaction.js).
|
||||
*/
|
||||
const { secretValues, redactEmailData, redactRenderedHtml, replaceMaskedSecrets, parseEmailData, MASK } = require('../../src/utils/emailSecretRedaction');
|
||||
|
||||
describe('email secret redaction', () => {
|
||||
const data = {
|
||||
customer_name: 'Ada', gallery_link: 'https://p.example/gallery/x/tok',
|
||||
gallery_password: 'Sunset-42!', client_password: '1234', welcome_message: 'hi',
|
||||
attachments: [{ filename: 'a.pdf', password: 'zip-secret' }],
|
||||
};
|
||||
|
||||
it('finds the secrets by key name, nested included, and skips sentinels', () => {
|
||||
expect(secretValues(data).sort()).toEqual(['1234', 'Sunset-42!', 'zip-secret']);
|
||||
expect(secretValues({ gallery_password: '{{password_security_message}}' })).toEqual([]);
|
||||
expect(secretValues({ gallery_password: 'No password required' })).toEqual([]);
|
||||
expect(secretValues({ gallery_password: '(set at creation)' })).toEqual([]);
|
||||
expect(secretValues({ gallery_password: '' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('masks the secrets in the variables and leaves everything else alone', () => {
|
||||
const redacted = redactEmailData(data);
|
||||
expect(redacted.gallery_password).toBe(MASK);
|
||||
expect(redacted.client_password).toBe(MASK);
|
||||
expect(redacted.attachments[0].password).toBe(MASK);
|
||||
expect(redacted.attachments[0].filename).toBe('a.pdf');
|
||||
expect(redacted.customer_name).toBe('Ada');
|
||||
expect(redacted.gallery_link).toBe(data.gallery_link);
|
||||
// sentinels stay readable
|
||||
expect(redactEmailData({ gallery_password: 'No password required' }).gallery_password).toBe('No password required');
|
||||
// the input is not mutated
|
||||
expect(data.gallery_password).toBe('Sunset-42!');
|
||||
});
|
||||
|
||||
it('strips the secrets from the rendered HTML, plain and HTML-escaped', () => {
|
||||
const html = '<li>Password: Sunset-42!</li><li>PIN: 1234</li><p>Tom & Ada's day</p>';
|
||||
const out = redactRenderedHtml(html, secretValues({ ...data, client_password: 'Tom & Ada\'s day' }));
|
||||
expect(out).toContain(`Password: ${MASK}`);
|
||||
expect(out).not.toContain('Sunset-42!');
|
||||
expect(out).toContain(`<p>${MASK}</p>`);
|
||||
expect(redactRenderedHtml(html, [])).toBe(html);
|
||||
expect(redactRenderedHtml(null, ['x'])).toBeNull();
|
||||
});
|
||||
|
||||
it('parses stored email_data leniently', () => {
|
||||
expect(parseEmailData('{"a":1}')).toEqual({ a: 1 });
|
||||
expect(parseEmailData({ a: 1 })).toEqual({ a: 1 });
|
||||
expect(parseEmailData('not json')).toEqual({});
|
||||
expect(parseEmailData(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores the pipeline sentinels but not a brace-wrapped real password', () => {
|
||||
expect(secretValues({ gallery_password: '(set at creation)', client_password: 'No password required' })).toEqual([]);
|
||||
expect(secretValues({ gallery_password: '{{Sunset-42!}}' })).toEqual(['{{Sunset-42!}}']);
|
||||
});
|
||||
|
||||
it('replaceMaskedSecrets swaps archive masks for the security sentinel, leaves the rest', () => {
|
||||
const out = replaceMaskedSecrets({ customer_name: 'Ada', gallery_password: MASK, client_password: MASK, nested: { pin: MASK, note: MASK } });
|
||||
expect(out).toEqual({
|
||||
customer_name: 'Ada',
|
||||
gallery_password: '{{password_security_message}}',
|
||||
client_password: '{{password_security_message}}',
|
||||
nested: { pin: '{{password_security_message}}', note: MASK },
|
||||
});
|
||||
});
|
||||
|
||||
it('masks a raw secret that the template turned into markup', () => {
|
||||
const html = '<p>PIN: Se<cr3t>Pin42! and Se<cr3t>Pin42!</p>';
|
||||
expect(redactRenderedHtml(html, ['Se<cr3t>Pin42!'])).toBe(`<p>PIN: ${MASK} and ${MASK}</p>`);
|
||||
});
|
||||
|
||||
it('masks a secret in a quoted attribute value that contains ">"', () => {
|
||||
const html = '<a title="Sunset42 > details" href="/x">Sunset42</a>';
|
||||
expect(redactRenderedHtml(html, ['Sunset42'])).toBe(`<a title="${MASK} > details" href="/x">${MASK}</a>`);
|
||||
// unbalanced quote: the broken tag is treated as text, still scrubbed
|
||||
expect(redactRenderedHtml('<a title="Sunset42>Sunset42</a>', ['Sunset42'])).toBe(`<a title="${MASK}>${MASK}</a>`);
|
||||
});
|
||||
|
||||
it('masks a secret inside an HTML comment, ">" included', () => {
|
||||
const html = '<!-- PIN: 7788 --><p>x</p><!-- a > 7788 -->';
|
||||
expect(redactRenderedHtml(html, ['7788'])).toBe(`<!-- PIN: ${MASK} --><p>x</p><!-- a > ${MASK} -->`);
|
||||
});
|
||||
|
||||
it('masks overlapping secrets completely and leaves markup alone', () => {
|
||||
const html = '<p>Password: Sunset-42! PIN: Sunset-42!7788</p><a href="https://x.example/?p=Sunset-42!" style="color:red" title=7788>href</a>';
|
||||
const out = redactRenderedHtml(html, ['Sunset-42!', 'Sunset-42!7788', 'href', 'style', '7788']);
|
||||
expect(out).toBe(`<p>Password: ${MASK} PIN: ${MASK}</p><a href="https://x.example/?p=${MASK}" style="color:red" title=${MASK}>${MASK}</a>`);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ describe('resolvePhotoContentType', () => {
|
||||
});
|
||||
|
||||
describe('serving routes use the resolver', () => {
|
||||
const routes = ['gallery.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
|
||||
const routes = ['gallery/media.js', 'gallery/downloads.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
|
||||
it.each(routes)('%s sets no Content-Type from photo.mime_type directly', (name) => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '../../src/routes', name), 'utf8');
|
||||
expect(src).not.toMatch(/'Content-Type':\s*photo\.mime_type/);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
const dns = require('dns');
|
||||
const http = require('http');
|
||||
const axios = require('axios');
|
||||
const { validateExternalUrlAsync } = require('../../src/utils/networkValidation');
|
||||
const { pinnedRequestOptions } = require('../../src/utils/pinnedRequest');
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
it('never performs a second DNS lookup that could reach a private listener', async () => {
|
||||
const received = jest.fn();
|
||||
const server = http.createServer((req, res) => { received(); res.end('private'); });
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const url = `http://rebind.example:${server.address().port}/hook`;
|
||||
const preflight = jest.spyOn(dns.promises, 'lookup').mockResolvedValue([{ address: '192.0.2.1', family: 4 }]);
|
||||
const unsafeLookup = jest.spyOn(dns, 'lookup').mockImplementation((_host, opts, cb) => {
|
||||
if (typeof opts === 'function') { cb = opts; opts = {}; }
|
||||
cb(null, ...(opts.all ? [[{ address: '127.0.0.1', family: 4 }]] : ['127.0.0.1', 4]));
|
||||
});
|
||||
let options;
|
||||
try {
|
||||
const check = await validateExternalUrlAsync(url);
|
||||
options = pinnedRequestOptions(check);
|
||||
await expect(axios.post(url, 'private-data', { ...options, timeout: 200 })).rejects.toThrow();
|
||||
expect(preflight).toHaveBeenCalledTimes(1);
|
||||
expect(unsafeLookup).not.toHaveBeenCalled(); expect(received).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
options?.httpAgent.destroy(); options?.httpsAgent.destroy();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
it('preserves the original Host and refuses redirects while using the pinned address', async () => {
|
||||
const hosts = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
hosts.push(req.headers.host); res.writeHead(302, { Location: 'http://localhost/private' }); res.end();
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const host = `pinned.example:${server.address().port}`;
|
||||
// Test the transport in isolation: production checks reject this private IP.
|
||||
const options = pinnedRequestOptions({ valid: true, hostname: 'pinned.example', addresses: [{ address: '127.0.0.1', family: 4 }] });
|
||||
try {
|
||||
const res = await axios.post(`http://${host}/hook`, 'body', { ...options, validateStatus: () => true });
|
||||
expect(res.status).toBe(302); expect(hosts).toEqual([host]); expect(options.proxy).toBe(false);
|
||||
} finally { options.httpAgent.destroy(); options.httpsAgent.destroy(); await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
it('fails closed for missing DNS results', () => {
|
||||
expect(() => pinnedRequestOptions({ valid: true })).toThrow('validated destination');
|
||||
});
|
||||
|
||||
it('preserves TLS SNI and certificate hostname verification for a pinned connection', async () => {
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const dir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'picpeak-tls-pin-'));
|
||||
const key = path.join(dir, 'key.pem'), cert = path.join(dir, 'cert.pem');
|
||||
require('child_process').execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes',
|
||||
'-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=pinned.example',
|
||||
'-addext', 'subjectAltName=DNS:pinned.example'], { stdio: 'ignore' });
|
||||
const certificate = await fs.readFile(cert);
|
||||
const seen = [];
|
||||
const server = require('https').createServer({ key: await fs.readFile(key), cert: certificate }, (req, res) => {
|
||||
seen.push({ host: req.headers.host, servername: req.socket.servername }); res.end('ok');
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const makeOptions = hostname => {
|
||||
const options = pinnedRequestOptions({ valid: true, hostname, addresses: [{ address: '127.0.0.1', family: 4 }] });
|
||||
options.httpsAgent.options.ca = certificate;
|
||||
return options;
|
||||
};
|
||||
const allowed = makeOptions('pinned.example'), wrong = makeOptions('wrong.example');
|
||||
try {
|
||||
const host = `pinned.example:${server.address().port}`;
|
||||
expect((await axios.post(`https://${host}/hook`, 'data', { ...allowed, timeout: 2000 })).status).toBe(200);
|
||||
expect(seen).toEqual([{ host, servername: 'pinned.example' }]);
|
||||
await expect(axios.post(`https://wrong.example:${server.address().port}/hook`, 'data', { ...wrong, timeout: 2000 }))
|
||||
.rejects.toMatchObject({ code: 'ERR_TLS_CERT_ALTNAME_INVALID' });
|
||||
expect(seen).toHaveLength(1);
|
||||
} finally {
|
||||
for (const options of [allowed, wrong]) { options.httpAgent.destroy(); options.httpsAgent.destroy(); }
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const EventEmitter = require('events');
|
||||
jest.mock('../../src/utils/logger', () => ({ info: jest.fn() }));
|
||||
const logger = require('../../src/utils/logger');
|
||||
const middleware = require('../../src/middleware/apiRequestLogger');
|
||||
const { requestLogPath } = require('../../src/utils/requestLogPath');
|
||||
const marker = 'SECRET_TEST_CAPABILITY';
|
||||
it.each([
|
||||
`/api/gallery/g/photos?token=${marker}&password=${marker}`,
|
||||
`/api/gallery/g/verify-token/${marker}`,
|
||||
`/api/gallery/g/show/${marker}/state`,
|
||||
`/api/images/g/photo/1/signed/${marker}`,
|
||||
`/api/secure-images/g/secure/1/${marker}`,
|
||||
`/api/secure-images/g/secure-download/1/${marker}`,
|
||||
`/api/public/contracts/${marker}/sign`,
|
||||
`/api/customer/auth/password-reset/${marker}`,
|
||||
`/api/public/newsletter/unsubscribe/${marker}`,
|
||||
])('does not log capabilities on request or response: %s', (originalUrl) => {
|
||||
logger.info.mockClear();
|
||||
const res = new EventEmitter(); res.statusCode = 200;
|
||||
middleware({ originalUrl, method: 'GET' }, res, jest.fn());
|
||||
res.emit('finish');
|
||||
expect(logger.info).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(marker);
|
||||
});
|
||||
it('retains useful non-secret routes and removes control characters', () => {
|
||||
expect(requestLogPath('/api/admin/events/12?search=private')).toBe('/api/admin/events/12');
|
||||
expect(requestLogPath('/api/admin/events\nforged')).not.toContain('\n');
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
const http = require('http');
|
||||
it('directs Supertest to the actual IPv6 listener instead of an unrelated IPv4 port', async () => {
|
||||
jest.resetModules();
|
||||
const request = require('supertest');
|
||||
const server = http.createServer((_req, res) => { res.end('actual test listener'); });
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '::1', resolve); });
|
||||
try {
|
||||
const response = await request(server).get('/');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('actual test listener');
|
||||
expect(response.request.url).toContain('://[::1]:');
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
@@ -20,7 +20,7 @@ jest.mock('../../src/database/db', () => {
|
||||
const dbFn = () => ({
|
||||
insert(row) {
|
||||
inserted.push(row);
|
||||
return { onConflict: () => ({ ignore: async () => undefined }) };
|
||||
return { onConflict: () => ({ ignore: async () => undefined, merge: async () => undefined }) };
|
||||
},
|
||||
});
|
||||
return { db: dbFn };
|
||||
|
||||
@@ -87,16 +87,18 @@ When rate limits are exceeded, the following is logged:
|
||||
|
||||
## Configuration Settings
|
||||
|
||||
All rate limiting settings are configurable via the admin panel:
|
||||
The general limiter reads these keys from `app_settings` (cached for 60 seconds). They are edited in the admin panel under Settings → Security (the API rate limiter card), which saves through `PUT /api/admin/settings/security/rate-limit` (all six fields required). The route upserts, so a fresh install needs no rows first, and the live limiters are rebuilt on save, so a new window applies without a restart. The defaults below are what applies when a key has no row; the settings read fills them in, so the form shows the budget in force.
|
||||
|
||||
| Setting | Default | Range | Description |
|
||||
|---------|---------|-------|-------------|
|
||||
| rate_limit_enabled | true | - | Enable/disable rate limiting |
|
||||
| rate_limit_window_minutes | 15 | 1-60 | Time window for rate limit |
|
||||
| rate_limit_max_requests | 1000 | 10-10000 | Max requests for general endpoints |
|
||||
| rate_limit_auth_max_requests | 5 | 1-100 | Max requests for auth endpoints |
|
||||
| rate_limit_skip_authenticated | true | - | Skip rate limit for authenticated requests |
|
||||
| rate_limit_public_endpoints_only | false | - | Only rate limit public endpoints |
|
||||
| rate_limit_max_requests | 300 | 10-10000 | Per-IP budget for `/api/` requests that are not exempt |
|
||||
| rate_limit_auth_max_requests | 5 | 1-100 | Per-IP budget of *failed* admin-login / gallery-verify attempts (own bucket) |
|
||||
| rate_limit_skip_authenticated | true | - | Admin sessions are exempt; since v3.127.0-beta.0 a verified gallery viewer's image requests (thumbnail, preview, hero, photo) are exempt too |
|
||||
| rate_limit_public_endpoints_only | false | - | Only rate limit `/api/public/*` and `/api/gallery/*` |
|
||||
|
||||
The limiter keys on the client IP as Express reports it, so behind a proxy `TRUST_PROXY` has to cover that proxy or every visitor shares one budget. Several people behind one NAT (an office, a household, carrier NAT) share a budget by design; before v3.127.0-beta.0 a large gallery could exhaust it for a single viewer, which surfaced as blank tiles with no error (issue 1287).
|
||||
|
||||
## Database Tables
|
||||
|
||||
|
||||
@@ -13,5 +13,9 @@ module.exports = {
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.test.js'
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
// sanitize-html's htmlparser2 12 is ESM-only; see jest.sanitizeHtml.js.
|
||||
moduleNameMapper: {
|
||||
'^sanitize-html$': '<rootDir>/jest.sanitizeHtml.js'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* sanitize-html 2.17.6+ depends on htmlparser2 12, which ships ESM only.
|
||||
* Node 22.12+ loads it fine through require(esm); Jest 29's CommonJS module
|
||||
* registry cannot evaluate an ESM file and fails every suite that imports a
|
||||
* route or service using the sanitiser. Rather than bolting a Babel
|
||||
* transform onto node_modules for one dependency, hand this single module to
|
||||
* Node's own loader.
|
||||
*
|
||||
* process.getBuiltinModule (Node 22.3+) is the real core `module` even inside
|
||||
* Jest — a plain require('module') here returns Jest's wrapper, whose
|
||||
* createRequire() hands back an empty object for this package. createRequire()
|
||||
* on the real one resolves from backend/node_modules exactly like production.
|
||||
*
|
||||
* Wired in via moduleNameMapper in jest.config.js. The module is stateless,
|
||||
* so sharing one instance across test files changes nothing; it just cannot
|
||||
* be jest.mock()ed, and nothing mocks it.
|
||||
*/
|
||||
module.exports = process.getBuiltinModule('module').createRequire(__filename)('sanitize-html');
|
||||
@@ -1,3 +1,18 @@
|
||||
// Supertest 6 binds an IPv6 wildcard listener but hardcodes an IPv4 URL.
|
||||
// macOS can allocate that IPv6 port while a different IPv4 service owns it.
|
||||
// Address the listener's actual family so a test cannot reach that service.
|
||||
jest.mock('supertest/lib/test', () => {
|
||||
const Test = jest.requireActual('supertest/lib/test');
|
||||
const serverAddress = Test.prototype.serverAddress;
|
||||
Test.prototype.serverAddress = function(app, path) {
|
||||
const url = serverAddress.call(this, app, path);
|
||||
return app.address()?.family === 'IPv6'
|
||||
? url.replace('://127.0.0.1:', '://[::1]:')
|
||||
: url;
|
||||
};
|
||||
return Test;
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
@@ -8,3 +23,11 @@ beforeAll(() => {
|
||||
process.env.STORAGE_PATH = '/storage';
|
||||
}
|
||||
});
|
||||
|
||||
// Dispose resources loaded by this suite using the application's draining
|
||||
// shutdown. Individual fixtures still own temporary files and other DB pools.
|
||||
afterAll(async () => {
|
||||
await require('./src/services/serviceShutdown').stopServices();
|
||||
const loadedDb = require.cache[require.resolve('./src/database/db')];
|
||||
if (typeof loadedDb?.exports.db?.destroy === 'function') await loadedDb.exports.db.destroy();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) {
|
||||
await knex.schema.createTable('product_usage_state', (t) => {
|
||||
t.integer('id').primary();
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
t.text('private_key_encrypted');
|
||||
t.string('instance_binding', 64);
|
||||
t.bigInteger('sequence').notNullable().defaultTo(0);
|
||||
t.text('pending_packet');
|
||||
t.text('last_packet');
|
||||
t.text('last_receipt');
|
||||
t.string('last_report_date', 10);
|
||||
t.string('last_error', 80);
|
||||
t.text('feedback_preferences');
|
||||
t.string('lease_token', 36);
|
||||
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||
});
|
||||
}
|
||||
if (!(await knex('product_usage_state').where({ id: 1 }).first()))
|
||||
await knex('product_usage_state').insert({ id: 1 });
|
||||
if (!(await knex.schema.hasTable('product_usage_markers'))) {
|
||||
await knex.schema.createTable('product_usage_markers', (t) => {
|
||||
t.string('feature', 60).primary();
|
||||
// A marker is only a capability name, never a timestamp or user/event ID.
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.down = async function (knex) {
|
||||
await knex.schema.dropTableIfExists('product_usage_markers');
|
||||
await knex.schema.dropTableIfExists('product_usage_state');
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
// Separate from 201 deliberately. 201 already shipped on this branch, and
|
||||
// knex records it as applied — so folding the column into it would silently
|
||||
// skip every database that had already run it, and the first /disable would
|
||||
// fail on a missing column. Its own migration runs everywhere.
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
if (await knex.schema.hasColumn('product_usage_state', 'cancel_requested')) return;
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
// Set by /disable so an activation still generating its identity — during
|
||||
// which the row still reads `disabled` — cannot go on to complete after
|
||||
// the admin has asked to withdraw.
|
||||
t.boolean('cancel_requested').notNullable().defaultTo(false);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
if (!(await knex.schema.hasColumn('product_usage_state', 'cancel_requested'))) return;
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.dropColumn('cancel_requested');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
// Supersedes the boolean added in 202. A boolean cannot distinguish "a
|
||||
// withdrawal arrived while this activation was starting" from "a withdrawal
|
||||
// from an earlier participation was never cleared": clearing it needed its
|
||||
// own write, and a /disable landing between the lease and that write was
|
||||
// erased. A monotonic counter needs no clearing — enable() records the value
|
||||
// it started with and claims only if it is unchanged, so any intervening
|
||||
// withdrawal is visible whatever the previous state was.
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
if (!(await knex.schema.hasColumn('product_usage_state', 'cancel_seq')))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
|
||||
});
|
||||
if (await knex.schema.hasColumn('product_usage_state', 'cancel_requested'))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.dropColumn('cancel_requested');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
if (await knex.schema.hasColumn('product_usage_state', 'cancel_seq'))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.dropColumn('cancel_seq');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// Bounded, local-only audit receipts. Never retain an installation identity,
|
||||
// signing key, report/feedback payload or collector credential after opt-out.
|
||||
exports.up = async function (knex) {
|
||||
if (
|
||||
(await knex.schema.hasTable('product_usage_state')) &&
|
||||
!(await knex.schema.hasColumn('product_usage_state', 'privacy_receipts'))
|
||||
) {
|
||||
await knex.schema.alterTable('product_usage_state', (t) =>
|
||||
t.text('privacy_receipts')
|
||||
);
|
||||
}
|
||||
if (await knex.schema.hasColumn('product_usage_state', 'last_receipt')) {
|
||||
const row = await knex('product_usage_state').where({ id: 1 }).first();
|
||||
if (row?.last_receipt) {
|
||||
const receipt = JSON.parse(row.last_receipt);
|
||||
if (receipt.session_token) {
|
||||
delete receipt.session_token;
|
||||
await knex('product_usage_state')
|
||||
.where({ id: 1 })
|
||||
.update({ last_receipt: JSON.stringify(receipt) });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
exports.down = async function (knex) {
|
||||
if (
|
||||
(await knex.schema.hasTable('product_usage_state')) &&
|
||||
(await knex.schema.hasColumn('product_usage_state', 'privacy_receipts'))
|
||||
) {
|
||||
await knex.schema.alterTable('product_usage_state', (t) =>
|
||||
t.dropColumn('privacy_receipts')
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
// Existing participants retain their v1 consent and v1 allowlist. New fields
|
||||
// require a separate explicit, signed upgrade; migrations never opt anyone in.
|
||||
exports.up = async function (knex) {
|
||||
if (
|
||||
(await knex.schema.hasTable('product_usage_state')) &&
|
||||
!(await knex.schema.hasColumn('product_usage_state', 'consent_version'))
|
||||
) {
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.down = async function (knex) {
|
||||
if (
|
||||
(await knex.schema.hasTable('product_usage_state')) &&
|
||||
(await knex.schema.hasColumn('product_usage_state', 'consent_version'))
|
||||
) {
|
||||
await knex.schema.alterTable('product_usage_state', (t) => t.dropColumn('consent_version'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// Retry pacing for the collector. Without it every failed packet was retried
|
||||
// on the next admin request: /activity is open to any authenticated admin and
|
||||
// the settings ticker fires it every five minutes per open tab, so an
|
||||
// installation whose packet the collector rejects permanently hammered it
|
||||
// once per admin action, forever, with a failing request sitting on the
|
||||
// critical path of that action.
|
||||
//
|
||||
// `attempts` counts consecutive failures and `next_attempt_at` is the epoch-ms
|
||||
// gate the automatic sender honours. Explicit operator actions — Retry and
|
||||
// Disable — pass through regardless; the point is to pace the unattended loop,
|
||||
// not to make the admin wait out a backoff they asked to skip.
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
if (!(await knex.schema.hasColumn('product_usage_state', 'attempts')))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.integer('attempts').notNullable().defaultTo(0);
|
||||
});
|
||||
if (!(await knex.schema.hasColumn('product_usage_state', 'next_attempt_at')))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
for (const column of ['attempts', 'next_attempt_at'])
|
||||
if (await knex.schema.hasColumn('product_usage_state', column))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.dropColumn(column);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
/**
|
||||
* Opt-in recoverable storage for gallery passwords (#1271).
|
||||
*
|
||||
* Both columns hold an AES-256-GCM ciphertext (see utils/galleryPasswordVault)
|
||||
* and stay NULL unless the security setting
|
||||
* `security_gallery_password_recoverable` is on. The bcrypt hashes remain the
|
||||
* only thing the login path reads; these columns exist so an admin can show
|
||||
* or resend a password without regenerating it.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
await addColumnIfNotExists(knex, 'events', 'password_recoverable', (table) => {
|
||||
table.text('password_recoverable').nullable();
|
||||
});
|
||||
await addColumnIfNotExists(knex, 'events', 'client_password_recoverable', (table) => {
|
||||
table.text('client_password_recoverable').nullable();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasColumn('events', 'password_recoverable')) {
|
||||
await knex.schema.alterTable('events', (table) => table.dropColumn('password_recoverable'));
|
||||
}
|
||||
if (await knex.schema.hasColumn('events', 'client_password_recoverable')) {
|
||||
await knex.schema.alterTable('events', (table) => table.dropColumn('client_password_recoverable'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* `events.external_watch` — per-event opt-in for the external-media folder
|
||||
* watcher (issue 1187).
|
||||
*
|
||||
* Managed uploads are picked up by fileWatcher.js as soon as they land in
|
||||
* storage/events/active. A reference-mode event has no equivalent: new files
|
||||
* copied into its NAS folder sit there until an admin opens the event and
|
||||
* presses Import. services/externalMediaWatcher.js closes that gap for events
|
||||
* that ask for it.
|
||||
*
|
||||
* Opt-in per event rather than a global switch: every watched folder is a set
|
||||
* of inotify handles (or, on a mount that does not deliver events, a polling
|
||||
* stat of the whole tree), and a large install with hundreds of reference
|
||||
* events should not pay that for the ones nobody is still adding files to.
|
||||
*
|
||||
* Boolean with a false default so an existing install changes nothing on
|
||||
* upgrade — the column is read through formatBoolean() so SQLite's 0/1 and
|
||||
* Postgres' true/false both work.
|
||||
*/
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'external_watch'))) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.boolean('external_watch').notNullable().defaultTo(false);
|
||||
});
|
||||
console.log('208: added events.external_watch');
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasColumn('events', 'external_watch')) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('external_watch');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* `external_import_exclusions` — files an admin deleted from a reference
|
||||
* event, so the folder watcher (issue 1187) does not bring them back.
|
||||
*
|
||||
* Deleting an external photo removes its row but leaves the NAS original
|
||||
* alone (resolvePhotoStorageKey returns null for external rows, on purpose).
|
||||
* The manual Import only ran when an admin pressed it, so the deleted file
|
||||
* came back only if they asked. The watcher runs on its own, and a full pass
|
||||
* that skips only rows the event still has would re-import every deleted
|
||||
* photo on the next sweep — republishing what an admin removed, without any
|
||||
* new file arriving.
|
||||
*
|
||||
* One row per (event, root-relative path). Automatic passes skip these; the
|
||||
* manual Import button ignores the list and clears the rows for whatever it
|
||||
* imports, since pressing it is the explicit intent the exclusion exists to
|
||||
* protect.
|
||||
*/
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('external_import_exclusions'))) {
|
||||
await knex.schema.createTable('external_import_exclusions', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
|
||||
// Same shape as photos.external_relpath: relative to EXTERNAL_MEDIA_ROOT.
|
||||
t.text('external_relpath').notNullable();
|
||||
t.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
t.unique(['event_id', 'external_relpath'], { indexName: 'external_import_exclusions_event_relpath_unique' });
|
||||
});
|
||||
console.log('209: created external_import_exclusions');
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
await knex.schema.dropTableIfExists('external_import_exclusions');
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Fresh installs and upgraded databases expose the same event timestamp. */
|
||||
exports.up = async function (knex) {
|
||||
if (!await knex.schema.hasTable('events')) return;
|
||||
if (!await knex.schema.hasColumn('events', 'updated_at')) {
|
||||
await knex.schema.alterTable('events', table => {
|
||||
table.timestamp('updated_at');
|
||||
});
|
||||
}
|
||||
// Do not replace existing modification times on a repeated migration.
|
||||
await knex('events').whereNull('updated_at').update({ updated_at: knex.ref('created_at') });
|
||||
};
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasTable('events') && await knex.schema.hasColumn('events', 'updated_at')) {
|
||||
await knex.schema.alterTable('events', table => table.dropColumn('updated_at'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Non-expiring JWTs need revocation records that cleanup never removes. */
|
||||
exports.up = async function (knex) {
|
||||
if (!await knex.schema.hasTable('revoked_tokens')) return;
|
||||
if (!await knex.schema.hasColumn('revoked_tokens', 'expires_at')) return;
|
||||
const column = await knex('revoked_tokens').columnInfo('expires_at');
|
||||
if (!column.nullable) {
|
||||
await knex.schema.alterTable('revoked_tokens', table => {
|
||||
table.timestamp('expires_at').nullable().alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!await knex.schema.hasTable('revoked_tokens')) return;
|
||||
if (!await knex.schema.hasColumn('revoked_tokens', 'expires_at')) return;
|
||||
// Refuse to discard permanent revocations or silently give them a TTL.
|
||||
if (await knex('revoked_tokens').whereNull('expires_at').first()) {
|
||||
throw new Error('Cannot roll back while permanent token revocations exist');
|
||||
}
|
||||
const column = await knex('revoked_tokens').columnInfo('expires_at');
|
||||
if (column.nullable) {
|
||||
await knex.schema.alterTable('revoked_tokens', table => {
|
||||
table.timestamp('expires_at').notNullable().alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// Tracks whether this installation has ever been offered the one-time
|
||||
// usage-reporting opt-in prompt shown to an existing admin on their first
|
||||
// login after an update (see UsageService.markPromptShown()). A fresh
|
||||
// install that went through the setup wizard's own opt-in step sets this
|
||||
// too, so upgraded and brand-new installs share one "already asked" marker
|
||||
// and neither gets asked twice. Separate from `notice_dismissed`, which
|
||||
// governs the persistent, re-visitable dashboard banner instead.
|
||||
const { formatBoolean } = require('../../src/utils/dbCompat');
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
if (!(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))) {
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.boolean('prompt_shown').notNullable().defaultTo(false);
|
||||
});
|
||||
}
|
||||
// Existing participants already made their choice before this marker
|
||||
// existed. Preserve it through withdrawal, pending delivery and identity
|
||||
// recovery; none of those transitions should produce a fresh invitation.
|
||||
await knex('product_usage_state')
|
||||
.whereNot('status', 'disabled')
|
||||
.update({ prompt_shown: formatBoolean(true) });
|
||||
};
|
||||
exports.down = async function (knex) {
|
||||
if (
|
||||
(await knex.schema.hasTable('product_usage_state')) &&
|
||||
(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))
|
||||
) {
|
||||
await knex.schema.alterTable('product_usage_state', (t) => t.dropColumn('prompt_shown'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Migration 213: TOTP replay protection for admin MFA (GHSA-qcwx-r25m-j869).
|
||||
*
|
||||
* verifyTotp()/verifyTotpEncrypted() were stateless: otplib's window:1
|
||||
* tolerance means a captured 6-digit code stays valid across several real
|
||||
* time-steps (~90s), so the same code could complete two independent admin
|
||||
* logins. `two_factor_last_used_step` tracks, per admin, the absolute TOTP
|
||||
* time-step (Math.floor(Date.now() / 30000)) that their last successfully
|
||||
* consumed code matched; mfaService now rejects a code whose matched step
|
||||
* doesn't advance past it.
|
||||
*
|
||||
* Additive and idempotent: only adds a column, guarded by hasColumn, so it
|
||||
* is safe to re-run and touches no existing data.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('admin_users', 'two_factor_last_used_step'))) {
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
t.integer('two_factor_last_used_step').nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasColumn('admin_users', 'two_factor_last_used_step')) {
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
t.dropColumn('two_factor_last_used_step');
|
||||
});
|
||||
}
|
||||
};
|
||||
Generated
+329
-153
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.122.5-beta.0",
|
||||
"version": "3.131.3-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.122.5-beta.0",
|
||||
"version": "3.131.3-beta.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||
"ajv": "^8.20.0",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "1.18.1",
|
||||
"bcrypt": "6.0.0",
|
||||
@@ -38,7 +39,7 @@
|
||||
"knex": "^2.4.2",
|
||||
"mailparser": "^3.9.9",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "2.2.0",
|
||||
"multer": "2.3.0",
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
@@ -51,8 +52,8 @@
|
||||
"postcss": "8.5.23",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "2.17.5",
|
||||
"sharp": "0.35.3",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sharp": "0.35.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
@@ -70,7 +71,7 @@
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22"
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/json-schema-ref-parser": {
|
||||
@@ -1611,6 +1612,30 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
||||
@@ -1691,9 +1716,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1709,13 +1734,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1731,20 +1756,20 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -1754,9 +1779,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1770,9 +1795,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1786,9 +1811,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1802,9 +1827,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1818,9 +1843,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1834,9 +1859,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1850,9 +1875,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1866,9 +1891,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1882,9 +1907,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1898,9 +1923,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1914,9 +1939,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1932,13 +1957,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1954,13 +1979,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1976,13 +2001,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1998,13 +2023,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2020,13 +2045,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2042,13 +2067,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2064,13 +2089,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2086,17 +2111,17 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -2106,16 +2131,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -2125,9 +2150,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2144,9 +2169,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2163,9 +2188,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4006,16 +4031,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -5848,6 +5872,30 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esm": {
|
||||
"version": "3.2.25",
|
||||
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
|
||||
@@ -6130,6 +6178,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.9.tgz",
|
||||
@@ -8020,9 +8084,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "17.13.4",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz",
|
||||
"integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==",
|
||||
"version": "17.13.7",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz",
|
||||
"integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^9.3.0",
|
||||
@@ -8056,9 +8120,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8105,10 +8169,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
@@ -9132,9 +9195,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
|
||||
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -9335,9 +9398,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||
"version": "9.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
|
||||
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -10805,6 +10868,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
@@ -10978,18 +11050,122 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.5",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
|
||||
"integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
|
||||
"version": "2.17.7",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
|
||||
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"launder": "^1.7.1",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/dom-serializer": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
||||
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/domelementtype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
|
||||
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/domhandler": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
|
||||
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/domutils": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
|
||||
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^3.0.0",
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/htmlparser2": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
|
||||
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"domutils": "^4.0.2",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
@@ -11082,9 +11258,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
@@ -11098,31 +11274,31 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
"@img/sharp-darwin-arm64": "0.35.4",
|
||||
"@img/sharp-darwin-x64": "0.35.4",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||
"@img/sharp-linux-arm": "0.35.4",
|
||||
"@img/sharp-linux-arm64": "0.35.4",
|
||||
"@img/sharp-linux-ppc64": "0.35.4",
|
||||
"@img/sharp-linux-riscv64": "0.35.4",
|
||||
"@img/sharp-linux-s390x": "0.35.4",
|
||||
"@img/sharp-linux-x64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||
"@img/sharp-win32-arm64": "0.35.4",
|
||||
"@img/sharp-win32-ia32": "0.35.4",
|
||||
"@img/sharp-win32-x64": "0.35.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user