Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69fee5faba |
+2
-3
@@ -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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
buy_me_a_coffee: theluap
|
||||
@@ -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
|
||||
|
||||
@@ -25,14 +25,33 @@ jobs:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
target-branch: stable
|
||||
|
||||
# NOTE: stable release PRs are intentionally NOT auto-merged here
|
||||
# anymore. Fixes accumulate in the rolling release PR and are cut as
|
||||
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
|
||||
# or on demand via workflow_dispatch / a manual merge of the release
|
||||
# PR). Beta keeps instant releases — see release-please-beta.yml —
|
||||
# because same-day reporter verification depends on it.
|
||||
# Auto-approve + auto-merge the open stable release PR. See the beta
|
||||
# workflow for the full rationale. Skipped on the release-cutting run and
|
||||
# whenever no PAT is configured.
|
||||
- name: Auto-approve and enable auto-merge on the release PR
|
||||
if: ${{ steps.release.outputs.release_created != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# No checkout in this job — set the repo explicitly so gh works
|
||||
# without a git remote (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
|
||||
exit 0
|
||||
fi
|
||||
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
|
||||
# is a valid review; enable auto-merge as the PAT so the merge commit is
|
||||
# attributed to a real identity and triggers the tag-cutting run (#719).
|
||||
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
|
||||
else
|
||||
echo "No open release PR to auto-merge."
|
||||
fi
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
name: Cut Stable Release (daily batch)
|
||||
|
||||
# Stable fixes accumulate in release-please's rolling release PR instead of
|
||||
# each cutting its own patch version (the old per-merge auto-merge produced
|
||||
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
|
||||
# stable release PR once a day, so a day of N bugfixes ships as ONE version
|
||||
# with all N changelog entries — and one Docker build instead of N.
|
||||
#
|
||||
# - schedule only fires from the default branch (main); the stable copy of
|
||||
# this file is inert and exists to keep the branches in sync.
|
||||
# - Need a release NOW? Run this via workflow_dispatch, or merge the
|
||||
# release PR by hand — the schedule is a default, not a gate.
|
||||
# - Approval/merge mechanics mirror the old inline step (#719): approve as
|
||||
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
|
||||
# auto-merge as the PAT so the merge attributes to a real identity and
|
||||
# triggers the tag-cutting run. --auto waits for green checks.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
merge-stable-release-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Approve and enable auto-merge on the open stable release PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# No checkout — set the repo explicitly so gh works without a
|
||||
# git remote (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
|
||||
exit 0
|
||||
fi
|
||||
# Strict selection (review P1): this job runs daily even without a
|
||||
# stable push, and `gh pr list --head` matches the branch NAME only
|
||||
# — a fork PR can spoof `release-please--branches--stable`. Pin the
|
||||
# base to stable AND require a same-repo head (isCrossRepository
|
||||
# == false); a fork PR is cross-repository, so it can never be
|
||||
# picked and auto-merged with the privileged PAT.
|
||||
pr=$(gh pr list \
|
||||
--base stable \
|
||||
--head release-please--branches--stable \
|
||||
--state open \
|
||||
--json number,isCrossRepository \
|
||||
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
|
||||
if [ -z "$pr" ]; then
|
||||
echo "No open same-repo stable release PR — nothing to cut today."
|
||||
exit 0
|
||||
fi
|
||||
# Approve is tolerant — a pre-existing approval already satisfies
|
||||
# branch protection and re-approving can return non-zero.
|
||||
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
|
||||
# But the auto-merge enable is the load-bearing step: this scheduled
|
||||
# job is the ONLY automatic stable cut, so DON'T swallow its failure
|
||||
# (review P2) — an expired/under-scoped PAT would otherwise stop
|
||||
# releases while the workflow stays green.
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
|
||||
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
|
||||
# are already green — the normal case at 18:00, since the fixes
|
||||
# merged hours earlier and CI passed. So success is EITHER the PR is
|
||||
# already merged OR an auto-merge request is now pending; only a PR
|
||||
# that is still open with no auto-merge request is a real failure
|
||||
# (expired/under-scoped PAT) worth failing the job on (review round 2).
|
||||
# One snapshot of both fields (review round 3): querying state and
|
||||
# autoMergeRequest separately races — auto-merge can complete
|
||||
# between the two calls, so the first sees OPEN and the second sees
|
||||
# the request already cleared on the now-merged PR → false failure.
|
||||
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
|
||||
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
|
||||
if [ "$state" = "MERGED" ]; then
|
||||
echo "Stable release PR #$pr merged immediately (checks were already green)."
|
||||
elif [ "$automerge" = "true" ]; then
|
||||
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
|
||||
else
|
||||
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
|
||||
exit 1
|
||||
fi
|
||||
@@ -17,9 +17,9 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, beta, stable]
|
||||
branches: [main, beta]
|
||||
pull_request:
|
||||
branches: [main, beta, stable]
|
||||
branches: [main, beta]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -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
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "3.83.0-beta.0"
|
||||
".": "3.80.0-beta.0"
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{".":"3.46.11"}
|
||||
{
|
||||
".": "2.6.1"
|
||||
}
|
||||
|
||||
+953
-1069
File diff suppressed because it is too large
Load Diff
+4
-5
@@ -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! 🎉
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Smoke tests for backupService's config resolution + file-collection
|
||||
* and manifest validation paths — safety net ahead of the god-file
|
||||
* decomposition.
|
||||
*
|
||||
* Uses the same real-SQLite harness as
|
||||
* backupService.configurableWalker.test.js (bootCrmDb + a temp
|
||||
* STORAGE_PATH) rather than the broken deep-mock approach in
|
||||
* backupService.enhanced.test.js.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — config + file collection + manifest (smoke)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
let backupManifest;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
backupManifest = require('../../src/services/backupManifest');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').del();
|
||||
// Reset the storage tree so each test starts from a pristine walk.
|
||||
await fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
});
|
||||
|
||||
function seedFile(relPath, content = 'dummy bytes') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
return abs;
|
||||
}
|
||||
|
||||
async function insertBackupSetting(key, value) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: value,
|
||||
setting_type: 'backup',
|
||||
});
|
||||
}
|
||||
|
||||
describe('getBackupConfig', () => {
|
||||
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
|
||||
await insertBackupSetting('backup_enabled', 'true');
|
||||
await insertBackupSetting('backup_include_archived', 'false');
|
||||
await insertBackupSetting('backup_retention_days', '30');
|
||||
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
|
||||
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
|
||||
// Non-backup settings must not leak into the backup config.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'general_site_name',
|
||||
setting_value: 'PicPeak',
|
||||
setting_type: 'general',
|
||||
});
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config.backup_enabled).toBe(true);
|
||||
expect(config.backup_include_archived).toBe(false);
|
||||
expect(config.backup_retention_days).toBe(30);
|
||||
expect(config.backup_destination_path).toBe('/backups/picpeak');
|
||||
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
|
||||
expect(config).not.toHaveProperty('general_site_name');
|
||||
// Raw (unparsed) values are preserved on the non-enumerable __raw.
|
||||
expect(String(config.__raw.backup_retention_days)).toBe('30');
|
||||
});
|
||||
|
||||
it('returns an empty config object (not null) when nothing is configured', async () => {
|
||||
const config = await backupService.getBackupConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(Object.keys(config)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilesToBackup', () => {
|
||||
it('returns an empty list on a pristine storage tree', async () => {
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
|
||||
const content = 'not really a jpeg';
|
||||
const abs = seedFile('events/active/E9/pic.jpg', content);
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.path).toBe(abs);
|
||||
expect(entry.size).toBe(Buffer.byteLength(content));
|
||||
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
|
||||
// realm under Jest and fails the cross-realm instanceof check.
|
||||
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBackupManifest', () => {
|
||||
it('round-trips a generated manifest as valid', async () => {
|
||||
seedFile('events/active/E1/a.jpg', 'aaa');
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
|
||||
const manifest = await backupManifest.generateManifest({
|
||||
backupType: 'full',
|
||||
backupPath: '/backup/run-1',
|
||||
files,
|
||||
});
|
||||
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
|
||||
await backupManifest.saveManifest(manifest, manifestPath, 'json');
|
||||
|
||||
const result = await backupService.validateBackupManifest(manifestPath);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.manifest.backup.type).toBe('full');
|
||||
expect(result.manifest.files.count).toBe(files.length);
|
||||
expect(result.manifest.verification.total_checksum).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags a manifest missing required sections as invalid', async () => {
|
||||
const badPath = path.join(storagePath, 'manifest-broken.json');
|
||||
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
|
||||
|
||||
const result = await backupService.validateBackupManifest(badPath);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toMatch(/Missing required section/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
const crypto = require('crypto');
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('booking cutover — draft invoices on hold', () => {
|
||||
let db; let cleanup; let adminId; let customerId; let quoteService;
|
||||
|
||||
@@ -1,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 folder’s originals reachable after a second import', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Leknes/old.jpg');
|
||||
await touch('Trip/Sub/new.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
const before = await resolved(eventId, 'old.jpg');
|
||||
await runImport(eventId, 'Trip/Sub');
|
||||
const after = await resolved(eventId, 'old.jpg');
|
||||
|
||||
// The regression: `after` used to be <root>/Trip/Sub/Leknes/old.jpg.
|
||||
expect(after).toBe(before);
|
||||
expect(fs.existsSync(after)).toBe(true);
|
||||
});
|
||||
|
||||
it('every original in the event is still on disk afterwards', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Leknes/a.jpg');
|
||||
await touch('Trip/Leknes/b.jpg');
|
||||
await touch('Trip/Sub/c.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
await runImport(eventId, 'Trip/Sub');
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos).toHaveLength(3);
|
||||
for (const photo of photos) {
|
||||
expect(fs.existsSync(resolvePhotoFilePath(event, photo))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not re-insert a file the first import already took', async () => {
|
||||
// The dedupe check compares stored paths, so it has to be comparing the
|
||||
// same shape the insert writes.
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Sub/c.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
const second = await runImport(eventId, 'Trip/Sub');
|
||||
|
||||
expect(second.body.imported).toBe(0);
|
||||
expect(second.body.skipped).toBe(1);
|
||||
expect(await db('photos').where({ event_id: eventId }).count('* as c').first()).toEqual({ c: 1 });
|
||||
});
|
||||
|
||||
it('resolves a subfolder that repeats its parent’s name', async () => {
|
||||
// The old resolver stripped the relpath's first segment when it matched the
|
||||
// base path's last one, which broke exactly this layout.
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Trip/x.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(resolvePhotoFilePath(event, photo)).toBe(path.join(mediaRoot, 'Trip', 'Trip', 'x.jpg'));
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* PostgreSQL integration test for the external-path fold (#1163).
|
||||
*
|
||||
* Gated the same way as picpeakRestorePg: runs only when PICPEAK_PG_TEST_URL
|
||||
* points at a throwaway Postgres DB, e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_fold_test" \
|
||||
* npx jest __tests__/integration/externalRelpathFoldPg.test.js
|
||||
*
|
||||
* This exists because of a defect SQLite could not have caught. The two-pass
|
||||
* rewrite parks each row on a temporary value, and that value was first written
|
||||
* with a leading NUL. SQLite stores NUL in TEXT without complaint; Postgres
|
||||
* rejects it outright ("invalid byte sequence for encoding UTF8"), so migration
|
||||
* 187 would have rolled back on exactly the installs needing the repair — and
|
||||
* only on the engine most of them run.
|
||||
*
|
||||
* The staging value is therefore an engine-level contract, not an
|
||||
* implementation detail, and it is pinned here on the engine that constrains it.
|
||||
*/
|
||||
|
||||
const knex = require('knex');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('external relpath fold on Postgres', () => {
|
||||
let pgDb; let mediaRoot; let fold;
|
||||
|
||||
const touch = async (rel, bytes) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.promises.writeFile(full, Buffer.alloc(bytes));
|
||||
return bytes;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-foldpg-'));
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
jest.resetModules();
|
||||
({ foldExternalRelpaths: fold } = require('../../src/services/externalRelpathFold'));
|
||||
|
||||
pgDb = knex({ client: 'pg', connection: PG_URL });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (pgDb) await pgDb.destroy();
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true }).catch(() => {});
|
||||
delete process.env.EXTERNAL_MEDIA_ROOT;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await pgDb.raw('DROP TABLE IF EXISTS photos, events, app_settings CASCADE');
|
||||
await pgDb.schema.createTable('events', (t) => {
|
||||
t.increments('id');
|
||||
t.text('external_path');
|
||||
});
|
||||
await pgDb.schema.createTable('photos', (t) => {
|
||||
t.increments('id');
|
||||
t.integer('event_id');
|
||||
t.text('external_relpath');
|
||||
t.bigInteger('size_bytes');
|
||||
t.string('source_origin').defaultTo('managed');
|
||||
});
|
||||
await pgDb.schema.createTable('app_settings', (t) => {
|
||||
t.increments('id');
|
||||
t.string('setting_key');
|
||||
t.text('setting_value');
|
||||
t.string('setting_type');
|
||||
t.string('updated_at');
|
||||
});
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
});
|
||||
|
||||
const relpaths = async () =>
|
||||
(await pgDb('photos').orderBy('id').select('external_relpath')).map((r) => r.external_relpath);
|
||||
|
||||
it('completes the two-pass repair that a NUL staging value would abort', async () => {
|
||||
// The exact shape that forces staging: `photo.jpg` repairs up to
|
||||
// `Trip/photo.jpg`, while the row already holding `Trip/photo.jpg` folds
|
||||
// deeper. Every final value is distinct, but a final value equals another
|
||||
// row's current one, so the rewrite has to park first.
|
||||
const a = await touch('Trip/photo.jpg', 11);
|
||||
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
||||
await pgDb('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'photo.jpg', size_bytes: a, source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'Trip/photo.jpg', size_bytes: b, source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await fold(pgDb);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
|
||||
});
|
||||
|
||||
it('leaves no staging value behind', async () => {
|
||||
await touch('Trip/a.jpg', 8);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
|
||||
|
||||
await fold(pgDb);
|
||||
|
||||
const rows = await relpaths();
|
||||
expect(rows).toEqual(['Trip/a.jpg']);
|
||||
expect(rows.some((r) => r.includes('staging'))).toBe(false);
|
||||
});
|
||||
|
||||
it('folds and marks in one transaction', async () => {
|
||||
await touch('Trip/a.jpg', 8);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
|
||||
|
||||
await fold(pgDb);
|
||||
// Second run is a no-op: the marker committed with the rewrites.
|
||||
await fold(pgDb);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/a.jpg']);
|
||||
});
|
||||
});
|
||||
@@ -1,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Regression test for the bulk archive/delete ownership bypass.
|
||||
*
|
||||
* bulk-archive and bulk-delete acted on body-supplied event ids with no
|
||||
* ownership filter, so an admin/editor scoped to their own events (the
|
||||
* single-event routes enforce requireEventOwnership) could archive or
|
||||
* cascade-delete ANY event by id. filterOwnedEventIds is the helper those
|
||||
* routes now use to drop foreign/non-existent ids.
|
||||
*/
|
||||
|
||||
// events owned by admin 7; event 3 owned by someone else; event 4 is
|
||||
// ownerless (legacy). The mock models:
|
||||
// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id)
|
||||
const EVENTS = [
|
||||
{ id: 1, created_by: 7 },
|
||||
{ id: 2, created_by: 7 },
|
||||
{ id: 3, created_by: 99 }, // foreign
|
||||
{ id: 4, created_by: null }, // ownerless/legacy
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => {
|
||||
const q = {
|
||||
_ids: null,
|
||||
_adminId: null,
|
||||
whereIn(_col, ids) { this._ids = ids; return this; },
|
||||
andWhere(cb) {
|
||||
// Emulate the (created_by IS NULL OR created_by = admin.id) builder
|
||||
// by capturing the admin id the callback closes over via a probe.
|
||||
const probe = {
|
||||
_adminId: null,
|
||||
whereNull() { return this; },
|
||||
orWhere(_col, id) { this._adminId = id; return this; },
|
||||
};
|
||||
cb(probe);
|
||||
this._adminId = probe._adminId;
|
||||
return this;
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve(
|
||||
EVENTS
|
||||
.filter((e) => this._ids.includes(e.id))
|
||||
.filter((e) => e.created_by === null || e.created_by === this._adminId)
|
||||
.map((e) => ({ id: e.id }))
|
||||
);
|
||||
},
|
||||
};
|
||||
return q;
|
||||
},
|
||||
}));
|
||||
|
||||
const { filterOwnedEventIds } = require('../../src/middleware/ownership');
|
||||
|
||||
describe('filterOwnedEventIds', () => {
|
||||
it('super_admin gets every id, nothing denied', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'super_admin' }, [1, 3, 4, 999]
|
||||
);
|
||||
expect(allowed).toEqual([1, 3, 4, 999]);
|
||||
expect(denied).toEqual([]);
|
||||
});
|
||||
|
||||
it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999]
|
||||
);
|
||||
expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless
|
||||
expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing
|
||||
});
|
||||
|
||||
it('foreign-only request yields empty allowed', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'editor' }, [3]
|
||||
);
|
||||
expect(allowed).toEqual([]);
|
||||
expect(denied).toEqual([3]);
|
||||
});
|
||||
});
|
||||
@@ -1,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();
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* HTTP smoke tests for the core admin event CRUD endpoints:
|
||||
* POST /api/admin/events (create)
|
||||
* GET /api/admin/events (list + pagination)
|
||||
* GET /api/admin/events/:id (detail + stats)
|
||||
* PUT /api/admin/events/:id (update)
|
||||
* DELETE /api/admin/events/:id (cascade delete)
|
||||
*
|
||||
* Safety net ahead of the adminEvents.js god-file decomposition —
|
||||
* pins the request/response contracts of the main CRUD paths using
|
||||
* the same real-SQLite harness as slideshowAdmin.test.js.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-events-smoke-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
async function insertEvent(db, adminId, over = {}) {
|
||||
const base = {
|
||||
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Wedding',
|
||||
event_date: '2026-05-29',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('admin events CRUD endpoints (smoke)', () => {
|
||||
let db; let cleanup; let app; let adminId; let token;
|
||||
|
||||
// bootCrmDb's full migration run intermittently exceeds Jest's default
|
||||
// 5s beforeAll timeout on slower CI runners; raise it.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('email_queue').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
it('401s without an admin token', async () => {
|
||||
const res = await request(app).get('/api/admin/events');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('creates an event, mints slug + share link and persists the row', async () => {
|
||||
const res = await auth(request(app).post('/api/admin/events')).send({
|
||||
event_type: 'wedding',
|
||||
event_name: 'Smoke Wedding',
|
||||
event_date: '2026-09-01',
|
||||
// Field requirements default to ON (getEventFieldRequirements)
|
||||
// so customer + admin contact data must be supplied.
|
||||
customer_name: 'Client Person',
|
||||
customer_email: 'client@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
require_password: false,
|
||||
is_draft: true,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.slug).toContain('wedding-smoke-wedding');
|
||||
expect(typeof res.body.share_link).toBe('string');
|
||||
expect(res.body.is_draft).toBe(true);
|
||||
|
||||
const row = await db('events').where({ id: res.body.id }).first();
|
||||
expect(row).toBeDefined();
|
||||
expect(row.event_name).toBe('Smoke Wedding');
|
||||
expect(row.created_by).toBe(adminId);
|
||||
|
||||
// Folder structure is created under STORAGE_PATH/events/active/<slug>.
|
||||
const eventDir = path.join(process.env.STORAGE_PATH, 'events/active', res.body.slug);
|
||||
expect(fs.existsSync(path.join(eventDir, 'collages'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(eventDir, 'individual'))).toBe(true);
|
||||
|
||||
// Draft creates must NOT queue the gallery_created email.
|
||||
const queued = await db('email_queue').where({ event_id: res.body.id });
|
||||
expect(queued).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('400s on an invalid event type', async () => {
|
||||
const res = await auth(request(app).post('/api/admin/events')).send({
|
||||
event_type: 'not-a-real-type',
|
||||
event_name: 'Broken',
|
||||
require_password: false,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(Array.isArray(res.body.errors)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('lists events with pagination metadata and photo counts', async () => {
|
||||
await insertEvent(db, adminId, { event_name: 'Alpha' });
|
||||
await insertEvent(db, adminId, { event_name: 'Beta' });
|
||||
|
||||
const res = await auth(request(app).get('/api/admin/events'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.events).toHaveLength(2);
|
||||
expect(res.body.pagination).toMatchObject({ page: 1, total: 2, totalPages: 1 });
|
||||
for (const ev of res.body.events) {
|
||||
expect(ev.photo_count).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id', () => {
|
||||
it('returns the event with photo/view stats', async () => {
|
||||
const id = await insertEvent(db, adminId, { event_name: 'Detail Event' });
|
||||
const res = await auth(request(app).get(`/api/admin/events/${id}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_name).toBe('Detail Event');
|
||||
expect(res.body.photo_count).toBe(0);
|
||||
expect(res.body.total_views).toBe(0);
|
||||
expect(res.body.total_downloads).toBe(0);
|
||||
expect(Array.isArray(res.body.recent_photos)).toBe(true);
|
||||
});
|
||||
|
||||
it('404s for an unknown event id', async () => {
|
||||
const res = await auth(request(app).get('/api/admin/events/999999'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('updates mutable fields and persists them', async () => {
|
||||
const id = await insertEvent(db, adminId, { event_name: 'Before' });
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
|
||||
event_name: 'After',
|
||||
welcome_message: 'Hello guests',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.event_name).toBe('After');
|
||||
expect(row.welcome_message).toBe('Hello guests');
|
||||
});
|
||||
|
||||
it('404s when updating a missing event', async () => {
|
||||
const res = await auth(request(app).put('/api/admin/events/999999')).send({
|
||||
event_name: 'Ghost',
|
||||
});
|
||||
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', () => {
|
||||
it('cascade-deletes the event row', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).delete(`/api/admin/events/${id}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.message).toMatch(/deleted/i);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s when deleting a missing event', async () => {
|
||||
const res = await auth(request(app).delete('/api/admin/events/999999'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe('publicContracts routes', () => {
|
||||
contractId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
|
||||
}, 120000);
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('publicPaymentCheck routes', () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck'));
|
||||
}, 120000);
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('publicQuotes routes', () => {
|
||||
quoteId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
|
||||
}, 120000);
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Restore path containment must not break the normal restore wizard
|
||||
* (GHSA-fw4c, codex round 2).
|
||||
*
|
||||
* `source` is usually a SOURCE TYPE, not a path: RestoreWizard posts
|
||||
* 'local' | 's3' | 'upload', and restoreService.restore() branches on those
|
||||
* literals before deriving a directory. The first version of the containment
|
||||
* check treated `source` as a path, so path.resolve('local') landed outside
|
||||
* the configured backup roots and BOTH /validate and /start returned 400 —
|
||||
* blocking every normal restore.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-restorepath-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('restore path allowlist (GHSA-fw4c)', () => {
|
||||
let db; let cleanup; let checkRestorePathsAllowed;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
// Configure a backup root so the allowlist is actually active.
|
||||
for (const [key, value] of [['backup_destination_path', '/backup']]) {
|
||||
const existing = await db('app_settings').where({ setting_key: key }).first();
|
||||
if (existing) {
|
||||
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
|
||||
} else {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('allows the wizard\'s source TYPE tokens', async () => {
|
||||
for (const source of ['local', 's3', 'upload']) {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source, manifestPath: '/backup/manifests/backup-manifest-1.json',
|
||||
});
|
||||
expect(err).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows an s3:// source URL', async () => {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source: 's3://bucket/key/backup.tar.gz',
|
||||
manifestPath: '/backup/manifests/backup-manifest-1.json',
|
||||
});
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it('still rejects a manifestPath outside the configured roots', async () => {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source: 'local', manifestPath: '/etc/passwd',
|
||||
});
|
||||
expect(err).toMatch(/inside a configured backup location/i);
|
||||
});
|
||||
|
||||
it('still rejects a traversal manifestPath', async () => {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source: 'local', manifestPath: '/backup/../etc/shadow',
|
||||
});
|
||||
expect(err).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepts a real path source inside the roots', async () => {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
|
||||
});
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* Secure-image view route token binding (GHSA-g94x-8vv8-3c9f).
|
||||
*
|
||||
* The view route GET /api/secure-images/:slug/secure/:photoId/:token serves
|
||||
* via <img src> with the token in the URL, so it can't carry a gallery-token
|
||||
* header like the download sibling. Before the fix it validated only the
|
||||
* token signature and took the gallery/photo from the URL — so a token minted
|
||||
* on any PUBLIC gallery read every other gallery's photos with no password.
|
||||
*
|
||||
* Pins that the route now enforces the scope inside the token:
|
||||
* - the URL photoId must equal the token's minted photoId
|
||||
* - the gallery embedded in the token's sessionId must equal the URL gallery
|
||||
* A token minted on gallery A cannot read gallery B under either check; a
|
||||
* token used on its own gallery+photo passes the binding.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'secimg-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-storage-'));
|
||||
|
||||
// Stub the anti-bot/rate-limit middleware so the fingerprint is deterministic
|
||||
// — the token below is minted with the same fingerprint, so verifySecureToken
|
||||
// passes and the binding logic under test is what decides the outcome.
|
||||
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
|
||||
secureImageAccess: (req, _res, next) => {
|
||||
req.clientInfo = { fingerprint: 'test-fp', ip: '127.0.0.1', userAgent: 'jest' };
|
||||
next();
|
||||
},
|
||||
getSecurityStatus: (_req, res) => res.json({ ok: true }),
|
||||
}));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const secureImageService = require('../../src/services/secureImageService');
|
||||
|
||||
describe('secure-image view route token binding (GHSA-g94x)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let galleryA; let galleryB;
|
||||
let photoA; let photoB;
|
||||
|
||||
const mkEvent = async (slug, requirePassword) => {
|
||||
const r = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
require_password: requirePassword ? 1 : 0,
|
||||
share_link: `/gallery/${slug}/share`,
|
||||
share_token: `${slug}-share`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
const mkPhoto = async (eventId, slug, filename) => {
|
||||
const dir = path.join(process.env.STORAGE_PATH, 'events/active', slug);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, filename), Buffer.from('img'));
|
||||
const r = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename,
|
||||
path: `${slug}/${filename}`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
// Mint a token exactly as the mint route does — bound to (photoId, gallery
|
||||
// sessionId, fingerprint) — bypassing the anti-bot HTTP path.
|
||||
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
|
||||
photoId,
|
||||
`gallery_public_${eventId}_${Date.now()}`,
|
||||
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
|
||||
);
|
||||
|
||||
const view = (slug, photoId, token) => request(app)
|
||||
.get(`/api/secure-images/${slug}/secure/${photoId}/${token}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
galleryA = await mkEvent('secimg-public-a', false); // public — token source
|
||||
galleryB = await mkEvent('secimg-private-b', true); // password-protected — victim
|
||||
photoA = await mkPhoto(galleryA, 'secimg-public-a', 'a.jpg');
|
||||
photoB = await mkPhoto(galleryB, 'secimg-private-b', 'b.jpg');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/secure-images', require('../../src/routes/secureImages'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('rejects a gallery-A token used against gallery B (cross-photo)', async () => {
|
||||
const token = mint(photoA, galleryA);
|
||||
const res = await view('secimg-private-b', photoB, token);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/not valid for this photo/i);
|
||||
});
|
||||
|
||||
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
|
||||
const token = mint(photoA, galleryA);
|
||||
// URL photoId matches the token, so the photo check passes — the gallery
|
||||
// check (sessionId gallery A != URL gallery B) must catch it.
|
||||
const res = await view('secimg-private-b', photoA, token);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/not valid for this gallery/i);
|
||||
});
|
||||
|
||||
it('lets a token read its own gallery + photo (binding passes)', async () => {
|
||||
const token = mint(photoA, galleryA);
|
||||
const res = await view('secimg-public-a', photoA, token);
|
||||
// Binding passes; serving may 200/404/500 depending on the pipeline, but
|
||||
// it must NOT be rejected as a token mismatch.
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,7 @@ describe('admin Live Slideshow endpoints', () => {
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 120000);
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
|
||||
@@ -67,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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user