Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Nothaft e94e440858 screenshot: admin github button (#778) 2026-07-10 09:50:18 +02:00
357 changed files with 4101 additions and 28737 deletions
+2 -3
View File
@@ -56,9 +56,8 @@ DB_NAME=picpeak_prod
# Admin Account (initial setup) — OPTIONAL
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
# open /admin and PicPeak shows a setup screen. The one-time setup token is
# written to data/SETUP_TOKEN with mode 0600 — read it with
# `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged
# unless that write fails, so it never sits in `docker logs`.
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
# and saved to data/SETUP_TOKEN.
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
# credentials written to data/ADMIN_CREDENTIALS.txt).
#ADMIN_USERNAME=admin
-16
View File
@@ -203,14 +203,6 @@ jobs:
format: 'sarif'
output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
# Base-image CVEs with no released fix are not actionable: the
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
# so a fix lands in the next build automatically. Reporting them
# buries the findings someone can actually do something about.
# Dropping them is also the precondition for ever setting
# exit-code: 1, which build-backend's comment flags as a
# deliberate follow-up.
ignore-unfixed: true
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
@@ -433,14 +425,6 @@ jobs:
format: 'sarif'
output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
# Base-image CVEs with no released fix are not actionable: the
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
# so a fix lands in the next build automatically. Reporting them
# buries the findings someone can actually do something about.
# Dropping them is also the precondition for ever setting
# exit-code: 1, which build-backend's comment flags as a
# deliberate follow-up.
ignore-unfixed: true
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
+26 -7
View File
@@ -25,14 +25,33 @@ jobs:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
target-branch: stable
# NOTE: stable release PRs are intentionally NOT auto-merged here
# anymore. Fixes accumulate in the rolling release PR and are cut as
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
# or on demand via workflow_dispatch / a manual merge of the release
# PR). Beta keeps instant releases — see release-please-beta.yml —
# because same-day reporter verification depends on it.
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
# whenever no PAT is configured.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout in this job — set the repo explicitly so gh works
# without a git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
# is a valid review; enable auto-merge as the PAT so the merge commit is
# attributed to a real identity and triggers the tag-cutting run (#719).
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -1,86 +0,0 @@
name: Cut Stable Release (daily batch)
# Stable fixes accumulate in release-please's rolling release PR instead of
# each cutting its own patch version (the old per-merge auto-merge produced
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
# stable release PR once a day, so a day of N bugfixes ships as ONE version
# with all N changelog entries — and one Docker build instead of N.
#
# - schedule only fires from the default branch (main); the stable copy of
# this file is inert and exists to keep the branches in sync.
# - Need a release NOW? Run this via workflow_dispatch, or merge the
# release PR by hand — the schedule is a default, not a gate.
# - Approval/merge mechanics mirror the old inline step (#719): approve as
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
# auto-merge as the PAT so the merge attributes to a real identity and
# triggers the tag-cutting run. --auto waits for green checks.
on:
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
merge-stable-release-pr:
runs-on: ubuntu-latest
steps:
- name: Approve and enable auto-merge on the open stable release PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout — set the repo explicitly so gh works without a
# git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
exit 0
fi
# Strict selection (review P1): this job runs daily even without a
# stable push, and `gh pr list --head` matches the branch NAME only
# — a fork PR can spoof `release-please--branches--stable`. Pin the
# base to stable AND require a same-repo head (isCrossRepository
# == false); a fork PR is cross-repository, so it can never be
# picked and auto-merged with the privileged PAT.
pr=$(gh pr list \
--base stable \
--head release-please--branches--stable \
--state open \
--json number,isCrossRepository \
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
if [ -z "$pr" ]; then
echo "No open same-repo stable release PR — nothing to cut today."
exit 0
fi
# Approve is tolerant — a pre-existing approval already satisfies
# branch protection and re-approving can return non-zero.
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
# But the auto-merge enable is the load-bearing step: this scheduled
# job is the ONLY automatic stable cut, so DON'T swallow its failure
# (review P2) — an expired/under-scoped PAT would otherwise stop
# releases while the workflow stays green.
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
# are already green — the normal case at 18:00, since the fixes
# merged hours earlier and CI passed. So success is EITHER the PR is
# already merged OR an auto-merge request is now pending; only a PR
# that is still open with no auto-merge request is a real failure
# (expired/under-scoped PAT) worth failing the job on (review round 2).
# One snapshot of both fields (review round 3): querying state and
# autoMergeRequest separately races — auto-merge can complete
# between the two calls, so the first sees OPEN and the second sees
# the request already cleared on the now-merged PR → false failure.
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
if [ "$state" = "MERGED" ]; then
echo "Stable release PR #$pr merged immediately (checks were already green)."
elif [ "$automerge" = "true" ]; then
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
else
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
exit 1
fi
+2 -28
View File
@@ -17,9 +17,9 @@ name: Tests
on:
push:
branches: [main, beta, stable]
branches: [main, beta]
pull_request:
branches: [main, beta, stable]
branches: [main, beta]
workflow_dispatch:
permissions:
@@ -30,29 +30,6 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
# The .picpeak restore suites gate their real-Postgres cases behind
# PICPEAK_PG_TEST_URL and `describe.skip` themselves out when it is
# unset — so until now they never ran here. That hid the half that
# matters: sequence resync, operator/role preservation across a
# cross-instance restore, and (with #1041) whether a SQLite-shaped
# row actually lands in Postgres with the right STORED VALUES rather
# than merely not throwing. Everything else in the suite still runs
# on SQLite; this service only un-gates those cases.
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: picpeak
POSTGRES_PASSWORD: testpass
POSTGRES_DB: picpeak_test
options: >-
--health-cmd "pg_isready -U picpeak -d picpeak_test"
--health-interval 2s
--health-timeout 2s
--health-retries 30
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -75,9 +52,6 @@ jobs:
# The S3 path itself is covered separately by the integration
# suite when MinIO is provisioned.
SKIP_S3_TESTS: 'true'
# Un-gates the real-Postgres cases in the .picpeak restore suites
# (see the `services:` note above). Absent it they silently skip.
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
run: |
# Excluded suites — fail on upstream/beta too, tracked
# separately as test-infra debt:
+2 -24
View File
@@ -130,27 +130,5 @@ docker-compose.dev.yml
# New layout development files
new-layouts/
# Backend runtime storage (generated media, previews, thumbnails,
# CRM/accounting documents) — never commit. Matches main: a dev instance
# writes event photos into backend/storage/, and the narrower
# business-docs-only rule let `git add -A` sweep them into a commit.
backend/storage/
# Python bytecode. The ML sidecar lives on main only, so this branch never
# needed the rule — which is how a `git add -A` from a shared working tree
# committed 16 .pyc files here in #1247.
__pycache__/
*.pyc
# Issue / PR screenshots belong on a `screenshots/*` branch, never on main or
# stable — that is what those branches exist for. Two landed at the repo root
# on main in #1241 and shipped as part of the source tree; nothing stopped it.
#
# Anchored with a leading slash so docs/ keeps its own images.
/issue-*.png
/issue-*.jpg
/screenshot-*.png
/screenshot-*.jpg
/*-screenshot.png
/*-screenshot.jpg
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.83.0-beta.0"
".": "3.82.4-beta.0"
}
+3 -1
View File
@@ -1 +1,3 @@
{".":"3.46.11"}
{
".": "2.6.1"
}
+1013 -1070
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -163,14 +163,13 @@ PicPeak runs on two long-lived branches:
| Branch | Role | What targets it |
|---|---|---|
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
| **`stable`** | Curated release channel. Production-recommended. | Security fixes and regular bugfix backports, kept small and free of unrelated features. |
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
### Which branch should my PR target?
- **New feature** → target `main`.
- **Bugfix that ONLY affects active dev** → target `main`.
- **Bugfix that current stable users need** → target `main`; regular bug fixes are generally backported automatically to `stable`. Maintainers handle conflicts or create a separate focused backport PR when needed.
- **Security vulnerability** → report privately using [SECURITY.md](SECURITY.md). Security fixes are always released on both `stable` and `main`; coordinate any fix with the maintainers before opening a public PR.
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
@@ -188,6 +187,6 @@ See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteri
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
- Security vulnerabilities: Follow the [security policy](SECURITY.md) and use [private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
Thank you for contributing! 🎉
Thank you for contributing! 🎉
+2 -7
View File
@@ -111,15 +111,10 @@ docker compose up -d
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is deliberately *not* printed to the logs — that would leave a live
bootstrap credential in `docker logs`):
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
```bash
docker compose exec backend cat /app/data/SETUP_TOKEN
docker compose logs backend | grep -i "setup token"
```
It is bind-mounted, so `sudo cat data/SETUP_TOKEN` on the host works too. Only
if that file could not be written does the backend fall back to logging the
token (`docker compose logs backend | grep -i "setup token"`).
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
+1 -6
View File
@@ -62,18 +62,13 @@ The actual mechanics, in order:
## Hotfix path (backport to current stable)
Regular bug fixes are generally backported automatically from `main` to `stable`. Keep backports focused on the fix, without unrelated features, and resolve conflicts manually when needed.
**Security fixes are always released on both `stable` and `main`.** Do not wait for a full promotion to deliver a security update. A fix first applied to `stable` must also be forward-ported to `main`; a fix first applied to `main` must also reach `stable`. See [SECURITY.md](SECURITY.md) for the support policy.
When a backport needs manual handling:
If a critical bug or security issue affects the current stable and `main` has moved too far for a full promotion to be appropriate, backport just the fix:
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
2. Cherry-pick or hand-write the minimal fix.
3. Open a PR to `stable` with the smallest possible diff.
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
6. For security fixes, verify that the fix has been published through **both** release channels; merging the code is only part of delivery.
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
+68 -61
View File
@@ -1,81 +1,88 @@
# Security Policy
## Scope
This policy covers the PicPeak backend, frontend, all-in-one (AIO) image, optional
ML component, and the Docker images published by the PicPeak project. Other
PicPeak repositories define their own supported versions and release channels.
## Supported Versions
Security support follows the current release channels:
We release patches for security vulnerabilities. Currently supported versions:
| Version or channel | Security support |
| --- | --- |
| Latest stable release from `stable` | Supported; security fixes are published through this channel |
| Latest beta release from `main` | Supported; security fixes are published through this channel |
| Superseded stable or beta releases | Upgrade to the latest release in the same channel; older releases are not maintained separately |
| 2.x and earlier | No longer supported |
See the [latest stable release](https://github.com/PicPeak/picpeak/releases/latest)
and [all releases, including betas](https://github.com/PicPeak/picpeak/releases).
Version numbers differ between channels; each channel receives its own updates.
### Security fixes and bug backports
**Security fixes are always released on both `stable` and `main`.** A fix that
lands on one branch must also reach the other branch and be published through
both release channels. Security updates do not wait for the next full
`main`-to-`stable` promotion.
Regular bug fixes are also generally backported automatically to `stable`.
Backports remain focused on the fix, without pulling in unrelated features.
Maintainers resolve conflicts or handle a backport manually when necessary.
The [release process](RELEASING.md) describes backports, forward-ports and
publication. Operators must apply the published updates to their installations.
| Version | Supported |
| ------- | ------------------ |
| 2.x.x | :white_check_mark: |
| < 2.0 | :x: |
## Reporting a Vulnerability
**Do not report vulnerabilities in public issues, discussions or pull requests.**
We take the security of PicPeak seriously. If you have discovered a security vulnerability, please follow these steps:
Report privately through:
### 1. **Do NOT create a public GitHub issue**
- [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) (preferred).
- Email **info@picpeak.app** if you cannot use GitHub's private reporting form.
### 2. Report the vulnerability privately by:
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- **Alternative:** Email us at **info@picpeak.app** with the details
- Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
Include the affected component, version or image tag, deployment method,
reproduction steps, expected impact and any suggested fix. Share only the
information needed to reproduce the problem; remove credentials and personal
data from logs or examples.
### 3. You can expect:
- Acknowledgment within 48 hours
- Regular updates on our progress
- Credit in the fix announcement (unless you prefer to remain anonymous)
We aim to acknowledge reports within 48 hours. This is a response target, not a
guaranteed service level or a promised resolution time. We will provide progress
updates and coordinate disclosure with the reporter. Reporter credit is optional;
tell us if you prefer to remain anonymous.
## Security Measures
## Deployment Security
PicPeak implements several security measures:
Security depends on both the software and its configuration. Operators should:
### Authentication & Authorization
- JWT-based authentication with secure token storage
- bcrypt password hashing with configurable rounds
- Role-based access control for admin functions
- Session timeout management
- Use HTTPS and configure the reverse proxy and trusted proxy settings correctly.
- Use strong credentials and keep deployment secrets private.
- Apply updates for the chosen release channel and restrict unnecessary network access.
- Keep backups and verify that they can be restored.
### Input Validation
- All user inputs are validated and sanitized
- SQL injection prevention through parameterized queries
- XSS protection via Content Security Policy
- File upload restrictions and validation
See the deployment guides for [HTTPS](https://docs.picpeak.app/deployment/ssl-certificates),
[reverse proxies](https://docs.picpeak.app/deployment/reverse-proxy),
[security settings](https://docs.picpeak.app/guides/admin-settings/security)
and [backup and restore](https://docs.picpeak.app/guides/backup-restore).
### Rate Limiting
- API rate limiting to prevent abuse
- Brute force protection on authentication endpoints
- Configurable limits per endpoint
### Data Protection
- HTTPS enforcement in production
- Secure cookie settings
- CORS configuration
- Sensitive data encryption
### Infrastructure
- Regular dependency updates
- Security headers (HSTS, X-Frame-Options, etc.)
- Activity logging for audit trails
- Automated backups
## Best Practices for Deployment
1. **Always use HTTPS** in production
2. **Change default passwords** immediately
3. **Keep dependencies updated** regularly
4. **Configure firewall rules** appropriately
5. **Monitor logs** for suspicious activity
6. **Backup regularly** and test restoration
## Vulnerability Disclosure
We coordinate disclosure with the reporter while preparing fixes. Security fixes
are published through both supported channels. Advisories and release notes
identify affected versions, the fixed version in each channel, the impact and
any required mitigation or upgrade steps. Reporter credit is included with
permission.
We believe in responsible disclosure. Once a vulnerability is fixed:
For ordinary bugs and support requests, use
[GitHub Issues](https://github.com/PicPeak/picpeak/issues) or
[GitHub Discussions](https://github.com/PicPeak/picpeak/discussions).
1. We'll publish a security advisory
2. Credit researchers (with permission)
3. Detail the impact and mitigation steps
4. Release patches for all supported versions
## Contact
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
Thank you for helping keep PicPeak and its users safe!
+2 -4
View File
@@ -170,12 +170,10 @@ If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your ad
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is not logged — that would leave a live credential in `docker logs`):
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
```bash
docker compose exec backend cat /app/data/SETUP_TOKEN
docker compose logs backend | grep -i "setup token"
```
Only if that write fails does the backend log the token instead.
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods
+9 -27
View File
@@ -27,35 +27,17 @@ FROM node:22-alpine
WORKDIR /app
# knexfile.js picks its config block by NODE_ENV, and the `development` block
# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that
# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while
# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in
# the same log. The compose files still override this, so nothing changes for
# compose users. See #1038.
ENV NODE_ENV=production
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
# stage's declaration never reached this stage. Consuming it in the RUN below
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
# image always picks up current Alpine security updates instead of reusing a
# stale cached upgrade layer.
ARG CACHEBUST=1
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
RUN apk upgrade --no-cache
# Remove the npm CLI from the final image. Nothing runs npm here: the
# entrypoint is node, runtime deps are COPY'd from the builder stage, and
# wait-for-db.sh invokes the migration runners via node directly. npm's
# bundled node_modules kept tripping Trivy (sigstore, tar 7.5.19,
# brace-expansion 5.0.7 — even npm 12.0.1 still ships the vulnerable
# copies), so shipping no npm ends that alert class instead of chasing
# per-release patches. Note: `docker exec … npm run <script>` no longer
# works in the container — use `node migrations/run-migrations-safe.js`
# and friends instead.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# Upgrade the npm CLI in the final image so its bundled deps are patched
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
# dependencies come from the builder stage (COPY --from=builder node_modules
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
# Node >=22.9, satisfied by node:22-alpine.
RUN npm install -g npm@11
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
@@ -1,498 +0,0 @@
/**
* Restoring an archive must put the photos back into their categories.
*
* The archive writer already persists `category_name` per photo in
* `photos_manifest.json` — that is why the manifest exists, and the comment
* above it says so: "(and category linkage) can't be derived from the
* extracted files alone". The restore route then read only
* `original_filename` from it and kept deriving the category from the ZIP's
* first path segment.
*
* Archives store photos exactly as they sit on disk, so an event whose photos
* live in the gallery root produces a FLAT zip. `path.dirname()` is '.' for
* every entry, no category is resolved, and every restored photo lands with
* `category_id = null` — silently, with a 200 response.
*
* These pin the manifest as the source of truth, with the directory as the
* fallback that keeps foldered and legacy archives working.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('archive restore restores categories (flat archives included)', () => {
let tmpDir; let db; let cleanup; let app; let storagePath;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-restore-cat-'));
storagePath = path.join(tmpDir, 'storage');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = storagePath;
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
// bootCrmDb points STORAGE_PATH at its own tmp dir; follow it rather than
// fighting it, so the archives the tests write are where the route looks.
storagePath = process.env.STORAGE_PATH;
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
app = express();
app.use(express.json());
app.use('/admin/archives', require('../../src/routes/adminArchives'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await db('photos').del();
await db('photo_categories').del();
await db('events').del();
});
/** A one-pixel JPEG is enough; the route only stats the extracted file. */
const PIXEL = Buffer.from(
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a'
+ 'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA'
+ 'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
'base64',
);
async function writeArchive(name, entries) {
// Required lazily: the suite calls jest.resetModules() in beforeAll, and
// archiver's readable-stream copy does not survive being split across the
// two module registries.
const archiver = require('archiver');
const archivePath = path.join(storagePath, 'archives', name);
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(archivePath);
const zip = archiver('zip', { zlib: { level: 0 } });
output.on('close', resolve);
zip.on('error', reject);
zip.pipe(output);
for (const [entryName, buffer] of Object.entries(entries)) {
zip.append(buffer, { name: entryName });
}
zip.finalize();
});
return path.join('archives', name);
}
async function seedArchivedEvent(archiveRelPath, slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-06-27',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
is_archived: 1, // sqlite stores booleans as 0/1, see utils/dbCompat
archive_path: archiveRelPath,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
const categoryOf = async (filename) => {
const photo = await db('photos').where('filename', filename).first();
if (!photo || !photo.category_id) return null;
const category = await db('photo_categories').where('id', photo.category_id).first();
return category ? category.name : null;
};
it('takes the category from the manifest when the archive is flat', async () => {
// Exactly the shape a gallery-root event archives to: no directories.
const manifest = JSON.stringify([
{ filename: 'a.jpg', original_filename: 'DSC_0001.jpg', category_name: 'Polterabend' },
{ filename: 'b.jpg', original_filename: 'DSC_0002.jpg', category_name: 'Ceremony' },
]);
const archiveRelPath = await writeArchive('flat.zip', {
'a.jpg': PIXEL,
'b.jpg': PIXEL,
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'flat-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// The whole bug: both of these used to be null.
expect(await categoryOf('a.jpg')).toBe('Polterabend');
expect(await categoryOf('b.jpg')).toBe('Ceremony');
});
it('stores a real timestamp on restored photos, not "[object Object]"', async () => {
// The jest+sqlite landmine: a Date handed to knex inside jest stores as
// the literal string "[object Object]". Production writes ms-numbers and
// is unaffected, so this only ever corrupts what tests read back — which
// is how it survives unnoticed.
const archiveRelPath = await writeArchive('timestamp.zip', {
'individual/STAMPED.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'STAMPED.jpg', original_filename: 'STAMPED.jpg', category_name: 'Ceremony' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'timestamp-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const photo = await db('photos').where({ event_id: eventId, filename: 'STAMPED.jpg' }).first();
expect(String(photo.uploaded_at)).not.toBe('[object Object]');
expect(Number.isNaN(new Date(photo.uploaded_at).getTime())).toBe(false);
});
it('reuses an existing category row instead of creating a duplicate', async () => {
const archiveRelPath = await writeArchive('reuse.zip', {
'c.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'c.jpg', original_filename: 'DSC_0003.jpg', category_name: 'Party' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'reuse-event');
await db('photo_categories').insert({
event_id: eventId, name: 'Party', slug: 'party', created_at: new Date(),
});
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('c.jpg')).toBe('Party');
const rows = await db('photo_categories').where({ event_id: eventId, name: 'Party' });
expect(rows).toHaveLength(1);
});
it('still falls back to the directory for legacy archives with no manifest', async () => {
// No manifest at all — the shape every archive had before the manifest
// landed. The directory is the only signal left, and it must keep working.
//
// `individual/` is what a REAL archive contains: entry names are the
// storage key minus `events/active/{slug}`, and that layout is
// `individual/` / `collages/`. Categories have never been directories, so
// the fallback invents a category with that name — not useful, but better
// than losing every category, and this pins what actually happens rather
// than a category-shaped folder no archive produces.
const archiveRelPath = await writeArchive('foldered.zip', {
'individual/d.jpg': PIXEL,
});
const eventId = await seedArchivedEvent(archiveRelPath, 'foldered-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('d.jpg')).toBe('individual');
});
it('reuses a GLOBAL category instead of cloning it into the event', async () => {
// Seeded categories (Ceremony, Reception, ...) have event_id NULL. An
// event-only lookup misses them, so the restore used to create a second
// "Ceremony" — and because is_global defaults to TRUE, that duplicate then
// appeared in every other event's category list.
const [g] = await db('photo_categories').insert({
event_id: null, name: 'Ceremony', slug: 'ceremony', is_global: true, created_at: new Date(),
}).returning('id');
const globalId = typeof g === 'object' ? g.id : g;
const archiveRelPath = await writeArchive('global.zip', {
'individual/gl.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'gl.jpg', original_filename: 'DSC_1.jpg', category_name: 'Ceremony' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'global-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const photo = await db('photos').where('filename', 'gl.jpg').first();
expect(photo.category_id).toBe(globalId);
// No clone, global or otherwise.
const all = await db('photo_categories').where('name', 'Ceremony');
expect(all).toHaveLength(1);
});
it('does not create a GLOBAL category when it has to invent one', async () => {
// is_global defaults to true on this column, so an unqualified insert would
// leak a restore's category name into every gallery on the instance.
const archiveRelPath = await writeArchive('newcat.zip', {
'individual/nc.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'nc.jpg', original_filename: 'DSC_2.jpg', category_name: 'Polterabend' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'newcat-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const created = await db('photo_categories').where('name', 'Polterabend').first();
expect(created.event_id).toBe(eventId);
expect(created.is_global === false || created.is_global === 0).toBe(true);
});
it('matches the manifest when the ZIP was written with original filenames', async () => {
// With general_use_original_filenames_for_downloads on at archive time,
// archiveService names entries after the ORIGINAL filename while the
// manifest stays keyed by photos.filename. Looking up the extracted
// basename missed every entry, so categories were lost on exactly those
// archives.
const archiveRelPath = await writeArchive('original-names.zip', {
'individual/DSC_4242.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'stored_9f8e7d.jpg', original_filename: 'DSC_4242.jpg', category_name: 'Drohne' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'original-names-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('DSC_4242.jpg')).toBe('Drohne');
});
it('prefers the event-scoped category when a global shares its name', async () => {
// The category API permits both. A single OR-lookup with .first() returned
// whichever the engine chose, so a photo could be reassigned to the global
// row and lose event-local settings such as allow_downloads.
const archiveRelPath = await writeArchive('collide.zip', {
'individual/co.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'co.jpg', original_filename: 'DSC_3.jpg', category_name: 'Reception' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'collide-event');
await db('photo_categories').insert({
event_id: null, name: 'Reception', slug: 'reception-global', is_global: true, created_at: new Date(),
});
const [own] = await db('photo_categories').insert({
event_id: eventId, name: 'Reception', slug: 'reception-own', is_global: false, created_at: new Date(),
}).returning('id');
const ownId = typeof own === 'object' ? own.id : own;
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const photo = await db('photos').where('filename', 'co.jpg').first();
expect(photo.category_id).toBe(ownId);
});
it('matches a sanitized original filename, as the ZIP would have written it', async () => {
// archiveService runs original names through sanitizeForZipEntry() before
// writing the entry, so the emitted name differs from the manifest column.
const archiveRelPath = await writeArchive('sanitized.zip', {
'individual/od_dr_DSC_5.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'stored_abc.jpg', original_filename: 'od/dr/DSC_5.jpg', category_name: 'Strand' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'sanitized-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('od_dr_DSC_5.jpg')).toBe('Strand');
});
it('ignores a legacy event-owned row when falling back to globals', async () => {
// The bug fixed here left rows behind on upgraded instances: event-owned
// AND is_global true, because the column defaults true. Matching on the
// flag alone would let one event's leftover be adopted by another event's
// restore, tying photos to a category that vanishes with someone else's
// gallery.
const otherEventId = await seedArchivedEvent('archives/none.zip', 'legacy-owner-event');
await db('photo_categories').insert({
event_id: otherEventId, name: 'Sunset', slug: 'sunset-legacy',
is_global: true, created_at: new Date(),
});
const archiveRelPath = await writeArchive('legacy-global.zip', {
'individual/lg.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'lg.jpg', original_filename: 'DSC_6.jpg', category_name: 'Sunset' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'legacy-global-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const photo = await db('photos').where('filename', 'lg.jpg').first();
const cat = await db('photo_categories').where('id', photo.category_id).first();
// Its own row, not the other event's leftover.
expect(cat.event_id).toBe(eventId);
});
it('drops an ambiguous original-name alias rather than guessing', async () => {
// Two photos in different ZIP folders can share an original basename;
// archiveService treats the paths as distinct and suffixes neither. Both
// would collapse onto one alias, and whichever won would hand the other
// photo someone else's category.
const archiveRelPath = await writeArchive('ambiguous.zip', {
'individual/SHARED.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'a_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Alpha' },
{ filename: 'b_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Beta' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'ambiguous-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// Falls back to the directory rather than picking Alpha or Beta at random.
expect(await categoryOf('SHARED.jpg')).toBe('individual');
for (const name of ['Alpha', 'Beta']) {
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
}
});
it('honours a manifest that says UNCATEGORIZED, instead of inventing one from the directory', async () => {
// The case the manifest-first change was for. A real archive puts every
// photo under `individual/`, so a photo the manifest records as having no
// category used to come back filed under a category called "individual" —
// the manifest being authoritative for "category X" but not for "none".
const manifest = JSON.stringify([
{ filename: 'u.jpg', original_filename: 'DSC_7000.jpg', category_name: null },
]);
const archiveRelPath = await writeArchive('uncategorized.zip', {
'individual/u.jpg': PIXEL,
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'uncategorized-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('u.jpg')).toBeNull();
// And no junk category row was created as a side effect.
const rows = await db('photo_categories').where({ event_id: eventId });
expect(rows).toHaveLength(0);
});
it('drops a canonical filename that two photos claim, rather than guessing', async () => {
// photos.filename is not unique within an event: s3AutoImporter takes
// path.basename(entry.key) and dedupes by path, so two imported files in
// different subfolders both land as IMG_1234.jpg. Both ZIP entries reduce
// to the same basename at restore, so keeping the last row seen would give
// one photo the other's category.
const archiveRelPath = await writeArchive('dup-canonical.zip', {
'individual/IMG_1234.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'IMG_1234.jpg', original_filename: 'a.jpg', category_name: 'Alpha' },
{ filename: 'IMG_1234.jpg', original_filename: 'b.jpg', category_name: 'Beta' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'dup-canonical-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('IMG_1234.jpg')).toBe('individual');
for (const name of ['Alpha', 'Beta']) {
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
}
});
it("drops a name that one row owns canonically and another claims as an alias", async () => {
// Undecidable: with original-filename archiving ON the ZIP entry under
// this name is the ALIAS owner's file, with it OFF it is the canonical
// owner's, and the manifest does not record which mode was used. The
// point of the two-pass split is that this now resolves the same way
// every run — the archive query has no ORDER BY, so it used to be a coin
// flip between dropping the name and overwriting it.
const archiveRelPath = await writeArchive('alias-vs-canonical.zip', {
'individual/CANON.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'CANON.jpg', original_filename: 'unrelated.jpg', category_name: 'Canonical' },
{ filename: 'other_stored.jpg', original_filename: 'CANON.jpg', category_name: 'Aliased' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'alias-vs-canonical-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// Falls back to the directory rather than guessing either row.
expect(await categoryOf('CANON.jpg')).toBe('individual');
for (const name of ['Canonical', 'Aliased']) {
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
}
});
it('picks the lowest id and warns when two categories share a name', async () => {
// Allowed: two event-scoped categories with the same display name and
// different slugs. .first() used to pick either, so a re-run could move
// photos between them and inherit the wrong allow_downloads.
const archiveRelPath = await writeArchive('dupe-category.zip', {
'individual/DUPE.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'DUPE.jpg', original_filename: 'DUPE.jpg', category_name: 'Ceremony' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'dupe-category-event');
const [first] = await db('photo_categories').insert({
name: 'Ceremony', slug: 'ceremony-a', is_global: 0, event_id: eventId,
}).returning('id');
await db('photo_categories').insert({
name: 'Ceremony', slug: 'ceremony-b', is_global: 0, event_id: eventId,
});
const firstId = typeof first === 'object' ? first.id : first;
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// Stable, not arbitrary: the same run twice lands on the same row.
const photo = await db('photos').where({ event_id: eventId, filename: 'DUPE.jpg' }).first();
expect(photo.category_id).toBe(firstId);
// And no third "Ceremony" was invented.
expect((await db('photo_categories').where({ event_id: eventId, name: 'Ceremony' })).length)
.toBe(2);
});
it('does not invent a category for a photo row that already exists', async () => {
// archiveEvent retains photo rows, so a restore can skip every insert.
// Resolving categories before that check created one from the stale
// manifest name that nothing then used — renaming a category while its
// event was archived left the old name behind as an empty duplicate.
const archiveRelPath = await writeArchive('existing-rows.zip', {
'individual/KEPT.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'KEPT.jpg', original_filename: 'KEPT.jpg', category_name: 'OldName' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'existing-rows-event');
await db('photos').insert({
event_id: eventId, filename: 'KEPT.jpg', path: 'whatever/KEPT.jpg', type: 'jpg',
uploaded_at: new Date().toISOString(),
});
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await db('photo_categories').where({ event_id: eventId, name: 'OldName' }).first())
.toBeFalsy();
});
});
@@ -40,7 +40,7 @@ jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('GET /api/admin/system-health/backup-coverage', () => {
let db;
@@ -29,7 +29,7 @@ jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('GET /api/admin/system-health/backup-integrity', () => {
let cleanup;
@@ -1,246 +0,0 @@
/**
* POST /admin/thumbnails/regenerate for external/reference photos (#1129).
*
* STABLE TWIN. Diverges from the main version in one place: stable has no
* responsive ?w= tiers (#1095/#1109), so there is no deleteThumbnailTiers call
* to assert and the "drops the tiers first" test is absent here. Everything
* else — the external rebuild, the thumbnail_path:null contract, video
* skipping, per-event scoping and the superseded-key deletion — is identical.
*
* The route used to resolve every source as `storage/events/active/<path>` and
* `fs.access` it. External and reference rows do not live there — their
* originals sit under `events.external_path` — so every one of them failed the
* check and was counted as an error.
*
* That alone would be inert. What made it destructive is that the tier
* deletion runs FIRST (deliberately, so S3 and external rows are not skipped):
* on a reference install the button dropped every ?w= tier and rebuilt
* nothing, while the UI reported success — the response is sent before the
* background loop starts.
*
* The background work is fired with setImmediate, so every assertion here has
* to wait for it to drain rather than trusting the response.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('admin thumbnail regeneration (#1129)', () => {
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
// One instance, not a fresh object per call — the route and the
// assertions have to be looking at the same mock.
jest.doMock('../../src/services/storage', () => {
const instance = { delete: jest.fn().mockResolvedValue(undefined) };
return { getStorage: () => instance };
});
jest.doMock('../../src/services/imageProcessor', () => ({
ensureThumbnail: jest.fn().mockResolvedValue('thumbnails/thumb_ext1_shot.jpg'),
ensurePreviewImage: jest.fn().mockResolvedValue('previews/p.jpg'),
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
}));
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
// success, which ends the jest worker mid-suite.
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
imageProcessor = require('../../src/services/imageProcessor');
storage = require('../../src/services/storage').getStorage();
app = express();
app.use(express.json());
app.use('/admin/thumbnails', require('../../src/routes/adminThumbnails'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
jest.clearAllMocks();
await db('photos').del();
await db('events').del();
});
async function seedEvent() {
const [row] = await db('events').insert({
slug: 'nas-wedding', event_type: 'wedding', event_name: 'nas',
event_date: '2026-01-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: 'nas-share', expires_at: new Date().toISOString(),
source_mode: 'reference', external_path: 'weddings/2026-08',
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function seedPhoto(eventId, overrides = {}) {
const [row] = await db('photos').insert({
event_id: eventId, filename: 'shot.jpg', path: 'nas-wedding/shot.jpg',
type: 'individual', ...overrides,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** The work runs in setImmediate; give it room to finish. */
const drain = () => new Promise((resolve) => setTimeout(resolve, 150));
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'external',
external_relpath: 'shot.jpg',
thumbnail_path: 'thumbnails/stale.jpg',
});
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
expect(res.status).toBe(200);
await drain();
// The whole bug: this used to be zero calls and one logged
// "Original file not found" per photo.
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
it('nulls thumbnail_path so the valid-thumbnail short-circuit cannot skip the rebuild', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'external',
external_relpath: 'shot.jpg',
thumbnail_path: 'thumbnails/still-on-disk.jpg',
});
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// Without this the endpoint is a no-op whenever the OLD thumbnail is still
// readable — which is the normal case after a settings change, and exactly
// when the admin pressed the button.
const [photoArg] = imageProcessor.ensureThumbnail.mock.calls[0];
expect(photoArg.thumbnail_path).toBeNull();
expect(photoArg.source_origin).toBe('external');
// Carried through so ensureThumbnail can resolve off the mount rather than
// under events/active.
expect(photoArg.external_relpath).toBe('shot.jpg');
});
it('leaves videos alone rather than handing a container file to Sharp', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'managed', media_type: 'video', filename: 'clip.mp4' });
await seedPhoto(eventId, { source_origin: 'managed', filename: 'still.jpg' });
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(res.body.count).toBe(1);
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
expect(imageProcessor.ensureThumbnail.mock.calls[0][0].filename).toBe('still.jpg');
});
/**
* On S3, ensureThumbnail downloads the source to a randomly-named temp file,
* and for non-RAW input withProcessableImage passes no outputBasename — so
* generateThumbnail derives the key from that random name and it differs on
* every run. Nulling thumbnail_path hides the old key from everything that
* would otherwise clean it up, so each regeneration would strand a full
* thumbnail in the bucket, once per photo per run.
*/
describe('superseded canonical renditions', () => {
it('removes the old thumbnail when the key moved', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_OLDRANDOM_shot.jpg',
});
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEWRANDOM_shot.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).toHaveBeenCalledWith('thumbnails/thumb_OLDRANDOM_shot.jpg');
});
it('does NOT delete when the key is unchanged — that is the new file', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_stable.jpg',
});
// Local storage resolves to a stable path, so the key is identical.
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_stable.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).not.toHaveBeenCalled();
});
it.each([
['a Windows-style legacy path', 'thumbnails\\thumb_ext1_shot.jpg'],
['a leading ./', './thumbnails/thumb_ext1_shot.jpg'],
['a doubled separator', 'thumbnails//thumb_ext1_shot.jpg'],
])('does not delete the file it just wrote when the old path is %s', async (_name, stored) => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'managed', thumbnail_path: stored });
// Both storage backends fold these to the same key, so this is the SAME
// object — deleting it would remove the freshly generated thumbnail and
// leave the row pointing at nothing.
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_ext1_shot.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).not.toHaveBeenCalled();
});
it('counts the photo as regenerated even if the old object cannot be removed', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_OLD.jpg',
});
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEW.jpg');
storage.delete.mockRejectedValueOnce(new Error('bucket said no'));
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// Losing the old object is untidy; the regeneration itself succeeded.
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
});
it('scopes to one event when asked', async () => {
const a = await seedEvent();
await seedPhoto(a, { source_origin: 'external', external_relpath: 'a.jpg' });
const [b] = await db('events').insert({
slug: 'other', event_type: 'wedding', event_name: 'other', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: 'other-share', expires_at: new Date().toISOString(),
}).returning('id');
await seedPhoto(typeof b === 'object' ? b.id : b, { source_origin: 'managed' });
const res = await request(app).post('/admin/thumbnails/regenerate').send({ eventId: a });
await drain();
expect(res.body.count).toBe(1);
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
});
@@ -23,7 +23,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — configurable walker (backup_paths)', () => {
let db;
@@ -177,203 +177,4 @@ describe('backupService — configurable walker (backup_paths)', () => {
const filesOn = await backupService.getFilesToBackup(true);
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
});
// Issue #871 — the "What to Backup" checkboxes were stored but never read.
describe('UI opt-out toggles (issue #871)', () => {
it('unchecking Thumbnails excludes thumbnails/', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_thumbnails: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
});
it('unchecking Photos excludes events/active', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_photos: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).not.toContain('events/active/E1/photo.jpg');
});
it('defaults to including everything when the keys were never saved', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).toContain('events/active/E1/photo.jpg');
});
it("accepts the UI's plural backup_include_archives for the archived gate", async () => {
seedFile('events/archived/E4/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archives: true,
});
expect(files.map((f) => f.relativePath)).toContain('events/archived/E4/archived.jpg');
});
it('the UI plural key beats the migration-seeded singular key', async () => {
// Migration seeds backup_include_archived=true on every install; the
// form only ever writes the plural key, so unchecking Archives must
// win over the stale seeded value.
seedFile('events/archived/E5/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archived: true, // seeded default
backup_include_archives: false, // what the admin actually chose
});
expect(files.map((f) => f.relativePath)).not.toContain('events/archived/E5/archived.jpg');
});
it('rsync gets the de-selected paths and noise filters as --exclude args', async () => {
const excluded = await backupService.resolveExcludedBackupPaths({
backup_include_thumbnails: false,
backup_include_archives: false,
});
expect(excluded.map((r) => r.path)).toEqual(
expect.arrayContaining(['thumbnails', 'events/archived'])
);
const args = backupService.buildRsyncArgs(
{ backup_rsync_host: 'backup.example.com', backup_rsync_path: '/srv/backups' },
excluded.map((r) => `/${r.path}/`)
);
const excludes = args
.map((a, i) => (a === '--exclude' ? args[i + 1] : null))
.filter(Boolean);
expect(excludes).toEqual(expect.arrayContaining([
'.nfs*',
'/thumbnails/',
'/events/archived/',
]));
});
it('rows toggled off via include_in_default also become rsync excludes', async () => {
// The enabled-only loader hides these rows from the walker, but rsync
// syncs the whole storage root, so they must still appear as excludes.
await db('backup_paths').where('path', 'previews').update({
include_in_default: false,
});
const excluded = await backupService.resolveExcludedBackupPaths({});
expect(excluded.map((r) => r.path)).toContain('previews');
});
});
// Issue #871 — .nfs* silly-rename artifacts were uploaded to S3.
it('never backs up filesystem noise (.nfs*, .DS_Store)', async () => {
seedFile('thumbnails/E1/.nfs000000000000006600000008');
seedFile('events/active/E1/.DS_Store');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels.some((r) => r.includes('.nfs'))).toBe(false);
expect(rels.some((r) => r.includes('.DS_Store'))).toBe(false);
});
it('the walker honors backup_exclude_patterns (previously rsync-only)', async () => {
seedFile('events/active/E1/photo.jpg');
seedFile('events/active/E1/scratch.tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('events/active/E1/scratch.tmp');
});
it('glob patterns are literal outside the star (.nfs* must not eat anfs-…)', async () => {
seedFile('events/active/E1/anfs-photo.jpg');
seedFile('events/active/E1/notes-tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
// '.nfs*' used to compile to /^.nfs.*$/ whose dot matched any char;
// '*.tmp' used to compile to /^.*.tmp$/ which also matched 'notes-tmp'.
expect(rels).toContain('events/active/E1/anfs-photo.jpg');
expect(rels).toContain('events/active/E1/notes-tmp');
});
// Issue #871 — weekly schedules silently ran daily, and the dashboard's
// "next backup" was a hardcoded "tomorrow 02:00".
describe('schedule resolution + next run (issue #871)', () => {
it('a named label beats the stray default cron the UI used to send', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *', // old UI default, sent unconditionally
})).toBe('0 3 * * 0');
});
it('custom schedules use the cron field', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'custom',
backup_schedule_cron: '15 5 * * 2',
})).toBe('15 5 * * 2');
});
it('falls back to the default daily cron', () => {
expect(backupService.resolveScheduleCron({})).toBe('0 2 * * *');
});
it('getNextScheduledRun is null when backups are disabled', () => {
expect(backupService.getNextScheduledRun(null)).toBeNull();
expect(backupService.getNextScheduledRun({ backup_enabled: false })).toBeNull();
});
it('getNextScheduledRun returns the real next weekly fire time', () => {
const iso = backupService.getNextScheduledRun({
backup_enabled: true,
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *',
});
const next = new Date(iso);
expect(Number.isNaN(next.getTime())).toBe(false);
expect(next.getTime()).toBeGreaterThan(Date.now());
expect(next.getDay()).toBe(0); // Sunday
expect(next.getHours()).toBe(3); // 03:00
});
});
// Issue #871 — "Backup Size: 167.6 TB": file_size_bytes is a bigInteger
// column, node-postgres returns int8 as a string, and the S3 path did
// `backedUpSize += size` — string concatenation.
it('getDatabaseBackupInfo coerces file_size_bytes to a number', async () => {
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
backup_type: 'full',
status: 'completed',
file_path: '/backups/db/dump.sql.gz',
// Simulate the PG int8-as-string driver behaviour (sqlite stores
// whatever it is handed, so the string round-trips).
file_size_bytes: '421988',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
});
const info = await backupService.getDatabaseBackupInfo();
expect(typeof info.size).toBe('number');
expect(info.size).toBe(421988);
});
});
@@ -34,7 +34,7 @@ jest.mock('../../src/services/databaseBackup', () => ({
DatabaseBackupService: class {},
}));
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — inline DB dump + fail-loud guard', () => {
let db;
@@ -23,7 +23,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — per-Stage-B-path statistics', () => {
let db;
@@ -14,7 +14,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupService — config + file collection + manifest (smoke)', () => {
let db;
@@ -7,7 +7,7 @@
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('booking cutover — draft invoices on hold', () => {
let db; let cleanup; let adminId; let customerId; let quoteService;
@@ -1,227 +0,0 @@
/**
* Backfilling captured_at on a library imported before #1172.
*
* The point of the endpoint, rather than a migration: it resolves originals
* through resolvePhotoFilePath, which is the only path that reaches an
* external row. The thumbnail regenerator resolves under
* storage/events/active/<photo.path>, which never exists for those (#1129) —
* so it cannot be the model.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const sharp = require('sharp');
describe('capture date backfill (#1172)', () => {
let tmpDir; let db; let app; let mediaRoot;
const writeJpegWithExif = async (abs, iso) => {
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 9, g: 9, b: 9 } } })
.withExif({ IFD2: { DateTimeOriginal: exifDate } }).jpeg().toFile(abs);
};
const settle = async () => { for (let i = 0; i < 60; i++) { await new Promise((r) => setTimeout(r, 50)); const s = await status(); if (!s.body.isRunning) return s; } throw new Error('backfill did not settle'); };
const status = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capfill-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capfill-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seed({ relpath, exifIso, writeFile = true, archived = false }) {
await db('photos').del();
await db('events').del();
const [e] = await db('events').insert({
slug: 'capfill', event_type: 'wedding', event_name: 'capfill', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `capfill-${Math.random()}`, expires_at: new Date().toISOString(),
source_mode: 'reference', external_path: 'trip', is_archived: archived,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
if (writeFile) await writeJpegWithExif(path.join(mediaRoot, 'trip', relpath), exifIso);
const [p] = await db('photos').insert({
event_id: eventId, filename: path.basename(relpath), path: `capfill/${path.basename(relpath)}`,
// Root-relative, as this branch stores it (#1163) — the file lives at
// <mediaRoot>/trip/<relpath>.
type: 'individual', source_origin: 'external', external_relpath: `trip/${relpath}`,
uploaded_at: new Date().toISOString(), captured_at: null,
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
it('fills captured_at for an external photo the thumbnail regenerator cannot reach', async () => {
const { photoId } = await seed({ relpath: 'a.jpg', exifIso: '2026-06-01T09:45:03Z' });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
const done = await settle();
expect(done.body.lastResult.success).toBe(1);
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeTruthy();
});
it('counts a photo with no EXIF separately from a failure', async () => {
// "The mount is broken" and "these files carry no date" need different
// answers from an operator, so they are not the same number.
await db('photos').del(); await db('events').del();
const { photoId } = await seed({ relpath: 'plain.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await sharp({ create: { width: 40, height: 30, channels: 3, background: { r: 1, g: 1, b: 1 } } })
.jpeg().toFile(path.join(mediaRoot, 'trip', 'plain.jpg'));
await request(app).post('/api/admin/photos/repair-capture-dates');
const done = await settle();
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 1, failed: 0 });
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
});
it('counts an unreachable original as a failure, not as missing EXIF', async () => {
await seed({ relpath: 'gone.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await request(app).post('/api/admin/photos/repair-capture-dates');
const done = await settle();
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 0, failed: 1 });
});
it('reports nothing to do once every photo has a date', async () => {
const { photoId } = await seed({ relpath: 'b.jpg', exifIso: '2026-06-02T09:00:00Z' });
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
expect((await status()).body.withoutCaptureDate).toBe(0);
});
it('skips a watcher-imported video, which carries media_type "image"', async () => {
// fileWatcher.processNewPhoto sets type='video' and a video/* mime but
// never media_type (fileWatcher.js:128-130), so the row keeps the 'image'
// default from migration 048. Filtering on media_type alone queued it every
// run: extractCaptureDate returns null for a video, captured_at stays null,
// and the backlog never cleared.
const { eventId } = await seed({ relpath: 'clip.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await db('photos').del();
await db('photos').insert({
event_id: eventId, filename: 'clip.mp4', path: 'capfill/clip.mp4',
type: 'video', media_type: 'image', mime_type: 'video/mp4',
source_origin: 'external', external_relpath: 'trip/clip.mp4',
uploaded_at: new Date().toISOString(), captured_at: null,
});
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const s = await status();
// And it is not counted as a permanent backlog either.
expect(s.body.total).toBe(0);
expect(s.body.withoutCaptureDate).toBe(0);
});
it('never reports more dated photos than it has photos', async () => {
// Both counts come from one aggregate; as two queries an import committing
// between them produced withCaptureDate > total and a negative backlog.
const { photoId } = await seed({ relpath: 'counted.jpg', exifIso: '2026-06-05T08:00:00Z' });
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
const s = await status();
expect(s.body.total).toBe(1);
expect(s.body.withCaptureDate).toBe(1);
expect(s.body.withoutCaptureDate).toBe(0);
expect(s.body.withoutCaptureDate).toBeGreaterThanOrEqual(0);
});
it('skips archived events instead of failing them on every run', async () => {
// Archiving deletes the originals and keeps the rows, so an archived photo
// can never get a date. Counting it would fail it every pass and leave the
// status endpoint permanently reporting a backlog.
await seed({ relpath: 'archived.jpg', exifIso: '2026-06-04T09:00:00Z', archived: true });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const s = await status();
expect(s.body.total).toBe(0);
expect(s.body.withoutCaptureDate).toBe(0);
expect(s.body.isRunning).toBe(false);
});
it('does not overwrite a date written while it was running', async () => {
// whereNull on the update: an import or a replacement finishing mid-run has
// already written a better value than this pass would.
const { photoId } = await seed({ relpath: 'c.jpg', exifIso: '2026-06-03T09:00:00Z' });
const claimed = '2020-01-01T00:00:00.000Z';
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(1);
await db('photos').where({ id: photoId }).update({ captured_at: claimed });
const done = await settle();
expect(new Date((await db('photos').where({ id: photoId }).first()).captured_at).toISOString()).toBe(claimed);
expect(done.body.lastResult.success).toBe(0);
// Read but not written, so it is accounted for rather than dropped.
expect(done.body.lastResult.skipped).toBe(1);
});
it('does not date a row whose file was replaced while it was reading (#1201)', async () => {
// replacePhoto swaps a NEW file under an existing row and rewrites
// path/filename (reachable from replace_by_name). The replacement carries
// no date of its own, so captured_at is still NULL and the whereNull guard
// alone would let the previous file's EXIF date land on it. The write is
// fenced on the identity that was read, so the row is skipped instead —
// and not counted as updated either.
const { photoId } = await seed({ relpath: 'orig.jpg', exifIso: '2026-06-03T09:00:00Z' });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(1);
// Simulate the replacement landing before the loop writes.
await db('photos').where({ id: photoId })
.update({ path: 'capfill/replaced.jpg', filename: 'replaced.jpg' });
const done = await settle();
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
expect(done.body.lastResult.success).toBe(0);
// Not an error and not "no EXIF" — the date was found, another writer just
// got there first. It stays in the backlog for the next run.
expect(done.body.lastResult).toMatchObject({ noExif: 0, failed: 0, skipped: 1 });
});
});
@@ -14,7 +14,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService,
// nodemailer, etc.) on first use; the global 5 s per-test budget is
// too tight for that. Bump it for this file only.
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('discount line items (negative unit_price_minor)', () => {
let db;
@@ -6,7 +6,7 @@
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll.
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('event type slug rename cascade', () => {
let db;
@@ -1,161 +0,0 @@
/**
* External imports must record captured_at (#1172).
*
* Managed uploads get it from photoProcessor, which external media never goes
* through — so every externally imported photo carried captured_at NULL, and
* the gallery's "Date Taken" sort fell back to uploaded_at through its
* COALESCE. On a library imported in two batches that ordered a 12-day trip by
* which folder was imported first: the reporter's first two days landed at
* positions 4204-5296 of 5555.
*
* Driven through the real route against real files carrying real EXIF, because
* the whole question is whether the import reads the file it already has open.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const sharp = require('sharp');
describe('external import capture dates (#1172)', () => {
let tmpDir; let db; let app; let mediaRoot;
/**
* A real JPEG carrying DateTimeOriginal.
*
* IFD2, not IFD0 — DateTimeOriginal lives in the Exif IFD, and exifr does not
* see it anywhere else (IFD0 takes plain DateTime, which surfaces as
* ModifyDate instead).
*/
const writeJpegWithExif = async (rel, iso) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 10, g: 20, b: 30 } } })
.withExif({ IFD2: { DateTimeOriginal: exifDate } })
.jpeg()
.toFile(full);
return full;
};
const writeJpegNoExif = async (rel) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 200, g: 10, b: 10 } } })
.jpeg().toFile(full);
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capdate-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capdate-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('../../src/services/imageProcessor', () => {
const actual = jest.requireActual('../../src/services/imageProcessor');
return { ...actual, generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'), ensureThumbnail: jest.fn() };
});
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
const [e] = await db('events').insert({
slug: `capdate-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding', event_name: 'capdate', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `capdate-${Math.random()}`, expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId, external_path) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path, recursive: true });
it('records the EXIF capture date on import', async () => {
const eventId = await seedEvent();
await writeJpegWithExif('trip/a.jpg', '2026-06-01T09:45:03Z');
await runImport(eventId, 'trip');
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.captured_at).toBeTruthy();
// NOT asserted as an absolute instant. EXIF carries a naive wall-clock
// time and exifr resolves it against the HOST timezone, so the stored UTC
// value differs between a CEST developer machine and a UTC runner. What
// this fix is about is that the field is populated and orders correctly;
// that captured_at is not a true instant is a separate, pre-existing
// problem shared with managed uploads (#1172's own footnote).
expect(new Date(photo.captured_at).getUTCFullYear()).toBe(2026);
expect(new Date(photo.captured_at).getUTCMonth()).toBe(5); // June
});
it('imports a photo with no EXIF date rather than failing it', async () => {
// Plenty of sources carry none; that must stay an import, not an error.
const eventId = await seedEvent();
await writeJpegNoExif('trip/plain.jpg');
const res = await runImport(eventId, 'trip');
expect(res.body.imported).toBe(1);
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.captured_at).toBeNull();
});
it('orders a two-batch import by capture time, not by batch', async () => {
// The reported shape: the FIRST days of the trip imported second. Sorting
// on COALESCE(captured_at, uploaded_at) put them after the last days,
// because uploaded_at is the import timestamp.
const eventId = await seedEvent();
await writeJpegWithExif('late/day12.jpg', '2026-06-12T10:00:00Z');
await runImport(eventId, 'late');
await writeJpegWithExif('early/day01.jpg', '2026-06-01T10:00:00Z');
await runImport(eventId, 'early');
const rows = await db('photos')
.where({ event_id: eventId })
.orderByRaw('COALESCE(captured_at, uploaded_at) asc')
.select('filename');
expect(rows.map((r) => r.filename)).toEqual(['day01.jpg', 'day12.jpg']);
});
});
@@ -1,205 +0,0 @@
/**
* Two overlapping external imports insert every file twice (#1162).
*
* The route checked for an existing external_relpath and then inserted, with
* an fs.stat and a `sharp().metadata()` read sitting in between. A reporter
* double-clicked a slow import of a 6012-file tree and got 8004 rows.
*
* Both halves of the fix are driven here through the real route:
*
* - the in-flight guard, which turns the second click into a 409 instead of
* a second full walk of the tree;
* - convergence when the guard cannot help (another replica, another
* process), which is the unique index from migration 186 firing and the
* loop counting a skip rather than dying or duplicating.
*
* The second is exercised by inserting a competing row from inside the mocked
* `sharp().metadata()` call — literally inside the window the bug lived in.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('concurrent external imports (#1162)', () => {
let tmpDir; let db; let app; let mediaRoot;
// When set, the mocked sharp metadata read inserts this row first — the
// other run winning the race between our SELECT and our INSERT.
let stealDuringMetadata = null;
let thumbnailDelayMs = 0;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extdup-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
}
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extdup-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
// The window. In production this is a real decode of a NAS-hosted file —
// hundreds of milliseconds during which the row we just proved absent can
// appear. Standing in for the other run here makes that deterministic.
jest.doMock('sharp', () => () => ({
metadata: async () => {
if (stealDuringMetadata) {
const { db: liveDb } = require('../../src/database/db');
await liveDb('photos').insert(stealDuringMetadata);
stealDuringMetadata = null;
}
return { width: 100, height: 200 };
},
}));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(async () => {
if (thumbnailDelayMs) await new Promise((r) => setTimeout(r, thumbnailDelayMs));
return 'thumbnails/mock.jpg';
}),
ensureThumbnail: jest.fn(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
stealDuringMetadata = null;
thumbnailDelayMs = 0;
const [e] = await db('events').insert({
slug: `extdup-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'extdup',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `extdup-${Math.random()}`,
expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path: 'nas', recursive: true });
async function relpathCounts(eventId) {
const rows = await db('photos').where({ event_id: eventId }).select('external_relpath');
const counts = new Map();
for (const r of rows) counts.set(r.external_relpath, (counts.get(r.external_relpath) || 0) + 1);
return counts;
}
it('rejects a second import while the first is still running', async () => {
const eventId = await seedEvent();
// Enough to keep the first request inside its loop while the second
// arrives — the "slow import looks hung, so I clicked again" case.
thumbnailDelayMs = 20;
const [first, second] = await Promise.all([runImport(eventId), runImport(eventId)]);
const statuses = [first.status, second.status].sort();
expect(statuses).toEqual([200, 409]);
const rejected = first.status === 409 ? first : second;
expect(rejected.body.error).toMatch(/already running/i);
});
it('leaves exactly one row per file after both runs', async () => {
const eventId = await seedEvent();
thumbnailDelayMs = 20;
await Promise.all([runImport(eventId), runImport(eventId)]);
const counts = await relpathCounts(eventId);
expect(counts.size).toBe(3);
expect([...counts.values()]).toEqual([1, 1, 1]);
});
it('releases the event once the import finishes, so a re-import still works', async () => {
const eventId = await seedEvent();
expect((await runImport(eventId)).status).toBe(200);
// Not 409 — the guard is per run, not a permanent lock on the event.
const second = await runImport(eventId);
expect(second.status).toBe(200);
expect(second.body.imported).toBe(0);
expect(second.body.skipped).toBe(3);
});
it('converges when another writer wins the race mid-file', async () => {
// The guard is in-process, so it cannot see a second replica. This is what
// the unique index is for: the insert bounces, and the file is counted as
// skipped rather than duplicated or lost to a 500.
const eventId = await seedEvent();
stealDuringMetadata = {
event_id: eventId,
filename: 'a.jpg',
path: 'x/a.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: path.join('nas', 'individual', 'a.jpg'),
};
const res = await runImport(eventId);
expect(res.status).toBe(200);
const counts = await relpathCounts(eventId);
expect(counts.get(path.join('nas', 'individual', 'a.jpg'))).toBe(1);
// Two imported by us, one lost to the other writer and reported honestly.
expect(res.body.imported).toBe(2);
expect(res.body.skipped).toBe(1);
});
it('does not let one contended file abort the rest of the import', async () => {
const eventId = await seedEvent();
stealDuringMetadata = {
event_id: eventId,
filename: 'a.jpg',
path: 'x/a.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: path.join('nas', 'individual', 'a.jpg'),
};
await runImport(eventId);
// All three files present — the contended one via the other writer's row.
expect((await relpathCounts(eventId)).size).toBe(3);
});
});
@@ -1,175 +0,0 @@
/**
* Importing a second folder must not move the photos already in the event (#1163).
*
* events.external_path is overwritten by every import, and external_relpath
* used to be stored relative to it — so a second import silently rebased every
* existing row onto the new folder. The reporter had 7547 of 8004 originals
* pointing at files that do not exist, and nothing said so: thumbnails are
* written to local storage during the import while the base path is still
* correct, so the grid carries on rendering.
*
* Driven through the real route and the real resolver, against a real
* directory tree — the failure is entirely about whether a file is where the
* app looks for it.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('a second external import (#1163)', () => {
let tmpDir; let db; let app; let mediaRoot; let resolvePhotoFilePath;
const touch = async (rel) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await fs.promises.writeFile(full, 'not-a-real-jpeg');
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-ext2nd-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'ext2nd-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('sharp', () => () => ({ metadata: async () => ({ width: 100, height: 200 }) }));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'),
ensureThumbnail: jest.fn(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
({ resolvePhotoFilePath } = require('../../src/services/photoResolver'));
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
const [e] = await db('events').insert({
slug: `ext2nd-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'ext2nd',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `ext2nd-${Math.random()}`,
expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId, external_path) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path, recursive: true });
/** Where the app would go looking for this photo's original, right now. */
async function resolved(eventId, filename) {
const event = await db('events').where({ id: eventId }).first();
const photo = await db('photos').where({ event_id: eventId, filename }).first();
return resolvePhotoFilePath(event, photo);
}
it('stores paths relative to the media root, not to the imported folder', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/old.jpg');
await runImport(eventId, 'Trip');
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.external_relpath).toBe(path.join('Trip', 'Leknes', 'old.jpg'));
});
it('leaves the first folders originals reachable after a second import', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/old.jpg');
await touch('Trip/Sub/new.jpg');
await runImport(eventId, 'Trip');
const before = await resolved(eventId, 'old.jpg');
await runImport(eventId, 'Trip/Sub');
const after = await resolved(eventId, 'old.jpg');
// The regression: `after` used to be <root>/Trip/Sub/Leknes/old.jpg.
expect(after).toBe(before);
expect(fs.existsSync(after)).toBe(true);
});
it('every original in the event is still on disk afterwards', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/a.jpg');
await touch('Trip/Leknes/b.jpg');
await touch('Trip/Sub/c.jpg');
await runImport(eventId, 'Trip');
await runImport(eventId, 'Trip/Sub');
const event = await db('events').where({ id: eventId }).first();
const photos = await db('photos').where({ event_id: eventId });
expect(photos).toHaveLength(3);
for (const photo of photos) {
expect(fs.existsSync(resolvePhotoFilePath(event, photo))).toBe(true);
}
});
it('does not re-insert a file the first import already took', async () => {
// The dedupe check compares stored paths, so it has to be comparing the
// same shape the insert writes.
const eventId = await seedEvent();
await touch('Trip/Sub/c.jpg');
await runImport(eventId, 'Trip');
const second = await runImport(eventId, 'Trip/Sub');
expect(second.body.imported).toBe(0);
expect(second.body.skipped).toBe(1);
expect(await db('photos').where({ event_id: eventId }).count('* as c').first()).toEqual({ c: 1 });
});
it('resolves a subfolder that repeats its parents name', async () => {
// The old resolver stripped the relpath's first segment when it matched the
// base path's last one, which broke exactly this layout.
const eventId = await seedEvent();
await touch('Trip/Trip/x.jpg');
await runImport(eventId, 'Trip');
const event = await db('events').where({ id: eventId }).first();
const photo = await db('photos').where({ event_id: eventId }).first();
expect(resolvePhotoFilePath(event, photo)).toBe(path.join(mediaRoot, 'Trip', 'Trip', 'x.jpg'));
});
});
@@ -1,121 +0,0 @@
/**
* PostgreSQL integration test for the external-path fold (#1163).
*
* Gated the same way as picpeakRestorePg: runs only when PICPEAK_PG_TEST_URL
* points at a throwaway Postgres DB, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_fold_test" \
* npx jest __tests__/integration/externalRelpathFoldPg.test.js
*
* This exists because of a defect SQLite could not have caught. The two-pass
* rewrite parks each row on a temporary value, and that value was first written
* with a leading NUL. SQLite stores NUL in TEXT without complaint; Postgres
* rejects it outright ("invalid byte sequence for encoding UTF8"), so migration
* 187 would have rolled back on exactly the installs needing the repair — and
* only on the engine most of them run.
*
* The staging value is therefore an engine-level contract, not an
* implementation detail, and it is pinned here on the engine that constrains it.
*/
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('external relpath fold on Postgres', () => {
let pgDb; let mediaRoot; let fold;
const touch = async (rel, bytes) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await fs.promises.writeFile(full, Buffer.alloc(bytes));
return bytes;
};
beforeAll(async () => {
mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-foldpg-'));
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
jest.resetModules();
({ foldExternalRelpaths: fold } = require('../../src/services/externalRelpathFold'));
pgDb = knex({ client: 'pg', connection: PG_URL });
}, 60000);
afterAll(async () => {
if (pgDb) await pgDb.destroy();
await fs.promises.rm(mediaRoot, { recursive: true, force: true }).catch(() => {});
delete process.env.EXTERNAL_MEDIA_ROOT;
});
beforeEach(async () => {
await pgDb.raw('DROP TABLE IF EXISTS photos, events, app_settings CASCADE');
await pgDb.schema.createTable('events', (t) => {
t.increments('id');
t.text('external_path');
});
await pgDb.schema.createTable('photos', (t) => {
t.increments('id');
t.integer('event_id');
t.text('external_relpath');
t.bigInteger('size_bytes');
t.string('source_origin').defaultTo('managed');
});
await pgDb.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key');
t.text('setting_value');
t.string('setting_type');
t.string('updated_at');
});
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
});
const relpaths = async () =>
(await pgDb('photos').orderBy('id').select('external_relpath')).map((r) => r.external_relpath);
it('completes the two-pass repair that a NUL staging value would abort', async () => {
// The exact shape that forces staging: `photo.jpg` repairs up to
// `Trip/photo.jpg`, while the row already holding `Trip/photo.jpg` folds
// deeper. Every final value is distinct, but a final value equals another
// row's current one, so the rewrite has to park first.
const a = await touch('Trip/photo.jpg', 11);
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
await pgDb('events').insert({ id: 1, external_path: 'Trip/Sub' });
await pgDb('photos').insert([
{ event_id: 1, external_relpath: 'photo.jpg', size_bytes: a, source_origin: 'external' },
{ event_id: 1, external_relpath: 'Trip/photo.jpg', size_bytes: b, source_origin: 'external' },
]);
await fold(pgDb);
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
});
it('leaves no staging value behind', async () => {
await touch('Trip/a.jpg', 8);
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
await fold(pgDb);
const rows = await relpaths();
expect(rows).toEqual(['Trip/a.jpg']);
expect(rows.some((r) => r.includes('staging'))).toBe(false);
});
it('folds and marks in one transaction', async () => {
await touch('Trip/a.jpg', 8);
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
await fold(pgDb);
// Second run is a no-op: the marker committed with the rewrites.
await fold(pgDb);
expect(await relpaths()).toEqual(['Trip/a.jpg']);
});
});
@@ -1,216 +0,0 @@
/**
* Guest filters must respect show_feedback_to_guests (#1044 follow-up).
*
* Every filter token on /photos is an OR of two halves: what THIS viewer
* marked, and what ANYONE marked. The response fields built from the second
* half — like_count, comment_count — are all gated on
* show_feedback_to_guests. The FILTER was not.
*
* So with the setting off, the numbers were hidden but `?filter=liked` still
* returned exactly the photos other people had liked: the same information as
* a set instead of a count, one token at a time. These tests pin the gate on
* every token, and pin that the viewer's own half is never gated — filtering
* by what you yourself marked is yours to do regardless.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'filter-visibility-secret';
const SLUG = 'filter-visibility-event';
const ME = 'guest-me-identifier';
const SOMEONE_ELSE = 'guest-other-identifier';
describe('guest filters and show_feedback_to_guests (#1044)', () => {
let db;
let cleanup;
let app;
let eventId;
let mine;
let theirs;
let myGuestRowId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const setVisibility = (visible) => db('event_feedback_settings')
.where({ event_id: eventId })
.update({ show_feedback_to_guests: visible });
// A real verified guest, which is how the viewer's own feedback is actually
// identified — NOT the `guest_id` query parameter the frontend invents.
const guestToken = () => jwt.sign(
{ type: 'guest', guestId: myGuestRowId, eventId },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const filter = async (token, { as = 'me', claimGuestId } = {}) => {
const req = request(app)
.get(`/api/gallery/${SLUG}/photos`)
.query({ filter: token, ...(claimGuestId ? { guest_id: claimGuestId } : {}) })
.set('Authorization', `Bearer ${galleryToken()}`);
if (as === 'me') req.set('x-guest-token', guestToken());
const res = await req;
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Filter Visibility',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'filter-visibility-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const addPhoto = async (name) => {
const p = await db('photos').insert({
event_id: eventId,
filename: name,
path: `events/filter/${name}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
return p[0]?.id ?? p[0];
};
mine = await addPhoto('mine.jpg');
theirs = await addPhoto('theirs.jpg');
await db('event_feedback_settings').insert({
event_id: eventId,
feedback_enabled: true,
allow_likes: true,
allow_comments: true,
allow_ratings: true,
allow_favorites: true,
moderate_comments: false,
show_feedback_to_guests: true,
});
const guestRow = await db('gallery_guests').insert({
event_id: eventId,
name: 'Me',
identifier: ME,
created_at: new Date().toISOString(),
last_seen_at: new Date().toISOString(),
is_deleted: false,
}).returning('id');
myGuestRowId = guestRow[0]?.id ?? guestRow[0];
const feedback = (photoId, who, type, extra = {}) => db('photo_feedback').insert({
photo_id: photoId,
event_id: eventId,
guest_identifier: who,
// Submission links to the per-person guest row when one is present, and
// that is the column the viewer's own half resolves through.
guest_id: who === ME ? myGuestRowId : null,
feedback_type: type,
is_approved: true,
is_hidden: false,
created_at: new Date().toISOString(),
...extra,
});
// Everything on `theirs` belongs to somebody else; `mine` is this viewer's.
await feedback(mine, ME, 'like');
await feedback(theirs, SOMEONE_ELSE, 'like');
await feedback(theirs, SOMEONE_ELSE, 'favorite');
await feedback(theirs, SOMEONE_ELSE, 'comment', { comment_text: 'lovely' });
await feedback(theirs, SOMEONE_ELSE, 'rating', { rating: 5 });
// The denormalized counters the aggregate half of the filter reads.
await db('photos').where('id', theirs).update({
like_count: 1, favorite_count: 1, comment_count: 1, average_rating: 5,
});
await db('photos').where('id', mine).update({ like_count: 1 });
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('with feedback visible to guests', () => {
beforeAll(() => setVisibility(true));
it('shows other people\'s marks through every token, as before', async () => {
expect(await filter('liked')).toEqual([mine, theirs].sort((a, b) => a - b));
expect(await filter('favorited')).toEqual([theirs]);
expect(await filter('rated')).toEqual([theirs]);
expect(await filter('commented')).toEqual([theirs]);
});
});
describe('with feedback hidden from guests', () => {
beforeAll(() => setVisibility(false));
it('stops every token from selecting on other people\'s marks', async () => {
// `theirs` is the photo only other guests marked. It must not come back
// through any token — a filter that selects on hidden feedback reports
// that feedback just as surely as a count would.
expect(await filter('favorited')).toEqual([]);
expect(await filter('rated')).toEqual([]);
expect(await filter('commented')).toEqual([]);
});
it('still filters by what the viewer marked themselves', async () => {
// The viewer's own half is never gated: this is their own action, and
// hiding it would break "show me the ones I liked" for no privacy gain.
expect(await filter('liked')).toEqual([mine]);
});
it('drops the viewer\'s own feedback once an admin hides it', async () => {
// Moderation has to reach the filter too. getPhotoFeedback excludes
// hidden rows for the guest's OWN feedback, so a photo matching here
// would come back with nothing visible on it to explain why.
await db('photo_feedback')
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
.update({ is_hidden: true });
expect(await filter('liked')).toEqual([]);
await db('photo_feedback')
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
.update({ is_hidden: false });
expect(await filter('liked')).toEqual([mine]);
});
it('ignores a guest_id supplied by the caller', async () => {
// The own-half is resolved from the request identity. If it honoured the
// query string instead, anyone holding another guest's identifier could
// read that guest's hidden memberships one token at a time — straight
// back through the gate this file exists to pin.
expect(await filter('favorited', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
// And an anonymous caller claiming to be me gets nothing of mine.
expect(await filter('liked', { as: 'anon', claimGuestId: ME })).toEqual([]);
});
});
});
@@ -17,7 +17,7 @@ const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(60000);
let db; let cleanup; let service; let app;
@@ -19,7 +19,7 @@
*/
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(60000);
let db; let cleanup; let service; let adminId;
@@ -1,245 +0,0 @@
/**
* Hidden feedback, seen from the guest who left it (#1150).
*
* Everything in the system treats a hidden row as absent: getPhotoFeedback
* drops it even for the guest's own feedback, the /photos filters drop it, and
* updatePhotoFeedbackStats does not count it. One place disagreed — the
* per-viewer `is_liked` heart — so a like the photographer had hidden still
* showed as liked on a photo whose like_count was zero. (The `my_color_label`
* badge has the same shape on main; colour labels are not on this branch.)
*
* Making those two agree exposes the second half: the duplicate check that
* powers like/favorite toggling did NOT skip hidden rows, so the now-empty
* heart, when clicked, found the hidden row and toggled it OFF. The click
* appeared to do nothing and it took two more to get back to a filled heart.
*
* Hiding a non-comment is deliberate, not an accident of the raw route: #839
* and #1044 both ship it, with tests asserting that a hidden reaction or
* colour label stops counting. So the fix is to make hidden mean absent
* consistently — not to stop admins hiding these.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-feedback-secret';
const SLUG = 'hidden-own-feedback';
const ME = 'guest-me-identifier';
describe('a guest\'s own hidden feedback (#1150)', () => {
let db; let cleanup; let app; let feedbackService;
let eventId; let photoId; let myGuestRowId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const guestToken = () => jwt.sign(
{ type: 'guest', guestId: myGuestRowId, eventId },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const getPhoto = async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`)
.set('x-guest-token', guestToken());
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return (photos || []).find((p) => p.id === photoId);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
feedbackService = require('../../src/services/feedbackService');
const [ev] = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Hidden Own Feedback',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'hidden-own-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [p] = await db('photos').insert({
event_id: eventId, filename: 'shot.jpg', path: 'events/hidden/shot.jpg',
type: 'individual', uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = typeof p === 'object' ? p.id : p;
const [g] = await db('gallery_guests').insert({
event_id: eventId, name: 'Me', identifier: ME,
created_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
is_deleted: false,
}).returning('id');
myGuestRowId = typeof g === 'object' ? g.id : g;
await db('event_feedback_settings').insert({
event_id: eventId, feedback_enabled: true, allow_likes: true,
moderate_comments: false,
show_feedback_to_guests: true,
});
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
const like = () => db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, guest_identifier: ME,
guest_id: myGuestRowId, feedback_type: 'like',
is_approved: true, is_hidden: false, created_at: new Date().toISOString(),
});
beforeEach(async () => {
await db('photo_feedback').where({ photo_id: photoId }).del();
await db('photos').where('id', photoId).update({ like_count: 0 });
});
describe('the read surfaces agree with each other', () => {
it('un-fills the heart once the like is hidden', async () => {
await like();
await feedbackService.updatePhotoFeedbackStats(photoId);
expect((await getPhoto()).is_liked).toBe(true);
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
await feedbackService.updatePhotoFeedbackStats(photoId);
const photo = await getPhoto();
// like_count already ignored hidden rows, so the heart was the only
// thing still claiming this photo was liked.
expect(photo.like_count).toBe(0);
expect(photo.is_liked).toBe(false);
});
});
describe('and every other surface agrees', () => {
it('keeps a hidden like out of /my-feedback', async () => {
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
const res = await request(app)
.get(`/api/gallery/${SLUG}/my-feedback`)
.set('Authorization', `Bearer ${galleryToken()}`)
.set('x-guest-token', guestToken());
expect(res.status).toBe(200);
// In guest identity mode the Liked/Favorited/Rated chips and their
// filters are built from THIS array, not from is_liked — so a hidden
// like left an empty heart while the chip still counted it.
expect(res.body.filter((f) => f.feedback_type === 'like')).toHaveLength(0);
});
it('does not count a hidden row against the guest cap', async () => {
await db('event_feedback_settings')
.where({ event_id: eventId }).update({ max_likes_per_guest: 1 });
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
// The hidden row is room, not an occupant: the guest sees an empty
// heart, and meeting that click with limit_reached leaves the control
// dead until they un-like something they can still see.
const result = await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
});
expect(result.limit_reached).toBeUndefined();
await db('event_feedback_settings')
.where({ event_id: eventId }).update({ max_likes_per_guest: null });
});
it('leaves other anonymous rows alone when there is no identity to scope by', async () => {
// With neither guest_id nor guest_identifier the collapse scope degrades
// to `guest_identifier IS NULL` — every identifier-less row on the
// photo, i.e. other people's.
const anon = (extra) => ({
photo_id: photoId, event_id: eventId, feedback_type: 'like',
is_approved: true, created_at: new Date().toISOString(), ...extra,
});
const [h] = await db('photo_feedback').insert(anon({ is_hidden: true })).returning('id');
const hiddenId = typeof h === 'object' ? h.id : h;
await db('photo_feedback').insert(anon({ is_hidden: false }));
await db('photo_feedback').insert(anon({ is_hidden: false }));
await feedbackService.moderateFeedback(hiddenId, 'approve', 1);
expect(await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false }))
.toHaveLength(3);
});
it('collapses the replacement when an admin unhides the original', async () => {
await like();
const original = await db('photo_feedback').where({ photo_id: photoId }).first();
await db('photo_feedback').where('id', original.id).update({ is_hidden: true });
await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
});
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(2);
await feedbackService.moderateFeedback(original.id, 'approve', 1);
// Two visible rows for one guest would double-count in the tallies and
// need two toggles to clear, since each deletes a single row.
const visible = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
expect(visible).toHaveLength(1);
expect(visible[0].id).toBe(original.id);
});
});
describe('and clicking still works afterwards', () => {
it('re-liking creates a fresh row instead of toggling the hidden one off', async () => {
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
// What the guest sees is an empty heart, so this is an ADD.
const result = await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like',
guest_identifier: ME,
guest_id: myGuestRowId,
});
// Before this, the duplicate check found the hidden row and deleted it —
// `removed: true` — so the click did nothing visible and the moderation
// was silently undone.
expect(result.removed).toBeUndefined();
const visible = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
expect(visible).toHaveLength(1);
expect((await getPhoto()).is_liked).toBe(true);
});
});
});
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
// on first use; bump the budget for this file.
jest.setTimeout(120000);
jest.setTimeout(60000);
describe('incoming-invoice categorise / re-bill chain', () => {
let db;
@@ -32,7 +32,7 @@ jest.mock('../../src/services/restoreService', () => ({
},
}));
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('installFromBackupBoot', () => {
let db;
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(120000);
jest.setTimeout(30000);
let db;
let cleanup;
@@ -1,233 +0,0 @@
/**
* Shared run state for the maintenance sweeps (#1181).
*
* The behaviour that matters here cannot be observed from one process holding
* a module-level flag, which is exactly why the flag moved into the database.
* A second replica is simulated the only way that is honest in a single-process
* test: by asserting on the shared row itself, and by driving claim() twice —
* a second caller getting null is precisely what a second replica gets.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('maintenance job state (#1181)', () => {
let tmpDir; let db; let app; let jobs;
const dimStatus = () => request(app).get('/api/admin/photos/repair-dimensions/status');
const capStatus = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mjs-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mjs-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
jobs = require('../../src/services/maintenanceJobState');
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await db('maintenance_jobs').update({
is_running: false, started_at: null, heartbeat_at: null, finished_at: null, last_result: null, owner: null, claim_token: null,
});
});
test('the lease table is kept out of .picpeak archives', () => {
// It is live state, not data. An archive taken mid-sweep would otherwise
// carry is_running = true and a claim token owned by a process on the
// SOURCE install; restored inside the staleness window, the target reports
// the job as running and refuses new POSTs with no runner to release it.
// The importer filters on this same set, so archives written before the
// exclusion are skipped on restore too.
const { EXCLUDED_TABLES } = require('../../src/services/picpeakExportService');
expect(EXCLUDED_TABLES.has('maintenance_jobs')).toBe(true);
});
test('the migration seeds a row for each job', async () => {
const names = await db('maintenance_jobs').pluck('job_name');
expect(names.sort()).toEqual(['photo_capture_date_backfill', 'photo_dimension_repair']);
});
test('a second claim is refused while the first is alive', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
// What a second replica's POST does. Nothing about the first claim lives in
// this process, so this is the same question the other replica asks.
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
});
test('the two jobs claim independently', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
expect(await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL)).toEqual(expect.any(String));
});
test('each claim gets a distinct token', async () => {
const first = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
await jobs.release(jobs.JOB_DIMENSION_REPAIR, first);
const second = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
// Same process, same pid — so an owner string would have collided here and
// the fencing below would be worthless.
expect(second).not.toBe(first);
});
test('a claim whose heartbeat has gone quiet can be taken over', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
// The replica holding it was killed: no release, no further heartbeats.
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
});
test('a superseded runner cannot renew its lease', async () => {
const oldToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
const newToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
expect(newToken).toEqual(expect.any(String));
// The old runner is still alive and mid-loop. Its renewal must tell it so,
// which is what makes the route loop stop instead of running alongside the
// new owner.
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, oldToken)).toBe(false);
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, newToken)).toBe(true);
});
test('a superseded runner cannot release the new owner\'s claim', async () => {
const oldToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).update({ heartbeat_at: longAgo });
const newToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
// The old runner finishes late and tries to write its result. Unfenced,
// this cleared is_running under the new owner and let a THIRD sweep start.
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, oldToken, { success: 999, noExif: 0, failed: 0 })).toBe(false);
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
expect(state.isRunning).toBe(true);
expect(state.lastResult).toBeNull();
// And the row is still the new owner's to release.
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, newToken, { success: 1, noExif: 0, failed: 0 })).toBe(true);
});
test('a stale run reads as not running, so the button comes back', async () => {
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
// is_running is still true in the row — nothing released it — but a status
// poll must not leave the operator staring at a job that cannot finish.
expect((await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).first()).is_running).toBeTruthy();
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(false);
});
test('a heartbeat keeps a long run claimed', async () => {
const token = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, token)).toBe(true);
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
});
test('release stores the result and read gives it back parsed', async () => {
const token = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, token, { success: 3, noExif: 2, failed: 1 });
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
expect(state.isRunning).toBe(false);
expect(state.lastResult).toEqual({ success: 3, noExif: 2, failed: 1 });
});
test('releasing without a result keeps the previous run visible', async () => {
const first = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, first, { success: 7, noExif: 0, failed: 0 });
// The "nothing to do" path: claimed, found no candidates, released. It must
// not blank the numbers the last real run reported.
const second = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, second);
expect((await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL)).lastResult).toEqual({ success: 7, noExif: 0, failed: 0 });
});
test('a malformed result does not take the status endpoint down', async () => {
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ last_result: 'not json' });
const state = await jobs.read(jobs.JOB_DIMENSION_REPAIR);
expect(state.lastResult).toBeNull();
expect(state.isRunning).toBe(false);
});
test('both status endpoints report the shared row, not process memory', async () => {
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const capToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, capToken, { success: 1, noExif: 0, failed: 0 });
// Written straight to the row, exactly as another replica would have.
const dim = await dimStatus();
expect(dim.status).toBe(200);
expect(dim.body.isRunning).toBe(true);
const cap = await capStatus();
expect(cap.status).toBe(200);
expect(cap.body.isRunning).toBe(false);
expect(cap.body.lastResult).toEqual({ success: 1, noExif: 0, failed: 0 });
});
test('a POST is refused while another replica holds the claim', async () => {
// The claim was taken by "another replica" — this process knows nothing
// about it beyond the row.
await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.status).toBe(409);
const dimRes = await request(app).post('/api/admin/photos/repair-dimensions');
// The other job is untouched by that claim, so it is free to start.
expect(dimRes.status).toBe(200);
});
test('the no-op path releases the claim it took', async () => {
// No photos at all, so both endpoints take their "nothing to do" exit.
await db('photos').del();
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const row = await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).first();
expect(row.is_running).toBeFalsy();
// ...and a second POST is therefore accepted rather than 409ing forever.
expect((await request(app).post('/api/admin/photos/repair-capture-dates')).status).toBe(200);
});
});
@@ -1,102 +0,0 @@
/**
* PostgreSQL checks for the shared maintenance-job state (#1181).
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway database, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_mjs_test" \
* npx jest __tests__/integration/maintenanceJobStatePg.test.js
*
* What SQLite cannot answer: the claim leans on comparing a `timestamp` column
* against an ISO-8601 string, and on an UPDATE ... WHERE guard being atomic
* under real concurrent connections. SQLite compares those strings
* lexicographically and serialises writes anyway, so it would pass either way —
* exactly the shape of divergence that has bitten this repo before.
*/
const knex = require('knex');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('maintenance job state on Postgres', () => {
let pgDb;
let jobs;
const JOB = 'photo_dimension_repair';
beforeAll(async () => {
pgDb = knex({ client: 'pg', connection: PG_URL, pool: { min: 0, max: 10 } });
await pgDb.raw('DROP TABLE IF EXISTS maintenance_jobs');
await require('../../migrations/core/179_maintenance_job_state').up(pgDb);
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
jobs = require('../../src/services/maintenanceJobState');
}, 60000);
afterAll(async () => {
jest.dontMock('../../src/database/db');
if (pgDb) await pgDb.destroy();
});
beforeEach(async () => {
await pgDb('maintenance_jobs').update({
is_running: false, started_at: null, heartbeat_at: null, finished_at: null, last_result: null, owner: null, claim_token: null,
});
});
test('the ISO-string cutoff really compares as a timestamp, not as text', async () => {
expect(await jobs.claim(JOB)).toEqual(expect.any(String));
expect(await jobs.claim(JOB)).toBeNull();
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString() });
// If Postgres had rejected or mis-cast the ISO string this would either
// throw or never match.
expect(await jobs.claim(JOB)).toEqual(expect.any(String));
const row = await pgDb('maintenance_jobs').where({ job_name: JOB }).first();
expect(row.heartbeat_at).toBeInstanceOf(Date);
});
test('concurrent claims on real connections produce exactly one winner', async () => {
// The whole point of the conditional UPDATE. Ten connections race; nine
// must lose. SQLite cannot demonstrate this — it serialises writers.
const results = await Promise.all(Array.from({ length: 10 }, () => jobs.claim(JOB)));
expect(results.filter(Boolean)).toHaveLength(1);
// ...and the winner holds a token nobody else can forge.
expect(results.find(Boolean)).toEqual(expect.any(String));
});
test('a released job can be re-claimed exactly once again', async () => {
const token = await jobs.claim(JOB);
await jobs.release(JOB, token, { success: 2, failed: 0 });
const results = await Promise.all(Array.from({ length: 5 }, () => jobs.claim(JOB)));
expect(results.filter(Boolean)).toHaveLength(1);
expect((await jobs.read(JOB)).lastResult).toEqual({ success: 2, failed: 0 });
});
test('a superseded runner is fenced out on real Postgres', async () => {
const oldToken = await jobs.claim(JOB);
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString() });
const newToken = await jobs.claim(JOB);
expect(await jobs.heartbeat(JOB, oldToken)).toBe(false);
expect(await jobs.release(JOB, oldToken, { success: 999, failed: 0 })).toBe(false);
// The new owner still holds it, with its result unwritten.
expect((await jobs.read(JOB)).isRunning).toBe(true);
expect(await jobs.release(JOB, newToken, { success: 4, failed: 0 })).toBe(true);
});
test('read() reports a live claim as running and a stale one as not', async () => {
await jobs.claim(JOB);
expect((await jobs.read(JOB)).isRunning).toBe(true);
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 1000).toISOString() });
expect((await jobs.read(JOB)).isRunning).toBe(false);
});
});
@@ -1,197 +0,0 @@
'use strict';
/**
* Cross-engine .picpeak restore policy (#1041): a SQLite archive restored onto
* a PostgreSQL instance — the official small-install → full-stack upgrade
* path — now allowed by validateManifest's direction rule instead of the
* former CLI-only allowEngineSwitch flag. The coercion engine itself
* (typedColumnsFor / epochToIso / coerceForTargetEngine) landed with #1039;
* these tests pin the direction policy and the coercion's cross-engine
* value-correctness.
*
* Ungated: validateManifest direction rules and the pure coercion units.
* The reverse direction (pg backup onto a sqlite instance) staying blocked is
* pinned by picpeakRoundtrip.test.js, which runs on the real sqlite harness.
*
* Gated on PICPEAK_PG_TEST_URL (same contract as picpeakRestorePg.test.js):
* sqlite-shaped NDJSON rows land in real Postgres with correct stored VALUES,
* not just row counts, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:pw@127.0.0.1:7102/picpeak_xengine_test" \
* npx jest __tests__/integration/picpeakCrossEngine.test.js
*/
const knexLib = require('knex');
describe('validateManifest cross-engine direction (pg target)', () => {
let validateManifest;
beforeAll(() => {
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
// validateManifest wraps its knex_migrations lookup in try/catch — a
// throwing stub simply skips the forward-only check, which is not under
// test here.
jest.doMock('../../src/database/db', () => ({ db: () => { throw new Error('stub'); } }));
({ validateManifest } = require('../../src/services/picpeakImportService'));
});
afterAll(() => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
jest.resetModules();
});
it('allows a sqlite backup onto a pg instance (upgrade direction)', async () => {
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'sqlite' }, tables: {},
});
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
});
it('still allows same-engine pg → pg', async () => {
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
});
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
});
});
describe('epochToIso (landed with #1039)', () => {
let epochToIso;
beforeAll(() => {
jest.resetModules();
({ epochToIso } = require('../../src/services/picpeakImportService'));
});
it('converts epoch milliseconds', () => {
expect(epochToIso(1723400000000)).toBe('2024-08-11T18:13:20.000Z');
});
it('converts epoch SECONDS to the same instant, not January 1970', () => {
expect(epochToIso(1723400000)).toBe('2024-08-11T18:13:20.000Z');
});
it('converts numeric strings', () => {
expect(epochToIso('1723400000000')).toBe('2024-08-11T18:13:20.000Z');
});
it('passes non-numeric values through untouched', () => {
expect(epochToIso('2026-08-12 10:00:00')).toBe('2026-08-12 10:00:00');
});
});
describe('coerceForTargetEngine on sqlite-shaped rows', () => {
let coerceForTargetEngine;
beforeAll(() => {
jest.resetModules();
({ coerceForTargetEngine } = require('../../src/services/picpeakImportService'));
});
const types = { timestamps: ['created_at', 'expires_at'], booleans: ['is_active'] };
it('coerces 0/1 booleans and epoch timestamps, leaves date strings alone', () => {
const [row] = coerceForTargetEngine(
[{ id: 1, is_active: 1, created_at: 1723400000000, expires_at: '2026-09-01 12:00:00' }],
types
);
expect(row.is_active).toBe(true);
expect(row.created_at).toBe('2024-08-11T18:13:20.000Z');
expect(row.expires_at).toBe('2026-09-01 12:00:00'); // pg parses this natively
});
it('coerces falsy variants and passes null/empty through', () => {
const [row] = coerceForTargetEngine(
[{ is_active: 0, created_at: null, expires_at: '' }],
types
);
expect(row.is_active).toBe(false);
expect(row.created_at).toBeNull();
expect(row.expires_at).toBe('');
});
});
// ── Real-Postgres integration (gated) ────────────────────────────────────────
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('sqlite-shaped rows land correctly in real Postgres', () => {
let pgDb;
let svc;
beforeAll(async () => {
pgDb = knexLib({ client: 'pg', connection: PG_URL });
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
await pgDb.schema.createTable('xengine_events', (t) => {
t.increments('id');
t.string('slug');
t.boolean('is_active').defaultTo(true);
t.boolean('allow_downloads').defaultTo(true);
t.timestamp('created_at');
t.timestamp('expires_at');
});
await pgDb.schema.createTable('xengine_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.jsonb('setting_value');
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
svc = require('../../src/services/picpeakImportService');
});
afterAll(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
if (pgDb) {
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
await pgDb.destroy();
}
});
it('typedColumnsFor classifies boolean and timestamp columns via columnInfo()', async () => {
const types = await svc.typedColumnsFor(pgDb, 'xengine_events');
expect(types.booleans.sort()).toEqual(['allow_downloads', 'is_active']);
expect(types.timestamps.sort()).toEqual(['created_at', 'expires_at']);
});
it('inserts a sqlite archive row (0/1 booleans, epoch dates, json text) with correct stored values', async () => {
// Exactly what a sqlite-created .picpeak carries: integers for booleans,
// epoch numbers for #485-shape timestamps (ms here, seconds covered by the
// epochToIso unit), a "YYYY-MM-DD HH:MM:SS" string for clean ones, and
// json columns as TEXT (the crossEngine path skips serialiseJsonColumns —
// the text is already what pg wants).
const epoch = 1723400000000;
const eventRows = [
{ id: 1, slug: 'wedding', is_active: 1, allow_downloads: 0, created_at: epoch, expires_at: '2026-09-01 12:00:00' },
];
const settingRows = [{ id: 1, setting_key: 'brand', setting_value: '{"name":"PicPeak","dark":true}' }];
await pgDb.transaction(async (trx) => {
const evTypes = await svc.typedColumnsFor(trx, 'xengine_events');
await trx.batchInsert('xengine_events', svc.coerceForTargetEngine(eventRows, evTypes), 100);
const stTypes = await svc.typedColumnsFor(trx, 'xengine_settings');
await trx.batchInsert('xengine_settings', svc.coerceForTargetEngine(settingRows, stTypes), 100);
});
const ev = await pgDb('xengine_events').where({ id: 1 }).first();
expect(ev.is_active).toBe(true); // 1 → true, not backwards (#1028 class)
expect(ev.allow_downloads).toBe(false); // 0 → false
expect(new Date(ev.created_at).getTime()).toBe(epoch);
expect(new Date(ev.expires_at).toISOString().slice(0, 10)).toBe('2026-09-01');
const st = await pgDb('xengine_settings').where({ id: 1 }).first();
// jsonb parsed back by the driver — value intact, no double encoding.
expect(st.setting_value).toEqual({ name: 'PicPeak', dark: true });
});
it('id sequence works after explicit-id insert + resync (next natural insert)', async () => {
await svc.resyncSequences(['xengine_events']);
const [next] = await pgDb('xengine_events')
.insert({ slug: 'fresh', is_active: true })
.returning('id');
expect(Number(next.id || next)).toBe(2);
});
});
@@ -21,7 +21,7 @@ beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
({ createPicpeak } = require('../../src/services/picpeakExportService'));
}, 120000);
}, 60000);
afterAll(async () => {
await cleanup();
@@ -28,7 +28,7 @@ beforeAll(async () => {
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
const role = await db('roles').where({ name: 'super_admin' }).first();
superAdminRoleId = role.id;
}, 120000);
}, 60000);
afterAll(async () => {
await cleanup();
@@ -1,114 +0,0 @@
/**
* Publishing must not be a way around the configured gallery password policy.
*
* `POST /:id/publish` (#627) re-hashes `password_hash` from a plaintext the
* admin re-types in the publish dialog, and validated it with nothing but
* express-validator's `isLength({ min: 6 })`. So the configured complexity —
* moderate by default, meaning 8 characters plus upper, lower and a digit —
* governed event creation and password reset, while this door accepted
* `aaaaaa` and made it the live gallery password.
*
* Not an escalation: it needs admin auth plus events.edit, and such an admin
* could already set a weak password elsewhere. It is a policy gap — the admin
* UI advertises a complexity level this write path did not enforce.
*/
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-pubpolicy-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'publish-policy-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubpolicy-storage-'));
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
describe('publish enforces the gallery password policy', () => {
let db; let cleanup; let app; let token;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'admin');
token = mintAdminToken(adminId);
app = buildRouteApp('/admin/events', require('../../src/routes/adminEvents'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function seedDraft(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `Event ${slug}`,
event_date: '2026-09-01',
host_email: 'client@example.com',
admin_email: 'admin@example.com',
password_hash: 'original-hash',
require_password: 1,
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-token`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 1,
created_at: new Date().toISOString(),
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
it('refuses a password that misses the configured complexity', async () => {
const id = await seedDraft('weak-publish');
const res = await request(app)
.post(`/admin/events/${id}/publish`)
.set('Authorization', `Bearer ${token}`)
.send({ password: 'aaaaaa' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/security requirements/i);
// Rejected BEFORE the write, not after — the gallery must be untouched,
// and still a draft.
const after = await db('events').where({ id }).first();
expect(after.password_hash).toBe('original-hash');
expect(after.is_draft === 1 || after.is_draft === true).toBe(true);
});
it('still accepts a password that meets it', async () => {
const id = await seedDraft('strong-publish');
const res = await request(app)
.post(`/admin/events/${id}/publish`)
.set('Authorization', `Bearer ${token}`)
.send({ password: 'Sup3r-Secret' });
expect(res.status).toBe(200);
const bcrypt = require('bcrypt');
const after = await db('events').where({ id }).first();
expect(after.password_hash).not.toBe('original-hash');
expect(await bcrypt.compare('Sup3r-Secret', after.password_hash)).toBe(true);
});
it('leaves a publish without a password alone', async () => {
// The legacy sentinel path: no password in the body means no rehash, so
// the policy has nothing to check and must not block the publish.
const id = await seedDraft('no-password-publish');
const res = await request(app)
.post(`/admin/events/${id}/publish`)
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(200);
const after = await db('events').where({ id }).first();
expect(after.password_hash).toBe('original-hash');
});
});
@@ -1,260 +0,0 @@
/**
* scripts/regenerate-thumbnails.js against external photos (#1148).
*
* The same defect #1129 fixed in the admin route, still standing in the CLI
* fallback: the script resolved every source as
* `storage/events/active/<photo.path>` and fs.access'd it. External and
* reference rows do not live there — their originals sit under
* `events.external_path` — so every one failed the check and was counted as an
* error. On an install where all photos are external the script did nothing at
* all, while reporting one error per photo.
*
* Driven against a REAL file on a REAL external mount with the real
* imageProcessor, not a mock: the whole point is that the source resolves off
* the mount, and a mocked ensureThumbnail would assert nothing about that.
*
* Responsive tiers (#1095/#1109) do not exist on this branch, so the tier
* backfill in the main twin has nothing to port. Everything else does.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const sharp = require('sharp');
const { execFile } = require('child_process');
describe('regenerate-thumbnails script (#1148)', () => {
let tmpDir; let db; let cleanup; let regenerateThumbnails;
let eventId; let externalPhotoId; let videoPhotoId; let watcherVideoId; let repairPhotoId;
let vanishingPhotoId;
let externalRoot;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-script-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
// External sources are sandboxed under EXTERNAL_MEDIA_ROOT. Rows carry a
// path relative to that root (#1163), so the 'wedding/' prefix on each
// external_relpath below is the event folder, not decoration.
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpDir, 'media');
externalRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'wedding');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
await fs.promises.mkdir(externalRoot, { recursive: true });
jest.resetModules();
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
// A real image on the external mount — never under events/active.
await sharp({
create: { width: 1200, height: 800, channels: 3, background: { r: 10, g: 90, b: 160 } },
}).jpeg().toFile(path.join(externalRoot, 'shot.jpg'));
const [ev] = await db('events').insert({
slug: 'regen-script-event',
event_type: 'wedding',
event_name: 'Regen Script',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: '/gallery/regen-script-event/share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
source_mode: 'reference',
external_path: 'wedding',
created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [p] = await db('photos').insert({
event_id: eventId,
filename: 'shot.jpg',
// `path` is what the old script joined onto events/active. Left
// populated on purpose: the fix must ignore it for an external row.
path: 'regen-script-event/shot.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: 'wedding/shot.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
externalPhotoId = typeof p === 'object' ? p.id : p;
const [v] = await db('photos').insert({
event_id: eventId,
filename: 'clip.mp4',
path: 'regen-script-event/clip.mp4',
type: 'individual',
media_type: 'video',
mime_type: 'video/mp4',
source_origin: 'external',
external_relpath: 'wedding/clip.mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
videoPhotoId = typeof v === 'object' ? v.id : v;
// How fileWatcher.processNewPhoto actually writes a video: `type` and
// `mime_type` set, media_type left to its 'image' default. A media_type-only
// filter lets this through and hands the container to Sharp.
//
// The file has to EXIST, otherwise the row fails resolution and looks
// skipped for the wrong reason — the bug is Sharp being handed a video, not
// a missing source. Real MP4 header bytes, no image in sight.
await fs.promises.writeFile(
path.join(externalRoot, 'watched.mp4'),
Buffer.from('00000018667479706d70343200000000', 'hex')
);
const [wv] = await db('photos').insert({
event_id: eventId,
filename: 'watched.mp4',
path: 'regen-script-event/watched.mp4',
type: 'video',
mime_type: 'video/mp4',
source_origin: 'external',
external_relpath: 'wedding/watched.mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
watcherVideoId = typeof wv === 'object' ? wv.id : wv;
expect((await db('photos').where('id', watcherVideoId).first()).media_type).not.toBe('video');
// A photo whose thumbnail_path points at something that is no longer there.
await sharp({
create: { width: 900, height: 600, channels: 3, background: { r: 200, g: 40, b: 40 } },
}).jpeg().toFile(path.join(externalRoot, 'repair.jpg'));
const [rp] = await db('photos').insert({
event_id: eventId,
filename: 'repair.jpg',
path: 'regen-script-event/repair.jpg',
type: 'individual',
thumbnail_path: 'thumbnails/thumb_ext_missing_repair.jpg',
source_origin: 'external',
external_relpath: 'wedding/repair.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
repairPhotoId = typeof rp === 'object' ? rp.id : rp;
// A photo whose source is not on the mount at all — an unavailable mount,
// which is the failure an operator most needs to hear about.
const [vp] = await db('photos').insert({
event_id: eventId,
filename: 'missing.jpg',
path: 'regen-script-event/missing.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: 'missing.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
vanishingPhotoId = typeof vp === 'object' ? vp.id : vp;
({ regenerateThumbnails } = require('../../scripts/regenerate-thumbnails'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
it('builds a thumbnail for an external photo instead of erroring on events/active', async () => {
// The location the old script computed and fs.access'd. Nothing is there,
// which is the whole defect — it is not where an external original lives.
// (The old script cannot be driven from a test directly: it had no export
// and ran on require, calling process.exit. Making it importable is part
// of this fix.)
const legacyPath = path.join(process.env.STORAGE_PATH, 'events/active', 'regen-script-event/shot.jpg');
expect(fs.existsSync(legacyPath)).toBe(false);
const result = await regenerateThumbnails(eventId);
// The old script reported an error for this photo and wrote nothing.
// The unresolvable row fails; the external photo and the repair row build.
expect(result.errorCount).toBe(1);
expect(result.successCount).toBe(2);
const row = await db('photos').where('id', externalPhotoId).first();
expect(row.thumbnail_path).toBeTruthy();
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
expect(fs.existsSync(onDisk)).toBe(true);
// Named per-photo so two events referencing one NAS basename cannot
// clobber each other — the property ensureThumbnail owns and the reason
// the script must not build this name itself.
expect(path.basename(row.thumbnail_path)).toContain(`ext${externalPhotoId}_`);
});
it('leaves videos alone', async () => {
// A video thumbnail is a poster frame from videoProcessor; handing the
// container to Sharp produced one error per video row.
const row = await db('photos').where('id', videoPhotoId).first();
expect(row.thumbnail_path).toBeFalsy();
});
it('leaves a watcher-imported video alone, which carries no media_type', async () => {
// fileWatcher writes type + mime_type and lets media_type default to
// 'image', so filtering on media_type alone still fed these to Sharp. The
// signal is errorCount: the images are already done by now, so the only
// NEW thing that could fail this run is a video reaching Sharp. One error
// is the deliberately unresolvable row; two would be the video.
const result = await regenerateThumbnails(eventId);
expect(result.errorCount).toBe(1);
const row = await db('photos').where('id', watcherVideoId).first();
expect(row.thumbnail_path).toBeFalsy();
});
it('is idempotent — a second run skips instead of rebuilding', async () => {
const before = await db('photos').where('id', externalPhotoId).first();
const result = await regenerateThumbnails(eventId);
expect(result.errorCount).toBe(1);
expect(result.successCount).toBe(0);
expect(result.skipCount).toBe(2);
const after = await db('photos').where('id', externalPhotoId).first();
expect(after.thumbnail_path).toBe(before.thumbnail_path);
});
it('counts a repaired thumbnail as generated, not skipped', async () => {
// Both images are valid at this point. Destroy ONE thumbnail object while
// leaving thumbnail_path pointing at it — the corrupt/missing case.
const row = await db('photos').where('id', repairPhotoId).first();
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
await fs.promises.rm(onDisk);
const result = await regenerateThumbnails(eventId);
// On local and external storage the rebuilt key is identical, so inferring
// "skipped" from an unchanged path reports this repair as already valid —
// the one number an operator running this is actually reading.
expect(result.successCount).toBe(1);
expect(result.skipCount).toBe(1);
expect(result.errorCount).toBe(1);
expect(fs.existsSync(onDisk)).toBe(true);
});
/** Run the CLI the way cron does, and hand back its exit status. */
const runCli = (args = []) => new Promise((resolve) => {
execFile(
process.execPath,
[path.join(__dirname, '..', '..', 'scripts', 'regenerate-thumbnails.js'), ...args],
{ env: { ...process.env }, cwd: path.join(__dirname, '..', '..') },
(error, stdout, stderr) => resolve({ code: error?.code ?? 0, stdout, stderr })
);
});
it('exits nonzero when a photo could not be built', async () => {
// Exit status is the only thing a cron job reads, and `missing.jpg` has no
// source on the mount.
const failed = await runCli([String(eventId)]);
expect(failed.code).toBe(1);
expect(failed.stderr).toContain('completed with failures');
}, 120000);
it('exits zero when every photo resolves', async () => {
// Drop the unresolvable row: a clean run must not cry wolf at automation.
await db('photos').where('id', vanishingPhotoId).del();
const ok = await runCli([String(eventId)]);
expect(ok.code).toBe(0);
expect(ok.stdout).toContain('Script completed successfully');
}, 120000);
});
@@ -13,14 +13,14 @@ const { execFileSync } = require('child_process');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(60000);
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -183,24 +183,22 @@ describe('restoreService — PG branch scope contract (PR #596 review)', () => {
expect(window).toMatch(/was_successful:\s*true/);
});
it('the safe migration runner is invoked after the replay in restore()', () => {
it('npm run migrate:safe is invoked after the replay in restore()', () => {
// Contract from PR #596 round 4: backups taken on older picpeak
// versions must restore COMPLETELY on a newer image — even if new
// migrations have been added since the backup was taken. The
// restore() flow shells out to the safe migration runner AFTER the
// restore() flow shells out to `npm run migrate:safe` AFTER the
// operator-meta replay so the schema catches up to the running
// code WITHIN the restore boundary (not on the next container
// restart). Invoked as `node migrations/run-migrations-safe.js` —
// the runtime image ships no npm, so the former `npm run
// migrate:safe` would ENOENT into the non-fatal catch.
// restart).
//
// Contract:
// 1. A run-migrations-safe shell-out exists somewhere in restoreService
// 1. A `migrate:safe` shell-out exists somewhere in restoreService
// 2. It sits AFTER the replay drain — verification → replay →
// migrations is the documented order
// 3. It does NOT sit inside performDatabaseRestore (must run
// against the reinit'd pool from the parent restore())
const migrateLine = findFirst(/run-migrations-safe\.js/);
const migrateLine = findFirst(/['"]migrate:safe['"]/);
expect(migrateLine).toBeGreaterThan(0);
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
@@ -27,7 +27,7 @@ beforeAll(async () => {
setupService = require('../../src/services/setupService');
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
}, 120000);
}, 60000);
afterAll(async () => {
await cleanup();
@@ -1,150 +0,0 @@
/**
* Slideshow photo source (#1015).
*
* The bug: with `lightbox_preview_enabled` off (the default), /photos emitted
* `preview_url: null`, so the slideshow's `preview_url || hero_url || url`
* chain fell through to `hero_url` — a 1920x1080 `fit: 'cover'` centre crop
* meant for gallery header banners. With the "Black Bars (No crop)" fit the
* show then letterboxed an already-cropped frame: portrait photos lost their
* top and bottom and the setting looked broken.
*
* The contract pinned here: `slideshow_url` points at the aspect-preserved
* preview tier and is emitted for image photos REGARDLESS of the lightbox
* toggle, so the slideshow never has a reason to reach for `hero_url`.
* `preview_url` itself must stay gated — the lightbox opt-in is unchanged.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-src-test-secret';
const SLUG = 'slideshow-source-event';
describe('Slideshow photo source (#1015)', () => {
let db;
let cleanup;
let app;
let eventId;
let imagePhotoId;
let videoPhotoId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const setLightboxPreview = async (on) => {
await db('app_settings').where({ setting_key: 'lightbox_preview_enabled' }).del();
await db('app_settings').insert({
setting_key: 'lightbox_preview_enabled',
setting_value: JSON.stringify(on),
setting_type: 'general',
updated_at: new Date().toISOString(),
});
};
const fetchPhotos = async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`)
.expect(200);
return res.body.photos;
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Slideshow Source Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'slideshow-source-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const img = await db('photos').insert({
event_id: eventId,
filename: 'portrait.jpg',
path: 'events/slideshow-source/portrait.jpg',
type: 'individual',
mime_type: 'image/jpeg',
uploaded_at: new Date().toISOString(),
}).returning('id');
imagePhotoId = img[0]?.id ?? img[0];
const vid = await db('photos').insert({
event_id: eventId,
filename: 'clip.mp4',
path: 'events/slideshow-source/clip.mp4',
type: 'individual',
media_type: 'video',
mime_type: 'video/mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
videoPhotoId = vid[0]?.id ?? vid[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('emits slideshow_url for image photos even when lightbox previews are OFF', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.slideshow_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
// The regression: this is what used to be null, pushing the show to hero.
expect(image.preview_url).toBeNull();
});
it('leaves preview_url gated so the lightbox opt-in is unchanged', async () => {
await setLightboxPreview(true);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.preview_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
expect(image.slideshow_url).toBe(image.preview_url);
});
it('never points the slideshow at the cover-cropped hero tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
// hero_url still ships (the gallery header uses it) — it just must not be
// what the slideshow resolves to.
expect(image.hero_url).toBe(`/api/gallery/${SLUG}/hero/${imagePhotoId}`);
expect(image.slideshow_url).not.toBe(image.hero_url);
});
it('emits slideshow_url: null for videos, which have no preview tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const video = photos.find((p) => p.id === videoPhotoId);
expect(video.slideshow_url).toBeNull();
});
});
@@ -1,107 +0,0 @@
/**
* The admin photo list's category filter, and the value it answers to (#1211).
*
* The frontend used to send `category_id=0` for "Uncategorized". This route
* skips `'0'` outright — the guard reads `category_id !== '0'` — so no
* condition was applied and the whole event came back. Four lines below that
* guard sits the branch that does the work, keyed on the literal
* `uncategorized`, which nothing was sending.
*
* Reported in #1209 by someone trying to isolate a few thousand uncategorised
* imports. The frontend half is fixed in PhotoFilters; this pins the backend
* half of the same contract, because the failure mode was the two ends
* disagreeing about a string and neither one being wrong on its own.
*/
const request = require('supertest');
const express = require('express');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
describe('admin photo list — uncategorized filter (#1211)', () => {
let db; let cleanup; let app;
let eventId; let categoryId;
let uncategorisedIds; let categorisedId;
const list = async (query = '') => {
const res = await request(app).get(`/api/admin/events/${eventId}/photos${query}`);
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
};
beforeAll(async () => {
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const [ev] = await db('events').insert({
slug: 'uncat-filter', event_type: 'wedding', event_name: 'Uncat Filter',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/uncat-filter/share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [cat] = await db('photo_categories')
.insert({ name: 'Ceremony', slug: 'ceremony', event_id: eventId })
.returning('id');
categoryId = typeof cat === 'object' ? cat.id : cat;
const insertPhoto = async (filename, category) => {
const [p] = await db('photos').insert({
event_id: eventId, filename, path: `events/uncat/${filename}`,
type: 'individual', category_id: category,
uploaded_at: new Date().toISOString(),
}).returning('id');
return typeof p === 'object' ? p.id : p;
};
// Two with no category — the shape a plugin upload leaves behind — and one
// filed properly, so a filter that does nothing is visibly different from
// a filter that works.
uncategorisedIds = [await insertPhoto('a.jpg', null), await insertPhoto('b.jpg', null)];
categorisedId = await insertPhoto('c.jpg', categoryId);
uncategorisedIds.sort((a, b) => a - b);
app = express();
app.use(express.json());
app.use('/api/admin/events', require('../../src/routes/adminPhotos'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('returns only the photos with no category', async () => {
expect(await list('?category_id=uncategorized')).toEqual(uncategorisedIds);
});
it('returns everything when no category filter is given', async () => {
expect(await list()).toEqual([...uncategorisedIds, categorisedId].sort((a, b) => a - b));
});
it('still filters by a real category id', async () => {
expect(await list(`?category_id=${categoryId}`)).toEqual([categorisedId]);
});
it('treats 0 as no filter at all', async () => {
// Pinning the behaviour that made the bug silent rather than loud: '0' is
// not "uncategorized" and never was, it simply falls through the guard. A
// future change that made 0 mean uncategorized here would be fine too —
// but it must be a decision, not an accident, and this test forces it.
expect(await list('?category_id=0')).toEqual([...uncategorisedIds, categorisedId].sort((a, b) => a - b));
});
});
@@ -10,7 +10,7 @@ const { bootCrmDb } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(120000);
jest.setTimeout(30000);
let db;
let cleanup;
@@ -239,7 +239,7 @@ describe('workflow engine', () => {
expect(again.already).toBe(true);
});
test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => {
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
@@ -248,7 +248,7 @@ describe('workflow engine', () => {
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(7);
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
@@ -273,7 +273,7 @@ describe('workflow engine', () => {
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const reseeded = await db('workflows').where({ id: wf.id }).first();
expect(reseeded.version).toBe(wf.version + 1); // bumped
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(7);
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6);
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
@@ -9,7 +9,7 @@ const {
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(120000);
jest.setTimeout(30000);
let db;
let cleanup;
@@ -1,111 +0,0 @@
/**
* The roles-join fallback in adminAuth fabricates `role_name = 'super_admin'`
* to keep existing sessions working across the RBAC upgrade window. The catch
* around it used to be unconditional, so ANY transient database failure —
* connection reset, deadlock, statement timeout, pool exhaustion — took the
* same branch and handed the caller super_admin for the duration of the fault.
*
* `roleName` is the sole discriminator for every ownership check (ownership.js,
* adminProjects, adminUsers, adminApiTokens, projectService, ...), so that
* inverted the whole authorization model rather than failing the request.
* Issue #968. Same treatment apiTokenAuth already got for the v1 surface.
*/
const jwt = require('jsonwebtoken');
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), info: jest.fn() }));
// The joined query throws whatever the test stages; the role-less fallback
// query (no .leftJoin) always succeeds, which is what made the original bug
// reachable — it is the cheaper single-table read.
// `mock`-prefixed so jest's module-factory hoisting allows the reference.
let mockJoinError = null;
const mockAdminRow = { id: 7, username: 'scoped', email: 's@example.com', password_changed_at: null };
jest.mock('../../src/database/db', () => ({
db: () => ({
_joined: false,
leftJoin() { this._joined = true; return this; },
where() { return this; },
select() { return this; },
first() {
if (this._joined && mockJoinError) return Promise.reject(mockJoinError);
return Promise.resolve({ ...mockAdminRow });
},
}),
}));
const { adminAuth } = require('../../src/middleware/auth');
const SECRET = 'test-secret-for-admin-auth-fallback';
function makeReq() {
const token = jwt.sign(
{ id: mockAdminRow.id, type: 'admin' },
SECRET,
{ algorithm: 'HS256', issuer: 'picpeak-auth' },
);
return { headers: { authorization: `Bearer ${token}` }, ip: '127.0.0.1', connection: {} };
}
function makeRes() {
return {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
}
describe('adminAuth roles-join fallback (#968)', () => {
const OLD_SECRET = process.env.JWT_SECRET;
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
beforeEach(() => { mockJoinError = null; });
it('grants the upgrade-window fallback only for a genuinely missing roles table', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: roles');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.admin.roleName).toBe('super_admin');
});
it.each([
['connection reset', new Error('Connection terminated unexpectedly')],
['deadlock', new Error('deadlock detected')],
['pool exhaustion', new Error('Knex: Timeout acquiring a connection')],
['statement timeout', new Error('canceling statement due to statement timeout')],
])('does NOT fabricate super_admin on a transient failure (%s)', async (_label, err) => {
mockJoinError = err;
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
// Fails closed: request rejected, req.admin never populated. The specific
// status is 401 (adminAuth's blanket outer catch) — what matters is that
// the caller is not elevated and does not reach the route.
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
expect(res.statusCode).toBe(401);
});
it('does NOT fabricate super_admin when an unrelated table is missing', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: admin_sessions');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
});
});
@@ -1,72 +0,0 @@
/**
* The roles-join fallback in apiTokenAuth grants `super_admin` (upgrade-path
* parity with adminAuth). It must therefore fire ONLY when the roles schema is
* genuinely absent — a catch-all turns any transient database failure into a
* privilege escalation that reopens GHSA-9697 for a demoted token owner.
*/
const { isMissingRolesSchema } = require('../../src/middleware/apiTokenAuth');
describe('apiTokenAuth roles-schema fallback predicate (GHSA-9697)', () => {
it('accepts a genuinely missing roles table on both engines', () => {
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: roles'))).toBe(true);
expect(isMissingRolesSchema(
Object.assign(new Error('relation "roles" does not exist'), { code: '42P01' }),
)).toBe(true);
expect(isMissingRolesSchema(
Object.assign(new Error('column roles.name does not exist'), { code: '42703' }),
)).toBe(true);
});
it('rejects transient failures that must not elevate the caller', () => {
expect(isMissingRolesSchema(new Error('Connection terminated unexpectedly'))).toBe(false);
expect(isMissingRolesSchema(new Error('deadlock detected'))).toBe(false);
expect(isMissingRolesSchema(new Error('Knex: Timeout acquiring a connection'))).toBe(false);
expect(isMissingRolesSchema(new Error('canceling statement due to statement timeout'))).toBe(false);
expect(isMissingRolesSchema(undefined)).toBe(false);
});
it('rejects a missing-table error for an unrelated table', () => {
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: api_tokens'))).toBe(false);
});
// knex prefixes the failing SQL to err.message, and that SQL always names
// `roles` on this join — so the message substring proves nothing about the
// error, and only an exact driver phrase (or a SQLSTATE) may be trusted.
// These are real knex message shapes, captured from the actual query.
describe('with knex\'s SQL prefix on the message (#968)', () => {
const withSql = (driverMessage) => new Error(
'select `roles`.`name` as `role_name` from `admin_users` '
+ 'left join `roles` on `roles`.`id` = `admin_users`.`role_id` '
+ `where \`admin_users\`.\`id\` = 1 limit 1 - ${driverMessage}`,
);
it('accepts both legitimate upgrade-window states', () => {
// pre-054: the roles table does not exist yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such table: roles'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
// post-054, pre-057: roles exists, admin_users.role_id not added yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such column: admin_users.role_id'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
});
it('rejects an unrelated "does not exist" fault despite the SQL naming roles', () => {
// pgbouncer transaction pooling loses a named prepared statement
// (SQLSTATE 26000). Transient — the fallback query would succeed on a
// fresh connection, so accepting this would fabricate super_admin.
expect(isMissingRolesSchema(
Object.assign(withSql('prepared statement "S_1" does not exist'), { code: '26000' }),
)).toBe(false);
// The DB role/user, not the roles table.
expect(isMissingRolesSchema(
Object.assign(withSql('role "picpeak" does not exist'), { code: '28000' }),
)).toBe(false);
expect(isMissingRolesSchema(
Object.assign(withSql('database "picpeak" does not exist'), { code: '3D000' }),
)).toBe(false);
expect(isMissingRolesSchema(withSql('Connection terminated unexpectedly'))).toBe(false);
});
});
});
@@ -0,0 +1,103 @@
/**
* Regression test for the cross-event thumbnail enumeration leak.
*
* Thumbnails are served flat from /thumbnails/thumb_<name> with
* deterministic, enumerable filenames. photoAuth previously granted any
* holder of a gallery token for ANY active event access to ANY thumbnail
* (it set eventSlug=null and returned next() as long as the token's event
* existed), so a visitor to one gallery could pull another (password-
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
* access to the token's event by matching the requested file against
* photos.thumbnail_path for that event_id.
*/
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
const jwt = require('jsonwebtoken');
// Two events, each owning one thumbnail. The photos mock resolves a row
// only when BOTH event_id and thumbnail_path match — i.e. it models the
// real ownership query.
const EVENTS = [
{ id: 10, slug: 'event-a', is_active: 1 },
{ id: 20, slug: 'event-b', is_active: 1 },
];
const PHOTOS = [
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
];
jest.mock('../../src/database/db', () => ({
db: (table) => ({
_cond: null,
where(cond) { this._cond = cond; return this; },
first() {
if (table === 'events') {
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
}
if (table === 'photos') {
return Promise.resolve(
PHOTOS.find((p) => p.event_id === this._cond.event_id
&& p.thumbnail_path === this._cond.thumbnail_path) || null
);
}
return Promise.resolve(null);
},
}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const photoAuth = require('../../src/middleware/photoAuth');
function galleryToken(eventId) {
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
}
function makeReqRes(token, thumbPath) {
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
return { req, res };
}
describe('photoAuth — thumbnail ownership scoping', () => {
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
// Access denied: middleware must not pass the request through.
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.event).toMatchObject({ id: 20 });
});
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
});
@@ -1,70 +0,0 @@
/**
* Second security sweep on the same branch as the password-strength DoS fix
* (stable port: the maintenance and admin-preview cases do not apply here).
* Each block pins one gap the audit found:
*
* - the general rate limiter skipped anyone holding ANY verified JWT,
* including a gallery token minted for free on password-less galleries
* - the multipart branch of the CSRF Content-Type gate accepted cross-site
* form posts
*/
const jwt = require('jsonwebtoken');
process.env.JWT_SECRET = 'hardening-batch2-secret';
const fake = { maintenance: 'true', revoked: false, beforeCutoff: false, admin: { id: 1, password_changed_at: null } };
jest.mock('../../src/database/db', () => {
const db = jest.fn((table) => {
const q = {
where: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
first: jest.fn(async () => {
if (table === 'app_settings') {
return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance };
}
if (table === 'admin_users') return fake.admin;
return null;
}),
};
return q;
});
return { db, withRetry: (fn) => fn() };
});
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
process.env.FRONTEND_URL = 'https://photos.example.com';
const { isAuthenticated } = require('../../src/services/rateLimitService');
const { multipartOriginAllowed } = require('../../src/utils/requestOrigin');
const iat = Math.floor(Date.now() / 1000) - 10;
const adminToken = (extra = {}) => jwt.sign({ type: 'admin', id: 1, iat, ...extra }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
const galleryToken = () => jwt.sign({ type: 'gallery', eventId: 1, iat }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
describe('general rate limiter skip', () => {
const req = (token) => ({ path: '/api/gallery/x/photos', headers: { authorization: `Bearer ${token}` }, cookies: {} });
it('is granted to an admin session', () => {
expect(isAuthenticated(req(adminToken()))).toBe(true);
});
it('is NOT granted to a gallery token', () => {
expect(isAuthenticated(req(galleryToken()))).toBe(false);
});
});
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': '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('rejects cross-site form posts', () => {
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false);
expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false);
expect(multipartOriginAllowed(req({ origin: 'null' }))).toBe(false);
});
});
@@ -1,35 +0,0 @@
/**
* Migration 167 (projects.created_by) — idempotent on re-run, reversible,
* and backfills the owner from a project's single linked event (GHSA-wrg5).
*/
const path=require('path'), fs=require('fs'), os=require('os');
process.env.NODE_ENV='test';
process.env.TEST_DATABASE_PATH=path.join(fs.mkdtempSync(path.join(os.tmpdir(),'picpeak-mig167-')),'db.sqlite');
process.env.JWT_SECRET='mig';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const mig = require('../../migrations/core/167_add_projects_created_by');
describe('migration 167', () => {
let db, cleanup;
beforeAll(async()=>{ ({db,cleanup}=await bootCrmDb()); await seedMinimal(db); },120000);
afterAll(async()=>{ if(cleanup) await cleanup(); });
it('is idempotent on re-run and reversible', async () => {
await mig.up(db); // already applied by boot; must no-op
await mig.up(db); // and again
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
await mig.down(db);
expect(await db.schema.hasColumn('projects','created_by')).toBe(false);
await mig.up(db); // re-apply cleanly
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
});
it('backfills created_by from a single linked event owner', async () => {
const p = await db('projects').insert({name:'bf',status:'active',created_at:new Date(),updated_at:new Date()}).returning('id');
const pid = p[0]?.id ?? p[0];
await db('events').insert({slug:'bf-ev',event_type:'wedding',event_name:'bf',event_date:'2026-08-01',
host_email:'h@e.com',admin_email:'a@e.com',password_hash:'x',share_token:'t1',share_link:'/g/bf-ev/t1',
created_by: 4242, project_id: pid, expires_at:new Date(Date.now()+864e5).toISOString(),
is_active:1,is_archived:0,is_draft:0,created_at:new Date().toISOString()});
await mig.up(db);
const row = await db('projects').where({id:pid}).first();
expect(row.created_by).toBe(4242);
});
});
@@ -1,83 +0,0 @@
/**
* GHSA-jhcf round 3: scoping the activity feed does nothing about the rows
* already on disk. expenseService used to pass adminId into logActivity's
* `eventId` slot, so upgraded instances carry accounting rows whose event_id
* is an ADMIN id — and the scope predicate happily matches those against a
* same-numbered event the caller owns.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mig168-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mig168-test-secret';
const { bootCrmDb } = require('../integration/helpers/crmDb');
const migration = require('../../migrations/core/168_fix_expense_activity_event_id');
describe('migration 168 — legacy accounting activity rows (GHSA-jhcf)', () => {
let db; let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('re-attributes the admin id and clears event_id, leaving real rows alone', async () => {
await db('activity_logs').insert([
// Legacy shape: event_id is really admin #7, no actor recorded.
{
activity_type: 'expense_created',
actor_type: 'system',
actor_id: null,
event_id: 7,
metadata: JSON.stringify({ expenseId: 1 }),
created_at: new Date().toISOString(),
},
{
activity_type: 'incoming_invoice_captured',
actor_type: 'system',
actor_id: null,
event_id: 9,
metadata: JSON.stringify({ inboundDocumentId: 2 }),
created_at: new Date().toISOString(),
},
// A genuine event-scoped row from another subsystem must survive intact.
{
activity_type: 'photo_uploaded',
actor_type: 'admin',
actor_id: 3,
event_id: 7,
metadata: JSON.stringify({}),
created_at: new Date().toISOString(),
},
]);
await migration.up(db);
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
expect(expense.event_id == null).toBe(true);
expect(Number(expense.actor_id)).toBe(7);
expect(expense.actor_type).toBe('admin');
const captured = await db('activity_logs').where({ activity_type: 'incoming_invoice_captured' }).first();
expect(captured.event_id == null).toBe(true);
expect(Number(captured.actor_id)).toBe(9);
const photo = await db('activity_logs').where({ activity_type: 'photo_uploaded' }).first();
expect(Number(photo.event_id)).toBe(7);
expect(Number(photo.actor_id)).toBe(3);
});
it('is idempotent on re-run', async () => {
await expect(migration.up(db)).resolves.toBeUndefined();
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
expect(Number(expense.actor_id)).toBe(7);
expect(expense.event_id == null).toBe(true);
});
});
@@ -1,242 +0,0 @@
/**
* Repairing the bundled templates' fixed image height (#1131).
*
* The risk in a migration that rewrites user-visible CSS is doing too much,
* so most of what is pinned here is what it must NOT touch: the other pixel
* heights inside the very same templates (a 1px divider, an 8px scrollbar),
* and any rule a user wrote themselves.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const migration = require('../../migrations/core/175_fix_css_template_photo_height');
const ELEGANT_DARK = `
.photo-card {
border-radius: 12px;
}
.photo-card img {
width: 100%;
height: 200px;
object-fit: cover;
transition: transform 0.3s ease;
}
`;
const LIQUID_GLASS_DARK = `
.gallery-page::after {
content: '';
height: 1px;
background: linear-gradient(90deg, transparent, #fff, transparent);
}
.photo-card img {
width: 100%;
height: 240px;
object-fit: cover;
filter: brightness(0.9);
}
.gallery-page ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
@media (max-width: 640px) {
.photo-card img {
height: 180px;
}
}
`;
describe('migration 175 — CSS template image height (#1131)', () => {
let knex; let tmpDir;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig175-'));
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
await knex.schema.createTable('css_templates', (t) => {
t.increments('id').primary();
t.string('name');
t.text('css_content');
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => { await knex('css_templates').del(); });
const contentOf = async (name) =>
(await knex('css_templates').where({ name }).first()).css_content;
it('relaxes the default template so the layouts h-full can win', async () => {
await knex('css_templates').insert({ name: 'Elegant Dark', css_content: ELEGANT_DARK });
await migration.up(knex);
const css = await contentOf('Elegant Dark');
expect(css).toContain('height: 100%');
expect(css).not.toContain('height: 200px');
// Everything else about the rule survives.
expect(css).toContain('object-fit: cover');
expect(css).toContain('transition: transform 0.3s ease');
});
it('fixes both the base rule and the mobile override of the dark glass template', async () => {
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
await migration.up(knex);
const css = await contentOf('Liquid Glass Dark');
expect(css).not.toContain('height: 240px');
expect(css).not.toContain('height: 180px');
expect(css.match(/height: 100%/g)).toHaveLength(2);
});
it('leaves the divider and the scrollbar alone', async () => {
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
await migration.up(knex);
// The whole reason this matches full rule bodies rather than every
// `height: <n>px`: these are in the same stylesheet and are correct.
const css = await contentOf('Liquid Glass Dark');
expect(css).toContain('height: 1px');
expect(css).toContain('width: 8px');
expect(css).toContain('height: 8px');
});
/**
* The case that forced the scope wider. `sanitizeCSS` strips control
* characters, so any template ever saved through the editor — including a
* save that only changed its name — has had every newline REMOVED. An
* exact-text migration finds nothing on those installs, is recorded as
* applied, and leaves them broken permanently.
*/
it('fixes a template that has been through the editor, newlines and all', async () => {
const { sanitizeCSS } = require('../../src/utils/cssSanitizer');
const { sanitized } = sanitizeCSS(ELEGANT_DARK);
// Precondition: the sanitizer really did flatten it.
expect(sanitized).not.toContain('\n');
expect(sanitized).toContain('height: 200px');
await knex('css_templates').insert({ name: 'Saved Once', css_content: sanitized });
await migration.up(knex);
const css = await contentOf('Saved Once');
expect(css).not.toContain('200px');
expect(css).toContain('height: 100%');
});
it('relaxes a user-authored fixed height too, but only on .photo-card img', async () => {
// Deliberately broader than the seeded text — see the migration header. A
// pixel height on the image cannot be right under any of the seven
// layouts, whoever wrote it; a height anywhere else is none of our
// business.
const mine = '.photo-card img {\n height: 220px;\n}\n.hero { height: 400px; }';
await knex('css_templates').insert({ name: 'My Own', css_content: mine });
await migration.up(knex);
const css = await contentOf('My Own');
expect(css).toContain('height: 100%');
expect(css).not.toContain('220px');
expect(css).toContain('.hero { height: 400px; }');
});
it('does not rewrite other properties that merely end in -height', async () => {
// `line-height: 200px` contains `height: 200px` as a substring, so an
// unanchored pattern silently rewrites it — in a migration that cannot be
// undone.
const mine = [
'.photo-card img {',
' line-height: 200px;',
' max-height: 300px;',
' min-height: 14px;',
' --tile-height: 220px;',
' height: 200px;',
'}',
].join('\n');
await knex('css_templates').insert({ name: 'Adjacent Props', css_content: mine });
await migration.up(knex);
const css = await contentOf('Adjacent Props');
expect(css).toContain('line-height: 200px');
expect(css).toContain('max-height: 300px');
expect(css).toContain('min-height: 14px');
expect(css).toContain('--tile-height: 220px');
// Only the real one moved.
expect(css).toContain('height: 100%');
expect(css).not.toMatch(/(?<![\w-])height:\s*200px/);
});
it('handles a grouped selector list', async () => {
// Requiring `{` straight after `img` skipped these entirely — and the
// migration is still recorded as applied, so the template kept the bug.
const mine = '.photo-card img, .thumbnail img {\n height: 200px;\n}';
await knex('css_templates').insert({ name: 'Grouped', css_content: mine });
await migration.up(knex);
const css = await contentOf('Grouped');
expect(css).toContain('.photo-card img, .thumbnail img {');
expect(css).toContain('height: 100%');
expect(css).not.toContain('200px');
});
it('skips a nested rule rather than rewriting the wrong declaration', async () => {
// Valid nested CSS that passes the validator. A brace-greedy body would
// capture the inner block and rewrite the CAPTION's height, which cannot
// be undone. Leaving it untouched is the lesser evil.
const mine = '.photo-card img {\n & + .caption { height: 200px; }\n}';
await knex('css_templates').insert({ name: 'Nested', css_content: mine });
await migration.up(knex);
expect(await contentOf('Nested')).toBe(mine);
});
it('leaves non-pixel heights on the image alone', async () => {
const mine = '.photo-card img { height: 50vh; }\n.photo-card img { height: auto; }';
await knex('css_templates').insert({ name: 'Relative', css_content: mine });
await migration.up(knex);
expect(await contentOf('Relative')).toBe(mine);
});
it('is idempotent and safe on a row with no CSS', async () => {
await knex('css_templates').insert([
{ name: 'Elegant Dark', css_content: ELEGANT_DARK },
{ name: 'Empty', css_content: null },
]);
await migration.up(knex);
const once = await contentOf('Elegant Dark');
await migration.up(knex);
expect(await contentOf('Elegant Dark')).toBe(once);
expect(await contentOf('Empty')).toBeNull();
});
it('no-ops when the table does not exist yet', async () => {
await knex.schema.dropTable('css_templates');
await expect(migration.up(knex)).resolves.toBeUndefined();
await knex.schema.createTable('css_templates', (t) => {
t.increments('id').primary();
t.string('name');
t.text('css_content');
});
});
});
@@ -1,546 +0,0 @@
/**
* One row per external file per event (#1162).
*
* The migration has two halves and they fail differently: the cleanup can take
* out the wrong row of a pair (losing a thumbnail, orphaning an event's hero),
* and the index can fail to be created at all — leaving an install that looks
* migrated and is still racing. Both are pinned here.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const migration = require('../../migrations/core/176_external_relpath_unique');
describe('migration 176 — unique (event_id, external_relpath) (#1162)', () => {
let knex; let tmpDir;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig186-'));
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
for (const table of [
'photos', 'events', 'photo_categories', 'photo_feedback',
'photo_admin_marks', 'photo_faces', 'image_access_logs', 'transfer_files',
]) {
await knex.schema.dropTableIfExists(table);
}
await knex.schema.createTable('events', (t) => {
t.increments('id').primary();
t.integer('hero_photo_id');
t.string('download_zip_path');
t.string('download_zip_generated_at');
});
await knex.schema.createTable('photo_categories', (t) => {
t.increments('id').primary();
t.integer('hero_photo_id');
});
await knex.schema.createTable('photos', (t) => {
t.increments('id').primary();
t.integer('event_id');
t.string('external_relpath');
t.string('thumbnail_path');
t.string('source_origin').defaultTo('managed');
t.integer('feedback_count').defaultTo(0);
t.integer('like_count').defaultTo(0);
t.decimal('average_rating', 3, 2).defaultTo(0);
t.integer('favorite_count').defaultTo(0);
t.integer('reaction_count').defaultTo(0);
t.integer('color_label_count').defaultTo(0);
t.string('face_status');
t.integer('view_count').defaultTo(0);
t.integer('download_count').defaultTo(0);
t.integer('face_count');
t.string('face_started_at');
t.text('face_error');
});
// Declared exactly as the real schema declares them — CASCADE and all.
// The point of these tables here is that SQLite does NOT enforce any of
// it (PicPeak never sets `PRAGMA foreign_keys = ON`), so a bare delete of
// the photo row leaves every one of them dangling.
await knex.schema.createTable('photo_feedback', (t) => {
t.increments('id').primary();
t.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
t.integer('event_id');
t.string('feedback_type');
t.text('comment_text');
t.string('guest_identifier');
// Per-person guest identity (migration 078). Nullable: galleries without
// guest identity leave it NULL and fall back to guest_identifier.
t.integer('guest_id');
t.integer('rating');
t.boolean('is_hidden').defaultTo(false);
t.boolean('is_approved').defaultTo(true);
});
await knex.schema.createTable('photo_admin_marks', (t) => {
t.increments('id').primary();
t.integer('photo_id').notNullable().references('id').inTable('photos').onDelete('CASCADE');
t.integer('event_id');
t.integer('admin_id');
t.integer('rating');
// Independently writable alongside rating, per photoAdminMarksService.
t.string('color_label', 16);
t.unique(['photo_id', 'admin_id'], 'photo_admin_marks_photo_admin_uniq');
});
await knex.schema.createTable('photo_faces', (t) => {
t.increments('id').primary();
t.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
t.integer('event_id');
// purgePhotoFaces rebuilds the people that lose members, so the cluster
// link and the vectors recomputeCentroid reads have to be here for this
// to exercise the real path rather than a stub.
t.integer('person_id');
t.binary('embedding');
t.float('det_score');
});
await knex.schema.createTable('image_access_logs', (t) => {
t.increments('id').primary();
t.integer('photo_id');
});
await knex.schema.createTable('transfer_files', (t) => {
t.increments('id').primary();
t.integer('transfer_id');
t.integer('photo_id');
t.unique(['transfer_id', 'photo_id'], 'transfer_files_unique');
});
});
/** Two duplicate rows for the same file: id 1 survives, id 2 is doomed. */
const seedPair = async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
]);
};
const rows = () => knex('photos').orderBy('id', 'asc').select('*');
it('collapses a duplicated pair to one row and leaves distinct paths alone', async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't1', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't2', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/y.jpg', thumbnail_path: 't3', source_origin: 'external' },
]);
await migration.up(knex);
const after = await rows();
expect(after.map((r) => r.external_relpath)).toEqual(['a/x.jpg', 'a/y.jpg']);
// Lowest id survives when both sides are equally complete.
expect(after[0].id).toBe(1);
});
it('does not collapse the same path across different events', async () => {
// The constraint is per event. Two events referencing the same NAS folder
// is a supported setup, and treating those as duplicates would delete one
// event's entire library.
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
{ event_id: 2, external_relpath: 'a/x.jpg', source_origin: 'external' },
]);
await migration.up(knex);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
});
it('never touches managed rows, however many carry NULL', async () => {
// Every managed photo has external_relpath NULL. Grouping on it without
// the NOT NULL filter would make them all one enormous "duplicate" group
// and delete the entire library bar one row.
await knex('photos').insert([
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
]);
await migration.up(knex);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 3 });
});
it('keeps the row that has a thumbnail, not merely the lowest id', async () => {
// An import killed mid-flight leaves rows without a thumbnail. Dropping
// the completed one would blank a tile in the grid for no reason.
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: null, source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 'thumb.jpg', source_origin: 'external' },
]);
await migration.up(knex);
const after = await rows();
expect(after).toHaveLength(1);
expect(after[0].thumbnail_path).toBe('thumb.jpg');
});
it('repoints a hero that pointed at the row being removed', async () => {
// events.hero_photo_id is ON DELETE SET NULL, so without this the cleanup
// silently strips the event's hero image — a visible regression caused
// entirely by the fix.
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
]);
await knex('events').insert({ id: 1, hero_photo_id: 2 });
await knex('photo_categories').insert({ id: 1, hero_photo_id: 2 });
await migration.up(knex);
expect((await knex('events').where({ id: 1 }).first()).hero_photo_id).toBe(1);
expect((await knex('photo_categories').where({ id: 1 }).first()).hero_photo_id).toBe(1);
});
it('leaves a hero that pointed at the survivor untouched', async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
]);
await knex('events').insert({ id: 1, hero_photo_id: 1 });
await migration.up(knex);
expect((await knex('events').where({ id: 1 }).first()).hero_photo_id).toBe(1);
});
it('makes a second insert of the same path impossible afterwards', async () => {
// The whole point. Without this the route is still racing, and the
// migration is recorded as applied.
await knex('photos').insert({ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' });
await migration.up(knex);
await expect(
knex('photos').insert({ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' })
).rejects.toThrow(/unique/i);
});
it('still admits managed rows once the index exists', async () => {
await migration.up(knex);
await knex('photos').insert([
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
]);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
});
it('leaves nothing dangling behind the deleted row', async () => {
// SQLite never enforces the ON DELETE CASCADE these tables declare, so a
// bare delete strands biometric embeddings, feedback and marks pointing at
// a photo id that no longer exists — on every SQLite install.
await seedPair();
await knex('photo_faces').insert({ photo_id: 2, event_id: 1 });
await knex('image_access_logs').insert({ photo_id: 2 });
await migration.up(knex);
expect(await knex('photo_faces').where('photo_id', 2).first()).toBeUndefined();
expect(await knex('image_access_logs').where('photo_id', 2).first()).toBeUndefined();
});
it('does not carry the duplicate\'s faces over to the survivor', async () => {
// Both rows were scanned independently, so the survivor already holds its
// own embeddings. Moving these would fabricate a second copy of every face
// and split the person clusters built from them.
await seedPair();
await knex('photo_faces').insert([{ photo_id: 1, event_id: 1 }, { photo_id: 2, event_id: 1 }]);
await migration.up(knex);
expect(await knex('photo_faces').count('* as c').first()).toEqual({ c: 1 });
});
it('moves a guest comment to the survivor rather than deleting it', async () => {
// The duplicates were separate tiles in the grid, so a guest could have
// commented on either. Silently dropping that inside a fix for silent data
// loss would be its own bug.
await seedPair();
await knex('photo_feedback').insert({
photo_id: 2, event_id: 1, feedback_type: 'comment',
comment_text: 'lovely shot', guest_identifier: 'guest-a',
});
await migration.up(knex);
const rows = await knex('photo_feedback');
expect(rows).toHaveLength(1);
expect(rows[0].photo_id).toBe(1);
expect(rows[0].comment_text).toBe('lovely shot');
});
it('keeps both comments when the same guest commented on both tiles', async () => {
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'comment', comment_text: 'one', guest_identifier: 'g' },
{ photo_id: 2, event_id: 1, feedback_type: 'comment', comment_text: 'two', guest_identifier: 'g' },
]);
await migration.up(knex);
const rows = await knex('photo_feedback').orderBy('id');
expect(rows.map((r) => r.comment_text)).toEqual(['one', 'two']);
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
});
it('does not double-count a like the same guest left on both tiles', async () => {
// Unlike comments, a like is a per-guest toggle: moving it would show two
// likes from one person.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'g' },
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g' },
]);
await migration.up(knex);
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 1 });
});
it('moves a like from a guest the survivor has never seen', async () => {
await seedPair();
await knex('photo_feedback').insert({
photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'other',
});
await migration.up(knex);
const rows = await knex('photo_feedback');
expect(rows).toHaveLength(1);
expect(rows[0].photo_id).toBe(1);
});
it('moves an admin mark, and drops it when that admin already marked the survivor', async () => {
// photo_admin_marks is UNIQUE(photo_id, admin_id), so a blind move would
// throw and abort the migration.
await seedPair();
await knex('photo_admin_marks').insert([
{ photo_id: 1, event_id: 1, admin_id: 7, rating: 5 },
{ photo_id: 2, event_id: 1, admin_id: 7, rating: 2 },
{ photo_id: 2, event_id: 1, admin_id: 9, rating: 4 },
]);
await migration.up(knex);
const rows = await knex('photo_admin_marks').orderBy('admin_id');
expect(rows.map((r) => [r.admin_id, r.rating])).toEqual([[7, 5], [9, 4]]);
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
});
it('respects the transfer_files uniqueness when moving membership', async () => {
await seedPair();
await knex('transfer_files').insert([
{ transfer_id: 3, photo_id: 1 },
{ transfer_id: 3, photo_id: 2 },
{ transfer_id: 4, photo_id: 2 },
]);
await migration.up(knex);
const rows = await knex('transfer_files').orderBy('transfer_id');
expect(rows.map((r) => r.transfer_id)).toEqual([3, 4]);
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
});
it('recomputes the survivor\'s feedback totals after reparenting rows', async () => {
// photos carries denormalized counters (migration 033). A survivor that
// now OWNS the feedback but still renders zero is the visible half of
// getting this wrong.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g1' },
{ photo_id: 2, event_id: 1, feedback_type: 'rating', rating: 4, guest_identifier: 'g1' },
]);
await migration.up(knex);
const survivor = await knex('photos').where('id', 1).first();
expect(survivor.like_count).toBe(1);
expect(Number(survivor.average_rating)).toBe(4);
expect(survivor.feedback_count).toBe(1);
});
it('keeps two people who share a device apart', async () => {
// guest_identifier is per-device; guest_id is per-person (migration 078),
// and feedbackService scopes by guest_id when it is present. Keying on the
// identifier alone would read these as one person and delete a rating.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'rating', rating: 5, guest_identifier: 'shared', guest_id: 10 },
{ photo_id: 2, event_id: 1, feedback_type: 'rating', rating: 2, guest_identifier: 'shared', guest_id: 11 },
]);
await migration.up(knex);
const rows = await knex('photo_feedback').orderBy('guest_id');
expect(rows.map((r) => [r.guest_id, r.rating])).toEqual([[10, 5], [11, 2]]);
});
it('still dedupes one person voting on both tiles', async () => {
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'shared', guest_id: 10 },
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'shared', guest_id: 10 },
]);
await migration.up(knex);
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 1 });
});
it('still clears face rows on a branch that has no face feature', async () => {
// DIVERGES FROM MAIN, deliberately. Face recognition (#1090) is main-only:
// there is no faceProcessor on this branch, so purgePhotoFaces cannot be
// called and there are no event_people counts or centroids to reconcile.
// What still matters is the half that is not optional — the rows must not
// dangle, because SQLite never enforces the CASCADE that would remove
// them. The service reaches for purgePhotoFaces, finds nothing, and falls
// back to a plain delete; this pins that fallback.
//
// If faces are ever backported, main's version of this test comes with
// them.
await seedPair();
await knex('photo_faces').insert({ photo_id: 2, event_id: 1, person_id: 5 });
await migration.up(knex);
expect(await knex('photo_faces').count('* as c').first()).toEqual({ c: 0 });
});
it('keeps a hidden moderation record from swallowing the visible replacement', async () => {
// feedbackService lets both coexist and counts only the visible one.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'g', is_hidden: true },
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g', is_hidden: false },
]);
await migration.up(knex);
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 2 });
});
it('merges the independent halves of one admin\'s mark', async () => {
// rating and color_label are written independently, so the same admin can
// have rated one tile and coloured the other.
await seedPair();
await knex('photo_admin_marks').insert([
{ photo_id: 1, event_id: 1, admin_id: 7, rating: 5, color_label: null },
{ photo_id: 2, event_id: 1, admin_id: 7, rating: null, color_label: 'red' },
]);
await migration.up(knex);
const rows = await knex('photo_admin_marks');
expect(rows).toHaveLength(1);
expect([rows[0].rating, rows[0].color_label]).toEqual([5, 'red']);
});
it('requeues the survivor when the duplicate held the only scan', async () => {
// Otherwise the sole embeddings go with the purge and nothing re-queues:
// the photo just silently stops having a face.
await seedPair();
await knex('photo_faces').insert({ photo_id: 2, event_id: 1 });
await migration.up(knex);
expect((await knex('photos').where('id', 1).first()).face_status).toBe('pending');
});
it('carries the duplicate\'s views and downloads over', async () => {
await seedPair();
await knex('photos').where('id', 1).update({ view_count: 2, download_count: 1 });
await knex('photos').where('id', 2).update({ view_count: 5, download_count: 3 });
await migration.up(knex);
const survivor = await knex('photos').where('id', 1).first();
expect([survivor.view_count, survivor.download_count]).toEqual([7, 4]);
});
it('fails loudly rather than recording itself applied without the index', async () => {
// Swallowing a failed CREATE INDEX would leave the install permanently
// racy — the in-flight guard only covers one process — with nothing to
// trigger a retry. Driven through the helper the migration calls, against
// a table that still holds duplicates — i.e. what it would face if the
// dedupe above had not achieved uniqueness.
await seedPair();
const { createExternalRelpathIndex } = require('../../src/services/externalPhotoDedupe');
await expect(createExternalRelpathIndex(knex)).rejects.toThrow(/unique/i);
});
it('invalidates the pre-built download zip for the affected event', async () => {
// The cached archive still contains the rows just removed, and every
// ordinary photo-deletion path invalidates it for exactly that reason.
// getZipInfo treats a cleared record as a miss and rebuilds on request.
await seedPair();
await knex('events').insert({
id: 1, download_zip_path: 'events/active/x/.download-cache/all.zip',
download_zip_generated_at: '2026-01-01',
});
await migration.up(knex);
const ev = await knex('events').where('id', 1).first();
expect(ev.download_zip_path).toBeNull();
expect(ev.download_zip_generated_at).toBeNull();
});
it('leaves an untouched event\'s zip alone', async () => {
await seedPair();
await knex('events').insert([
{ id: 1, download_zip_path: 'a.zip', download_zip_generated_at: '2026-01-01' },
{ id: 2, download_zip_path: 'b.zip', download_zip_generated_at: '2026-01-01' },
]);
await migration.up(knex);
expect((await knex('events').where('id', 2).first()).download_zip_path).toBe('b.zip');
});
it('is idempotent', async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
]);
await migration.up(knex);
const once = await rows();
await migration.up(knex);
expect(await rows()).toEqual(once);
});
it('rolls back to an unconstrained table', async () => {
await migration.up(knex);
await migration.down(knex);
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
]);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
});
it('no-ops before 041 has added the column', async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.createTable('photos', (t) => { t.increments('id').primary(); });
await expect(migration.up(knex)).resolves.toBeUndefined();
});
});
@@ -1,364 +0,0 @@
/**
* Folding the event's base path into every external row (#1163).
*
* Two things can go wrong and both are silent, which is why they are pinned
* here rather than left to review: folding a path that was ALREADY folded
* (every original moves), and "repairing" a healthy install because the media
* root happened to be unmounted when the migration ran (every original moves).
*
* The repair itself is driven against a real temp directory tree, because the
* whole mechanism is "is this file actually there" and a mocked fs would only
* be testing the mock.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
describe('migration 177 — external_relpath from the media root (#1163)', () => {
let knex; let tmpDir; let mediaRoot; let migration;
/** Writes `bytes` bytes and returns the size, so fixtures can record it the
* way an import would have. */
const touch = async (rel, bytes = 8) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await fs.promises.writeFile(full, Buffer.alloc(bytes));
return bytes;
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig187-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
// The service caches the root on first call, so it must not have been
// resolved before EXTERNAL_MEDIA_ROOT was set above.
jest.resetModules();
migration = require('../../migrations/core/177_external_relpath_from_root');
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
delete process.env.EXTERNAL_MEDIA_ROOT;
});
beforeEach(async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.dropTableIfExists('events');
await knex.schema.dropTableIfExists('app_settings');
await knex.schema.createTable('events', (t) => {
t.increments('id').primary();
t.string('external_path');
});
await knex.schema.createTable('photos', (t) => {
t.increments('id').primary();
t.integer('event_id');
t.string('external_relpath');
t.integer('size_bytes');
t.string('source_origin').defaultTo('managed');
});
await knex.schema.createTable('app_settings', (t) => {
t.increments('id').primary();
t.string('setting_key');
t.text('setting_value');
t.string('setting_type');
t.string('updated_at');
});
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
});
const relpaths = async () =>
(await knex('photos').orderBy('id', 'asc').select('external_relpath'))
.map((r) => r.external_relpath);
it('folds the base path into every row of a healthy event', async () => {
await touch('Trip/Leknes/a.jpg');
await touch('Trip/Leknes/b.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'Leknes/a.jpg', source_origin: 'external' },
{ event_id: 1, external_relpath: 'Leknes/b.jpg', source_origin: 'external' },
]);
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Leknes/a.jpg', 'Trip/Leknes/b.jpg']);
});
it('repairs rows an earlier import had rebased', async () => {
// The reported shape: a parent imported first, a child imported second, so
// events.external_path is the child and the parent's rows resolve into a
// path that does not exist.
const oldSize = await touch('Trip/Leknes/old.jpg', 11); // from the first import
const newSize = await touch('Trip/Sub/new.jpg', 22); // from the second
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'Leknes/old.jpg', size_bytes: oldSize, source_origin: 'external' },
{ event_id: 1, external_relpath: 'new.jpg', size_bytes: newSize, source_origin: 'external' },
]);
await migration.up(knex);
// The old row is placed where the file actually is; the new one keeps
// resolving exactly where it resolved before.
expect(await relpaths()).toEqual(['Trip/Leknes/old.jpg', 'Trip/Sub/new.jpg']);
});
it('refuses an ancestor whose file is a different size', async () => {
// The dangerous case: the row's own file was simply deleted, and an
// UNRELATED file one directory up happens to share its name. Adopting it
// would make downloads serve the wrong original — worse than a dead link.
await touch('Trip/photo.jpg', 999);
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert({
event_id: 1, external_relpath: 'photo.jpg', size_bytes: 42, source_origin: 'external',
});
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Sub/photo.jpg']);
});
it('refuses an ancestor when the row records no size to check against', async () => {
// Nothing to verify provenance with, so the row stays where it resolves
// today rather than adopting a same-named stranger.
await touch('Trip/photo.jpg', 100);
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert({
event_id: 1, external_relpath: 'photo.jpg', size_bytes: null, source_origin: 'external',
});
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Sub/photo.jpg']);
});
it('leaves nothing folded when a rewrite fails partway', async () => {
// Without a transaction, a crash between the first event's UPDATE and the
// marker leaves mixed formats behind — and the next run folds the already
// folded rows a second time, putting every original one directory deeper.
await touch('A/one.jpg');
await touch('B/two.jpg');
await knex('events').insert([
{ id: 1, external_path: 'A' },
{ id: 2, external_path: 'B' },
]);
await knex('photos').insert([
{ event_id: 1, external_relpath: 'one.jpg', source_origin: 'external' },
{ event_id: 2, external_relpath: 'two.jpg', source_origin: 'external' },
]);
// app_settings is written last, in the same transaction as the rewrites.
await knex.schema.dropTableIfExists('app_settings_backup');
await knex.raw('CREATE TRIGGER fail_marker BEFORE INSERT ON app_settings '
+ "BEGIN SELECT RAISE(ABORT, 'boom'); END");
await expect(migration.up(knex)).rejects.toThrow(/boom/);
await knex.raw('DROP TRIGGER fail_marker');
// Every row still base-relative, and no marker — so a retry is correct.
expect(await relpaths()).toEqual(['one.jpg', 'two.jpg']);
expect(await knex('app_settings').where('setting_key', 'external_relpath_root_relative').first())
.toBeUndefined();
});
it('removes the losing row when two paths converge, instead of stranding it', async () => {
// Trip/Sub/c.jpg imported once via `Trip` (as `Sub/c.jpg`) and once via
// `Trip/Sub` (as `c.jpg`). Both fold to the same path. Skipping the loser
// would leave it base-relative under a root-only resolver — pointing at
// <root>/c.jpg — with the marker claiming the conversion is complete.
const size = await touch('Trip/Sub/c.jpg', 33);
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'Sub/c.jpg', size_bytes: size, source_origin: 'external' },
{ event_id: 1, external_relpath: 'c.jpg', size_bytes: size, source_origin: 'external' },
]);
await migration.up(knex);
const rows = await knex('photos').select('external_relpath');
expect(rows).toHaveLength(1);
expect(rows[0].external_relpath).toBe('Trip/Sub/c.jpg');
});
it('survives a final path that equals another row\'s current path', async () => {
// `photo.jpg` repairs to `Trip/photo.jpg` while the row already holding
// `Trip/photo.jpg` folds to `Trip/Sub/Trip/photo.jpg`. Every FINAL value is
// distinct, but a one-pass rewrite collides halfway through — and on
// Postgres that 23505 is misread by the migration runner as "already
// applied", leaving everything unconverted.
const a = await touch('Trip/photo.jpg', 11);
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'photo.jpg', size_bytes: a, source_origin: 'external' },
{ event_id: 1, external_relpath: 'Trip/photo.jpg', size_bytes: b, source_origin: 'external' },
]);
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
});
it('does not re-prefix a row inserted while the probe was running', async () => {
// Phase 1 runs outside the transaction and can take minutes on a cold
// mount. An import finishing in that window writes an already
// root-relative row, which a `where event_id` bulk update would prefix a
// second time with the stale base.
await touch('Trip/a.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
const { foldExternalRelpaths } = require('../../src/services/externalRelpathFold');
const realStat = fs.promises.stat;
let injected = false;
jest.spyOn(fs.promises, 'access').mockImplementation(async (...args) => {
if (!injected) {
injected = true;
await knex('photos').insert({
event_id: 1, external_relpath: 'Trip/late.jpg', source_origin: 'external',
});
}
return realStat(args[0]).then(() => undefined);
});
await foldExternalRelpaths(knex);
fs.promises.access.mockRestore();
expect((await relpaths()).sort()).toEqual(['Trip/a.jpg', 'Trip/late.jpg']);
});
it('leaves a row it cannot place resolving where it resolves today', async () => {
// Never guess below current behaviour: a file that is genuinely gone must
// not have its path rewritten to some other file that happens to exist.
await touch('Trip/Sub/present.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'present.jpg', source_origin: 'external' },
{ event_id: 1, external_relpath: 'vanished.jpg', source_origin: 'external' },
]);
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Sub/present.jpg', 'Trip/Sub/vanished.jpg']);
});
it('folds without repairing when the media root is unmounted', async () => {
// An unmounted share leaves the mountpoint as an empty directory, so every
// file looks missing. Repairing off that signal would move every original
// on a perfectly healthy install.
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'Leknes/a.jpg', source_origin: 'external' },
]);
// mediaRoot is empty — see beforeEach.
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Sub/Leknes/a.jpg']);
});
it('leaves managed rows alone', async () => {
await touch('Trip/a.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert([
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
{ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' },
]);
await migration.up(knex);
expect(await relpaths()).toEqual([null, 'Trip/a.jpg']);
});
it('leaves an event with no base path alone — its rows are already root-relative', async () => {
await touch('a.jpg');
await knex('events').insert({ id: 1, external_path: null });
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
await migration.up(knex);
expect(await relpaths()).toEqual(['a.jpg']);
});
it('folds each event with its own base', async () => {
await touch('A/one.jpg');
await touch('B/two.jpg');
await knex('events').insert([
{ id: 1, external_path: 'A' },
{ id: 2, external_path: 'B' },
]);
await knex('photos').insert([
{ event_id: 1, external_relpath: 'one.jpg', source_origin: 'external' },
{ event_id: 2, external_relpath: 'two.jpg', source_origin: 'external' },
]);
await migration.up(knex);
expect(await relpaths()).toEqual(['A/one.jpg', 'B/two.jpg']);
});
it('tolerates a base path with stray slashes', async () => {
await touch('Trip/a.jpg');
await knex('events').insert({ id: 1, external_path: '/Trip/' });
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/a.jpg']);
});
it('does not fold twice when run again', async () => {
// The failure this guards is total: every original on the install moves one
// directory deeper, and there is no undo.
await touch('Trip/a.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
await migration.up(knex);
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/a.jpg']);
});
it('does not fold twice when the base repeats in the relpath', async () => {
// The inference this migration deliberately does NOT use: `Trip/x.jpg`
// under base `Trip` already "starts with the base", but has not been
// folded — it is a subfolder that shares its parent's name.
await touch('Trip/Trip/x.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert({ event_id: 1, external_relpath: 'Trip/x.jpg', source_origin: 'external' });
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Trip/x.jpg']);
});
it('rollback does not clear the marker, so a re-run cannot double-fold', async () => {
await touch('Trip/a.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
await migration.up(knex);
await migration.down(knex);
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/a.jpg']);
});
it('no-ops before 041 has added the column', async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.createTable('photos', (t) => { t.increments('id').primary(); });
await expect(migration.up(knex)).resolves.toBeUndefined();
});
});
@@ -1,83 +0,0 @@
/**
* Legacy preview keys must not survive the encoder change.
*
* The old generator kept the SOURCE basename verbatim while always writing
* JPEG, so a `.webp` upload produced `preview_shot.webp` holding a JPEG. The
* route now derives Content-Type from the key, and sets `nosniff` — so that
* legacy object would be announced as image/webp and render as a broken image.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const migration = require('../../migrations/core/178_reset_legacy_preview_paths');
describe('migration 178 — legacy preview keys (#1166 follow-up)', () => {
let knex; let tmpDir;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig188-'));
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.createTable('photos', (t) => {
t.increments('id').primary();
t.string('preview_path');
t.string('thumbnail_path');
});
});
it('clears the mislabelled .webp keys that would render broken', async () => {
await knex('photos').insert({ preview_path: 'previews/preview_shot.webp' });
await migration.up(knex);
expect((await knex('photos').first()).preview_path).toBeNull();
});
it('clears .jpg keys too, because a byte-correct one can still be flattened', async () => {
// A legacy .jpg key is valid JPEG, but it may be a flattened rendition of a
// transparent or animated source, and nothing in the key says so. One lazy
// regeneration is cheaper than reasoning about which of them lied.
await knex('photos').insert([
{ preview_path: 'previews/preview_a.jpg' },
{ preview_path: 'previews/preview_b.png' },
]);
await migration.up(knex);
expect(await knex('photos').whereNotNull('preview_path').count('* as c').first()).toEqual({ c: 0 });
});
it('leaves thumbnails alone — they are a different cache', async () => {
await knex('photos').insert({ preview_path: 'previews/p.jpg', thumbnail_path: 'thumbnails/t.jpg' });
await migration.up(knex);
expect((await knex('photos').first()).thumbnail_path).toBe('thumbnails/t.jpg');
});
it('is idempotent and safe with nothing to clear', async () => {
await migration.up(knex);
await expect(migration.up(knex)).resolves.toBeUndefined();
});
it('no-ops before 104 has added the column', async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.createTable('photos', (t) => { t.increments('id').primary(); });
await expect(migration.up(knex)).resolves.toBeUndefined();
});
});
@@ -83,7 +83,7 @@ describe('admin CRM routes — auth + permission gate', () => {
// Invalid: signed with a different secret. adminAuth must reject.
const jwt = require('jsonwebtoken');
invalidToken = jwt.sign({ id: adminId, type: 'admin' }, 'WRONG-SECRET', { issuer: 'picpeak-auth' });
}, 120000);
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -180,27 +180,6 @@ describe('admin events CRUD endpoints (smoke)', () => {
});
expect(res.status).toBe(404);
});
// #822 — hero_logo_visible/position are nullable (null = "inherit the global
// branding toggle"), but the validator used .optional() without
// { nullable: true }, so an explicit null was rejected with 400.
it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => {
const id = await insertEvent(db, adminId, { hero_logo_visible: 1 });
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: null,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.hero_logo_visible).toBeNull();
});
it('still rejects a non-boolean hero_logo_visible', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: 'maybe',
});
expect(res.status).toBe(400);
});
});
describe('DELETE /:id', () => {
@@ -1,223 +0,0 @@
/**
* Regression test: deleting an event must remove its stored objects.
*
* deleteEventCascade() cleaned up the local filesystem only (#608). On an
* S3/R2 storage backend that cleanup is a no-op, so every deleted gallery
* left its originals and derived tiers in the bucket — unreferenced,
* invisible in the UI, and billed forever. Measured on a v3.45.16 install
* against Cloudflare R2: deleting a 403-photo event changed the bucket
* object count by exactly zero.
*
* The keys must be collected BEFORE the transaction deletes the photo
* rows, because afterwards nothing knows which objects were this event's.
*/
const os = require('os');
const path = require('path');
// The cascade runs a real `fs.rm(..., { recursive: true })` over
// {STORAGE_PATH}/events/{active,archived}/{slug}. Point that at a throwaway
// directory before requiring the module under test — the default resolves
// into the working tree.
process.env.STORAGE_PATH = path.join(os.tmpdir(), 'picpeak-cascade-storage-test');
const mockStorage = { delete: jest.fn().mockResolvedValue(undefined) };
const mockEvent = {
id: 42,
slug: 'other-demo-2026-01-01',
event_name: 'Demo',
source_mode: 'managed',
// Written through the backend by archiveService, so it is a bucket object
// and the fs.unlink in the cascade never touched it on S3.
archive_path: 'archives/other-demo-2026-01-01.zip',
// The pre-built "Download All" zip. Lives under the event prefix, so the
// recursive fs.rm covers it on local disk and nothing covers it on S3.
download_zip_path: 'events/active/other-demo-2026-01-01/.download-cache/all.zip',
};
const mockPhotos = [
{
id: 1,
path: 'other-demo-2026-01-01/photo_one.jpg',
thumbnail_path: 'thumbnails/thumb_aaa_photo_one.jpg',
hero_path: null,
preview_path: 'previews/prev_aaa_photo_one.jpg',
watermark_path: 'watermarked/wm_aaa_photo_one.jpg',
source_origin: 'managed',
},
{
id: 2,
path: 'other-demo-2026-01-01/photo_two.jpg',
thumbnail_path: 'thumbnails/thumb_bbb_photo_two.jpg',
hero_path: null,
preview_path: null,
watermark_path: null,
source_origin: 'managed',
},
{
// External photos live outside the managed backend and must be left alone.
id: 3,
path: 'ignored.jpg',
thumbnail_path: null,
hero_path: null,
preview_path: null,
watermark_path: null,
source_origin: 'external',
},
];
let mockPhotoRowsDeleted = false;
let mockJobRowsDeleted = false;
// Photos in OTHER events that share a canonical derivative key with this one.
let mockSharedDerivatives = [];
// The shared-derivative probe: db('photos').whereNot(...).where(cb).select(...)
const sharedProbe = {
where: () => sharedProbe,
whereIn: () => sharedProbe,
orWhereIn: () => sharedProbe,
select: async () => mockSharedDerivatives,
};
function mockMakeDb() {
const table = (name) => {
const chain = {
where: () => chain,
first: async () => (name === 'events' ? mockEvent : undefined),
whereNotNull: () => chain,
whereNot: () => sharedProbe,
orWhereIn: () => chain,
whereIn: () => chain,
select: async () => {
if (name === 'photos') {
// The whole point: if this runs after the transaction, the rows
// are gone and we would collect nothing.
return mockPhotoRowsDeleted ? [] : mockPhotos;
}
return [];
},
del: async () => {
if (name === 'photos') mockPhotoRowsDeleted = true;
if (name === 'download_jobs') mockJobRowsDeleted = true;
return 1;
},
};
return chain;
};
// #1132 guards the merge-dismissals delete behind a hasTable check.
table.schema = { hasTable: async () => false };
table.transaction = async (cb) => cb(table);
return table;
}
jest.mock('../../src/database/db', () => ({
db: mockMakeDb(),
logActivity: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../src/services/storage', () => ({
getStorage: () => mockStorage,
}));
const { deleteEventCascade } = require('../../src/routes/adminEvents/helpers');
describe('deleteEventCascade — storage cleanup', () => {
beforeEach(() => {
mockStorage.delete.mockClear();
mockPhotoRowsDeleted = false;
mockJobRowsDeleted = false;
mockSharedDerivatives = [];
});
it('deletes originals and every derived tier from the storage backend', async () => {
await deleteEventCascade(42, { id: 1, username: 'admin' });
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
expect(deleted).toEqual(expect.arrayContaining([
'events/active/other-demo-2026-01-01/photo_one.jpg',
'events/active/other-demo-2026-01-01/photo_two.jpg',
'thumbnails/thumb_aaa_photo_one.jpg',
'thumbnails/thumb_bbb_photo_two.jpg',
'previews/prev_aaa_photo_one.jpg',
]));
});
it('deletes pre-generated watermarks and the archive zip', async () => {
await deleteEventCascade(42, { id: 1, username: 'admin' });
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
// Both are storage-backend objects that only fs.unlink ever touched, so
// both survived an event delete on S3.
expect(deleted).toEqual(expect.arrayContaining([
'watermarked/wm_aaa_photo_one.jpg',
'archives/other-demo-2026-01-01.zip',
]));
});
it('deletes the Download All cache, which only fs.rm ever covered', async () => {
await deleteEventCascade(42, { id: 1, username: 'admin' });
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
// Sits under events/active/{slug}/.download-cache/ — swept by the
// recursive fs.rm on local disk, invisible to it on S3 where the prefix
// is not a directory. Gallery-sized. (download_jobs is main-only, so the
// per-job archives main also sweeps have no counterpart here.)
expect(deleted).toContain(
'events/active/other-demo-2026-01-01/.download-cache/all.zip'
);
});
it('leaves a derivative alone when another event still points at it', async () => {
// Canonical thumbnail/hero/preview keys are not event-scoped — the
// basename is the photo's filename, and filenames are not unique across
// events. Deleting one a surviving gallery still references would blank
// its tile.
mockSharedDerivatives = [{
thumbnail_path: 'thumbnails/thumb_aaa_photo_one.jpg',
hero_path: null,
preview_path: null,
watermark_path: null,
}];
await deleteEventCascade(42, { id: 1, username: 'admin' });
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
expect(deleted).not.toContain('thumbnails/thumb_aaa_photo_one.jpg');
// The originals are slug-scoped and must still go.
expect(deleted).toContain('events/active/other-demo-2026-01-01/photo_one.jpg');
// So must a derivative nobody else claims.
expect(deleted).toContain('thumbnails/thumb_bbb_photo_two.jpg');
});
it('never asks the backend to delete the same key twice', async () => {
await deleteEventCascade(42, { id: 1, username: 'admin' });
const managed = mockStorage.delete.mock.calls
.map(([key]) => key)
.filter((key) => !key.startsWith('thumbnails/thumb_w') && !key.startsWith('previews/preview_w'));
expect(managed).toEqual([...new Set(managed)]);
});
it('leaves external/reference photos in place', async () => {
await deleteEventCascade(42, { id: 1, username: 'admin' });
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
expect(deleted).not.toEqual(expect.arrayContaining(['ignored.jpg']));
expect(deleted).not.toEqual(expect.arrayContaining(['events/active/ignored.jpg']));
});
it('still completes the delete when the storage backend throws', async () => {
mockStorage.delete.mockRejectedValue(new Error('bucket unreachable'));
await expect(deleteEventCascade(42, { id: 1, username: 'admin' }))
.resolves.toEqual({ id: 42, name: 'Demo' });
mockStorage.delete.mockResolvedValue(undefined);
});
});
+2 -2
View File
@@ -39,7 +39,7 @@ const {
bootCrmDb, mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(60000);
let db;
let cleanup;
@@ -95,7 +95,7 @@ beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
}, 120000);
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -1,238 +0,0 @@
/**
* Admin photo view route Content-Type (#908).
*
* The route built `image/<ext>` from the filename, producing invalid
* types like image/mp4 for videos. AdminAuthenticatedVideo fetches this
* URL into a blob whose type inherits the header, and browsers refuse to
* play a <video> blob labeled image/* — blank/grey admin video preview.
*
* Pins (incl. external-review hardening):
* - the header is ALWAYS image/* or video/*: a stored non-media MIME
* (chunked uploads store the client-sent type unvalidated) is never
* echoed — text/html inline under the app origin would be XSS
* - stored video/ MIME wins; MIME-less videos map from the extension
* (.mov → video/quicktime), unknown video extensions get video/mp4
* - images IGNORE the stored MIME (migration 039 backfilled image/jpeg
* onto every legacy row, PNGs included) and use the extension,
* normalized (jpg → image/jpeg); extensionless files get image/jpeg
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-ct-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-ct-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-ct-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'admin-ct-test-event';
describe('admin photo view Content-Type (#908)', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
const addPhoto = async (filename, extra = {}) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from(`bytes-${filename}`));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
...extra,
}).returning('id');
return r[0]?.id ?? r[0];
};
const getPhotoRes = (photoId) => request(app)
.get(`/api/admin/photos/${eventId}/photo/${photoId}`)
.set('Authorization', `Bearer ${adminToken}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Admin CT Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'admin-ct-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'admin-ct-admin',
email: 'admin-ct-admin@example.com',
password_hash: await bcrypt.hash('AdminCt123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'admin-ct-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('serves a video with its stored mime_type, not image/<ext>', async () => {
const id = await addPhoto('clip.mp4', { media_type: 'video', mime_type: 'video/mp4' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('maps MIME-less videos from their extension (.mov → video/quicktime)', async () => {
const id = await addPhoto('clip-nomime.mov', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/quicktime');
});
it('falls back to video/mp4 for a video with an unknown extension', async () => {
const id = await addPhoto('clip-unknown.xyz', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('rejects malformed video/ MIME values that would break setHeader', async () => {
// Header-invalid chars in the stored value must not 500 the route —
// fall back to the extension map instead.
const id = await addPhoto('crlf.mp4', {
media_type: 'video',
mime_type: 'video/mp4\r\nX-Evil: 1',
});
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
expect(res.headers['x-evil']).toBeUndefined();
const bare = await addPhoto('bare.webm', { media_type: 'video', mime_type: 'video/' });
const res2 = await getPhotoRes(bare);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/webm');
});
it('preserves an auto-imported avif via the safe stored-MIME allowlist', async () => {
// .avif isn't in EXTENSION_TO_MIME; s3AutoImporter stores image/avif.
// Map-only would mislabel it image/jpeg — the allowlist keeps it.
const id = await addPhoto('imported.avif', { mime_type: 'image/avif' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/avif');
});
it('preserves other importer raster types too (apng, x-icon)', async () => {
const apng = await addPhoto('anim.apng', { mime_type: 'image/apng' });
expect((await getPhotoRes(apng)).headers['content-type']).toBe('image/apng');
const ico = await addPhoto('fav.ico', { mime_type: 'image/x-icon' });
expect((await getPhotoRes(ico)).headers['content-type']).toBe('image/x-icon');
});
it('does NOT honor a stored scriptable image type (image/svg+xml)', async () => {
// svg is inline-scriptable and must never be echoed — allowlist excludes it.
const id = await addPhoto('vector.svg', { mime_type: 'image/svg+xml' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('never echoes a stored non-media MIME type (inline XSS guard)', async () => {
const id = await addPhoto('evil.png', { mime_type: 'text/html' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('ignores the migration-039 image/jpeg backfill on legacy PNG rows', async () => {
const id = await addPhoto('legacy.png', { mime_type: 'image/jpeg' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('normalizes jpg to the canonical image/jpeg', async () => {
const id = await addPhoto('shot.jpg');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('keeps the extension fallback for images without a stored mime_type', async () => {
const id = await addPhoto('shot.png');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('handles Object.prototype key extensions without a 500 (.constructor)', async () => {
// The extension-to-MIME lookup must be own-property only — a raw
// index access returns an inherited function for these keys and the
// downstream startsWith throws. Serve image/jpeg instead of 500.
const id = await addPhoto('payload.constructor');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const id2 = await addPhoto('payload.__proto__', { media_type: 'video' });
const res2 = await getPhotoRes(id2);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/mp4');
});
it('does not synthesize types from unmapped image extensions', async () => {
// Raw interpolation would produce image/svg+xml (scriptable inline)
// or arbitrary strings from client-controlled filenames — the shared
// map is the allowlist, everything else is served as image/jpeg.
const svg = await addPhoto('vector.svg+xml');
const res = await getPhotoRes(svg);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const weird = await addPhoto('weird.xyz');
const res2 = await getPhotoRes(weird);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('image/jpeg');
});
it('extensionless files get image/jpeg, never a bare image/', async () => {
const id = await addPhoto('noext');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
});
@@ -1,42 +0,0 @@
/**
* Source-inspection contract test for #1078.
*
* POST /api/admin/thumbnails/regenerate-previews hands its selected rows to
* ensurePreviewImage, which branches on `source_origin` (and then reads
* `external_relpath` / `filename`) to reach an external/reference photo on its
* media mount. When the select list omitted those columns, every external row
* looked managed, resolvePhotoStorageKey returned null, and the endpoint
* reported success while silently generating nothing for reference galleries.
*/
const fs = require('fs');
const path = require('path');
describe('regenerate-previews selects the columns ensurePreviewImage branches on (#1078)', () => {
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'routes', 'adminThumbnails.js'),
'utf8',
);
// The select feeding the regenerate-previews handler, from the route
// declaration to the end of that statement.
const selectStatement = (() => {
const routeIdx = src.indexOf('/regenerate-previews');
expect(routeIdx).toBeGreaterThan(-1);
const selectIdx = src.indexOf('.select(', routeIdx);
expect(selectIdx).toBeGreaterThan(-1);
return src.slice(selectIdx, src.indexOf(';', selectIdx));
})();
it.each(['source_origin', 'external_relpath', 'filename'])(
'selects %s',
(column) => {
expect(selectStatement).toContain(`'${column}'`);
}
);
it('still selects the columns the managed path needs', () => {
for (const column of ['id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path']) {
expect(selectStatement).toContain(`'${column}'`);
}
});
});
@@ -135,9 +135,9 @@ function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
);
}
function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) {
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
return jwt.sign(
{ eventId, eventSlug, type: 'gallery', ...extra },
{ eventId, eventSlug, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
@@ -288,56 +288,6 @@ describe('GET /auth/session — symmetry with protected middleware', () => {
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 () => {
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,
@@ -1,127 +0,0 @@
/**
* Regression test for GHSA-9hmx-68vc-qpqw — share-link login must not bypass
* the gallery password.
*
* POST /auth/gallery/share-login validates only the share token. For a
* password-protected gallery it previously minted a full `type:'gallery'`
* access token on the share token alone, letting anyone holding the share URL
* read the gallery without the password. The fix: when the gallery requires a
* password, return `{ requires_password: true }` with NO token and NO cookie.
*/
const express = require('express');
const request = require('supertest');
process.env.JWT_SECRET = 'share-login-test-secret';
const events = [];
jest.mock('../../src/database/db', () => {
function dbFn(table) {
if (table === 'events') {
let filter = () => true;
return {
where(criteria) {
filter = (row) => Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
return row[k] === v;
});
return this;
},
async first() { return events.find(filter); },
};
}
return { where() { return this; }, async first() { return undefined; } };
}
dbFn.raw = async () => {};
return { db: dbFn, logActivity: async () => {} };
});
// Share token is stored plainly on the fake event row.
jest.mock('../../src/services/shareLinkService', () => ({
getEventShareToken: (event) => event.share_token,
resolveShareIdentifier: async () => ({ event: null }),
}));
const mockSetGalleryAuthCookies = jest.fn();
jest.mock('../../src/utils/tokenUtils', () => ({
setGalleryAuthCookies: (...args) => mockSetGalleryAuthCookies(...args),
clearGalleryAuthCookies: jest.fn(),
getGalleryTokenFromRequest: jest.fn(),
setAdminAuthCookies: jest.fn(),
}));
jest.mock('../../src/utils/authSecurity', () => ({
trackFailedAttempt: jest.fn(async () => {}),
trackSuccessfulLogin: jest.fn(async () => {}),
checkAccountLockout: jest.fn(async () => ({ isLocked: false })),
resetLockout: jest.fn(async () => {}),
}));
// Collaborators the router imports at load but the share-login path doesn't hit.
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: async () => true }));
jest.mock('../../src/services/mfaService', () => ({}));
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn(), sessionTimeoutMiddleware: (req, res, next) => next() }));
jest.mock('../../src/utils/tokenRevocation', () => ({ revokeToken: jest.fn(async () => {}), isTokenRevoked: async () => false }));
const authRouter = require('../../src/routes/auth');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
return app;
}
const SHARE_TOKEN = 'a'.repeat(64);
beforeEach(() => {
events.length = 0;
mockSetGalleryAuthCookies.mockClear();
});
describe('POST /auth/gallery/share-login password enforcement', () => {
it('does NOT mint a token for a password-protected gallery', async () => {
events.push({
id: 1, slug: 'private-gallery', is_active: 1, is_archived: 0,
require_password: 1, share_token: SHARE_TOKEN, event_name: 'Private',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'private-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(res.body.requires_password).toBe(true);
expect(res.body.token).toBeUndefined();
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
it('mints a token for a public (no-password) gallery', async () => {
events.push({
id: 2, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.event).toBeDefined();
expect(mockSetGalleryAuthCookies).toHaveBeenCalledTimes(1);
});
it('rejects a wrong share token regardless of password setting', async () => {
events.push({
id: 3, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: 'b'.repeat(64) });
expect(res.status).toBe(401);
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
});
@@ -1,211 +0,0 @@
/**
* Authorization / ownership gaps (GHSA permission cluster):
* - jm7j: API-token list must scope to the caller (non-super sees only own)
* - gprq: API-token revoke must be owner-or-super_admin
* - 3rqx: event update must not mass-assign identity/secret columns
* - j2f4: category hero must belong to that category
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-authz-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'authz-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-authz-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken,
} = require('../integration/helpers/crmDb');
describe('authorization / ownership gaps', () => {
let db; let cleanup; let app;
let superId; let superTok; let adminId; let adminTok;
const grantPermissionToRole = async (roleName, permName) => {
const role = await db('roles').where({ name: roleName }).first();
const perm = await db('permissions').where({ name: permName }).first();
const exists = await db('role_permissions')
.where({ role_id: role.id, permission_id: perm.id }).first();
if (!exists) {
await db('role_permissions').insert({ role_id: role.id, permission_id: perm.id });
}
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: superId } = await seedMinimal(db));
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
const pass = await bcrypt.hash('x', 4);
const ins = await db('admin_users').insert({
username: 'plain-admin', email: 'plain@example.com',
password_hash: pass, must_change_password: false, created_at: new Date(),
}).returning('id');
adminId = ins[0]?.id ?? ins[0];
await assignAdminRole(db, adminId, 'admin');
// Grant settings.edit to the admin role BEFORE any request populates the
// 60s permission cache, so the revoke test exercises the ownership check
// (404) rather than the missing-permission gate (403). This models a
// custom role that carries settings.edit — the scenario GHSA-gprq needs.
await grantPermissionToRole('admin', 'settings.edit');
adminTok = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/api-tokens', require('../../src/routes/adminApiTokens'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/categories', require('../../src/routes/adminCategories'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
describe('API tokens (jm7j / gprq)', () => {
let superTokenId;
beforeAll(async () => {
const res = await auth(request(app).post('/api/admin/api-tokens'), superTok)
.send({ name: 'super-token', scopes: ['read'] });
expect(res.status).toBe(201);
superTokenId = res.body.id;
});
it('non-super admin does not see another admin\'s tokens in the list', async () => {
const res = await auth(request(app).get('/api/admin/api-tokens'), adminTok);
expect(res.status).toBe(200);
expect(res.body.find((t) => t.id === superTokenId)).toBeUndefined();
});
it('super_admin sees all tokens', async () => {
const res = await auth(request(app).get('/api/admin/api-tokens'), superTok);
expect(res.status).toBe(200);
expect(res.body.find((t) => t.id === superTokenId)).toBeDefined();
});
it('a non-owner (with settings.edit) cannot revoke another admin\'s token', async () => {
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), adminTok);
expect(res.status).toBe(404);
const row = await db('api_tokens').where({ id: superTokenId }).first();
expect(row.revoked_at).toBeFalsy();
});
it('the owner can revoke their own token', async () => {
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), superTok);
expect(res.status).toBe(200);
});
});
describe('event update mass-assignment (3rqx)', () => {
it('ignores identity/secret columns in the request body', async () => {
const seedShareToken = 'orig-share-token';
const ins = await db('events').insert({
slug: 'authz-mass-assign', event_type: 'wedding', event_name: 'Before',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'orig-hash', share_link: '/gallery/authz/share', share_token: seedShareToken, expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ins[0]?.id ?? ins[0];
const res = await auth(request(app).put(`/api/admin/events/${eventId}`), superTok).send({
event_name: 'After',
created_by: 99999,
slug: 'hijacked-slug',
share_token: 'hijacked-token',
password_hash: 'hijacked-hash',
is_archived: 1,
archive_path: '/hijacked/archive/path',
hero_logo_path: '/etc/passwd',
is_draft: 1,
project_id: 99999,
// Case-variant keys — SQLite matches columns case-insensitively.
Password_Hash: 'case-hijack-hash',
Created_By: 88888,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id: eventId }).first();
expect(row.event_name).toBe('After'); // legit field applied
expect(row.created_by).toBe(superId); // ownership untouched (+ case-variant)
expect(row.slug).toBe('authz-mass-assign'); // routing identity untouched
expect(row.share_token).toBe(seedShareToken); // secret untouched
expect(row.password_hash).toBe('orig-hash'); // secret untouched (+ case-variant)
expect(row.is_archived).toBeFalsy(); // archive lifecycle untouched
expect(row.archive_path).toBeFalsy(); // forged archive path rejected
expect(row.hero_logo_path).toBeFalsy(); // fs.unlink primitive blocked
expect(row.is_draft).toBeFalsy(); // publish workflow not bypassed
expect(row.project_id).toBeFalsy(); // server-managed relationship untouched
});
it('returns 200 (no-op) when the body contains only protected fields', async () => {
const ins = await db('events').insert({
slug: 'authz-empty-update', event_type: 'wedding', event_name: 'Keep',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/authz-empty/share', share_token: 'authz-empty-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const id = ins[0]?.id ?? ins[0];
// Body reduces to {} after the denylist — must not 500 (Knex rejects
// .update({})).
const res = await auth(request(app).put(`/api/admin/events/${id}`), superTok)
.send({ created_by: 1, slug: 'x', is_archived: 1 });
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.event_name).toBe('Keep');
});
});
describe('category hero cross-category (j2f4)', () => {
it('rejects a hero photo that is not in the category', async () => {
const evIns = await db('events').insert({
slug: 'authz-cat', event_type: 'wedding', event_name: 'Cat Event',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/authz-cat/share', share_token: 'authz-cat-share', expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const evId = evIns[0]?.id ?? evIns[0];
const mkCat = async (name) => {
const c = await db('photo_categories').insert({
event_id: evId, name, slug: name.toLowerCase(), created_at: new Date().toISOString(),
}).returning('id');
return c[0]?.id ?? c[0];
};
const cat1 = await mkCat('Cat1');
const cat2 = await mkCat('Cat2');
const pIns = await db('photos').insert({
event_id: evId, filename: 'p.jpg', path: 'authz-cat/p.jpg', type: 'individual',
category_id: cat1, uploaded_at: new Date().toISOString(),
}).returning('id');
const photoInCat1 = pIns[0]?.id ?? pIns[0];
// Pointing cat2's hero at a photo that lives in cat1 must be refused.
const bad = await auth(request(app).put(`/api/admin/categories/${cat2}/hero`), superTok)
.send({ hero_photo_id: photoInCat1 });
expect(bad.status).toBe(404);
// The photo's own category accepts it.
const ok = await auth(request(app).put(`/api/admin/categories/${cat1}/hero`), superTok)
.send({ hero_photo_id: photoInCat1 });
expect(ok.status).toBe(200);
});
});
});
@@ -1,95 +0,0 @@
/**
* Full-instance export is super_admin only (GHSA-pv6w-rj34-wj9v).
*
* GET /api/admin/backup/picpeak/export dumps every table unredacted (bcrypt
* hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3 secrets). It was gated only by
* requirePermission('backup.create'), which the built-in `admin` role holds —
* so any non-super_admin admin could download the whole database. Pins that
* `admin` now gets 403 and `super_admin` passes the gate.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bkexport-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkexport-test-secret';
// The export otherwise walks the whole DB and writes a zip — stub it so the
// super_admin happy path is fast and deterministic; the gate is what's tested.
// The route deletes path.dirname(filePath) recursively after download, so the
// stub MUST live in its own dir — a bare os.tmpdir() file would make the route
// wipe the whole temp root (and other jest workers' DB files).
const mockExportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-export-stub-'));
const mockExportPath = path.join(mockExportDir, 'export.picpeak');
fs.writeFileSync(mockExportPath, 'stub');
jest.mock('../../src/services/picpeakExportService', () => ({
createPicpeak: jest.fn(async () => ({ filePath: mockExportPath })),
}));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('backup export super_admin gate (GHSA-pv6w)', () => {
let db;
let cleanup;
let app;
let adminToken; let superToken;
const mkUser = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
return jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
adminToken = await mkUser('limited-admin', 'admin');
superToken = await mkUser('root-admin', 'super_admin');
app = express();
app.use(express.json());
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
fs.rmSync(mockExportDir, { recursive: true, force: true });
});
it('denies the built-in admin role (was: full DB dump)', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(403);
});
it('allows super_admin', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
});
@@ -1,233 +0,0 @@
/**
* Dashboard endpoints must not leak other admins' data to event-scoped
* editors — GHSA-c2jj (/stats), GHSA-gqx7 (/analytics), GHSA-jhcf (/activity).
*
* All three are gated only by `analytics.view`, which the `editor` role holds.
* But the events LIST restricts editors to their own rows
* (adminEvents/crud.js: roleName === 'editor' → created_by = admin.id), so an
* editor saw instance-wide totals — and, via /analytics topGalleries, other
* admins' gallery names and SLUGS (the public gallery URL component) — for
* events invisible to them everywhere else.
*
* Scoping deliberately keys on `editor` to mirror the events list exactly, so
* the `admin` role's dashboard is unchanged.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dashscope-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dashscope-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('dashboard scoping (GHSA-c2jj / gqx7 / jhcf)', () => {
let db; let cleanup; let app;
let editorToken; let superToken;
let ownEventId; let foreignEventId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
const token = jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
return { id, token };
};
const mkEvent = async (slug, createdBy) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `${slug}-name`,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: `tok-${slug}`,
share_link: `/gallery/${slug}/tok-${slug}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const editor = await mkAdmin('scoped-editor', 'editor');
const sup = await mkAdmin('root-admin', 'super_admin');
editorToken = editor.token;
superToken = sup.token;
ownEventId = await mkEvent('own-gallery', editor.id);
foreignEventId = await mkEvent('foreign-gallery', sup.id);
// One photo + one view per event so the aggregates are non-zero.
for (const [eventId, name] of [[ownEventId, 'own'], [foreignEventId, 'foreign']]) {
await db('photos').insert({
event_id: eventId,
filename: `${name}.jpg`,
path: `events/active/${name}.jpg`,
type: 'individual',
size_bytes: 1000,
uploaded_at: new Date().toISOString(),
});
await db('access_logs').insert({
event_id: eventId,
action: 'view',
ip_address: `10.0.0.${eventId}`,
user_agent: 'Mozilla/5.0',
timestamp: new Date().toISOString(),
});
await db('activity_logs').insert({
activity_type: 'photo_viewed',
actor_type: 'admin',
actor_name: `${name}-actor`,
event_id: eventId,
created_at: new Date().toISOString(),
});
}
app = express();
app.use(express.json());
app.use('/api/admin/dashboard', require('../../src/routes/adminDashboard'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('/stats counts only the editor\'s own events and photos', async () => {
const res = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
expect(Number(res.body.totalEvents)).toBe(1);
expect(Number(res.body.totalPhotos)).toBe(1);
// The catalogued original bytes — this is what carries the per-event
// scoping, and what `storageUsed` reported before #1164.
expect(Number(res.body.catalogedBytes)).toBe(1000);
});
it('/stats reports disk usage unscoped, because disk is not per-event', async () => {
// storageUsed is a measurement of the storage root (#1164), so it is the
// same number for every admin by design. Pinned so a future reviewer
// reading "everything on this endpoint is scoped" does not turn it into a
// sum of this editor's photos again — which is the bug that was fixed.
const res = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
expect(res.body.storageUsed).not.toBe(1000);
expect(res.body).toHaveProperty('storageBreakdown');
});
it('/stats reports the catalogued figure on an S3 backend, not a near-zero disk walk', async () => {
// STORAGE_PATH holds only incidental local files when objects live in a
// bucket, so walking it would report near-zero and drag the soft-limit
// recommendation with it.
const prev = process.env.STORAGE_BACKEND;
process.env.STORAGE_BACKEND = 's3';
try {
const res = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
expect(res.body.storageUsed).toBeNull();
expect(res.body.storageMeasurement).toBe('catalog');
expect(Number(res.body.catalogedBytes)).toBe(1000);
} finally {
if (prev === undefined) delete process.env.STORAGE_BACKEND;
else process.env.STORAGE_BACKEND = prev;
}
});
it('/analytics does not expose a foreign gallery name or slug', async () => {
const res = await request(app)
.get('/api/admin/dashboard/analytics?days=7')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const body = JSON.stringify(res.body);
expect(body).not.toContain('foreign-gallery');
expect(body).not.toContain('foreign-gallery-name');
expect(res.body.topGalleries.map((g) => g.slug)).toEqual(['own-gallery']);
});
it('/activity does not surface a foreign event\'s entries', async () => {
const res = await request(app)
.get('/api/admin/dashboard/activity')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const actors = res.body.map((a) => a.actorName);
expect(actors).toContain('own-actor');
expect(actors).not.toContain('foreign-actor');
});
it('leaves super_admin unscoped across all three', async () => {
const stats = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${superToken}`);
expect(Number(stats.body.totalEvents)).toBe(2);
const analytics = await request(app)
.get('/api/admin/dashboard/analytics?days=7')
.set('Authorization', `Bearer ${superToken}`);
expect(analytics.body.topGalleries.map((g) => g.slug).sort())
.toEqual(['foreign-gallery', 'own-gallery']);
const activity = await request(app)
.get('/api/admin/dashboard/activity')
.set('Authorization', `Bearer ${superToken}`);
expect(activity.body.map((a) => a.actorName)).toContain('foreign-actor');
});
});
/**
* Codex round 2: the /activity filter trusts `activity_logs.event_id`, but
* expenseService was passing `adminId` into logActivity's third positional
* parameter — which is `eventId`. Admin and event id sequences overlap, so a
* foreign admin's expense metadata could surface under an editor's event.
* Those writers now pass the actor instead, leaving event_id NULL.
*/
describe('activity writers do not put admin ids in event_id (GHSA-jhcf)', () => {
it('expenseService passes the actor, not adminId, as the event id', () => {
const fs2 = require('fs');
const src = fs2.readFileSync(
require('path').join(__dirname, '../../src/services/expenseService.js'), 'utf8',
);
// No logActivity call may end with a bare `, adminId)` — that slot is eventId.
const offenders = src.split('\n').filter(
(l) => l.includes('logActivity(') && /,\s*adminId\s*\)/.test(l),
);
expect(offenders).toEqual([]);
// And the actor form must actually be in use.
expect(src).toContain("{ type: 'admin', id: adminId }");
});
});
@@ -1,122 +0,0 @@
/**
* 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);
});
});
@@ -1,120 +0,0 @@
/**
* Manual database backup must not honour a caller-supplied destination
* (GHSA-jw8m-43r2-jqrm).
*
* POST /api/admin/database-backup/backup forwarded req.body straight into
* databaseBackupService.backup(), which merges options over its config:
* const { destinationPath = '/backup/database', ... } = { ...config, ...options }
* `destinationPath` is not a persistable setting (the /config allowlist only
* accepts `database_backup_*` keys), so the request body was its ONLY source.
*
* The `admin` role holds backup.create but neither settings.edit nor
* backup.restore — so it could aim a full DB dump (bcrypt hashes, gallery
* password hashes, encrypted SMTP creds) at the PUBLIC /uploads static mount
* (server.js mounts it with no auth middleware) and fetch it unauthenticated.
*
* Pins that destinationPath from the body is ignored, while the legitimate
* knobs still pass through.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-test-secret';
// Capture what the route hands the service; never run a real backup.
const mockBackup = jest.fn(async () => ({ success: true }));
jest.mock('../../src/services/databaseBackup', () => ({
databaseBackupService: {
get isRunning() { return false; },
backup: (...args) => mockBackup(...args),
},
}));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('manual database backup destination (GHSA-jw8m)', () => {
let db; let cleanup; let app; let adminToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'admin' }).first();
const r = await db('admin_users').insert({
username: 'limited-admin',
email: 'limited-admin@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
adminToken = jwt.sign(
{ id, username: 'limited-admin', type: 'admin', role: 'admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
app = express();
app.use(express.json());
app.use('/api/admin/database-backup', require('../../src/routes/adminDatabaseBackup'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(() => mockBackup.mockClear());
it('ignores a caller-supplied destinationPath', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({ destinationPath: '/app/storage/uploads' });
expect(res.status).toBe(200);
// Give the fire-and-forget call a tick to land.
await new Promise((resolve) => setImmediate(resolve));
expect(mockBackup).toHaveBeenCalled();
const opts = mockBackup.mock.calls[0][0];
expect(opts).not.toHaveProperty('destinationPath');
expect(JSON.stringify(opts)).not.toContain('uploads');
});
it('still forwards the legitimate backup knobs', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({ compress: false, validateIntegrity: false, destinationPath: '/tmp/evil' });
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
const opts = mockBackup.mock.calls[0][0];
expect(opts.compress).toBe(false);
expect(opts.validateIntegrity).toBe(false);
expect(opts).not.toHaveProperty('destinationPath');
});
it('omits absent knobs entirely so service/config defaults still apply', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({});
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
// An explicit `{compress: undefined}` would override config on spread —
// absent keys must simply not be present.
expect(mockBackup.mock.calls[0][0]).toEqual({});
});
});
@@ -1,101 +0,0 @@
/**
* GHSA-2qc2 / GHSA-32h4 / GHSA-3335 — feedback moderation, deletion, and the
* pending-moderation list are by-feedback-id (or global) and lacked ownership
* scoping, so a restricted editor could act on / enumerate feedback for events
* it does not own. super_admin keeps global access.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fbown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'fbown-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fbown-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
describe('feedback ownership scoping', () => {
let db; let cleanup; let app;
let superTok; let editorTok; let editorId;
let foreignFeedbackId;
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId: superId } = await seedMinimal(db);
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
const ins = await db('admin_users').insert({
username: 'editor', email: 'editor@example.com',
password_hash: await bcrypt.hash('x', 4), must_change_password: false, created_at: new Date(),
}).returning('id');
editorId = ins[0]?.id ?? ins[0];
await assignAdminRole(db, editorId, 'editor');
editorTok = mintAdminToken(editorId);
// Event owned by super_admin (NOT the editor).
const ev = await db('events').insert({
slug: 'fbown-foreign', event_type: 'wedding', event_name: 'Foreign',
event_date: '2026-08-01', host_email: 'h@e.com', admin_email: 'a@e.com',
password_hash: 'x', share_link: '/g/fbown/s', share_token: 'fbown-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ev[0]?.id ?? ev[0];
const ph = await db('photos').insert({
event_id: eventId, filename: 'p.jpg', path: 'fbown-foreign/p.jpg', type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
const photoId = ph[0]?.id ?? ph[0];
const fb = await db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, feedback_type: 'comment',
comment_text: 'hi', is_approved: 0, is_hidden: 0, created_at: new Date().toISOString(),
}).returning('id');
foreignFeedbackId = fb[0]?.id ?? fb[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/feedback', require('../../src/routes/adminFeedback'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('editor cannot moderate feedback on an event it does not own (404)', async () => {
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), editorTok);
expect(res.status).toBe(404);
const row = await db('photo_feedback').where({ id: foreignFeedbackId }).first();
expect([false, 0]).toContain(row.is_approved); // untouched
});
it('editor cannot delete foreign feedback, row survives', async () => {
const res = await auth(request(app).delete(`/api/admin/feedback/feedback/${foreignFeedbackId}`), editorTok);
// Denied either at the events.delete permission layer (editor lacks it →
// 403) or the ownership layer (404) — both must leave the row intact.
expect([403, 404]).toContain(res.status);
expect(await db('photo_feedback').where({ id: foreignFeedbackId }).first()).toBeDefined();
});
it('editor sees no foreign feedback in pending-moderation', async () => {
const res = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), editorTok);
expect(res.status).toBe(200);
expect(res.body.find((f) => f.id === foreignFeedbackId)).toBeUndefined();
});
it('super_admin CAN moderate and see it', async () => {
const pending = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), superTok);
expect(pending.body.find((f) => f.id === foreignFeedbackId)).toBeDefined();
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), superTok);
expect(res.status).toBe(200);
});
});
@@ -1,278 +0,0 @@
/**
* Single-photo gallery downloads must go through the storage backend (#1048).
*
* `GET /api/gallery/:slug/download/:photoId` resolved a LOCAL filesystem path
* unconditionally and handed it to res.sendFile. On an S3/R2 deployment
* managed photos never exist on local disk, so every per-photo download 404'd
* with ENOENT — while download-all and secure-images worked fine, because they
* already went through getStorage(). The gallery looks healthy until a guest
* clicks the download button on a single photo.
*
* The local branch is pinned just as hard: sendFile emits Content-Length,
* Accept-Ranges, ETag and Last-Modified and answers Range with a 206. Routing
* local installs through a bare stream.pipe(res) to share one code path would
* silently drop all of that, and a resumed download would append a second full
* body onto the partial file.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dl-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'download-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dl-storage-'));
const { Readable } = require('stream');
const SLUG = 'download-gallery';
const FILENAME = 'original.jpg';
// Deliberately not written to disk anywhere: if the route reads the
// filesystem instead of the backend, it cannot produce these bytes.
const mockObjectBody = Buffer.from('S3-ONLY-ORIGINAL-BYTES-not-on-local-disk');
const mockBackendKind = { value: 's3' };
const mockStorage = {
kind: () => mockBackendKind.value,
stat: jest.fn(async () => ({ size: mockObjectBody.length, mtime: new Date('2026-08-20T10:00:00Z') })),
get: jest.fn(async () => Readable.from([mockObjectBody])),
getRange: jest.fn(async (key, start, end) => Readable.from([mockObjectBody.subarray(start, end + 1)])),
delete: jest.fn(async () => undefined),
exists: jest.fn(async () => true),
};
jest.mock('../../src/services/storage', () => ({
getStorage: () => mockStorage,
initStorage: async () => mockStorage,
}));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('single-photo download through the storage backend (#1048)', () => {
let db; let cleanup; let app; let eventId; let photoId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Downloads',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/s`,
share_token: 'download-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];
const row = await db('photos').insert({
event_id: eventId,
filename: FILENAME,
path: `${SLUG}/${FILENAME}`,
type: 'individual',
source_origin: 'managed',
mime_type: 'image/jpeg',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = row[0]?.id ?? row[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(); });
beforeEach(() => {
mockBackendKind.value = 's3';
mockStorage.get.mockClear();
mockStorage.getRange.mockClear();
});
it('streams the stored object instead of 404ing on a local path', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.buffer(true)
.parse((response, cb) => {
const chunks = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(200);
// The bytes only exist in the backend — proof it did not read the disk.
expect(res.body.equals(mockObjectBody)).toBe(true);
expect(mockStorage.get).toHaveBeenCalledWith(`events/active/${SLUG}/${FILENAME}`);
// Never written locally, so a filesystem read could not have served this.
expect(fs.existsSync(path.join(process.env.STORAGE_PATH, 'events/active', SLUG, FILENAME))).toBe(false);
});
it('sends Content-Length so the browser can show download progress', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.headers['content-length']).toBe(String(mockObjectBody.length));
expect(res.headers['accept-ranges']).toBe('bytes');
expect(res.headers['content-disposition']).toContain(FILENAME);
});
it('answers a Range request with 206 and only the requested bytes', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=0-9')
.buffer(true)
.parse((response, cb) => {
const chunks = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(206);
expect(res.headers['content-range']).toBe(`bytes 0-9/${mockObjectBody.length}`);
expect(res.headers['content-length']).toBe('10');
expect(res.body.equals(mockObjectBody.subarray(0, 10))).toBe(true);
expect(mockStorage.getRange).toHaveBeenCalledWith(`events/active/${SLUG}/${FILENAME}`, 0, 9);
});
it('ignores a malformed Range rather than emitting a nonsense 206', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=abc-def');
expect(res.status).toBe(200);
expect(res.headers['content-range']).toBeUndefined();
});
it('404s cleanly when the object is missing from the backend', async () => {
mockStorage.stat.mockResolvedValueOnce(null);
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(404);
// The error must not inherit the image headers staged for a successful
// download, or the browser saves a .jpg containing JSON.
expect(res.headers['content-type']).toMatch(/json/);
expect(res.headers['content-disposition']).toBeUndefined();
});
it('keeps res.sendFile on a local backend rather than a bare pipe', async () => {
mockBackendKind.value = 'local';
const abs = path.join(process.env.STORAGE_PATH, 'events/active', SLUG, FILENAME);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, 'local-disk-bytes');
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(200);
expect(mockStorage.get).not.toHaveBeenCalled();
// sendFile's signature: conditional-request headers a raw pipe never sets.
expect(res.headers.etag).toBeDefined();
expect(res.headers['last-modified']).toBeDefined();
fs.rmSync(abs, { force: true });
});
it('does not serve a partial body when the If-Range validator is stale', async () => {
// The object was replaced since the client's last attempt. Answering 206
// from the new bytes would let it splice two versions into one file.
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=0-9')
.set('If-Range', new Date('2020-01-01T00:00:00Z').toUTCString());
expect(res.status).toBe(200);
expect(res.headers['content-range']).toBeUndefined();
expect(res.headers['content-length']).toBe(String(mockObjectBody.length));
});
it('still serves 206 when the If-Range validator matches', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=0-9')
.set('If-Range', new Date('2026-08-20T10:00:00Z').toUTCString());
expect(res.status).toBe(206);
expect(res.headers['content-range']).toBe(`bytes 0-9/${mockObjectBody.length}`);
});
it('errors cleanly when the object vanishes between stat and get', async () => {
// HeadObject succeeding does not mean GetObject will — a concurrent
// delete lands here. The staged image headers must not escape with it.
const gone = new Error('NoSuchKey');
gone.name = 'NoSuchKey';
mockStorage.get.mockRejectedValueOnce(gone);
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(404);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.headers['content-disposition']).toBeUndefined();
});
it('does not send 206 headers before the range fetch can fail', async () => {
// writeHead(206) before the await would make this ERR_HTTP_HEADERS_SENT.
mockStorage.getRange.mockRejectedValueOnce(new Error('connection reset'));
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=0-9');
expect(res.status).toBe(500);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.headers['content-range']).toBeUndefined();
});
it('answers HEAD from stat instead of draining the object out of S3', async () => {
const before = (await db('photos').where('id', photoId).first()).download_count || 0;
const logsBefore = (await db('access_logs').where({ photo_id: photoId, action: 'download' })).length;
const res = await request(app).head(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(200);
expect(res.headers['content-length']).toBe(String(mockObjectBody.length));
expect(res.headers['accept-ranges']).toBe('bytes');
// The whole point: no egress for a metadata probe.
expect(mockStorage.get).not.toHaveBeenCalled();
expect(mockStorage.getRange).not.toHaveBeenCalled();
// And no side effects: a probe is not a download.
const after = (await db('photos').where('id', photoId).first()).download_count || 0;
expect(after).toBe(before);
const logsAfter = (await db('access_logs').where({ photo_id: photoId, action: 'download' })).length;
expect(logsAfter).toBe(logsBefore);
});
it('returns a clean error when the range stream dies before its first chunk', async () => {
// Resolves, then errors — writeHead would already have committed the 206,
// leaving a connection reset as the only possible outcome.
const { Readable: R } = require('stream');
mockStorage.getRange.mockImplementationOnce(async () => {
const dead = new R({ read() { this.destroy(new Error('socket hang up')); } });
return dead;
});
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=0-9');
expect(res.status).toBe(500);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.headers['content-range']).toBeUndefined();
});
});
@@ -1,87 +0,0 @@
/**
* GHSA-rh8r-7x3h-36rv — the unauthenticated GET /api/gallery/resolve/:identifier
* must NOT return a gallery's secret share_token (nor the share links that
* embed it) for a bare *slug* lookup. Slugs appear in gallery URLs and are
* guessable; handing back the secret turns a known slug into share-link
* access to a no-password gallery. The token is only returned when the caller
* resolved via the token / full share link (i.e. already holds it).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-resolve-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'resolve-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-resolve-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'resolve-test-event';
const SHARE_TOKEN = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
describe('GET /api/gallery/resolve/:identifier (GHSA-rh8r)', () => {
let db; let cleanup; let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Resolve Test',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/${SHARE_TOKEN}`,
share_token: SHARE_TOKEN,
require_password: 0, // no-password → the token IS the access credential
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
});
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('does NOT leak the share_token (or share links) for a bare slug lookup', async () => {
const res = await request(app).get(`/api/gallery/resolve/${SLUG}`);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(SLUG);
expect(res.body.matchType).toBe('slug');
// The secret must be absent — and must not sneak out via the share links.
expect(res.body.token).toBeUndefined();
expect(res.body.share_link).toBeUndefined();
expect(res.body.share_url).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
});
it('DOES return the token when the caller already resolved via the token', async () => {
const res = await request(app).get(`/api/gallery/resolve/${SHARE_TOKEN}`);
expect(res.status).toBe(200);
expect(res.body.token).toBe(SHARE_TOKEN);
expect(res.body.matchType).toMatch(/token/);
});
it('does NOT leak the token via SQL LIKE wildcards in the link_partial fallback', async () => {
// Before the escaping fix, an anonymous request of 32 underscores matched
// any share_link ending in a 32-char token (`_` = single-char wildcard),
// resolved as matchType 'link_partial', and handed back the bearer token.
// The share_token here has no underscores, so an escaped LIKE must miss.
const res = await request(app).get(`/api/gallery/resolve/${'_'.repeat(SHARE_TOKEN.length)}`);
expect(res.status).toBe(404);
expect(res.body.token).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
});
});
@@ -1,188 +0,0 @@
/**
* SQLite boolean coercion in the guest gallery surface (#1028).
*
* SQLite stores booleans as 0/1; Postgres stores true/false. The /photos
* payload and every download guard compared strictly against `true`/`false`,
* so on SQLite:
*
* allow_downloads: 0 !== false → true (button shown while disabled)
* allow_user_uploads: 1 === true → false (button hidden while enabled)
* if (allow_downloads === false) → never fires, so ALL download endpoints
* kept serving with downloads switched off
*
* (The download-jobs route asserted on main is #858, which is beta-only —
* this branch covers the three download endpoints that exist here.)
*
* The harness runs on SQLite, so these assertions exercise the real engine
* values rather than a mock. Every test here fails on the unfixed code.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-flags-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sqlite-flags-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-flags-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'sqlite-flags-gallery';
describe('gallery flags survive SQLite 0/1 storage (#1028)', () => {
let db; let cleanup; let app; let eventId; let photoId;
async function setEventFlags(patch) {
await db('events').where('id', eventId).update(patch);
}
async function getPayload() {
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
return res.body.event;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'SQLite Flags',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/s`,
share_token: 'sqlite-flags-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
// Password-free so verifyGalleryAccess takes the public path and loads
// the row with SELECT * — i.e. the raw 0/1 values, same as production.
require_password: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = ev[0]?.id ?? ev[0];
const ph = await db('photos').insert({
event_id: eventId,
filename: 'p.jpg',
path: `${SLUG}/p.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = ph[0]?.id ?? ph[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
test('the engine under test really is SQLite storing 0/1', async () => {
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
await setEventFlags({ allow_downloads: 0 });
const row = await db('events').where('id', eventId).first('allow_downloads');
expect(row.allow_downloads).toBe(0);
});
describe('with downloads disabled (allow_downloads = 0)', () => {
beforeAll(async () => {
await setEventFlags({ allow_downloads: 0, allow_user_uploads: 1 });
});
test('payload reports allow_downloads false (was true — header button shown)', async () => {
expect((await getPayload()).allow_downloads).toBe(false);
});
test('payload reports allow_user_uploads true (was false — upload button hidden)', async () => {
expect((await getPayload()).allow_user_uploads).toBe(true);
});
test('single-photo download is refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(403);
});
test('download-all is refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
expect(res.status).toBe(403);
});
test('download-selected is refused', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.send({ photo_ids: [photoId] });
expect(res.status).toBe(403);
});
});
describe('with downloads enabled (allow_downloads = 1)', () => {
beforeAll(async () => {
await setEventFlags({ allow_downloads: 1, allow_user_uploads: 0 });
});
test('payload reports allow_downloads true / allow_user_uploads false', async () => {
const event = await getPayload();
expect(event.allow_downloads).toBe(true);
expect(event.allow_user_uploads).toBe(false);
});
test('download-all is no longer refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
expect(res.status).not.toBe(403);
});
});
describe('protection flags', () => {
test('0/1 protection toggles are reported the way they are stored', async () => {
await setEventFlags({
disable_right_click: 1,
enable_devtools_protection: 1,
use_canvas_rendering: 1,
watermark_downloads: 1,
overlay_protection: 0,
});
const event = await getPayload();
expect(event.disable_right_click).toBe(true);
expect(event.enable_devtools_protection).toBe(true);
expect(event.use_canvas_rendering).toBe(true);
expect(event.watermark_downloads).toBe(true);
expect(event.overlay_protection).toBe(false);
});
});
describe('per-category download blocking (#640) on SQLite', () => {
test('a category with allow_downloads = 0 is reported as blocked', async () => {
const cat = await db('photo_categories').insert({
name: 'Blocked', slug: 'blocked', event_id: eventId, is_global: 0, allow_downloads: 0,
}).returning('id');
const categoryId = cat[0]?.id ?? cat[0];
await db('photos').where('id', photoId).update({ category_id: categoryId });
await setEventFlags({ allow_downloads: 1 });
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
const category = res.body.categories.find((c) => c.id === categoryId);
expect(category.allow_downloads).toBe(false);
const photo = res.body.photos.find((p) => p.id === photoId);
expect(photo.category_allow_downloads).toBe(false);
// …and the per-category guard on the single-photo route fires.
const dl = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(dl.status).toBe(403);
});
});
});
@@ -1,170 +0,0 @@
/**
* "Date Taken" ordering across SQLite's storage classes (#1172).
*
* photos.captured_at does not hold one type on SQLite. Three writers put three
* different things in it:
*
* integer managed uploads — photoProcessor.js:441 hands knex a Date, which
* the sqlite3 binding stores as epoch milliseconds
* text external imports and the capture-date backfill, which write
* ISO-8601 ('2026-06-03T01:15:00.000Z')
* null no capture date, so the sort falls through to uploaded_at —
* itself text, in knex's 'YYYY-MM-DD HH:MM:SS' shape
*
* A plain COALESCE over that mixture is not an ordering. SQLite sorts INTEGER
* before TEXT unconditionally, so every managed photo carrying EXIF came back
* ahead of every photo that did not, whatever the dates said. And among the
* text values 'T' (0x54) outranks the space (0x20), so a same-day ISO 01:15
* sorted behind a fallback 23:00.
*
* Both failures predate #1172 — the first needs only two managed photos — but
* the sort is what that issue is about, so they are fixed and pinned here.
* Every test below fails on the unfixed ORDER BY.
*/
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-capsort-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capsort-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-capsort-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'capsort-gallery';
describe('capture-date ordering on SQLite (#1172)', () => {
let db; let cleanup; let app; let eventId;
// Managed uploads store an epoch-millisecond INTEGER, because
// photoProcessor.js:441 hands knex a Date and the sqlite3 binding converts
// it. That conversion cannot be reproduced from inside jest — there the
// binding's type dispatch misses sandbox-created Dates and writes the string
// "[object Object]" instead (CLAUDE.md). Verified outside jest: a Date lands
// as {"c":1830211200000,"ty":"integer"}. So these tests write the integer
// production would have written, rather than a Date that jest mangles.
const managed = (iso) => new Date(iso).getTime();
const addPhoto = async (filename, capturedAt, uploadedAt) => {
const row = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
captured_at: capturedAt,
uploaded_at: uploadedAt,
}).returning('id');
return row[0]?.id ?? row[0];
};
const orderedFilenames = async (order = 'asc') => {
const res = await request(app).get(`/api/gallery/${SLUG}/photos?sort=capture_date&order=${order}`);
expect(res.status).toBe(200);
return res.body.photos.map((p) => p.filename);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Capture Sort',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/s`,
share_token: 'capsort-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
require_password: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = ev[0]?.id ?? ev[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(); });
beforeEach(async () => { await db('photos').where({ event_id: eventId }).del(); });
test('the fixture really does put three storage classes in one column', async () => {
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
await addPhoto('m.jpg', managed('2026-06-03T01:15:00Z'), '2026-01-01 00:00:00');
await addPhoto('e.jpg', '2020-01-01T00:00:00.000Z', '2026-01-01 00:00:00');
await addPhoto('n.jpg', null, '2026-01-01 00:00:00');
const rows = await db.raw('select filename, typeof(captured_at) as t from photos order by filename');
const byName = Object.fromEntries((rows.rows || rows).map((r) => [r.filename, r.t]));
// Exactly the mixture that made COALESCE meaningless.
expect(byName).toEqual({ 'm.jpg': 'integer', 'e.jpg': 'text', 'n.jpg': 'null' });
});
test('a managed EXIF date does not outrank an earlier one stored as text', async () => {
// The pre-existing failure, reachable with managed photos alone: integer
// beat text regardless of the dates, so this came back exactly reversed.
await addPhoto('managed-2027.jpg', managed('2027-12-31T00:00:00Z'), '2026-01-01 00:00:00');
await addPhoto('external-2020.jpg', '2020-01-01T00:00:00.000Z', '2026-01-01 00:00:00');
expect(await orderedFilenames('asc')).toEqual(['external-2020.jpg', 'managed-2027.jpg']);
expect(await orderedFilenames('desc')).toEqual(['managed-2027.jpg', 'external-2020.jpg']);
});
test('a photo with no capture date sorts by its upload time, not ahead of everything', async () => {
await addPhoto('has-exif-2027.jpg', managed('2027-12-31T00:00:00Z'), '2027-12-31 00:00:00');
await addPhoto('no-exif-2020.jpg', null, '2020-01-01 00:00:00');
expect(await orderedFilenames('asc')).toEqual(['no-exif-2020.jpg', 'has-exif-2027.jpg']);
});
test('an ISO capture time and a fallback upload time compare by clock, not by separator', async () => {
// Same day: 'T' vs ' ' decided this before, so 01:15 sorted after 23:00.
await addPhoto('iso-0115.jpg', '2026-06-03T01:15:00.000Z', '2026-06-03 05:00:00');
await addPhoto('fallback-2300.jpg', null, '2026-06-03 23:00:00');
expect(await orderedFilenames('asc')).toEqual(['iso-0115.jpg', 'fallback-2300.jpg']);
});
test('an epoch-integer uploaded_at is compared as a date, not as its digits', async () => {
// uploaded_at is not always text either: a legacy archive restore leaves
// epoch milliseconds in it (a .picpeak restore from an install that stored them that way).
// Reading that with substr() would have compared the string '1830297600000'
// against '2020-01-01 00:00:00', putting the 2028 row first.
await addPhoto('epoch-upload-2028.jpg', null, new Date('2028-01-01T00:00:00Z').getTime());
await addPhoto('captured-2020.jpg', managed('2020-01-01T00:00:00Z'), '2020-01-01 00:00:00');
const [row] = await db.raw('select typeof(uploaded_at) as t from photos where filename = \'epoch-upload-2028.jpg\'');
expect((row.t || row).toString()).toBe('integer');
expect(await orderedFilenames('asc')).toEqual(['captured-2020.jpg', 'epoch-upload-2028.jpg']);
});
test('all three storage classes order together correctly', async () => {
await addPhoto('c-managed-2026-08.jpg', managed('2026-08-15T12:00:00Z'), '2026-09-01 00:00:00');
await addPhoto('a-external-2026-06.jpg', '2026-06-03T01:15:00.000Z', '2026-09-01 00:00:00');
await addPhoto('d-fallback-2026-09.jpg', null, '2026-09-01 00:00:00');
await addPhoto('b-managed-2026-07.jpg', managed('2026-07-04T09:30:00Z'), '2026-09-01 00:00:00');
expect(await orderedFilenames('asc')).toEqual([
'a-external-2026-06.jpg',
'b-managed-2026-07.jpg',
'c-managed-2026-08.jpg',
'd-fallback-2026-09.jpg',
]);
});
});
@@ -1,240 +0,0 @@
/**
* Hidden/client-only photo access control across the bulk + secure photo
* routes (GHSA cluster: fpwq / ghf8 / 3jvw / 9cc4 / 2hqg / jc22).
*
* A photo with visibility='hidden' is client-only. The main photo-list and
* single-photo download/view routes enforced this, but the bulk-download,
* protected-image, and secure-image routes shipped without the check —
* letting an ordinary guest reach hidden photos. These tests pin that
* guests are refused and PIN-clients (accessLevel='client') still succeed.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-hidden-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-photo-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-hidden-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'hidden-photo-test-event';
describe('hidden-photo access control (GHSA cluster)', () => {
let db;
let cleanup;
let app;
let eventId;
let visibleId;
let hiddenId;
const guestToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const clientToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', accessLevel: 'client' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Hidden Photo Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'hidden-photo-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(photoDir, { recursive: true });
// A real 1x1 PNG so the protected /view route's Sharp processing path
// succeeds (fake bytes 500 on metadata()). Content, not extension,
// drives Sharp's format detection.
const PNG_1x1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMCAQGV2rY9AAAAAElFTkSuQmCC',
'base64'
);
const mkPhoto = async (filename, visibility) => {
fs.writeFileSync(path.join(photoDir, filename), PNG_1x1);
const p = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
visibility,
uploaded_at: new Date().toISOString(),
}).returning('id');
return p[0]?.id ?? p[0];
};
visibleId = await mkPhoto('visible.jpg', 'visible');
hiddenId = await mkPhoto('hidden.jpg', 'hidden');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/images', require('../../src/routes/protectedImages'));
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('download-selected (GHSA-ghf8, medium)', () => {
it('omits a hidden photo for a guest even when its id is requested', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photo_ids: [visibleId, hiddenId] });
// The visible photo still zips; the hidden one is filtered out. If
// only the hidden id were requested, the filter empties the set → 404.
expect(res.status).toBe(200);
const solo = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photo_ids: [hiddenId] });
expect(solo.status).toBe(404);
});
it('includes the hidden photo for a client', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${clientToken()}`)
.send({ photo_ids: [hiddenId] });
expect(res.status).toBe(200);
});
});
describe('download-all (GHSA-fpwq, medium)', () => {
it('streams for a guest without erroring (hidden photos filtered)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download-all`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(200);
});
});
describe('protected-image view (GHSA-9cc4)', () => {
it('403s a hidden photo for a guest', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('serves a visible photo for a guest', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${visibleId}/view`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(200);
});
it('serves a hidden photo for a client', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
});
});
describe('signed-URL mint (GHSA-3jvw)', () => {
it('403s minting a signed URL for a hidden photo as a guest', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
expect(res.body.url).toContain('/signed/');
});
});
describe('legacy secure-token mint (protectedImages generate-secure-token)', () => {
it('403s a hidden photo for a guest', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
});
describe('secure-token mint (GHSA-2hqg)', () => {
it('403s minting a secure token for a hidden photo as a guest', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photoId: hiddenId });
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${clientToken()}`)
.send({ photoId: hiddenId });
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
});
// A capability minted while a photo is visible must stop serving once the
// photo is hidden — unless minted by a client (clientBypass in the token).
describe('signed-URL TOCTOU (hidden AFTER minting)', () => {
afterEach(async () => {
await db('photos').where({ id: visibleId }).update({ visibility: 'visible' });
});
it("a guest's pre-minted signed URL stops serving once the photo is hidden", async () => {
const mint = await request(app)
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(mint.status).toBe(200);
const url = mint.body.url;
// Still visible → serves.
expect((await request(app).get(url)).status).toBe(200);
// Hide it → the guest token (no clientBypass) must now be refused.
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
expect((await request(app).get(url)).status).toBe(403);
});
it("a client's pre-minted signed URL keeps serving after the photo is hidden", async () => {
const mint = await request(app)
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(mint.status).toBe(200);
const url = mint.body.url;
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
expect((await request(app).get(url)).status).toBe(200);
});
});
});
@@ -1,119 +0,0 @@
/**
* Regression test for GHSA-4j34-x562-5vfq — broken access control in the legacy
* /api/events router.
*
* The legacy router exposed create/list/update/delete/extend guarded by
* adminAuth ALONE (no requirePermission, no requireEventOwnership), so any
* back-office account — down to a read-only viewer — could read every gallery's
* password_hash/share_token and take over any gallery. The fix removes that
* router entirely and migrates its one UI-used route (POST /:id/extend) to the
* canonical /api/admin/events mount, where it inherits the permission +
* ownership guards.
*
* This test pins two invariants:
* 1. The legacy source file is gone (nothing can re-mount it).
* 2. The migrated extend route enforces ownership — a non-owning editor gets
* 403, the owner succeeds.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-legacy-acl-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'legacy-acl-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
async function insertEvent(db, ownerId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Owner Gallery',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: ownerId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('GHSA-4j34: legacy /api/events router removed + extend guarded', () => {
it('the legacy events router source file no longer exists', () => {
expect(fs.existsSync(path.join(__dirname, '../../src/routes/events.js'))).toBe(false);
});
describe('POST /api/admin/events/:id/extend ownership enforcement', () => {
let db; let cleanup; let app;
let ownerId; let ownerToken;
let editorId; let editorToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: ownerId } = await seedMinimal(db));
await assignAdminRole(db, ownerId, 'super_admin');
ownerToken = mintAdminToken(ownerId);
// A second, non-owning account with the low-trust editor role.
[editorId] = await db('admin_users').insert({
username: 'editor1', email: 'editor1@example.com',
password_hash: 'x', is_active: 1,
}).returning('id');
editorId = editorId?.id ?? editorId;
await assignAdminRole(db, editorId, 'editor');
editorToken = mintAdminToken(editorId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { await cleanup(); });
it('lets the owner extend their own gallery', async () => {
const id = await insertEvent(db, ownerId, { expires_at: '2026-06-01T00:00:00.000Z' });
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 10 });
expect(res.status).toBe(200);
expect(new Date(res.body.expires_at).toISOString()).toBe('2026-06-11T00:00:00.000Z');
});
it('403s a non-owning editor trying to extend someone else\'s gallery', async () => {
const id = await insertEvent(db, ownerId); // owned by the super_admin
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ days: 30 });
expect(res.status).toBe(403); // requireEventOwnership blocks it
});
it('validates the days field', async () => {
const id = await insertEvent(db, ownerId);
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 9999 });
expect(res.status).toBe(400);
});
});
});
@@ -1,123 +0,0 @@
/**
* Logo diagnostic must not leak the filesystem layout, and must mirror what
* resolveLogoFile actually tries (GHSA-29vm, codex round 2).
*
* Round 1 relativised `resolvedTo` and the candidate paths but still echoed
* `sources[].value` verbatim — and branding_logo_path is stored ABSOLUTE by
* multer, so the layout went out anyway. It also dropped the raw-absolute
* candidate, which the resolver retains (subject to containment), making the
* diagnostic report every candidate as missing for a legitimately contained
* absolute logo while `resolvedTo` named the file.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-logodiag-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'logodiag-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('logo diagnostic disclosure (GHSA-29vm)', () => {
let db; let cleanup; let app; let token;
// bootCrmDb() sets STORAGE_PATH itself, so resolve these AFTER it runs.
let STORAGE; let logoDir; let logoPath;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// A legitimately contained absolute logo in a NON-standard storage subdir.
STORAGE = process.env.STORAGE_PATH;
logoDir = path.join(STORAGE, 'custom');
logoPath = path.join(logoDir, 'logo.png');
fs.mkdirSync(logoDir, { recursive: true });
fs.writeFileSync(logoPath, 'png');
const setting = { setting_key: 'branding_logo_path', setting_value: JSON.stringify(logoPath), setting_type: 'branding' };
const existing = await db('app_settings').where({ setting_key: 'branding_logo_path' }).first();
if (existing) await db('app_settings').where({ setting_key: 'branding_logo_path' }).update(setting);
else await db('app_settings').insert(setting);
const role = await db('roles').where({ name: 'super_admin' }).first();
const r = await db('admin_users').insert({
username: 'diag-admin', email: 'diag@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
token = jwt.sign(
{ id, username: 'diag-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' },
);
app = express();
app.use(express.json());
app.use('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('does not leak absolute paths, cwd or storage root anywhere in the payload', async () => {
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
const body = JSON.stringify(res.body);
expect(body).not.toContain(STORAGE);
expect(body).not.toContain(process.cwd());
expect(res.body.storageRoot).toBeUndefined();
expect(res.body.cwd).toBeUndefined();
});
it('still finds a contained absolute logo outside the standard subdirs', async () => {
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
expect(source).toBeTruthy();
// The resolver keeps the contained absolute candidate, so the diagnostic
// must show it existing rather than reporting everything missing.
expect(source.candidates.some((c) => c.exists)).toBe(true);
expect(res.body.resolvedTo).toMatch(/^<STORAGE>\//);
});
it('shows the <STORAGE>/<value> candidate for a ROOT-RELATIVE logo URL (round 3)', async () => {
// `/custom/logo.png` is a URL, not a disk path, but path.isAbsolute() says
// true for both. Gating the stripped joins on isAbsolute() therefore hid
// `<STORAGE>/custom/logo.png` — a candidate resolveLogoFile does try and
// can resolve — so the diagnostic claimed nothing existed for a logo that
// renders fine, and collapsed the configured value to its basename.
await db('app_settings').where({ setting_key: 'branding_logo_path' })
.update({ setting_value: JSON.stringify('/custom/logo.png') });
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
expect(source.candidates.some((c) => c.path === '<STORAGE>/custom/logo.png' && c.exists)).toBe(true);
// …and the disclosure guarantee still holds for this shape.
const body = JSON.stringify(res.body);
expect(body).not.toContain(STORAGE);
expect(body).not.toContain(process.cwd());
await db('app_settings').where({ setting_key: 'branding_logo_path' })
.update({ setting_value: JSON.stringify(logoPath) });
});
});
@@ -1,80 +0,0 @@
/**
* POST /api/auth/password-strength is unauthenticated and feeds its body into
* zxcvbn, whose matching is superlinear and runs synchronously on the event
* loop. Behind express.json({ limit: '50mb' }) that made a single request a
* whole-process denial of service: measured on this codebase, 1,000 characters
* blocked for ~5 seconds and 5,000 did not return in two minutes.
*
* The control is the length cap inside validatePassword(), so it holds for
* every caller. These tests pin the cap itself rather than the route, and use
* a wall-clock ceiling that only an unbounded zxcvbn call can breach.
*/
const { validatePassword, MAX_PASSWORD_LENGTH } = require('../../src/utils/passwordValidation');
describe('password validation length cap (zxcvbn DoS)', () => {
it('rejects an over-length password without doing superlinear work', () => {
const huge = 'aA1!'.repeat(MAX_PASSWORD_LENGTH); // 4x the cap
const started = Date.now();
const result = validatePassword(huge);
const elapsed = Date.now() - started;
expect(result.valid).toBe(false);
expect(result.errors.join(' ')).toMatch(/at most 128 characters/);
// Unbounded, this input would not return for minutes.
expect(elapsed).toBeLessThan(250);
});
it('is bounded at the cap itself, the worst input it will still analyse', () => {
const atCap = 'aA1!'.repeat(MAX_PASSWORD_LENGTH / 4);
expect(atCap).toHaveLength(MAX_PASSWORD_LENGTH);
// 128 was chosen so the worst input the validator will still analyse costs
// about as much as an ordinary request (~41ms measured); 512 cost 1.4s.
const started = Date.now();
validatePassword(atCap);
expect(Date.now() - started).toBeLessThan(1000);
});
it('still accepts an ordinary strong password', () => {
const result = validatePassword('Tr0ub4dour&3-horse-battery');
expect(result.valid).toBe(true);
});
it('does not spin when a caller asks for a length the cap forbids', async () => {
// Codex review. generateSecurePassword retried by recursing on any invalid
// candidate, so the new cap made every candidate invalid for length > 128
// and turned the call into unbounded recursion. It now refuses up front,
// and the retry loop is bounded.
const { generateSecurePassword } = require('../../src/utils/passwordValidation');
expect(generateSecurePassword({ length: 16 })).toHaveLength(16);
expect(generateSecurePassword({ length: MAX_PASSWORD_LENGTH }))
.toHaveLength(MAX_PASSWORD_LENGTH);
expect(() => generateSecurePassword({ length: MAX_PASSWORD_LENGTH + 1 }))
.toThrow(/at most 128/);
});
it('does not echo the rejected password back in the error body', async () => {
// Codex review round 2. express-validator's errors.array() carries the
// submitted `value`, so the 400 for an oversized password returned the
// password itself -- reflecting a credential, and re-allocating up to the
// 50mb body limit on an unauthenticated endpoint, which partly undid the
// DoS fix this branch exists for.
const src = require('fs').readFileSync(
require('path').join(__dirname, '../../src/routes/auth.js'), 'utf8');
// No route may hand errors.array() straight to the response.
expect(src).not.toMatch(/errors:\s*errors\.array\(\)/);
// ...and the shared helper that replaces it must drop `value`.
const helper = require('fs').readFileSync(
require('path').join(__dirname, '../../src/utils/routeHelpers.js'), 'utf8');
expect(helper).toMatch(/safeValidationErrors\s*=\s*\(errors\)\s*=>\s*errors\.array\(\)\.map\(\(\{ value, \.\.\.rest \}\)/);
});
it('applies the cap through the context wrapper too', async () => {
const { validatePasswordInContext } = require('../../src/utils/passwordValidation');
const huge = 'aA1!'.repeat(MAX_PASSWORD_LENGTH);
const result = await validatePasswordInContext(huge, 'admin', {});
expect(result.valid).toBe(false);
});
});
@@ -1,312 +0,0 @@
/**
* Per-photo engagement counters (#895).
*
* Pins the contract that the admin EVENT > IMAGES table depends on:
* - photos.view_count increments when the full-size photo is served
* (it existed in the schema + admin UI but had NO writer at all)
* - the slideshow kiosk never increments views (migration 138 design)
* - single-photo downloads increment download_count (regression pin)
* - zip downloads (download-all, download-selected) increment
* download_count for the contained photos — previously they didn't,
* so zip-heavy galleries showed 0 per-photo downloads forever
* - the admin event-detail total_downloads counts singles AND zips
* (it counted action='download' only, disagreeing with the dashboard)
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'engagement-test-secret';
// Real files on disk so /photo and the zip routes actually stream bytes.
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'engagement-test-event';
describe('photo engagement counters (#895)', () => {
let db;
let cleanup;
let app;
let eventId;
let photoIds;
let adminToken;
const galleryToken = (extra = {}) => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const getPhoto = async (id) => db('photos').where('id', id).first();
// The counter writes are fire-and-forget on purpose — give the event
// loop a beat before asserting.
const settle = () => new Promise((r) => setTimeout(r, 400));
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Engagement Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'engagement-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(photoDir, { recursive: true });
photoIds = [];
for (let i = 0; i < 3; i++) {
const filename = `photo-${i}.jpg`;
fs.writeFileSync(path.join(photoDir, filename), Buffer.from(`fake-jpeg-bytes-${i}`));
const p = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(p[0]?.id ?? p[0]);
}
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'engagement-admin',
email: 'engagement-admin@example.com',
password_hash: await bcrypt.hash('EngagementAdmin123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'engagement-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('photos').where('event_id', eventId).update({ view_count: 0, download_count: 0 });
await db('access_logs').where('event_id', eventId).del();
});
describe('view_count via the view beacon (#895 — previously never written)', () => {
const beacon = (photoId, token = galleryToken()) => request(app)
.post(`/api/gallery/${SLUG}/photo/${photoId}/view`)
.set('Authorization', `Bearer ${token}`);
it('increments exactly the beaconed photo', async () => {
expect((await beacon(photoIds[0])).status).toBe(204);
expect((await getPhoto(photoIds[0])).view_count).toBe(1);
expect((await beacon(photoIds[0])).status).toBe(204);
expect((await getPhoto(photoIds[0])).view_count).toBe(2);
// Other photos untouched
expect((await getPhoto(photoIds[1])).view_count).toBe(0);
});
it('serving the image bytes does NOT count (preloads must not inflate)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photo/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
});
it('rejects the slideshow kiosk (migration 138 design)', async () => {
const res = await beacon(photoIds[0], galleryToken({ accessLevel: 'slideshow' }));
expect(res.status).toBeGreaterThanOrEqual(400);
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
});
it("404s a photo that isn't in the event", async () => {
const res = await beacon(999999);
expect(res.status).toBe(404);
});
});
describe('download_count', () => {
it('single-photo download increments (regression pin)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
expect((await getPhoto(photoIds[1])).download_count).toBe(0);
});
it('download-selected increments exactly the selected photos (#895)', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ photo_ids: [photoIds[0], photoIds[1]] });
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
expect((await getPhoto(photoIds[1])).download_count).toBe(1);
expect((await getPhoto(photoIds[2])).download_count).toBe(0);
});
it('download-all increments every downloadable photo (#895)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download-all`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
for (const id of photoIds) {
expect((await getPhoto(id)).download_count).toBe(1);
}
});
it('skipped archive entries do not count (missing source file)', async () => {
// Own event so the on-the-fly archiver path is guaranteed — the
// main event may have a cached zip from the previous test's
// background generation, and racing its build/invalidate hangs.
// The route also fires a background pre-zip build after streaming;
// against this event's intentionally missing file it crashes with
// an async ENOENT that jest attributes to whatever test is running
// by then — neutralize it, it's not under test here.
const downloadZipService = require('../../src/services/downloadZipService');
const generateZipSpy = jest.spyOn(downloadZipService, 'generateZip')
.mockResolvedValue({ success: false, error: 'disabled in test' });
const slug2 = `${SLUG}-skip`;
const ev = await db('events').insert({
slug: slug2,
event_type: 'wedding',
event_name: 'Engagement Skip Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${slug2}/share`,
share_token: 'engagement-skip-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
const eventId2 = ev[0]?.id ?? ev[0];
const dir2 = path.join(process.env.STORAGE_PATH, 'events/active', slug2);
fs.mkdirSync(dir2, { recursive: true });
const ids2 = [];
for (let i = 0; i < 2; i++) {
// Only photo 0 gets a real file — photo 1's source is missing.
if (i === 0) fs.writeFileSync(path.join(dir2, `photo-${i}.jpg`), Buffer.from('skip-test-bytes'));
const p = await db('photos').insert({
event_id: eventId2,
filename: `photo-${i}.jpg`,
path: `${slug2}/photo-${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
ids2.push(p[0]?.id ?? p[0]);
}
const token2 = jwt.sign(
{ eventId: eventId2, eventSlug: slug2, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const res = await request(app)
.get(`/api/gallery/${slug2}/download-all`)
.set('Authorization', `Bearer ${token2}`);
expect(res.status).toBe(200);
await settle();
expect((await db('photos').where('id', ids2[0]).first()).download_count).toBe(1);
// photo-1's source was missing → skipped from the zip → not counted
expect((await db('photos').where('id', ids2[1]).first()).download_count).toBe(0);
generateZipSpy.mockRestore();
});
});
describe('admin photos list exposes the counters (#895 follow-up)', () => {
it('returns view_count and download_count so the Engagement column can render them', async () => {
// The list mapper builds an explicit object — before this fix it
// omitted both fields, so the admin table showed 0 forever even
// though the DB counted correctly.
await request(app)
.post(`/api/gallery/${SLUG}/photo/${photoIds[0]}/view`)
.set('Authorization', `Bearer ${galleryToken()}`);
await request(app)
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
await settle();
const res = await request(app)
.get(`/api/admin/photos/${eventId}/photos`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
const row = res.body.photos.find((p) => p.id === photoIds[0]);
expect(row.view_count).toBe(1);
expect(row.download_count).toBe(1);
const untouched = res.body.photos.find((p) => p.id === photoIds[1]);
expect(untouched.view_count).toBe(0);
expect(untouched.download_count).toBe(0);
});
});
describe('admin event-detail total_downloads (#895 — one definition everywhere)', () => {
it('counts singles and every zip variant, one row each', async () => {
const row = (action) => ({
event_id: eventId,
ip_address: '127.0.0.1',
user_agent: 'jest',
action,
});
await db('access_logs').insert([
row('download'),
row('download_all'),
row('download_all_presigned'),
row('download_selected'),
row('view'), // not a download
]);
const res = await request(app)
.get(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.total_downloads).toBe(4);
});
});
});
@@ -1,181 +0,0 @@
/**
* Project ownership — GHSA-wrg5 (project routes) and GHSA-93x4 (project email
* endpoints).
*
* Project routes authorized on generic events.view / events.edit with NO
* ownership check, so an editor could enumerate, read, update and aggregate
* projects belonging to other admins' events. The email endpoints keyed on an
* email_queue id alone, so any id could be previewed/resent/cancelled.
*
* `projects` had no owner column. It was added in migration 167 (backfilled
* from linked events) rather than relying only on the transitive
* events.project_id -> events.created_by path, because a brand-new EMPTY
* project has no linked event to infer an owner from — which is exactly where
* the create -> attach flow begins.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-projown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projown-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('project ownership (GHSA-wrg5 / GHSA-93x4)', () => {
let db; let cleanup; let app;
let editorToken; let superToken; let editorId; let superId;
let ownProjectId; let foreignProjectId; let foreignEventId; let foreignEmailId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
return {
id,
token: jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' },
),
};
};
const mkProject = async (name, createdBy) => {
const r = await db('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
await db('feature_flags').insert({ key: 'projects', value: 1 })
.onConflict('key').merge({ value: 1 });
const editor = await mkAdmin('proj-editor', 'editor');
const sup = await mkAdmin('proj-super', 'super_admin');
editorToken = editor.token; editorId = editor.id;
superToken = sup.token; superId = sup.id;
ownProjectId = await mkProject('own-project', editorId);
foreignProjectId = await mkProject('foreign-project', superId);
// A foreign event linked to the foreign project, plus a queued email on it.
const ev = await db('events').insert({
slug: 'foreign-ev',
event_type: 'wedding',
event_name: 'Foreign Event',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: 'ftok', share_link: '/gallery/foreign-ev/ftok',
created_by: superId,
project_id: foreignProjectId,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
foreignEventId = ev[0]?.id ?? ev[0];
const em = await db('email_queue').insert({
event_id: foreignEventId,
recipient_email: 'client@example.com',
email_type: 'gallery_created',
status: 'sent',
created_at: new Date().toISOString(),
}).returning('id');
foreignEmailId = em[0]?.id ?? em[0];
app = express();
app.use(express.json());
app.use('/api/admin/projects', require('../../src/routes/adminProjects'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('lists only the editor\'s own projects', async () => {
const res = await request(app)
.get('/api/admin/projects')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const names = (res.body.projects || res.body.data?.projects || []).map((p) => p.name);
expect(names).toContain('own-project');
expect(names).not.toContain('foreign-project');
});
it('refuses to read a foreign project', async () => {
const res = await request(app)
.get(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
});
it('refuses to update or aggregate a foreign project', async () => {
const update = await request(app)
.put(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ name: 'hijacked' });
expect([403, 404]).toContain(update.status);
const overview = await request(app)
.get(`/api/admin/projects/${foreignProjectId}/overview`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(overview.status);
// And the name must not have changed.
const row = await db('projects').where({ id: foreignProjectId }).first();
expect(row.name).toBe('foreign-project');
});
it('refuses to attach a FOREIGN event to an owned project', async () => {
const res = await request(app)
.post(`/api/admin/projects/${ownProjectId}/events`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ eventId: foreignEventId });
expect([403, 404]).toContain(res.status);
const ev = await db('events').where({ id: foreignEventId }).first();
expect(ev.project_id).toBe(foreignProjectId); // still attached to its own
});
it('refuses to preview or act on a foreign queued email (GHSA-93x4)', async () => {
const preview = await request(app)
.get(`/api/admin/projects/email/${foreignEmailId}/preview`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(preview.status);
const cancel = await request(app)
.post(`/api/admin/projects/email/${foreignEmailId}/cancel`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(cancel.status);
});
it('leaves super_admin unrestricted', async () => {
const res = await request(app)
.get(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).toBe(200);
});
});
@@ -1,115 +0,0 @@
/**
* Project ownership edge cases (GHSA-wrg5, codex round 2).
*
* The first predicate union'd "any linked event I can see" with the stored
* owner, which opened two holes:
* - a project owned by B containing ONE legacy ownerless event became
* readable by everyone (and /overview aggregates B's other events,
* invoices and emails);
* - migration 167 deliberately leaves multi-owner projects NULL, and a NULL
* owner was treated as "everyone's".
* The stored owner is now authoritative, and a NULL owner only derives access
* when EVERY linked event is accessible.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-projedge-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projedge-test-secret';
const bcrypt3 = require('bcrypt');
const { bootCrmDb: boot3, seedMinimal: seed3 } = require('../integration/helpers/crmDb');
describe('project ownership edge cases (GHSA-wrg5, round 2)', () => {
let db3; let cleanup3; let ownership; let editorA; let editorB;
const mkAdmin3 = async (username, roleName) => {
const role = await db3('roles').where({ name: roleName }).first();
const r = await db3('admin_users').insert({
username, email: `${username}@example.com`,
password_hash: await bcrypt3.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkProject3 = async (name, createdBy) => {
const r = await db3('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkEvent3 = async (slug, createdBy, projectId) => {
const r = await db3('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
created_by: createdBy, project_id: projectId,
expires_at: new Date(Date.now() + 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db: db3, cleanup: cleanup3 } = await boot3());
await seed3(db3);
ownership = require('../../src/middleware/ownership');
editorA = await mkAdmin3('edge-a', 'editor');
editorB = await mkAdmin3('edge-b', 'editor');
}, 120000);
afterAll(async () => { if (cleanup3) await cleanup3(); });
it('one ownerless event in B\'s project does not expose it to A', async () => {
const pid = await mkProject3('b-project', editorB);
await mkEvent3('b-owned-ev', editorB, pid);
await mkEvent3('legacy-ev', null, pid); // ownerless legacy event
const idsA = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(idsA).not.toContain(Number(pid));
const idsB = await ownership.ownedProjectIds({ id: editorB, roleName: 'editor' });
expect(idsB).toContain(Number(pid));
});
it('a mixed-owner project left NULL by migration 167 is not global', async () => {
const pid = await mkProject3('ambiguous', null);
await mkEvent3('mix-a-ev', editorA, pid);
await mkEvent3('mix-b-ev', editorB, pid);
for (const who of [editorA, editorB]) {
const ids = await ownership.ownedProjectIds({ id: who, roleName: 'editor' });
expect(ids).not.toContain(Number(pid));
}
});
it('a NULL-owner project whose events are all mine IS mine', async () => {
const pid = await mkProject3('legacy-mine', null);
await mkEvent3('mine-ev', editorA, pid);
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(ids).toContain(Number(pid));
});
it('a project whose creator was deleted falls back to its events', async () => {
const ghost = await mkAdmin3('ghost-admin', 'editor');
const pid = await mkProject3('orphaned', ghost);
await mkEvent3('orphan-ev', editorA, pid);
await db3('admin_users').where({ id: ghost }).del();
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(ids).toContain(Number(pid));
});
it('super_admin stays unrestricted', async () => {
expect(await ownership.ownedProjectIds({ id: 1, roleName: 'super_admin' })).toBeNull();
});
});
@@ -25,18 +25,14 @@ 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;
@@ -55,18 +51,7 @@ 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);
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -146,40 +131,6 @@ 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', () => {
@@ -33,7 +33,7 @@ describe('publicPaymentCheck routes', () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck'));
}, 120000);
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -60,7 +60,7 @@ describe('publicQuotes routes', () => {
quoteId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
}, 120000);
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -1,86 +0,0 @@
/**
* Restore path containment must not break the normal restore wizard
* (GHSA-fw4c, codex round 2).
*
* `source` is usually a SOURCE TYPE, not a path: RestoreWizard posts
* 'local' | 's3' | 'upload', and restoreService.restore() branches on those
* literals before deriving a directory. The first version of the containment
* check treated `source` as a path, so path.resolve('local') landed outside
* the configured backup roots and BOTH /validate and /start returned 400 —
* blocking every normal restore.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-restorepath-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('restore path allowlist (GHSA-fw4c)', () => {
let db; let cleanup; let checkRestorePathsAllowed;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// Configure a backup root so the allowlist is actually active.
for (const [key, value] of [['backup_destination_path', '/backup']]) {
const existing = await db('app_settings').where({ setting_key: key }).first();
if (existing) {
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
} else {
await db('app_settings').insert({
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
});
}
}
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('allows the wizard\'s source TYPE tokens', async () => {
for (const source of ['local', 's3', 'upload']) {
const err = await checkRestorePathsAllowed({
source, manifestPath: '/backup/manifests/backup-manifest-1.json',
});
expect(err).toBeNull();
}
});
it('allows an s3:// source URL', async () => {
const err = await checkRestorePathsAllowed({
source: 's3://bucket/key/backup.tar.gz',
manifestPath: '/backup/manifests/backup-manifest-1.json',
});
expect(err).toBeNull();
});
it('still rejects a manifestPath outside the configured roots', async () => {
const err = await checkRestorePathsAllowed({
source: 'local', manifestPath: '/etc/passwd',
});
expect(err).toMatch(/inside a configured backup location/i);
});
it('still rejects a traversal manifestPath', async () => {
const err = await checkRestorePathsAllowed({
source: 'local', manifestPath: '/backup/../etc/shadow',
});
expect(err).toBeTruthy();
});
it('accepts a real path source inside the roots', async () => {
const err = await checkRestorePathsAllowed({
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
});
expect(err).toBeNull();
});
});
@@ -1,136 +0,0 @@
/**
* Secure-image view route token binding (GHSA-g94x-8vv8-3c9f).
*
* The view route GET /api/secure-images/:slug/secure/:photoId/:token serves
* via <img src> with the token in the URL, so it can't carry a gallery-token
* header like the download sibling. Before the fix it validated only the
* token signature and took the gallery/photo from the URL — so a token minted
* on any PUBLIC gallery read every other gallery's photos with no password.
*
* Pins that the route now enforces the scope inside the token:
* - the URL photoId must equal the token's minted photoId
* - the gallery embedded in the token's sessionId must equal the URL gallery
* A token minted on gallery A cannot read gallery B under either check; a
* token used on its own gallery+photo passes the binding.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'secimg-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-storage-'));
// Stub the anti-bot/rate-limit middleware so the fingerprint is deterministic
// — the token below is minted with the same fingerprint, so verifySecureToken
// passes and the binding logic under test is what decides the outcome.
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
secureImageAccess: (req, _res, next) => {
req.clientInfo = { fingerprint: 'test-fp', ip: '127.0.0.1', userAgent: 'jest' };
next();
},
getSecurityStatus: (_req, res) => res.json({ ok: true }),
}));
const request = require('supertest');
const express = require('express');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const secureImageService = require('../../src/services/secureImageService');
describe('secure-image view route token binding (GHSA-g94x)', () => {
let db;
let cleanup;
let app;
let galleryA; let galleryB;
let photoA; let photoB;
const mkEvent = async (slug, requirePassword) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
require_password: requirePassword ? 1 : 0,
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-share`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkPhoto = async (eventId, slug, filename) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', slug);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from('img'));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${slug}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
// Mint a token exactly as the mint route does — bound to (photoId, gallery
// sessionId, fingerprint) — bypassing the anti-bot HTTP path.
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
photoId,
`gallery_public_${eventId}_${Date.now()}`,
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
);
const view = (slug, photoId, token) => request(app)
.get(`/api/secure-images/${slug}/secure/${photoId}/${token}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
galleryA = await mkEvent('secimg-public-a', false); // public — token source
galleryB = await mkEvent('secimg-private-b', true); // password-protected — victim
photoA = await mkPhoto(galleryA, 'secimg-public-a', 'a.jpg');
photoB = await mkPhoto(galleryB, 'secimg-private-b', 'b.jpg');
app = express();
app.use(express.json());
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a gallery-A token used against gallery B (cross-photo)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-private-b', photoB, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this photo/i);
});
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
const token = mint(photoA, galleryA);
// URL photoId matches the token, so the photo check passes — the gallery
// check (sessionId gallery A != URL gallery B) must catch it.
const res = await view('secimg-private-b', photoA, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this gallery/i);
});
it('lets a token read its own gallery + photo (binding passes)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-public-a', photoA, token);
// Binding passes; serving may 200/404/500 depending on the pipeline, but
// it must NOT be rejected as a token mismatch.
expect(res.status).not.toBe(403);
});
});
@@ -75,7 +75,7 @@ describe('admin Live Slideshow endpoints', () => {
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
}, 30000);
afterAll(async () => { await cleanup(); });
@@ -67,10 +67,11 @@ async function insertEvent(db, over = {}) {
describe('public Live Slideshow routes', () => {
let db; let cleanup; let app;
// bootCrmDb runs the full migration set against a fresh SQLite file and the
// chain keeps growing via backports. Hook-argument timeouts OVERRIDE the
// 120s jest.config default (same trap as the jest.setTimeout pins) — keep
// this at 120000, matching the config.
// bootCrmDb runs the full migration set against a fresh SQLite file, which
// takes <2s locally but has been observed to exceed Jest's default 5s
// `beforeAll` timeout on slower GitHub Actions runners (~5.4s — runner-to-
// runner I/O variance). Raise the hook timeout so this doesn't intermittently
// block PRs on CI; doesn't affect happy-path local runs.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
@@ -85,7 +86,7 @@ describe('public Live Slideshow routes', () => {
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
}, 30000);
afterAll(async () => { await cleanup(); });
@@ -1,155 +0,0 @@
/**
* v1 API tokens must respect event ownership (GHSA-9697).
*
* migration 081 documents the intent — "the token's effective permissions are
* the intersection of the user's role permissions and the token's own scope
* flags" — but it was never implemented:
*
* - apiTokenAuth selected only id/username/email/role_id, so
* req.admin.roleName was undefined and every ownership helper (which all
* key on roleName) could not distinguish a super_admin from a viewer.
* - No v1 route applied requirePermission or a created_by predicate, so any
* valid token listed every event and — worst — GET /events/:id/share-link
* returned ANY event's share_token, which is the gallery access credential.
*
* Scenario pinned here: a token owned by a restricted (non-super_admin) admin
* must see only its owner's events, and must not obtain a foreign share_token.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1own-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1own-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1own-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
describe('v1 event ownership (GHSA-9697)', () => {
let db; let cleanup; let app;
let editorToken; let superToken;
let ownEventId; let foreignEventId;
const FOREIGN_SHARE_TOKEN = 'f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0';
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkToken = async (adminId, scopes = 'admin') => {
const { plaintext, hashed } = generateApiToken();
await db('api_tokens').insert({
name: `tok-${adminId}`,
hashed_token: hashed,
scopes,
created_by: adminId,
created_at: new Date().toISOString(),
});
return plaintext;
};
const mkEvent = async (slug, createdBy, shareToken) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: shareToken,
share_link: `/gallery/${slug}/${shareToken}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const editorId = await mkAdmin('restricted-editor', 'editor');
const superId = await mkAdmin('root-admin', 'super_admin');
editorToken = await mkToken(editorId);
superToken = await mkToken(superId);
ownEventId = await mkEvent('own-event', editorId, 'a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
foreignEventId = await mkEvent('foreign-event', superId, FOREIGN_SHARE_TOKEN);
app = express();
app.use(express.json());
app.use('/api/v1', require('../../src/routes/v1/events'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('lists only the token owner\'s events', async () => {
const res = await request(app)
.get('/api/v1/events')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const slugs = res.body.events.map((e) => e.slug);
expect(slugs).toContain('own-event');
expect(slugs).not.toContain('foreign-event');
});
it('refuses to read a foreign event', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
});
it('does NOT hand out a foreign event\'s share_token', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}/share-link`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
expect(JSON.stringify(res.body)).not.toContain(FOREIGN_SHARE_TOKEN);
});
it('still allows the owner to read their own event and share link', async () => {
const detail = await request(app)
.get(`/api/v1/events/${ownEventId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect(detail.status).toBe(200);
const share = await request(app)
.get(`/api/v1/events/${ownEventId}/share-link`)
.set('Authorization', `Bearer ${editorToken}`);
expect(share.status).toBe(200);
expect(share.body.share_token).toBe('a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
});
it('leaves super_admin tokens unrestricted', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}/share-link`)
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).toBe(200);
expect(res.body.share_token).toBe(FOREIGN_SHARE_TOKEN);
});
});
@@ -1,108 +0,0 @@
/**
* v1 token scopes must intersect the owner's CURRENT role permissions
* (GHSA-9697, codex round 2).
*
* Migration 081 documents effective permissions as the intersection of the
* owner's role permissions and the token's scope flags. requireApiScope only
* ever checked the scope half, so a token minted while its owner was
* super_admin kept full write access after the owner was demoted to viewer —
* userManagementService never touches api_tokens, so the token outlives the
* demotion. Ownership scoping alone does not close this: the demoted owner
* still *owns* their events.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1perm-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1perm-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1perm-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
describe('v1 token scopes intersect role permissions (GHSA-9697)', () => {
let db; let cleanup; let app; let viewerToken; let viewerEventId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'viewer' }).first();
const r = await db('admin_users').insert({
username: 'demoted-owner',
email: 'demoted@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const ownerId = r[0]?.id ?? r[0];
// A token still carrying the broad 'admin' scope from before demotion.
const { plaintext, hashed } = generateApiToken();
await db('api_tokens').insert({
name: 'stale-token',
hashed_token: hashed,
scopes: 'admin',
created_by: ownerId,
created_at: new Date().toISOString(),
});
viewerToken = plaintext;
const ev = await db('events').insert({
slug: 'viewer-ev',
event_type: 'wedding',
event_name: 'Viewer Event',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: 'vtok',
share_link: '/gallery/viewer-ev/vtok',
created_by: ownerId,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
viewerEventId = ev[0]?.id ?? ev[0];
app = express();
app.use(express.json());
app.use('/api/v1', require('../../src/routes/v1/events'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('denies event creation to a demoted viewer despite an admin-scope token', async () => {
const res = await request(app)
.post('/api/v1/events')
.set('Authorization', `Bearer ${viewerToken}`)
.send({ event_name: 'Nope', event_type: 'wedding' });
expect(res.status).toBe(403);
});
it('denies photo upload to a demoted viewer on their OWN event', async () => {
const res = await request(app)
.post(`/api/v1/events/${viewerEventId}/photos`)
.set('Authorization', `Bearer ${viewerToken}`)
.attach('photo', Buffer.from('x'), 'a.jpg');
expect(res.status).toBe(403);
});
it('still allows the viewer to READ their own event', async () => {
const res = await request(app)
.get(`/api/v1/events/${viewerEventId}`)
.set('Authorization', `Bearer ${viewerToken}`);
expect(res.status).toBe(200);
});
});
@@ -22,7 +22,7 @@ const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
jest.setTimeout(120000);
jest.setTimeout(30000);
describe('backupIntegrityService.verifyDocumentArtefacts', () => {
let db;
@@ -1,241 +0,0 @@
/**
* Backup/restore hardening — GHSA-h652 (unbounded gunzip) and GHSA-hgp8
* (unkeyed manifest checksum).
*
* h652: decompressFile() piped gunzip straight to disk with no expanded-size
* bound, so a small crafted .gz could fill the volume.
*
* hgp8: the manifest checksum is a plain SHA-256 — it proves the manifest was
* not corrupted, not that it is authentic. BACKUP_MANIFEST_KEY upgrades new
* manifests to a keyed HMAC. It is deliberately OPT-IN and verify-if-present:
* the key cannot live in the database (the database is inside the backup), so
* a mandatory HMAC would lock an operator out of the exact disaster-recovery
* case this system exists for.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const zlib = require('zlib');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bkharden-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkharden-test-secret';
const { restoreService } = require('../../src/services/restoreService');
const backupManifest = require('../../src/services/backupManifest');
describe('decompressFile expanded-size bound (GHSA-h652)', () => {
let dir;
beforeAll(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-gz-')); });
afterAll(() => { fs.rmSync(dir, { recursive: true, force: true }); });
afterEach(() => { delete process.env.RESTORE_MAX_DECOMPRESSED_BYTES; });
it('aborts when the decompressed stream exceeds the limit', async () => {
// 5 MB of zeroes compresses to a few KB — the classic shape of the attack.
const gzPath = path.join(dir, 'bomb.gz');
fs.writeFileSync(gzPath, zlib.gzipSync(Buffer.alloc(5 * 1024 * 1024, 0)));
process.env.RESTORE_MAX_DECOMPRESSED_BYTES = String(64 * 1024); // 64 KB
await expect(
restoreService.decompressFile(gzPath, path.join(dir, 'out-bomb'))
).rejects.toThrow(/exceeds limit/i);
});
it('still decompresses a normal file within the limit', async () => {
const payload = Buffer.from('SELECT 1;\n'.repeat(100));
const gzPath = path.join(dir, 'ok.gz');
fs.writeFileSync(gzPath, zlib.gzipSync(payload));
const outPath = path.join(dir, 'out-ok');
await restoreService.decompressFile(gzPath, outPath);
expect(fs.readFileSync(outPath)).toEqual(payload);
});
});
describe('manifest checksum keying (GHSA-hgp8)', () => {
// validateManifest requires all of these sections to be present.
const baseManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: null },
});
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
it('produces a different digest when a key is set', () => {
const m = baseManifest();
const unkeyed = backupManifest.calculateManifestChecksum(m, { keyed: false });
const keyed = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
expect(keyed).not.toBe(unkeyed);
});
it('validates a legacy unkeyed manifest even when a key IS configured', () => {
// Disaster recovery: manifests written before keying must not become
// un-restorable the moment the operator sets a key.
const m = baseManifest();
m.verification.checksum_algorithm = 'sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
it('accepts a keyed manifest when the matching key is configured', () => {
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
it('rejects a keyed manifest whose body was tampered with', () => {
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
m.files.manifest[0].path = '../../etc/passwd';
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
});
it('does NOT brick restore when a keyed manifest meets a missing key', () => {
// Key lost with the host — the precise moment a restore is needed.
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
delete process.env.BACKUP_MANIFEST_KEY;
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
});
describe('manifest checksum coverage (canonicalization)', () => {
const fullManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
});
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
it('covers nested file entries (the old replacer dropped them)', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
// Tampering a file path must now change the digest.
m.files.manifest[0].path = '../../etc/passwd';
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
});
it('still accepts a manifest written with the legacy serialization', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
m, { keyed: false, legacy: true }
);
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
});
describe('checksum verification is shared and downgrade-aware (codex round 2)', () => {
const fullManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
});
afterEach(() => {
delete process.env.BACKUP_MANIFEST_KEY;
delete process.env.BACKUP_MANIFEST_REQUIRE_KEYED;
});
it('accepts a legacy-serialized manifest through the SHARED verifier', () => {
// restoreService recomputed the digest itself with the canonical
// serializer, which rejected every pre-existing backup.
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
m, { keyed: false, legacy: true },
);
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(true);
expect(res.warnings.join(' ')).toMatch(/legacy checksum serialization/i);
});
it('warns but accepts an unkeyed manifest when a key is configured', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(true);
expect(res.warnings.join(' ')).toMatch(/authenticity NOT established/i);
});
it('REJECTS the algorithm downgrade once REQUIRE_KEYED is on', () => {
// Attacker rewrites the manifest, strips checksum_algorithm and recomputes
// a plain SHA-256. With the strict flag set that must not verify.
const m = fullManifest();
m.files.manifest[0].path = '../../etc/passwd';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/downgrade/i);
});
it('rejects a keyed manifest with no key when REQUIRE_KEYED is on', () => {
const m = fullManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'k' });
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
});
it('REJECTS a manifest whose checksum was stripped entirely', () => {
// The cheapest bypass of every rule above: delete the field instead of
// forging it. Both the helper's early return and restoreService's
// `if (…total_checksum)` guard used to wave that through.
const m = fullManifest();
delete m.verification.total_checksum;
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/no checksum/i);
delete m.verification;
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
});
it('REJECTS an unkeyed manifest under REQUIRE_KEYED even with no key configured', () => {
// Strict mode is a claim about the manifests, not about this host — so a
// fresh disaster-recovery box that lost BACKUP_MANIFEST_KEY must not
// silently start accepting plain SHA-256 manifests again.
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
delete process.env.BACKUP_MANIFEST_KEY;
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/downgrade/i);
});
});
@@ -1,159 +0,0 @@
/**
* Regression test: business documents must be written under STORAGE_PATH.
*
* quoteService.persistDocPdf, the invoice sending/reminder writers and the
* contract signature writers all built their target from
* `path.join(process.cwd(), 'storage', 'business-docs', ...)`. Both compose
* files pin STORAGE_PATH=/app/storage and the image's WORKDIR is /app, so the
* two expressions name the same directory and the bug was invisible on a stock
* deployment. Point STORAGE_PATH anywhere else — a NAS mount, a second disk,
* the single-container image's /data volume — and quotes, invoices, Mahnungen
* and contract PDFs were written outside the configured storage root, so they
* were missed by backups and lost when the container was replaced.
*
* Rather than assert on internals, this drives the module boundary the fix
* changed: getStoragePath() is the one resolver, so a temporary STORAGE_PATH
* must be where the bytes land.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
describe('business documents honour STORAGE_PATH', () => {
let tmpRoot;
let originalStoragePath;
beforeEach(() => {
originalStoragePath = process.env.STORAGE_PATH;
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-storage-'));
process.env.STORAGE_PATH = tmpRoot;
jest.resetModules();
});
afterEach(() => {
if (originalStoragePath === undefined) delete process.env.STORAGE_PATH;
else process.env.STORAGE_PATH = originalStoragePath;
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it('getStoragePath is the resolver the writers share', () => {
const { getStoragePath } = require('../../src/config/storage');
expect(getStoragePath()).toBe(tmpRoot);
});
it('no business-document writer still targets process.cwd()/storage', () => {
// Whitespace is collapsed before matching on purpose. The first version of
// this test compared against the single-line literal and therefore missed
// persistSignatureImage(), whose identical path.join was simply spread over
// seven lines — it reported green while signature PNGs still wrote outside
// STORAGE_PATH. Formatting must not decide whether a bug is visible.
const writers = [
'src/services/quoteService.js',
'src/services/invoice/sending.js',
'src/services/invoice/reminders.js',
'src/services/contract/signatureAssets.js',
'src/routes/adminDev.js',
];
const offenders = writers.filter((rel) => {
const source = fs.readFileSync(path.join(__dirname, '../../', rel), 'utf8');
return /process\.cwd\(\),'storage'/.test(source.replace(/\s+/g, ''));
});
expect(offenders).toEqual([]);
});
it('generated contract PDFs pass the containment check that serves them', () => {
// assertContractPdfPath guards the admin and public contract download
// routes. It listed only <cwd>/storage/business-docs/contract, so once the
// writers moved to STORAGE_PATH every freshly generated contract was
// refused with PATH_OUTSIDE_STORAGE — a worse failure than the bug being
// fixed. Both roots must be accepted.
const { assertContractPdfPath } = require('../../src/utils/safePath');
const { getStoragePath } = require('../../src/config/storage');
// assertPathInside realpaths both the file and each root, so the guard only
// means anything against a filesystem that actually has them — write them.
const write = (...segments) => {
const p = path.join(getStoragePath(), 'business-docs', 'contract', ...segments);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, 'bytes');
return p;
};
const generated = write('2026', 'C-2026-0001.pdf');
expect(() => assertContractPdfPath(generated)).not.toThrow();
// Signature PNGs live under the same root and are served by the same guard.
const signature = write('signatures', '7', 'customer-1.png');
expect(() => assertContractPdfPath(signature)).not.toThrow();
// And the guard still refuses a real file outside every allowed root.
const foreign = path.join(tmpRoot, 'outside.pdf');
fs.writeFileSync(foreign, 'bytes');
expect(() => assertContractPdfPath(foreign)).toThrow(/outside the storage roots/i);
});
it('the guard takes its root from the shared resolver, not its own fallback', () => {
// The regression this pins: the guard used to compute
// `STORAGE_PATH || <cwd>/storage` itself. That agrees with getStoragePath()
// only while STORAGE_PATH is set — unset, the shared resolver falls back
// module-relative to <repo>/storage while the guard fell back to
// <cwd>/storage, and the backend is normally started from backend/. Writers
// and guard then disagreed and contract downloads 403'd.
//
// Mocking the resolver is what makes this provable AND safe. If the guard
// consumes getStoragePath(), the mock moves its root; if it rolled its own
// expression, the mock would have no effect and the assertion fails. It
// also keeps every path inside the tmpdir — an earlier version of this test
// deleted `<resolved root>/business-docs` in cleanup, which with
// STORAGE_PATH unset resolves to a developer's real, gitignored
// <repo>/storage and would have destroyed local documents on `npm test`.
jest.resetModules();
jest.doMock('../../src/config/storage', () => ({ getStoragePath: () => tmpRoot }));
const { assertContractPdfPath } = require('../../src/utils/safePath');
const root = path.join(tmpRoot, 'business-docs', 'contract', '2026');
fs.mkdirSync(root, { recursive: true });
const generated = path.join(root, 'C-2026-0002.pdf');
fs.writeFileSync(generated, 'bytes');
expect(() => assertContractPdfPath(generated)).not.toThrow();
jest.dontMock('../../src/config/storage');
});
it('writes land under STORAGE_PATH, not the working directory', () => {
const { getStoragePath } = require('../../src/config/storage');
// Mirror what persistDocPdf does: derive the root, create it, write.
const root = path.join(getStoragePath(), 'business-docs', 'quote', '2026');
fs.mkdirSync(root, { recursive: true });
const filePath = path.join(root, 'Q-2026-0001.pdf');
fs.writeFileSync(filePath, 'pdf-bytes');
expect(fs.existsSync(filePath)).toBe(true);
expect(filePath.startsWith(tmpRoot)).toBe(true);
// And crucially NOT beside the process working directory.
expect(filePath.startsWith(path.join(process.cwd(), 'storage'))).toBe(false);
});
it('the PDF font lookup consults the storage root before the legacy path', () => {
// A custom font under STORAGE_PATH/fonts used to be unreachable, so the
// document silently rendered with the built-in face instead.
const fontDir = path.join(tmpRoot, 'fonts');
fs.mkdirSync(fontDir, { recursive: true });
const fontPath = path.join(fontDir, 'Brand.ttf');
fs.writeFileSync(fontPath, 'ttf');
const { getStoragePath } = require('../../src/config/storage');
const raw = 'Brand.ttf';
const candidates = [
path.join(getStoragePath(), raw.replace(/^\/+/, '')),
path.join(getStoragePath(), 'fonts', path.basename(raw)),
path.join(process.cwd(), 'storage', 'fonts', path.basename(raw)),
];
const found = candidates.find((p) => fs.existsSync(p));
expect(found).toBe(fontPath);
});
});
@@ -1,52 +0,0 @@
const path = require('path');
const os = require('os');
const fs = require('fs').promises;
// Point storage at a throwaway temp dir before requiring the service so the
// module-level getStoragePath() picks it up if evaluated.
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-test-${process.pid}`);
const chunkedUpload = require('../../src/services/chunkedUploadService');
describe('chunkedUploadService.initializeUpload filename sanitisation (GHSA-pc72-jf53-w28j)', () => {
afterAll(async () => {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
});
it('strips directory-traversal components from the stored filename', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: '../../uploads/logos/evil.svg',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
const meta = chunkedUpload.getUploadStatus(uploadId);
// basename('../../uploads/logos/evil.svg') === 'evil.svg' — the traversal
// is gone, so path.join(tempDir, filename) can no longer escape tempDir.
expect(meta.filename).toBe('evil.svg');
});
it('keeps a normal filename intact', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: 'clip.mp4',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
expect(uploadId).toBeTruthy();
});
it('rejects a filename that collapses to nothing', async () => {
await expect(
chunkedUpload.initializeUpload({
filename: '../',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
})
).rejects.toThrow(/Invalid filename/);
});
});

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