Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9874c26815 |
-114
@@ -1,114 +0,0 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
# Build Backend Docker Image
|
||||
- name: build-backend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_BRANCH}-latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG:-dev}
|
||||
|
||||
# Build Frontend Docker Image
|
||||
- name: build-frontend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_BRANCH}-latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG:-dev}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
- develop
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: release
|
||||
|
||||
steps:
|
||||
# Build Backend Release
|
||||
- name: build-backend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
|
||||
# Build Frontend Release
|
||||
- name: build-frontend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
|
||||
# -------- NEW: Publish Docker images to GitHub Container Registry --------
|
||||
- name: push-backend-ghcr
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: ghcr.io/the-luap/picpeak-backend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: ghcr.io
|
||||
username:
|
||||
from_secret: GITHUB_USERNAME
|
||||
password:
|
||||
from_secret: GITHUB_TOKEN
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG}
|
||||
|
||||
- name: push-frontend-ghcr
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: ghcr.io/the-luap/picpeak-frontend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: ghcr.io
|
||||
username:
|
||||
from_secret: GITHUB_USERNAME
|
||||
password:
|
||||
from_secret: GITHUB_TOKEN
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
+176
-10
@@ -4,21 +4,65 @@
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# JWT Secret (generate with: openssl rand -base64 64)
|
||||
JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
# JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run
|
||||
# (Docker: the secrets-init service writes it to a private volume and reuses it
|
||||
# across restarts). Set it explicitly only to pin your own value.
|
||||
# Generate one with: openssl rand -base64 64
|
||||
#JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: follows NODE_ENV (production=true, dev=false)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP
|
||||
#
|
||||
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
|
||||
# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
|
||||
# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads
|
||||
# req.secure from Express, which respects the X-Forwarded-Proto header
|
||||
# when the proxy is in the trust list.
|
||||
#
|
||||
# Requirements for auto mode:
|
||||
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
|
||||
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
|
||||
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
|
||||
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
|
||||
# 192.168.x, link-local). Proxies outside those ranges need custom
|
||||
# trust proxy configuration.
|
||||
# COOKIE_SECURE=auto
|
||||
|
||||
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
|
||||
# COOKIE_SAMESITE=Lax
|
||||
|
||||
# Cookie Domain — set this if serving auth cookies across subdomains.
|
||||
# Leave unset for same-origin setups.
|
||||
# COOKIE_DOMAIN=.example.com
|
||||
|
||||
# Database Configuration (PostgreSQL)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=your_secure_postgres_password_here
|
||||
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
|
||||
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
|
||||
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
|
||||
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
|
||||
#DB_PASSWORD=your_secure_postgres_password_here
|
||||
DB_NAME=picpeak_prod
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_PASSWORD=your_secure_redis_password_here
|
||||
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
|
||||
# IMPORTANT: Same warning applies - avoid $ or escape as $$
|
||||
#REDIS_PASSWORD=your_secure_redis_password_here
|
||||
|
||||
# Admin Account (initial setup)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
# 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
|
||||
# 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
|
||||
#ADMIN_EMAIL=admin@yourdomain.com
|
||||
#ADMIN_PASSWORD=your_secure_admin_password_here
|
||||
|
||||
# Email Configuration
|
||||
# For Gmail: use app-specific password
|
||||
@@ -31,9 +75,30 @@ SMTP_PASS=your-app-specific-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Application URLs
|
||||
# Use full origin with scheme, no trailing slash.
|
||||
# Admin UI is served by the frontend at /admin.
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com:3001
|
||||
VITE_API_URL=https://yourdomain.com:3001/api
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
|
||||
# Static HTML title + description used for social link previews when the
|
||||
# fetcher doesn't trigger the per-event OG endpoint — most notably the
|
||||
# WhatsApp Business API and various 3rd-party preview-service caches
|
||||
# (#521). Set these to your brand so link previews aren't generic.
|
||||
# Substituted into index.html at frontend-container start, so changes
|
||||
# take effect on the next `docker compose up -d frontend` — no rebuild
|
||||
# required.
|
||||
BRAND_TITLE=PicPeak
|
||||
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
|
||||
|
||||
# API URL for email assets (logos, images in notification emails)
|
||||
# This must be the publicly accessible URL where email recipients can load images.
|
||||
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
|
||||
API_URL=https://yourdomain.com/api
|
||||
|
||||
# Frontend API base
|
||||
# For pre-built images and production behind a reverse proxy, keep '/api'.
|
||||
# If you rebuild the frontend yourself, you may set a full URL at build time.
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Port Configuration (optional)
|
||||
# BACKEND_PORT=3001
|
||||
@@ -41,10 +106,111 @@ VITE_API_URL=https://yourdomain.com:3001/api
|
||||
# DB_PORT=5432
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# Release Channel
|
||||
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
|
||||
# 'stable' uses the :stable tag (same as :latest on main)
|
||||
# 'beta' uses the :beta tag for pre-release versions
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# Update Check Configuration
|
||||
# Set to 'false' to disable update notifications in admin UI
|
||||
UPDATE_CHECK_ENABLED=true
|
||||
|
||||
# Timezone
|
||||
TZ=UTC
|
||||
|
||||
# Analytics (Optional - Umami)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
VITE_UMAMI_SHARE_URL=
|
||||
VITE_UMAMI_SHARE_URL=
|
||||
|
||||
# Storage variables (host paths)
|
||||
# These control where data is stored on the host. Defaults are local folders.
|
||||
APP_STORAGE=./storage
|
||||
APP_DATA=./data
|
||||
LOGS=./logs
|
||||
|
||||
# ─── Storage Backend ────────────────────────────────────────────────────────
|
||||
# PicPeak can store photos, thumbnails and archive zips on the local filesystem
|
||||
# (default) or on any S3-compatible object store (AWS S3, MinIO, Cloudflare R2,
|
||||
# Backblaze B2, Wasabi, DigitalOcean Spaces, …).
|
||||
#
|
||||
# STORAGE_BACKEND=local (default)
|
||||
# Uses STORAGE_PATH on the local filesystem. Backwards compatible — every
|
||||
# existing deployment keeps working unchanged.
|
||||
#
|
||||
# STORAGE_BACKEND=s3
|
||||
# Reads STORAGE_S3_* below. Auto-import via the filesystem watcher is
|
||||
# disabled in this mode (S3 has no inotify) — every photo must enter via the
|
||||
# admin upload UI/API. Run `node backend/scripts/migrate-storage.js` to copy
|
||||
# existing local content to S3 before flipping the env.
|
||||
#
|
||||
# STORAGE_BACKEND=local
|
||||
#
|
||||
# STORAGE_S3_BUCKET=picpeak
|
||||
# STORAGE_S3_REGION=us-east-1
|
||||
# STORAGE_S3_ACCESS_KEY=AKIAxxxxxxxxxxxxxxxx
|
||||
# STORAGE_S3_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# Custom endpoint — set this for MinIO / R2 / B2 / Spaces. Leave unset for AWS.
|
||||
# STORAGE_S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com
|
||||
# Optional namespace prefix inside the bucket — useful for multi-deployment buckets.
|
||||
# STORAGE_S3_PREFIX=picpeak
|
||||
# STORAGE_S3_FORCE_PATH_STYLE=false # MinIO needs true; auto-on when endpoint is set
|
||||
# STORAGE_S3_SSL=true
|
||||
#
|
||||
# Minimum IAM policy (AWS S3) for the bucket above:
|
||||
# {
|
||||
# "Version": "2012-10-17",
|
||||
# "Statement": [{
|
||||
# "Effect": "Allow",
|
||||
# "Action": [
|
||||
# "s3:GetObject", "s3:PutObject", "s3:DeleteObject",
|
||||
# "s3:ListBucket", "s3:GetBucketLocation"
|
||||
# ],
|
||||
# "Resource": [
|
||||
# "arn:aws:s3:::picpeak",
|
||||
# "arn:aws:s3:::picpeak/*"
|
||||
# ]
|
||||
# }]
|
||||
# }
|
||||
#
|
||||
# EXTERNAL_MEDIA_ROOT (above) always lives on the local filesystem regardless
|
||||
# of STORAGE_BACKEND — reference-mode galleries are not migrated to S3 in v1.
|
||||
|
||||
# ─── Outbound Webhooks (#327) ────────────────────────────────────────────────
|
||||
# PicPeak POSTs event/photo lifecycle notifications to URLs you configure
|
||||
# under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a
|
||||
# per-webhook secret in the X-PicPeak-Signature header.
|
||||
#
|
||||
# WEBHOOK_ALLOW_PRIVATE_URLS (default: false)
|
||||
# Block URLs resolving to private IPs / loopback / .local etc. as an
|
||||
# SSRF mitigation. Set to "true" ONLY in dev when your receiver is on
|
||||
# the same docker network or localhost. Production deployments must
|
||||
# leave this OFF.
|
||||
# WEBHOOK_ALLOW_PRIVATE_URLS=false
|
||||
#
|
||||
# WEBHOOK_DELIVERY_INTERVAL_MS (default: 5000)
|
||||
# How often the worker polls webhook_deliveries for pending rows.
|
||||
# WEBHOOK_DELIVERY_INTERVAL_MS=5000
|
||||
#
|
||||
# WEBHOOK_DELIVERY_CONCURRENCY (default: 5)
|
||||
# Maximum in-flight deliveries per worker tick. One slow consumer can
|
||||
# monopolize all 5 slots — bump this if your receivers are slow OR ship
|
||||
# a separate webhook-only deployment.
|
||||
# WEBHOOK_DELIVERY_CONCURRENCY=5
|
||||
#
|
||||
# WEBHOOK_HTTP_TIMEOUT_MS (default: 10000)
|
||||
# Per-request timeout. Beyond this, the delivery is recorded as a
|
||||
# network error and retried.
|
||||
# WEBHOOK_HTTP_TIMEOUT_MS=10000
|
||||
#
|
||||
# WEBHOOK_MAX_ATTEMPTS (default: 5)
|
||||
# Total attempts before a delivery is marked failed. Backoff between
|
||||
# attempts is exponential: 1m, 5m, 30m, 2h, 12h.
|
||||
# WEBHOOK_MAX_ATTEMPTS=5
|
||||
|
||||
# Note on FRONTEND_API_URL (documentation only):
|
||||
# When using pre-built frontend images, runtime env vars cannot override the built JS.
|
||||
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
||||
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
|
||||
# should you change VITE_API_URL at build time.
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Full history for proper mirroring
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config --global user.name "the-luap"
|
||||
git config --global user.email "paul-nothaft@hotmail.de"
|
||||
|
||||
- name: Remove sensitive files and directories
|
||||
run: |
|
||||
echo "Current files before cleanup:"
|
||||
ls -la | head -10 || true
|
||||
echo "..."
|
||||
|
||||
# Remove sensitive files/directories if they exist
|
||||
echo "Removing sensitive files..."
|
||||
rm -rf .gitea/ || true
|
||||
rm -rf scripts/ || true
|
||||
rm -rf .drone* || true
|
||||
rm -rf photo-sharing-prd.md || true
|
||||
rm -rf CLAUDE.md || true
|
||||
rm -rf storage/ || true
|
||||
|
||||
|
||||
|
||||
echo "Sensitive files removal completed"
|
||||
|
||||
# Add and commit the cleanup if there are changes
|
||||
git add -A
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: remove sensitive files for GitHub mirror"
|
||||
echo "✅ Committed cleanup of sensitive files"
|
||||
else
|
||||
echo "✅ No sensitive files to remove"
|
||||
fi
|
||||
|
||||
echo "Final file structure (top level):"
|
||||
ls -la | head -10 || true
|
||||
|
||||
- name: Check GitHub token
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
if [ -z "$GITHUBTOKEN" ]; then
|
||||
echo "ERROR: GITHUBTOKEN secret is not set!"
|
||||
exit 1
|
||||
else
|
||||
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||
fi
|
||||
|
||||
- name: Push to GitHub
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
# Remove existing github remote if it exists
|
||||
git remote remove github || true
|
||||
|
||||
# Add GitHub remote
|
||||
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
||||
|
||||
# Verify remote was added
|
||||
echo "GitHub remote added:"
|
||||
git remote -v
|
||||
|
||||
# Push to GitHub main branch
|
||||
echo "Pushing to GitHub..."
|
||||
git push github main --force
|
||||
echo "✅ Push to GitHub completed!"
|
||||
|
||||
- name: Workflow completed
|
||||
run: |
|
||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
|
||||
echo "🔒 Sensitive files have been removed from the mirror"
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Test and Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend linting
|
||||
working-directory: ./backend
|
||||
run: npm run lint || true # Continue on lint errors for now
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: ./backend
|
||||
run: npm test || true # Continue on test failures for now
|
||||
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Run frontend linting
|
||||
working-directory: ./frontend
|
||||
run: npm run lint || true # Continue on lint errors for now
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ./frontend
|
||||
run: npm run build
|
||||
@@ -1,267 +0,0 @@
|
||||
name: Version and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitea/**'
|
||||
- '.drone.yml'
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.version.outputs.new_version }}
|
||||
version_changed: ${{ steps.version.outputs.version_changed }}
|
||||
component_changed: ${{ steps.version.outputs.component_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name 'Gitea Actions Bot'
|
||||
git config --global user.email 'actions@gitea.local'
|
||||
|
||||
- name: Detect changes and bump version
|
||||
id: version
|
||||
run: |
|
||||
set -e # Exit on error
|
||||
|
||||
echo "=== Debug Info ==="
|
||||
echo "GitHub event before: ${{ github.event.before }}"
|
||||
echo "GitHub SHA: ${{ github.sha }}"
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Git log (last 5): $(git log --oneline -5)"
|
||||
|
||||
# Get the commit range for changed files
|
||||
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
|
||||
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
# First commit or no previous commit, check against HEAD~1 if it exists
|
||||
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||
COMMIT_RANGE="HEAD~1..HEAD"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
echo "First commit detected, checking all files"
|
||||
CHANGED_FILES=$(git ls-files)
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
|
||||
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
|
||||
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
|
||||
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
|
||||
|
||||
echo "Backend files changed: $BACKEND_CHANGED"
|
||||
echo "Frontend files changed: $FRONTEND_CHANGED"
|
||||
echo "Root files changed: $ROOT_CHANGED"
|
||||
|
||||
# Get current versions
|
||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
|
||||
echo "Current backend version: $BACKEND_VERSION"
|
||||
echo "Current frontend version: $FRONTEND_VERSION"
|
||||
|
||||
# Determine what to update based on changes
|
||||
BACKEND_UPDATE=false
|
||||
FRONTEND_UPDATE=false
|
||||
COMPONENT_CHANGED="none"
|
||||
|
||||
if [ "$ROOT_CHANGED" -gt 0 ]; then
|
||||
# Root changes affect both components
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Root changes detected - updating both components"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Both components changed
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
# Use the higher version as source
|
||||
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
else
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
fi
|
||||
echo "Both backend and frontend changed - updating both"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
|
||||
# Only backend changed
|
||||
BACKEND_UPDATE=true
|
||||
COMPONENT_CHANGED="backend"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Only backend changed - updating backend"
|
||||
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Only frontend changed
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="frontend"
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
echo "Only frontend changed - updating frontend"
|
||||
else
|
||||
echo "No relevant changes detected"
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=none" >> $GITHUB_OUTPUT
|
||||
echo "new_version=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Component changed: $COMPONENT_CHANGED"
|
||||
echo "Source version: $SOURCE_VERSION"
|
||||
echo "Backend update: $BACKEND_UPDATE"
|
||||
echo "Frontend update: $FRONTEND_UPDATE"
|
||||
|
||||
# Calculate new version
|
||||
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
|
||||
MAJOR="${version_parts[0]}"
|
||||
MINOR="${version_parts[1]}"
|
||||
PATCH="${version_parts[2]}"
|
||||
|
||||
# Increment patch version
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||
|
||||
# Update versions in package.json files
|
||||
if [ "$BACKEND_UPDATE" = true ]; then
|
||||
echo "Updating backend version to $NEW_VERSION"
|
||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
if [ "$FRONTEND_UPDATE" = true ]; then
|
||||
echo "Updating frontend version to $NEW_VERSION"
|
||||
cd frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Check if there are changes to commit
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit version bump
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
set -e # Exit on any error
|
||||
|
||||
# First, ensure we have the latest changes
|
||||
echo "Fetching latest changes..."
|
||||
git fetch origin main
|
||||
|
||||
# Check if we're behind and need to update
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
|
||||
if [ "$LOCAL" != "$REMOTE" ]; then
|
||||
echo "Local is behind remote, pulling changes..."
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
git add backend/package.json backend/package-lock.json frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
|
||||
fi
|
||||
|
||||
# Pull latest changes before pushing to avoid conflicts
|
||||
echo "Pulling latest changes from origin/main..."
|
||||
if ! git pull --rebase origin main; then
|
||||
echo "Rebase failed, attempting to resolve..."
|
||||
# If rebase fails, abort and try a regular merge
|
||||
git rebase --abort || true
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
|
||||
# Push the changes with retry logic
|
||||
echo "Pushing version bump..."
|
||||
PUSH_SUCCESS=false
|
||||
|
||||
for i in 1 2 3; do
|
||||
echo "Push attempt $i of 3..."
|
||||
|
||||
# Try to push
|
||||
if git push origin main 2>&1; then
|
||||
echo "Successfully pushed version bump on attempt $i"
|
||||
PUSH_SUCCESS=true
|
||||
break
|
||||
else
|
||||
echo "Push failed on attempt $i"
|
||||
|
||||
if [ $i -lt 3 ]; then
|
||||
echo "Waiting 5 seconds before retry..."
|
||||
sleep 5
|
||||
|
||||
echo "Pulling latest changes..."
|
||||
git fetch origin main
|
||||
|
||||
# Try rebase first, fall back to merge
|
||||
if ! git rebase origin/main; then
|
||||
echo "Rebase failed, trying merge..."
|
||||
git rebase --abort 2>/dev/null || true
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$PUSH_SUCCESS" = "false" ]; then
|
||||
echo "ERROR: Failed to push after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Git tag
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
|
||||
fi
|
||||
|
||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
|
||||
git push origin "v${{ steps.version.outputs.new_version }}"
|
||||
|
||||
trigger-drone:
|
||||
needs: version-bump
|
||||
if: needs.version-bump.outputs.version_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Drone Build
|
||||
run: |
|
||||
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
||||
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
||||
echo "Drone will automatically trigger on the new tag"
|
||||
# Drone CI will automatically trigger on the tag push event
|
||||
@@ -0,0 +1,4 @@
|
||||
# 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
|
||||
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📚 Documentation
|
||||
url: https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
|
||||
about: Please read the documentation before opening an issue
|
||||
- name: 💬 Discussions
|
||||
url: https://github.com/the-luap/picpeak/discussions
|
||||
url: https://github.com/PicPeak/picpeak/discussions
|
||||
about: Ask questions and discuss with the community
|
||||
- name: 🔒 Security Issues
|
||||
url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
@@ -9,7 +9,7 @@ assignees: ''
|
||||
|
||||
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
|
||||
|
||||
Instead, please email security@example.com with the details.
|
||||
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
|
||||
|
||||
For minor security improvements or questions, you can use this template:
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# Docker Build and Push Workflow
|
||||
|
||||
This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io).
|
||||
|
||||
## Features
|
||||
|
||||
- 🔧 **Automatic builds** on push to main/develop branches, PRs, and releases
|
||||
- 🏗️ **Multi-architecture support** (linux/amd64 and linux/arm64)
|
||||
- 🏷️ **Smart tagging** based on branches, versions, and commits
|
||||
- 🔒 **Security scanning** with Trivy vulnerability scanner
|
||||
- 💾 **Build caching** for faster subsequent builds
|
||||
- 📊 **Build summaries** in GitHub Actions UI
|
||||
|
||||
## Authentication
|
||||
|
||||
The workflow uses the built-in `GITHUB_TOKEN` for authentication with GitHub Container Registry. No additional setup or personal access tokens are required.
|
||||
|
||||
### Required Permissions
|
||||
|
||||
The workflow automatically sets the necessary permissions:
|
||||
- `contents: read` - To checkout the repository
|
||||
- `packages: write` - To push images to ghcr.io
|
||||
- `security-events: write` - To upload security scan results
|
||||
|
||||
## Image Tags
|
||||
|
||||
Images are automatically tagged based on the trigger event:
|
||||
|
||||
| Event | Tags Generated |
|
||||
|-------|---------------|
|
||||
| Push to main | `latest`, `main`, `main-<short-sha>` |
|
||||
| Push to develop | `develop`, `develop-<short-sha>` |
|
||||
| Pull Request | `pr-<number>` |
|
||||
| Release (v1.2.3) | `1.2.3`, `1.2`, `1`, `latest` |
|
||||
| Manual trigger | Based on branch + optional push |
|
||||
|
||||
## Usage
|
||||
|
||||
### Pull Images
|
||||
|
||||
Once published, images can be pulled using:
|
||||
|
||||
```bash
|
||||
# Pull backend image
|
||||
docker pull ghcr.io/picpeak/picpeak/backend:latest
|
||||
|
||||
# Pull frontend image
|
||||
docker pull ghcr.io/picpeak/picpeak/frontend:latest
|
||||
|
||||
# Pull specific version
|
||||
docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0
|
||||
|
||||
# Pull for specific architecture
|
||||
docker pull --platform linux/arm64 ghcr.io/picpeak/picpeak/backend:latest
|
||||
```
|
||||
|
||||
### Using in Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: ghcr.io/picpeak/picpeak/backend:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
ports:
|
||||
- "3001:3000"
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/picpeak/picpeak/frontend:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
```
|
||||
|
||||
### Using in Kubernetes
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: picpeak-backend
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: ghcr.io/picpeak/picpeak/backend:latest
|
||||
imagePullPolicy: Always
|
||||
```
|
||||
|
||||
## Manual Workflow Trigger
|
||||
|
||||
You can manually trigger the workflow from the Actions tab:
|
||||
|
||||
1. Go to Actions → "Build and Push Docker Images"
|
||||
2. Click "Run workflow"
|
||||
3. Select branch and whether to push images
|
||||
4. Click "Run workflow"
|
||||
|
||||
## Security Scanning
|
||||
|
||||
The workflow includes Trivy vulnerability scanning that:
|
||||
- Scans for CRITICAL and HIGH severity vulnerabilities
|
||||
- Uploads results to GitHub Security tab
|
||||
- Available under Security → Code scanning alerts
|
||||
|
||||
## Build Optimization
|
||||
|
||||
The workflow uses several optimization techniques:
|
||||
|
||||
1. **GitHub Actions Cache**: Speeds up builds by caching layers
|
||||
2. **Multi-stage builds**: Reduces final image size
|
||||
3. **Parallel builds**: Backend and frontend build simultaneously
|
||||
4. **Smart rebuilds**: Only rebuilds changed components
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permission Denied Errors
|
||||
|
||||
If you encounter permission errors when pushing images:
|
||||
|
||||
1. **First-time setup**: The first push creates a private package. You may need to:
|
||||
- Go to your package settings at `https://github.com/users/YOUR_USERNAME/packages`
|
||||
- Link the package to your repository
|
||||
- Set package visibility (public/private)
|
||||
|
||||
2. **Organization repositories**: Ensure the organization allows GitHub Actions to create packages
|
||||
|
||||
### Build Failures
|
||||
|
||||
Check the workflow logs in the Actions tab for detailed error messages. Common issues:
|
||||
- Missing dependencies in package.json
|
||||
- Dockerfile syntax errors
|
||||
- Network issues during package installation
|
||||
|
||||
### Image Not Found
|
||||
|
||||
If images aren't visible after successful push:
|
||||
- Check package visibility settings
|
||||
- Ensure you're authenticated to pull private images:
|
||||
```bash
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
|
||||
```
|
||||
|
||||
## Package Management
|
||||
|
||||
### View Packages
|
||||
|
||||
Your Docker images are available at:
|
||||
- Backend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Fbackend`
|
||||
- Frontend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Ffrontend`
|
||||
|
||||
### Delete Old Versions
|
||||
|
||||
To save storage, you can delete old versions:
|
||||
1. Go to package settings
|
||||
2. Click on "Manage versions"
|
||||
3. Select versions to delete
|
||||
4. Click "Delete selected versions"
|
||||
|
||||
### Set Retention Policy
|
||||
|
||||
Configure automatic cleanup in package settings:
|
||||
1. Go to package settings
|
||||
2. Click on "Manage Actions access"
|
||||
3. Set retention days for untagged versions
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use semantic versioning** for releases (e.g., v1.2.3)
|
||||
2. **Test images locally** before pushing to production
|
||||
3. **Monitor security alerts** from Trivy scans
|
||||
4. **Clean up old images** regularly to save storage
|
||||
5. **Use specific tags** in production (avoid `latest`)
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Registry
|
||||
|
||||
To use a different registry, update the workflow:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
REGISTRY: docker.io # or your custom registry
|
||||
BACKEND_IMAGE_NAME: yourusername/picpeak-backend
|
||||
```
|
||||
|
||||
### Additional Platforms
|
||||
|
||||
To build for more platforms:
|
||||
|
||||
```yaml
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
```
|
||||
|
||||
### Custom Build Arguments
|
||||
|
||||
Add build arguments in the workflow:
|
||||
|
||||
```yaml
|
||||
build-args: |
|
||||
NODE_VERSION=20
|
||||
API_URL=${{ secrets.API_URL }}
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [GitHub Container Registry Docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)
|
||||
- [Docker Build Action](https://github.com/docker/build-push-action)
|
||||
- [Trivy Security Scanner](https://github.com/aquasecurity/trivy)
|
||||
- [Multi-platform Builds](https://docs.docker.com/build/building/multi-platform/)
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Bypass size gate
|
||||
|
||||
# Caps how large a PR a "review-bypass" collaborator (e.g. @Luca-Timo) can
|
||||
# self-merge without a maintainer review. The branch-protection bypass list
|
||||
# alone is binary — once a user is on it they can merge anything without
|
||||
# review. This workflow reports a REQUIRED status check that fails when a
|
||||
# bypass user's PR exceeds the configured size threshold, which blocks the
|
||||
# merge even with bypass enabled. Other contributors are unaffected (the
|
||||
# check reports success for them so the required-check gate doesn't trip).
|
||||
#
|
||||
# To tune: edit LINE_LIMIT or BYPASS_USERS below.
|
||||
#
|
||||
# Trigger note: uses `pull_request_target` so the workflow has the elevated
|
||||
# permissions of the base repo's GITHUB_TOKEN (read PR metadata, write
|
||||
# checks). The script never executes code FROM the PR — it only reads
|
||||
# metadata via the API — so this is safe against fork-PR attacks.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
size-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Compute PR size and report check status
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// Tune these two constants if the policy shifts.
|
||||
const LINE_LIMIT = 300;
|
||||
const BYPASS_USERS = ['Luca-Timo'];
|
||||
|
||||
const pr = context.payload.pull_request;
|
||||
const author = pr.user.login;
|
||||
const linesChanged = pr.additions + pr.deletions;
|
||||
const filesChanged = pr.changed_files;
|
||||
|
||||
let conclusion, title, summary;
|
||||
|
||||
if (!BYPASS_USERS.includes(author)) {
|
||||
// Not a bypass user — this gate doesn't apply to them. They
|
||||
// go through normal review. Report success so the required
|
||||
// check doesn't block their merge.
|
||||
conclusion = 'success';
|
||||
title = 'Not applicable';
|
||||
summary = `This gate only restricts review-bypass for: ${BYPASS_USERS.join(', ')}. PRs from other authors (${author} here) go through the normal review path and are unaffected.`;
|
||||
} else if (linesChanged <= LINE_LIMIT) {
|
||||
conclusion = 'success';
|
||||
title = `OK — within bypass limit (${linesChanged} lines)`;
|
||||
summary = `Small PR: ${linesChanged} lines changed across ${filesChanged} file(s). Within the ${LINE_LIMIT}-line self-merge limit for @${author}. Can be merged without a maintainer review.`;
|
||||
} else {
|
||||
conclusion = 'failure';
|
||||
title = `Too large for bypass (${linesChanged} lines)`;
|
||||
summary = `Large PR: ${linesChanged} lines changed across ${filesChanged} file(s). Exceeds the ${LINE_LIMIT}-line self-merge limit for @${author} — needs an approving review from a maintainer before merge. Split into smaller PRs or wait for review.`;
|
||||
}
|
||||
|
||||
await github.rest.checks.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'bypass-size-gate',
|
||||
head_sha: pr.head.sha,
|
||||
status: 'completed',
|
||||
conclusion,
|
||||
output: { title, summary }
|
||||
});
|
||||
@@ -0,0 +1,587 @@
|
||||
name: Build and Push Docker Images
|
||||
|
||||
# This workflow is triggered by:
|
||||
# - Push to main/stable branches (main → ':main' rolling tag for active-dev
|
||||
# builds; stable → ':stable' + ':latest' for the curated channel)
|
||||
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
|
||||
# - GitHub Releases (created by Release Please)
|
||||
# - Pull requests (build verification only, no push by default)
|
||||
# - Manual workflow dispatch
|
||||
#
|
||||
# Multi-arch strategy:
|
||||
# Each image (backend, frontend) is built once per architecture on a
|
||||
# native runner — linux/amd64 on ubuntu-latest, linux/arm64 on
|
||||
# ubuntu-24.04-arm. Each leg pushes by digest to GHCR. A follow-up
|
||||
# merge job combines the digests into a multi-arch manifest and applies
|
||||
# the human-readable tags. This is the pattern documented at
|
||||
# https://docs.docker.com/build/ci/github-actions/multi-platform/
|
||||
#
|
||||
# Native runners are used instead of QEMU because npm install under
|
||||
# QEMU was previously too slow/unreliable for regular branch builds.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, stable ]
|
||||
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
|
||||
pull_request:
|
||||
branches: [ main, stable ]
|
||||
release:
|
||||
types: [ published ] # Triggered when Release Please creates a release
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
description: 'Push images to registry'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
|
||||
# Once release-please authors releases with a PAT (#719), a new version fires
|
||||
# BOTH the tag-push and the release-published triggers (GITHUB_TOKEN used to
|
||||
# suppress them). They build the same immutable version, so collapse them into a
|
||||
# single run by grouping on the ref. Branch and PR builds use different refs and
|
||||
# still run independently; a superseding push cancels an in-flight run for the
|
||||
# same ref (only the newest build per ref is kept).
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
|
||||
# "Compute image names" step. GHCR requires all-lowercase repository names,
|
||||
# but ${{ github.repository }} preserves the original case (e.g. "Luca-Timo/...").
|
||||
# Computing them with bash parameter expansion (${VAR,,}) keeps the workflow
|
||||
# working on forks regardless of the owner's name casing.
|
||||
|
||||
# Default GITHUB_TOKEN to read-only at the workflow level. Each job that
|
||||
# needs to publish to GHCR sets `packages: write` explicitly. This keeps
|
||||
# the rest of the workflow (and any future steps) from inheriting unneeded
|
||||
# privileges (CKV2_GHA_1).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# -----------------------------------------------------------------------------
|
||||
# Backend: per-arch build, then merge into a multi-arch manifest
|
||||
# -----------------------------------------------------------------------------
|
||||
build-backend:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# Trivy uploads its SARIF to the Security tab from this job — see
|
||||
# the "Run Trivy" step below. Scanning per-arch by digest (#476)
|
||||
# is reliable; scanning the multi-arch index by tag from the
|
||||
# merge-* job was not.
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine if pushing
|
||||
id: push-decision
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Extract metadata for Backend (labels only)
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Backend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform backend service
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
|
||||
- name: Build Backend image (push by digest)
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }}
|
||||
# ignore-error: a flaky GitHub Actions cache write ("error writing
|
||||
# layer blob: not_found") must not fail an otherwise-successful build
|
||||
# that already pushed the image.
|
||||
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }},ignore-error=true
|
||||
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }}
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
- name: Export digest
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-backend-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Per-arch vulnerability scan (#476). Scanning the multi-arch
|
||||
# manifest from the merge-* job by tag is unreliable — Trivy's
|
||||
# remote resolver crashes intermittently with "no child with
|
||||
# platform linux/amd64 in index". The fix is to scan each leg
|
||||
# by its single-platform digest right here, where it just landed
|
||||
# in GHCR. Tag pinned (was @master) so the action + bundled
|
||||
# Trivy binary don't float between runs.
|
||||
#
|
||||
# exit-code is left unset (=0) for now: Trivy reports findings
|
||||
# to the Security tab but doesn't fail the build. Flipping that
|
||||
# to '1' to actually gate CI is a deliberate follow-up — needs an
|
||||
# audit pass first so the next beta build doesn't surprise red.
|
||||
- name: Run Trivy vulnerability scanner (per-arch, by digest)
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
env:
|
||||
# docker/build-push-action wraps every push in an OCI index
|
||||
# (carries the SLSA provenance attestation alongside the
|
||||
# actual image). Trivy's remote backend defaults to
|
||||
# linux/amd64 regardless of host arch when resolving an
|
||||
# index, which makes the arm64 leg crash with "no child
|
||||
# with platform linux/amd64". Telling Trivy which child to
|
||||
# scan keeps the provenance attestation intact and fixes
|
||||
# the resolver crash. Pin to matrix.platform so each leg
|
||||
# scans its own arch.
|
||||
TRIVY_PLATFORM: ${{ matrix.platform }}
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
# Distinct category per arch so the Security tab surfaces
|
||||
# per-platform findings independently — an amd64-only CVE in
|
||||
# a base layer doesn't get masked by the arm64 scan.
|
||||
category: 'backend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
|
||||
|
||||
merge-backend:
|
||||
needs: build-backend
|
||||
runs-on: ubuntu-latest
|
||||
# No security-events permission here — vulnerability scanning moved
|
||||
# to per-arch build-backend jobs (#476). This job's only job is to
|
||||
# combine the per-arch digests into a multi-arch manifest.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# Only run when at least one digest was pushed (i.e. not on PRs without push intent).
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
|
||||
steps:
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-backend-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
|
||||
# Active-dev branch (`main`, renamed from `beta` per #669) produces
|
||||
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Extract metadata for Backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Backend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform backend service
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=sha,format=short
|
||||
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
|
||||
# stable release tags). The default branch is now `main` (active dev),
|
||||
# so `is_default_branch` no longer maps to "stable" — be explicit.
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
|
||||
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
|
||||
# tag remains frozen at its last build — operators should update.
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
|
||||
|
||||
- name: Inspect manifest
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Frontend: per-arch build, then merge into a multi-arch manifest
|
||||
# -----------------------------------------------------------------------------
|
||||
build-frontend:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# See build-backend for the rationale (#476). Same pattern: per-arch
|
||||
# vulnerability scan by digest, SARIF uploaded to the Security tab.
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine if pushing
|
||||
id: push-decision
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Extract metadata for Frontend (labels only)
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Frontend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
|
||||
- name: Build Frontend image (push by digest)
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }}
|
||||
# ignore-error: a flaky GitHub Actions cache write ("error writing
|
||||
# layer blob: not_found") must not fail an otherwise-successful build
|
||||
# that already pushed the image.
|
||||
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }},ignore-error=true
|
||||
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }}
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
- name: Export digest
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-frontend-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Per-arch vulnerability scan (#476). See build-backend for the
|
||||
# full rationale; identical pattern here, only the image-ref +
|
||||
# SARIF filename + category change.
|
||||
- name: Run Trivy vulnerability scanner (per-arch, by digest)
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
env:
|
||||
# See build-backend for the rationale — pin Trivy's platform
|
||||
# to the matrix arch so its remote-index resolver picks the
|
||||
# right child instead of defaulting to linux/amd64 and
|
||||
# crashing on the arm64 leg.
|
||||
TRIVY_PLATFORM: ${{ matrix.platform }}
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
category: 'frontend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
|
||||
|
||||
merge-frontend:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
# See merge-backend — vulnerability scanning moved to the per-arch
|
||||
# build-frontend matrix (#476). This job only publishes the manifest.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
|
||||
steps:
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-frontend-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
|
||||
# Active-dev branch (`main`, renamed from `beta` per #669) produces
|
||||
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Extract metadata for Frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Frontend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=sha,format=short
|
||||
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
|
||||
# stable release tags). The default branch is now `main` (active dev),
|
||||
# so `is_default_branch` no longer maps to "stable" — be explicit.
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
|
||||
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
|
||||
# tag remains frozen at its last build — operators should update.
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
|
||||
|
||||
- name: Inspect manifest
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
summary:
|
||||
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
|
||||
echo "✅ **Backend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Backend build (per-arch)**: ${{ needs.build-backend.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.merge-backend.result }}" == "success" ]]; then
|
||||
echo "✅ **Backend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "${{ needs.merge-backend.result }}" == "skipped" ]]; then
|
||||
echo "ℹ️ **Backend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Backend manifest merge**: ${{ needs.merge-backend.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
|
||||
echo "✅ **Frontend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Frontend build (per-arch)**: ${{ needs.build-frontend.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.merge-frontend.result }}" == "success" ]]; then
|
||||
echo "✅ **Frontend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "${{ needs.merge-frontend.result }}" == "skipped" ]]; then
|
||||
echo "ℹ️ **Frontend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Published manifests include both \`linux/amd64\` and \`linux/arm64\` (built natively, no QEMU)." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- PR number (for pull requests, when push is enabled)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Short SHA" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`stable\` (for main branch and stable releases)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`beta\` (for beta branch and pre-releases)" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,221 @@
|
||||
name: Fresh-install smoke
|
||||
|
||||
# Verifies that a clean Postgres install boots cleanly under the same
|
||||
# conditions a new user hits on their first `docker compose up -d`. The
|
||||
# specific scenarios this guards against — see #484 for the original
|
||||
# reproduction:
|
||||
#
|
||||
# 1. Bind-mounted host directories owned by a UID other than 1001
|
||||
# (the container's nodejs user). The entrypoint must self-chown
|
||||
# and drop privileges via su-exec.
|
||||
# 2. Cold-start Postgres with no prior schema (the FK-order bug fixed
|
||||
# in #494, the index/created_at error fixed in #511, and any
|
||||
# future migration-order issue that only surfaces on an empty DB).
|
||||
#
|
||||
# Triggers only on changes that touch the install path so unrelated PRs
|
||||
# don't pay the build cost.
|
||||
|
||||
on:
|
||||
# No `paths:` filter — branch protection on `main` + `stable` lists
|
||||
# `fresh-install` as a REQUIRED check, and a path-filtered trigger
|
||||
# that skipped on unrelated PRs (e.g. frontend-only) would leave the
|
||||
# required check "missing" forever and block the merge. Better to
|
||||
# pay the boot cost on every PR than maintain a per-path allowlist
|
||||
# that drifts as the install surface evolves. (Branches also updated
|
||||
# post-#669 rename: beta → main, old main → stable.)
|
||||
push:
|
||||
branches: [main, stable]
|
||||
pull_request:
|
||||
branches: [main, stable]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
fresh-install:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Build for the runner's arch only — we just need a runnable image.
|
||||
# The full multi-arch build is the docker-build workflow's job.
|
||||
- name: Build backend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
load: true
|
||||
tags: picpeak-backend:smoke
|
||||
cache-from: type=gha,scope=install-smoke
|
||||
# ignore-error: a flaky GHA cache write must not fail the build.
|
||||
cache-to: type=gha,mode=max,scope=install-smoke,ignore-error=true
|
||||
|
||||
- name: Create Docker network
|
||||
run: docker network create picpeak-smoke
|
||||
|
||||
# Mount as UID 1000 (the typical GitHub Actions runner user, and a
|
||||
# common mismatch case on Linux hosts). The entrypoint must chown
|
||||
# this to 1001 itself — that's the regression we're guarding.
|
||||
- name: Prepare host bind-mount dirs owned by UID 1000
|
||||
run: |
|
||||
mkdir -p smoke-mounts/storage smoke-mounts/data smoke-mounts/logs
|
||||
chmod 755 smoke-mounts smoke-mounts/*
|
||||
ls -ld smoke-mounts/*
|
||||
|
||||
- name: Start Postgres
|
||||
run: |
|
||||
docker run -d --name picpeak-smoke-pg --network picpeak-smoke \
|
||||
-e POSTGRES_USER=picpeak \
|
||||
-e POSTGRES_PASSWORD=smokepass \
|
||||
-e POSTGRES_DB=picpeak_prod \
|
||||
--health-cmd="pg_isready -U picpeak -d picpeak_prod" \
|
||||
--health-interval=2s --health-timeout=2s --health-retries=30 \
|
||||
postgres:15-alpine
|
||||
|
||||
- name: Wait for Postgres healthy
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-pg 2>/dev/null || echo starting)
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo "postgres healthy after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "postgres did not become healthy in 60s"
|
||||
docker logs picpeak-smoke-pg
|
||||
exit 1
|
||||
|
||||
- name: Start backend with mismatched-UID bind mounts (fresh install)
|
||||
run: |
|
||||
docker run -d --name picpeak-smoke-bk --network picpeak-smoke \
|
||||
-e NODE_ENV=production \
|
||||
-e JWT_SECRET=smoketestsecretvalueof32characters \
|
||||
-e DB_HOST=picpeak-smoke-pg \
|
||||
-e DB_USER=picpeak \
|
||||
-e DB_PASSWORD=smokepass \
|
||||
-e DB_NAME=picpeak_prod \
|
||||
-e ADMIN_EMAIL=admin@smoke.local \
|
||||
-e ADMIN_PASSWORD=smokeAdminPass12345 \
|
||||
-e STORAGE_PATH=/app/storage \
|
||||
-v "$PWD/smoke-mounts/storage:/app/storage" \
|
||||
-v "$PWD/smoke-mounts/data:/app/data" \
|
||||
-v "$PWD/smoke-mounts/logs:/app/logs" \
|
||||
picpeak-backend:smoke
|
||||
|
||||
- name: Wait for backend healthy
|
||||
run: |
|
||||
for i in $(seq 1 120); do
|
||||
status=$(docker inspect -f '{{.State.Status}}' picpeak-smoke-bk 2>/dev/null || echo missing)
|
||||
health=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-bk 2>/dev/null || echo none)
|
||||
if [ "$status" = "exited" ]; then
|
||||
echo "FAIL: backend exited during cold-start (restart loop scenario)"
|
||||
docker logs picpeak-smoke-bk
|
||||
echo "--- error.log ---"
|
||||
cat smoke-mounts/logs/error.log 2>/dev/null || echo "(no error.log)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$health" = "healthy" ]; then
|
||||
echo "backend healthy after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "FAIL: backend did not become healthy in 120s"
|
||||
docker ps -a
|
||||
docker logs picpeak-smoke-bk
|
||||
exit 1
|
||||
|
||||
- name: Verify chown happened (container view)
|
||||
run: |
|
||||
# All three dirs should now be owned by nodejs (UID 1001).
|
||||
# If the entrypoint's self-chown branch didn't fire, they'd
|
||||
# still be owned by the runner UID and node would have hit
|
||||
# EACCES creating storage subdirs.
|
||||
for d in /app/storage /app/data /app/logs; do
|
||||
owner_uid=$(docker exec picpeak-smoke-bk stat -c '%u' "$d")
|
||||
if [ "$owner_uid" != "1001" ]; then
|
||||
echo "FAIL: $d is owned by UID $owner_uid (expected 1001)"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: $d owned by UID $owner_uid"
|
||||
done
|
||||
|
||||
- name: Verify app is actually serving
|
||||
run: |
|
||||
# /health is what docker's HEALTHCHECK polls, but hit it
|
||||
# directly to confirm the response shape matches what the
|
||||
# frontend + reverse proxy expect.
|
||||
body=$(docker exec picpeak-smoke-bk wget -qO- http://localhost:3000/health)
|
||||
echo "/health => $body"
|
||||
echo "$body" | grep -q '"status":"ok"' || {
|
||||
echo "FAIL: /health did not return status:ok"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Verify node runs as nodejs (not root)
|
||||
run: |
|
||||
# dumb-init runs as root (PID 1), node must be running as
|
||||
# nodejs (UID 1001) — if su-exec drop didn't happen the app
|
||||
# would be running as root which is the security regression
|
||||
# we're guarding against. Alpine ships BusyBox ps, which
|
||||
# doesn't support `-p PID` or pgrep, so list + awk instead.
|
||||
user=$(docker exec picpeak-smoke-bk ps -o user,comm | awk '$2=="node" {print $1; exit}')
|
||||
if [ "$user" != "nodejs" ]; then
|
||||
echo "FAIL: node running as '$user' (expected nodejs)"
|
||||
docker exec picpeak-smoke-bk ps -o pid,user,comm
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: node running as $user"
|
||||
|
||||
- name: Verify no restart loop
|
||||
run: |
|
||||
restart_count=$(docker inspect -f '{{.RestartCount}}' picpeak-smoke-bk)
|
||||
if [ "$restart_count" -gt 0 ]; then
|
||||
echo "FAIL: container restarted $restart_count time(s) — install loop bug returning"
|
||||
docker logs picpeak-smoke-bk
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: 0 restarts"
|
||||
|
||||
# Restart with `--user 5005:5005` (no root, can't chown) against
|
||||
# bind mounts owned by 1000 — entrypoint must fail loud with the
|
||||
# actionable preflight error, not silently restart-loop.
|
||||
- name: Verify preflight fails loud on unwritable mounts
|
||||
run: |
|
||||
docker rm -f picpeak-smoke-bk2 2>/dev/null || true
|
||||
set +e
|
||||
out=$(docker run --rm --user 5005:5005 --network picpeak-smoke \
|
||||
-e NODE_ENV=production -e JWT_SECRET=x \
|
||||
-e DB_HOST=picpeak-smoke-pg -e DB_USER=picpeak \
|
||||
-e DB_PASSWORD=smokepass -e DB_NAME=picpeak_prod \
|
||||
-e STORAGE_PATH=/app/storage \
|
||||
-v "$PWD/smoke-mounts/storage:/app/storage" \
|
||||
-v "$PWD/smoke-mounts/data:/app/data" \
|
||||
-v "$PWD/smoke-mounts/logs:/app/logs" \
|
||||
picpeak-backend:smoke 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
echo "$out"
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "FAIL: preflight should have exited non-zero"
|
||||
exit 1
|
||||
fi
|
||||
echo "$out" | grep -q "is not writable by UID 5005" || {
|
||||
echo "FAIL: preflight error message missing or wrong"
|
||||
exit 1
|
||||
}
|
||||
echo "ok: preflight failed loud with actionable error"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f picpeak-smoke-bk picpeak-smoke-bk2 picpeak-smoke-pg 2>/dev/null || true
|
||||
docker network rm picpeak-smoke 2>/dev/null || true
|
||||
@@ -0,0 +1,36 @@
|
||||
name: PR Title Lint
|
||||
|
||||
# Release Please derives version bumps and the changelog from Conventional
|
||||
# Commit prefixes (feat:, fix:, ...). PRs whose title/commits use other
|
||||
# conventions (e.g. gitmoji) are silently ignored, so their changes ship
|
||||
# without a version bump or a changelog entry. This check fails a PR whose
|
||||
# title is not a valid Conventional Commit so the release stays automated.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
lint-pr-title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Validate PR title is a Conventional Commit
|
||||
uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
perf
|
||||
revert
|
||||
docs
|
||||
style
|
||||
chore
|
||||
refactor
|
||||
test
|
||||
build
|
||||
ci
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Release Please (Beta)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
steps:
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
# A dedicated token (fine-grained PAT) makes the release PR run CI
|
||||
# automatically (no "workflows awaiting approval") and lets it be
|
||||
# merged without a manual review. Falls back to GITHUB_TOKEN so the
|
||||
# workflow still works before the secret is added (#719).
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config-beta.json
|
||||
manifest-file: .release-please-manifest-beta.json
|
||||
target-branch: main
|
||||
|
||||
# Auto-approve + enable auto-merge on the open release PR so betas publish
|
||||
# with no manual clicks. Approval uses GITHUB_TOKEN (github-actions[bot]) —
|
||||
# a different identity than the PR author (RELEASE_PLEASE_TOKEN) — so it is
|
||||
# a valid review (requires the org's "Allow GitHub Actions to approve pull
|
||||
# requests" + the repo's "Allow auto-merge"). Only meaningful when a PAT is
|
||||
# set: without it the PR is bot-authored and can't be self-approved, so we
|
||||
# skip and leave today's manual flow. Best-effort — never blocks the run.
|
||||
- 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 }}
|
||||
# This job has no checkout, so gh can't infer the repo from a git
|
||||
# remote — set it explicitly (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--main --state open --json number --jq '.[0].number // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
# Approve as github-actions[bot] (GITHUB_TOKEN) — a different identity
|
||||
# than the PR author (the PAT) — so it counts as a valid review.
|
||||
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
|
||||
# Enable auto-merge as the PAT so the eventual merge commit is
|
||||
# attributed to a real identity. If enabled via GITHUB_TOKEN the merge
|
||||
# push is suppressed by recursion prevention and the follow-up run that
|
||||
# cuts the tag/release never fires (#719).
|
||||
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 }}
|
||||
run: |
|
||||
echo "## Beta Release Created!" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Best-effort "What's New" highlights on the freshly-created release. Runs in
|
||||
# this same workflow run (not a `release:` trigger) because release-please
|
||||
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
|
||||
whatsnew:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
permissions:
|
||||
contents: write # edit the release body
|
||||
models: read # GitHub Models (free tier)
|
||||
uses: ./.github/workflows/whatsnew-highlights.yml
|
||||
with:
|
||||
tag: ${{ needs.release-please.outputs.tag_name }}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [stable]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}
|
||||
steps:
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
# Dedicated token so the release PR runs CI + can auto-merge without a
|
||||
# manual review. Falls back to GITHUB_TOKEN before the secret is set (#719).
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
|
||||
# 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 }}
|
||||
run: |
|
||||
echo "## Release Created! " >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Best-effort "What's New" highlights on the freshly-created release. Runs in
|
||||
# this same workflow run (not a `release:` trigger) because release-please
|
||||
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
|
||||
whatsnew:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
permissions:
|
||||
contents: write # edit the release body
|
||||
models: read # GitHub Models (free tier)
|
||||
uses: ./.github/workflows/whatsnew-highlights.yml
|
||||
with:
|
||||
tag: ${{ needs.release-please.outputs.tag_name }}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
name: Schema drift (#530)
|
||||
|
||||
# Verifies that `migrate:safe` can recover a DB that's been seeded only
|
||||
# by `initializeDatabase()` — the recovery scenario where the migrations
|
||||
# tracking table is empty but the schema already has the modern bootstrap.
|
||||
#
|
||||
# This is NOT how production reaches its state on normal installs or
|
||||
# upgrades. The scenario only fires when:
|
||||
# - A backup was restored that captured tables but not the migrations
|
||||
# table (manifest divergence),
|
||||
# - Someone manually invoked initializeDatabase() outside the migration
|
||||
# runner (recovery / debugging),
|
||||
# - The DB was moved between systems and the migrations table was not
|
||||
# copied along.
|
||||
#
|
||||
# When `detectExistingSchema()` sees the modern-bootstrap fingerprint
|
||||
# (photo_categories + cms_pages tables) but an empty migrations table,
|
||||
# it treats it as an "existing deployment" — which runs the legacy
|
||||
# chain first. Legacy/008 renames email_templates.subject → subject_en,
|
||||
# but core/029 (which runs later in this chain) inserts email templates
|
||||
# referencing the pre-rename column name. The chain dies with a
|
||||
# "column subject does not exist" error.
|
||||
#
|
||||
# Fix (in the same PR as this workflow): when the modern-bootstrap
|
||||
# fingerprint is detected, mark all legacy migrations as applied so the
|
||||
# chain matches what a fresh install runs — only core/*, in order.
|
||||
#
|
||||
# This workflow boots the failing scenario from scratch on every PR
|
||||
# that touches the migrations or db.js, so any future migration with
|
||||
# the same shape is caught before merge.
|
||||
|
||||
on:
|
||||
# No `paths:` filter — branch protection on `main` + `stable` lists
|
||||
# `upgrade-from-bootstrap` as a REQUIRED check. A path-filtered
|
||||
# trigger that skipped on unrelated PRs would leave the required
|
||||
# check "missing" forever, blocking every PR that doesn't touch
|
||||
# migrations. The ~75-second cost on every PR buys an unconditional
|
||||
# safety net. (Branches also updated post-#669 rename: beta → main,
|
||||
# old main → stable.)
|
||||
push:
|
||||
branches: [main, stable]
|
||||
pull_request:
|
||||
branches: [main, stable]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upgrade-from-bootstrap:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_USER: picpeak
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: picpeak_drift
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U picpeak -d picpeak_drift"
|
||||
--health-interval 2s
|
||||
--health-timeout 2s
|
||||
--health-retries 30
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install backend deps
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
# Step 1: simulate the recovery state — DB has the modern bootstrap
|
||||
# (post-initializeDatabase) but no migrations recorded. Calling
|
||||
# initializeDatabase() directly outside the migration runner is the
|
||||
# one-line repro for backup-restore-lost-migrations and manual-
|
||||
# invocation paths.
|
||||
- name: Seed DB with initializeDatabase() only
|
||||
working-directory: ./backend
|
||||
env:
|
||||
NODE_ENV: production
|
||||
DATABASE_CLIENT: pg
|
||||
DB_HOST: localhost
|
||||
DB_PORT: 5432
|
||||
DB_USER: picpeak
|
||||
DB_PASSWORD: testpass
|
||||
DB_NAME: picpeak_drift
|
||||
run: |
|
||||
node -e "require('./src/database/db').initializeDatabase().then(() => { console.log('bootstrap ok'); process.exit(0); }).catch(e => { console.error('bootstrap FAILED:', e.message); process.exit(1); })"
|
||||
|
||||
# Sanity-check the recovery shape before migrate:safe runs. If
|
||||
# initializeDatabase() ever stops producing photo_categories +
|
||||
# cms_pages, the fingerprint check would silently no-op and this
|
||||
# workflow would lose its teeth — assert the precondition.
|
||||
- name: Assert recovery-state fingerprint
|
||||
env:
|
||||
PGPASSWORD: testpass
|
||||
run: |
|
||||
installed=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND tablename IN ('photo_categories', 'cms_pages')")
|
||||
if [ "$installed" != "2" ]; then
|
||||
echo "FAIL: expected photo_categories + cms_pages from initializeDatabase(); got $installed."
|
||||
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
|
||||
exit 1
|
||||
fi
|
||||
# initializeDatabase() doesn't create the `migrations` tracking
|
||||
# table — that's the migrate:safe runner's job. So in the recovery
|
||||
# scenario, the table either (a) doesn't exist yet or (b) exists
|
||||
# but is empty (e.g. someone created it but didn't populate it).
|
||||
# Both are valid recovery states; check via to_regclass first so
|
||||
# we don't parse a SELECT against a nonexistent table.
|
||||
has_migrations_table=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT to_regclass('public.migrations')::text")
|
||||
if [ -z "$has_migrations_table" ]; then
|
||||
migrations_count=0
|
||||
else
|
||||
migrations_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations")
|
||||
fi
|
||||
if [ "$migrations_count" != "0" ]; then
|
||||
echo "FAIL: migrations table should be empty for the recovery scenario; has $migrations_count rows."
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: recovery state confirmed (bootstrap tables present, migrations table empty or absent)."
|
||||
|
||||
# Step 2: run migrate:safe — the test. Before #530's fix in
|
||||
# detectExistingSchema, this died at core/029 with a "column
|
||||
# subject does not exist" error. After the fix, it should complete
|
||||
# cleanly with every migration either applied or marked.
|
||||
- name: Run migrate:safe against the recovery state
|
||||
working-directory: ./backend
|
||||
env:
|
||||
NODE_ENV: production
|
||||
DATABASE_CLIENT: pg
|
||||
DB_HOST: localhost
|
||||
DB_PORT: 5432
|
||||
DB_USER: picpeak
|
||||
DB_PASSWORD: testpass
|
||||
DB_NAME: picpeak_drift
|
||||
run: npm run migrate:safe
|
||||
|
||||
# Step 3: schema-shape assertion. A fresh install through migrate:
|
||||
# safe produces 48 tables; the recovery scenario should converge
|
||||
# to the same number. Off-by-one is fine but a 10+ table delta
|
||||
# means a migration silently bailed in the recovery path.
|
||||
- name: Assert final schema matches fresh-install shape
|
||||
env:
|
||||
PGPASSWORD: testpass
|
||||
run: |
|
||||
tables=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public'")
|
||||
echo "Final table count: $tables"
|
||||
# Allow a small drift window — exact count creeps over time as
|
||||
# new migrations land; tight pin would force a workflow edit
|
||||
# on every schema PR. 40+ is a healthy floor that catches the
|
||||
# original bug (which left 17 tables) while staying robust to
|
||||
# forward changes.
|
||||
if [ "$tables" -lt 40 ]; then
|
||||
echo "FAIL: too few tables ($tables) — migrate:safe likely bailed mid-chain."
|
||||
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: schema converged to a fresh-install-equivalent shape."
|
||||
|
||||
# Step 4: verify the legacy migrations were all marked applied
|
||||
# (rather than silently bailing inside the chain). The fix in
|
||||
# detectExistingSchema marks legacy/* when the modern bootstrap
|
||||
# is detected — confirm the markings actually landed.
|
||||
- name: Assert legacy migrations marked applied
|
||||
env:
|
||||
PGPASSWORD: testpass
|
||||
run: |
|
||||
legacy_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations WHERE filename LIKE '008_%' OR filename LIKE '009_%' OR filename LIKE '013_%' OR filename LIKE '019_%' OR filename LIKE '020_%' OR filename LIKE '026_%' OR filename LIKE '028_%'")
|
||||
if [ "$legacy_count" -lt 7 ]; then
|
||||
echo "FAIL: legacy migrations not marked applied ($legacy_count of 7 expected)."
|
||||
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT filename FROM migrations WHERE filename LIKE '0%' ORDER BY filename"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: legacy migrations marked applied by detectExistingSchema."
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Tests
|
||||
|
||||
# Runs the backend Jest suite and the frontend Vitest suite on every PR.
|
||||
# Both suites already exist and cover the CRM service layer (quoteService,
|
||||
# contractService, invoiceService.*, customerHoursService, eventService.
|
||||
# calendar) plus the photo / settings / OG / auth surface — wiring them
|
||||
# into CI makes regressions visible at PR time instead of post-merge.
|
||||
#
|
||||
# Six backend suites are excluded via --testPathIgnorePatterns. They
|
||||
# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM
|
||||
# regressions). Excluding them here keeps CI green from day 1; revisit
|
||||
# each individually as its own fix.
|
||||
#
|
||||
# Triggers on any change that could affect either suite. The backend
|
||||
# job intentionally omits frontend paths and vice versa so unrelated
|
||||
# PRs don't pay both build costs.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, beta]
|
||||
pull_request:
|
||||
branches: [main, beta]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install backend deps
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
- name: Run Jest suite
|
||||
working-directory: ./backend
|
||||
env:
|
||||
# backupService tests would otherwise try a real S3 round-trip.
|
||||
# The S3 path itself is covered separately by the integration
|
||||
# suite when MinIO is provisioned.
|
||||
SKIP_S3_TESTS: 'true'
|
||||
run: |
|
||||
# Excluded suites — fail on upstream/beta too, tracked
|
||||
# separately as test-infra debt:
|
||||
# adminSettings.logo — supertest fixture
|
||||
# integration/adminPhotos.reference — supertest fixture
|
||||
# integration/webhookDelivery — supertest fixture
|
||||
# services/backupService.enhanced — knex mock chain
|
||||
# routes/__tests__/adminAuth — supertest fixture
|
||||
# (adminNotifications was excluded; #597 fix re-enables it.)
|
||||
npx jest \
|
||||
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \
|
||||
--ci
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Run Vitest suite
|
||||
working-directory: ./frontend
|
||||
run: npm test -- --run
|
||||
@@ -0,0 +1,98 @@
|
||||
# What's New highlights — GitHub Models release step (reusable)
|
||||
#
|
||||
# Called by the release-please workflows AFTER a release is created
|
||||
# (release-please.yml for `stable`, release-please-beta.yml for `main`). It runs
|
||||
# as a job in the SAME workflow run rather than on its own `release: published`
|
||||
# trigger, because release-please creates the release with the default
|
||||
# GITHUB_TOKEN and GitHub does not start new workflow runs from token-generated
|
||||
# events — a standalone `release:` workflow would simply never fire.
|
||||
#
|
||||
# What it does: condenses the new release's "### Features" into <=8 short
|
||||
# bullets via GitHub Models (free tier, `models: read`) and injects a
|
||||
# `<!-- whatsnew -->` block at the top of the release notes. The app reads that
|
||||
# block (backend utils/whatsNew.parseWhatsNew) and falls back to the raw
|
||||
# Features list for releases without it — so this is purely a quality upgrade,
|
||||
# never a hard dependency. Failure is isolated by `continue-on-error` + the
|
||||
# deterministic fallback below, so it can never break a release.
|
||||
#
|
||||
# GitHub Models is OPTIONAL. If it is disabled/unavailable for the org the AI
|
||||
# step fails soft (continue-on-error) and the deterministic fallback produces
|
||||
# the bullets instead — the feature works either way, Models just polishes them.
|
||||
#
|
||||
# Validated end-to-end on a fork (extract -> openai/gpt-4o-mini -> inject into
|
||||
# real release notes; app parseWhatsNew() reads the block back).
|
||||
|
||||
name: What's New highlights
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to annotate (e.g. v2.3.0)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
highlights:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # to edit the release body
|
||||
models: read # GitHub Models (free tier)
|
||||
# GH_REPO at job scope so every `gh` call targets the right repo without
|
||||
# needing an actions/checkout step. Without this, `gh` falls back to
|
||||
# parsing `.git/config` in the runner's empty workspace and dies with
|
||||
# "fatal: not a git repository" — which hard-fails the whole job before
|
||||
# any continue-on-error can save it.
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Extract Features from the published release
|
||||
id: feat
|
||||
# Belt-and-braces: the job-level comment says "never let highlights
|
||||
# break a release", but the original wiring only marked the AI +
|
||||
# inject steps as continue-on-error. A hiccup here (rate limit,
|
||||
# transient API error) would still hard-fail the job. Match the
|
||||
# design intent and fail soft.
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
BODY=$(gh release view "$TAG" --json body -q .body)
|
||||
FEATURES=$(printf '%s\n' "$BODY" | awk '/^#{2,4} +Features/{f=1;next} /^#{1,4} +\S/{f=0} f')
|
||||
{ echo "features<<EOF"; printf '%s\n' "$FEATURES"; echo EOF; } >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Summarize with GitHub Models
|
||||
if: ${{ steps.feat.outputs.features != '' }}
|
||||
id: ai
|
||||
continue-on-error: true # Models may be disabled/unavailable for the org; fall back deterministically below
|
||||
uses: actions/ai-inference@v1
|
||||
with:
|
||||
model: openai/gpt-4o-mini # catalog id (verified present); openai/gpt-4.1-mini or openai/gpt-5-nano also work
|
||||
system-prompt: >
|
||||
You write release highlights for the admins of a self-hosted
|
||||
photo-gallery + CRM app. Given raw changelog "Features" lines, output
|
||||
AT MOST 8 markdown bullets, each 3-4 words, user-facing, no scopes,
|
||||
no jargon, no issue numbers. One bullet per distinct user-visible
|
||||
feature. Output ONLY "- " bullets, nothing else.
|
||||
prompt: ${{ steps.feat.outputs.features }}
|
||||
|
||||
- name: Inject the What's New block
|
||||
if: ${{ steps.feat.outputs.features != '' }}
|
||||
continue-on-error: true # never let highlights break a release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
AI: ${{ steps.ai.outputs.response }}
|
||||
FEATURES: ${{ steps.feat.outputs.features }}
|
||||
run: |
|
||||
BULLETS="$AI"
|
||||
# Deterministic fallback if the model returned nothing (e.g. Models not yet enabled).
|
||||
if [ -z "$BULLETS" ]; then
|
||||
BULLETS=$(printf '%s\n' "$FEATURES" | head -8 \
|
||||
| sed -E 's/^\* \*\*[^:]+:\*\* */- /; s/ \(\[[^]]*\]\([^)]*\)\)//g')
|
||||
fi
|
||||
BODY=$(gh release view "$TAG" --json body -q .body)
|
||||
# Idempotent: strip any prior block before re-injecting.
|
||||
BODY=$(printf '%s' "$BODY" | perl -0pe 's/<!--\s*whatsnew\s*-->.*?<!--\s*\/whatsnew\s*-->\n*//is')
|
||||
gh release edit "$TAG" --notes "$(printf '<!-- whatsnew -->\n%s\n<!-- /whatsnew -->\n\n%s' "$BULLETS" "$BODY")"
|
||||
+61
-1
@@ -69,6 +69,66 @@ backend/data/
|
||||
backend/docs/
|
||||
backend/logs/
|
||||
logs/
|
||||
storage/
|
||||
# Anchored to repo root: matches the top-level runtime storage dir,
|
||||
# NOT backend/src/services/storage/ (the storage backend abstraction code).
|
||||
/storage/
|
||||
data/
|
||||
certbot/
|
||||
|
||||
# Ignore local contributor guide copy
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Working/planning documents (not for release)
|
||||
BUGS_AND_FEATURES.md
|
||||
frontend/TEST_PLAN.md
|
||||
docs/REFACTORING_PLAN.md
|
||||
docs/MULTIPLE_ADMINISTRATORS_PLAN.md
|
||||
docs/*_PLAN.md
|
||||
docs/test-*.md
|
||||
docs/feature-*.md
|
||||
|
||||
# Scaffolding documentation (local development reference)
|
||||
docs/DATABASE_SCHEMA.md
|
||||
docs/BACKEND_SERVICES.md
|
||||
docs/API_ROUTES.md
|
||||
docs/FRONTEND_ARCHITECTURE.md
|
||||
docs/DEVELOPER_ONBOARDING.md
|
||||
docs/ENVIRONMENT_VARIABLES.md
|
||||
|
||||
# Build artifact: OpenAPI spec generated locally + synced into the
|
||||
# picpeak-docs repo. Never tracked here — the docs site at
|
||||
# docs.picpeak.app is the source of truth.
|
||||
docs/openapi.json
|
||||
docs/openapi.yaml
|
||||
|
||||
# Local backup directory (from testing)
|
||||
backup/
|
||||
|
||||
# Local artifacts from browser tooling
|
||||
.playwright-mcp/
|
||||
|
||||
# Local-only E2E suite (never pushed; runs as pre-push gate on this machine)
|
||||
tests/e2e/local/
|
||||
playwright-local-results/
|
||||
e2e-test.log
|
||||
scripts/e2e-local.sh
|
||||
|
||||
# Local SQLite files in backend
|
||||
backend/*.sqlite*
|
||||
backend/*.db
|
||||
|
||||
# Test files and artifacts
|
||||
test-images/
|
||||
test-logo*.jpg
|
||||
test-logo*.png
|
||||
test-results/
|
||||
|
||||
# Development docker compose
|
||||
docker-compose.dev.yml
|
||||
|
||||
# New layout development files
|
||||
new-layouts/
|
||||
|
||||
# Generated CRM/accounting documents (runtime) — never commit
|
||||
backend/storage/business-docs/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "3.81.0-beta.0"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "2.6.1"
|
||||
}
|
||||
+2682
File diff suppressed because it is too large
Load Diff
@@ -1,368 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Product Overview
|
||||
|
||||
A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage
|
||||
- **Frontend**: React SPA with scrappbook.de-style design (requires implementation)
|
||||
- **Storage**: File-based with active/archived separation
|
||||
- **Services**: Background workers for email, archiving, file watching, and expiration monitoring
|
||||
- **Analytics**: Umami integration for engagement tracking
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
cd backend
|
||||
npm install # Install dependencies
|
||||
npm run migrate # Initialize database schema
|
||||
npm run dev # Start with hot-reload (port 3001)
|
||||
npm test # Run Jest tests
|
||||
npm run lint # ESLint checks
|
||||
```
|
||||
|
||||
### Running a Single Test
|
||||
```bash
|
||||
cd backend
|
||||
npm test -- path/to/test.test.js
|
||||
npm test -- --testNamePattern="test name"
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
See [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) for comprehensive deployment instructions including:
|
||||
- Docker Compose deployment
|
||||
- PM2 deployment
|
||||
- Manual installation
|
||||
- Non-nginx deployment options
|
||||
- SSL/HTTPS setup
|
||||
- Troubleshooting guide
|
||||
|
||||
**⚠️ CRITICAL PRODUCTION NOTICE:**
|
||||
- Production runs on a SEPARATE SERVER - never assume local changes affect production
|
||||
- ALWAYS request production server details before any troubleshooting
|
||||
- NO trial-and-error approaches in production - data loss is unacceptable
|
||||
- Every change must be thoroughly analyzed and tested locally first
|
||||
|
||||
## Key Product Requirements (from PRD)
|
||||
|
||||
### Core Features
|
||||
1. **File-Based System**: Drop photos in folders → automatic gallery creation
|
||||
2. **Automatic Expiration**: Default 30 days, with 7-day warning emails
|
||||
3. **Password Protection**: Secure access with customizable passwords
|
||||
4. **Automatic Archiving**: ZIP compression and storage after expiration
|
||||
5. **Email Notifications**: Creation, warning, and expiration notifications
|
||||
6. **Analytics**: Umami tracking for views, downloads, and engagement
|
||||
|
||||
### Folder Structure
|
||||
```
|
||||
/events/
|
||||
├── active/
|
||||
│ ├── wedding-smith-jones-2024-06-15/
|
||||
│ │ ├── collages/
|
||||
│ │ └── individual/
|
||||
│ └── birthday-emma-2024-07-20/
|
||||
└── archived/
|
||||
└── wedding-smith-jones-2024-06-15.zip
|
||||
```
|
||||
|
||||
## Frontend Implementation Requirements
|
||||
|
||||
### Design Style (scrappbook.de-inspired)
|
||||
- **Color Palette**: Primary green (#5C8762), neutral backgrounds
|
||||
- **Typography**: Clean, modern sans-serif (Noto Sans or similar)
|
||||
- **Layout**: Minimalist, modular sections with grid-based photo displays
|
||||
- **Aesthetic**: Professional yet approachable, photographer-focused
|
||||
|
||||
### Key Frontend Components to Build
|
||||
1. **Landing Page**: Password entry with event preview
|
||||
2. **Gallery View**:
|
||||
- Responsive photo grid with lazy loading
|
||||
- Toggle between collages/individual photos
|
||||
- Prominent expiration banner
|
||||
- Download urgency indicators
|
||||
3. **Photo Lightbox**: Full-screen viewing with zoom
|
||||
4. **Mobile-First**: Responsive design with touch gestures
|
||||
5. **Personalization**: Dynamic theming per event type
|
||||
|
||||
### User Experience Priorities
|
||||
- Clear expiration warnings (sticky banner)
|
||||
- One-click "Download All" for urgent galleries
|
||||
- Smooth image loading with skeleton screens
|
||||
- Intuitive navigation between photo categories
|
||||
- Professional presentation matching photographer branding
|
||||
|
||||
## Key Architecture Patterns
|
||||
|
||||
### Authentication Flow
|
||||
- JWT-based with separate tokens for admin and gallery access
|
||||
- Gallery tokens include event-specific claims
|
||||
- Auth middleware: `backend/src/middleware/auth.js`
|
||||
- `adminAuth` - Admin panel protection
|
||||
- `photoAuth` - Protected photo access
|
||||
- `verifyGalleryAccess` - Gallery-specific validation
|
||||
|
||||
### Database Schema (Knex/SQLite)
|
||||
Main tables:
|
||||
- `events` - Gallery metadata with expiration, custom messages, themes
|
||||
- `photos` - Photo records linked to events
|
||||
- `access_logs` - IP-based usage tracking
|
||||
- `email_queue` - Async email processing
|
||||
- `admin_users` - Admin authentication
|
||||
|
||||
### Service Architecture
|
||||
Background services run as separate processes:
|
||||
- **emailService**: Processes email queue with retry logic
|
||||
- **archiveService**: Creates ZIP archives of expired events
|
||||
- **expirationChecker**: Cron job for expiration warnings
|
||||
- **fileWatcher**: Monitors for new photo uploads
|
||||
- **backupService**: Scheduled backups with checksum-based change detection
|
||||
|
||||
### API Structure
|
||||
- `/api/admin/*` - Admin panel endpoints (requires adminAuth)
|
||||
- `/api/gallery/*` - Public gallery endpoints
|
||||
- `/api/auth/*` - Authentication endpoints
|
||||
- Rate limiting: 100 req/15min (general), 5 req/15min (auth)
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
1. **Security**: All gallery access requires valid JWT with event-specific claims
|
||||
2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings
|
||||
3. **Email Queue**: Async processing with retry logic, check `email_queue` table
|
||||
4. **File Processing**: Sharp library for thumbnail generation (300x300)
|
||||
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
|
||||
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
|
||||
|
||||
## Troubleshooting Guidelines
|
||||
|
||||
### Before ANY Production Troubleshooting:
|
||||
1. **ALWAYS request specific details**:
|
||||
- Production server URL/IP
|
||||
- Current error messages/logs
|
||||
- Recent changes or deployments
|
||||
- Affected users/galleries
|
||||
- Time of issue occurrence
|
||||
|
||||
2. **Thorough Analysis Required**:
|
||||
- Use detailed thinking/analysis for EVERY troubleshooting task
|
||||
- Review all related code before suggesting changes
|
||||
- Consider all potential side effects
|
||||
- Never make assumptions about production environment
|
||||
|
||||
3. **Safe Troubleshooting Steps**:
|
||||
- First, reproduce issue in local/dev environment
|
||||
- Analyze logs without modifying production
|
||||
- Create detailed action plan before any changes
|
||||
- Always have rollback strategy ready
|
||||
- Document every step taken
|
||||
|
||||
### Common Issues & Safe Approaches:
|
||||
- **Email not sending**: Check email_queue table, SMTP settings, service status
|
||||
- **Photos not loading**: Verify file permissions, storage paths, nginx config
|
||||
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
|
||||
- **Performance problems**: Analyze with monitoring tools first, never experiment
|
||||
|
||||
### Data Safety Rules:
|
||||
- NEVER delete or modify production data without explicit backup confirmation
|
||||
- ALWAYS verify backups exist before any data operations
|
||||
- NO direct database modifications without transaction safety
|
||||
- Log all actions for audit trail
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
- `JWT_SECRET` - Token signing
|
||||
- `ADMIN_URL`, `FRONTEND_URL` - CORS origins
|
||||
- `SMTP_*` - Email configuration
|
||||
- `DB_*` - PostgreSQL credentials (production)
|
||||
- `UMAMI_URL` - Umami instance URL (for server-side tracking)
|
||||
- `UMAMI_WEBSITE_ID` - Website ID from Umami
|
||||
|
||||
### Frontend (.env)
|
||||
- `VITE_API_URL` - Backend API URL
|
||||
- `VITE_UMAMI_URL` - Umami analytics URL
|
||||
- `VITE_UMAMI_WEBSITE_ID` - Website ID from Umami
|
||||
- `VITE_UMAMI_SHARE_URL` - (Optional) Public share URL for embedded dashboard
|
||||
|
||||
## Testing Approach
|
||||
- Jest with Supertest for API testing
|
||||
- Test files in `__tests__` directories
|
||||
- Database migrations run before tests
|
||||
- Mock email sending in tests
|
||||
|
||||
## Umami Analytics Integration
|
||||
|
||||
The frontend includes comprehensive Umami analytics integration for tracking user behavior and gallery performance.
|
||||
|
||||
### Tracked Events:
|
||||
- **Gallery Events**:
|
||||
- `gallery_password_entry` - Password attempts (success/failure)
|
||||
- `gallery_photo_view` - Individual photo views
|
||||
- `gallery_photo_download` - Single photo downloads
|
||||
- `gallery_bulk_download` - Bulk/all photo downloads
|
||||
- `gallery_expired` - Expired gallery access attempts
|
||||
- **Admin Events**:
|
||||
- `admin_login` - Admin authentication
|
||||
- `admin_event_created` - New event creation
|
||||
- `admin_event_archived` - Event archiving
|
||||
- `admin_event_deleted` - Event deletion
|
||||
- `admin_settings_updated` - Settings changes
|
||||
- **User Behavior**:
|
||||
- Search queries (with debouncing)
|
||||
- Expiration warning views
|
||||
- Page views with automatic tracking
|
||||
|
||||
### Setup:
|
||||
1. Install Umami (self-hosted or cloud)
|
||||
2. Create a website in Umami dashboard
|
||||
3. Set environment variables:
|
||||
```
|
||||
VITE_UMAMI_URL=https://your-umami-instance.com
|
||||
VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
VITE_UMAMI_SHARE_URL=https://your-umami-instance.com/share/...
|
||||
```
|
||||
|
||||
### Analytics Dashboard:
|
||||
- Admin panel includes analytics page at `/admin/analytics`
|
||||
- Summary view with key metrics
|
||||
- Option to embed full Umami dashboard
|
||||
- Real-time event tracking
|
||||
|
||||
## Accessibility & Performance Features
|
||||
|
||||
### Accessibility (WCAG 2.1 AA Compliance)
|
||||
- **Error Boundaries**: Graceful error handling with recovery options
|
||||
- **Skip Links**: Skip to main content for keyboard navigation
|
||||
- **ARIA Labels**: Proper labeling for screen readers
|
||||
- **Focus Management**: Focus trap in modals, visible focus indicators
|
||||
- **Keyboard Navigation**: Full keyboard support in gallery lightbox (arrows, escape, +/-, d for download)
|
||||
- **Loading States**: Skeleton screens instead of spinners for better UX
|
||||
- **Offline Support**: Visual indicator when offline
|
||||
- **Form Validation**: Accessible error messages with aria-describedby
|
||||
|
||||
### Performance Optimizations
|
||||
- **Lazy Loading**: Images load on scroll with Intersection Observer
|
||||
- **Skeleton Screens**: Instant visual feedback during loading
|
||||
- **Error Recovery**: Component-level error boundaries prevent full page crashes
|
||||
- **Optimistic Updates**: Immediate UI updates with background sync
|
||||
- **Debounced Search**: Prevents excessive API calls
|
||||
- **Analytics**: Non-blocking Umami integration
|
||||
|
||||
### Component Library Enhancements
|
||||
- `<ErrorBoundary>` - Catches and displays errors gracefully
|
||||
- `<PageErrorBoundary>` - Full-page error recovery
|
||||
- `<Skeleton>` - Flexible skeleton loader with variants
|
||||
- `<OfflineIndicator>` - Network status monitoring
|
||||
- `<SkipLink>` - Accessibility navigation
|
||||
- `useFocusTrap` - Modal focus management hook
|
||||
- `useOnlineStatus` - Network status hook
|
||||
|
||||
## Theme System & Branding
|
||||
|
||||
### Theme Features
|
||||
- **Dynamic Theming**: CSS variables for runtime theme switching
|
||||
- **Preset Themes**: Default, Wedding, Birthday, Corporate, Minimal
|
||||
- **Customization Options**:
|
||||
- Primary/Accent/Background/Text colors
|
||||
- Font family selection
|
||||
- Border radius (none, sm, md, lg)
|
||||
- Custom logo upload
|
||||
- Custom CSS injection
|
||||
- **Event-Specific Themes**: Override global theme per gallery
|
||||
- **Live Preview**: Real-time theme changes in admin panel
|
||||
|
||||
### Theme Context API
|
||||
```typescript
|
||||
const { theme, setTheme, setThemeByName } = useTheme();
|
||||
```
|
||||
|
||||
### Branding Settings
|
||||
- Company name, tagline, and support email
|
||||
- Custom footer text
|
||||
- Optional watermarking on downloads
|
||||
- Logo upload for gallery header
|
||||
|
||||
### CSS Variables
|
||||
```css
|
||||
--color-primary: #5C8762;
|
||||
--color-primary-light: #7aa583;
|
||||
--color-primary-dark: #4a6f4f;
|
||||
--color-accent: #22c55e;
|
||||
--color-background: #fafafa;
|
||||
--color-text: #171717;
|
||||
--font-family: 'Inter', sans-serif;
|
||||
--border-radius: 0.5rem;
|
||||
```
|
||||
|
||||
## Backup Service
|
||||
|
||||
### Overview
|
||||
The backup service provides automated, scheduled backups of all photo data with checksum-based change detection to minimize transfer overhead.
|
||||
|
||||
### Features
|
||||
- **Multiple Destinations**: Local directory, remote server (rsync), S3-compatible storage
|
||||
- **Change Detection**: SHA256 checksums track file changes, only modified files are backed up
|
||||
- **Scheduled Execution**: Configurable cron-based scheduling (default: 2 AM daily)
|
||||
- **Email Notifications**: Alerts on backup failure, optional success notifications
|
||||
- **Retention Management**: Automatic cleanup of old backup runs based on retention policy
|
||||
- **Progress Tracking**: Database storage of backup history, file states, and statistics
|
||||
|
||||
### Configuration
|
||||
Backup settings are stored in `app_settings` table with `backup_` prefix:
|
||||
- `backup_enabled`: Enable/disable the service
|
||||
- `backup_schedule`: Cron expression (e.g., '0 2 * * *')
|
||||
- `backup_destination_type`: 'local', 'rsync', or 's3'
|
||||
- `backup_retention_days`: How long to keep backup history
|
||||
- `backup_include_archived`: Whether to backup archived events
|
||||
- `backup_exclude_patterns`: File patterns to exclude
|
||||
|
||||
### API Endpoints
|
||||
- `GET /api/admin/backup/config` - Get current configuration
|
||||
- `PUT /api/admin/backup/config` - Update configuration
|
||||
- `GET /api/admin/backup/status` - Get backup status and history
|
||||
- `POST /api/admin/backup/run` - Trigger manual backup
|
||||
- `POST /api/admin/backup/test-connection` - Test destination connectivity
|
||||
|
||||
### Testing
|
||||
Run backup service test: `npm run test-backup`
|
||||
|
||||
### Database Tables
|
||||
- `backup_runs`: Tracks each backup execution with statistics
|
||||
- `backup_file_states`: Stores file checksums for change detection
|
||||
|
||||
## Success Metrics (from PRD)
|
||||
- Time to generate gallery: <2 minutes
|
||||
- Guest satisfaction: >90%
|
||||
- System uptime: 99.9%
|
||||
- Email delivery rate: >98%
|
||||
- Successful archiving: 100%
|
||||
|
||||
## Documentation & Development Practices
|
||||
|
||||
### Documentation Guidelines:
|
||||
- **NEVER create new documentation files for simple tasks**
|
||||
- **ALWAYS update existing documentation (like this CLAUDE.md)**
|
||||
- Only create new .md files when explicitly requested
|
||||
- Avoid creating temporary scripts for one-off tasks
|
||||
|
||||
### Development Best Practices:
|
||||
- Test all changes thoroughly in local environment first
|
||||
- Use version control for all changes
|
||||
- Keep commits atomic and well-described
|
||||
- Review impact on all integrated services
|
||||
- Consider backward compatibility
|
||||
- Update tests when changing functionality
|
||||
|
||||
### Production Deployment Checklist:
|
||||
- [ ] All tests passing locally
|
||||
- [ ] Linting and type checks pass
|
||||
- [ ] Database migrations tested with rollback plan
|
||||
- [ ] Environment variables documented
|
||||
- [ ] Backup strategy confirmed
|
||||
- [ ] Monitoring alerts configured
|
||||
- [ ] Rollback procedure documented
|
||||
- [ ] Stakeholders notified of maintenance window
|
||||
+1
-1
@@ -20,7 +20,7 @@ We are committed to providing a welcoming and inspiring community for all photog
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/the-luap/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
|
||||
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/PicPeak/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
|
||||
+43
-11
@@ -33,12 +33,12 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme
|
||||
|
||||
Unsure where to begin? You can start by looking through these issues:
|
||||
|
||||
* [Good first issues](https://github.com/the-luap/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
|
||||
* [Help wanted issues](https://github.com/the-luap/picpeak/labels/help%20wanted) - issues which need extra attention
|
||||
* [Good first issues](https://github.com/PicPeak/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
|
||||
* [Help wanted issues](https://github.com/PicPeak/picpeak/labels/help%20wanted) - issues which need extra attention
|
||||
|
||||
### Pull Requests
|
||||
|
||||
1. **Fork the repo** and create your branch from `main`
|
||||
1. **Fork the repo** and create your branch from `main` (active development)
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
cd backend && npm install
|
||||
@@ -50,7 +50,10 @@ Unsure where to begin? You can start by looking through these issues:
|
||||
- Linting passes: `npm run lint`
|
||||
4. **Write tests** if you've added code
|
||||
5. **Update documentation** if needed
|
||||
6. **Create a Pull Request**
|
||||
6. **Attach a screenshot for any UI change** (see below)
|
||||
7. **Create a Pull Request**
|
||||
|
||||
> **📸 Screenshots are required for UI changes.** Any PR that changes a user-facing surface — a component, page, layout, style, or in-app copy — must include at least one screenshot of the result in the PR description, showing before/after where it helps reviewers see the difference. PRs that touch the UI without a screenshot will be asked to add one before review. Backend-only or otherwise non-visual changes don't need one.
|
||||
|
||||
## 💻 Development Setup
|
||||
|
||||
@@ -79,6 +82,15 @@ cp .env.example .env
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
**After pulling changes that touch `backend/package.json` / `backend/package-lock.json` (or the frontend equivalents)**, rebuild the affected image so the live-mounted source can `require()` the new deps:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d --build backend
|
||||
# (or `frontend`, or both)
|
||||
```
|
||||
|
||||
The dev compose bakes `node_modules` into the image while live-mounting `./backend/src` and `./frontend/src` from disk. A dep added on disk won't be picked up until the image is rebuilt — typical symptom is a `MODULE_NOT_FOUND` restart loop on the affected container.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
@@ -144,17 +156,37 @@ picpeak/
|
||||
│ └── public/ # Static assets
|
||||
```
|
||||
|
||||
## 🌿 Branch model
|
||||
|
||||
PicPeak runs on two long-lived branches:
|
||||
|
||||
| Branch | Role | What targets it |
|
||||
|---|---|---|
|
||||
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
|
||||
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
|
||||
|
||||
### Which branch should my PR target?
|
||||
|
||||
- **New feature** → target `main`.
|
||||
- **Bugfix that ONLY affects active dev** → target `main`.
|
||||
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
|
||||
|
||||
**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.
|
||||
|
||||
If you're not sure which branch to target, default to `main` and a maintainer will retarget during review.
|
||||
|
||||
## 🔄 Release Process
|
||||
|
||||
1. Update version numbers in package.json files
|
||||
2. Update CHANGELOG.md
|
||||
3. Create a new release on GitHub
|
||||
4. Docker images are automatically built and published
|
||||
Releases are cut independently from `main` (pre-release versions for the active channel) and `stable` (semver releases for the curated channel). `release-please` handles version bumps, changelog generation, and Docker image publication automatically — contributors don't update `package.json` or `CHANGELOG.md` by hand.
|
||||
|
||||
Periodic `main → stable` merges promote a batch of `main` work to the stable channel. The maintainer chooses when (typically every ~4 weeks, sooner if a hot bug demands it).
|
||||
|
||||
See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteria, conflict-resolution checklist for the `main → stable` merge, hotfix backport path, versioning rules).
|
||||
|
||||
## 📮 Contact
|
||||
|
||||
- Create an [issue](https://github.com/the-luap/picpeak/issues) for bugs or features
|
||||
- Join [discussions](https://github.com/the-luap/picpeak/discussions) for questions
|
||||
- Security issues: Open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
|
||||
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
|
||||
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
@@ -1,415 +0,0 @@
|
||||
# 🚀 PicPeak Deployment Guide
|
||||
|
||||
This guide covers deploying PicPeak using Docker Compose with direct port exposure. For internet-facing deployments, you'll need to add a reverse proxy (nginx, Traefik, Caddy, etc.) for SSL/HTTPS.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Configuration](#configuration)
|
||||
- [Deployment](#deployment)
|
||||
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||
- [Maintenance](#maintenance)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Domain name (for production)
|
||||
- SMTP server credentials for emails
|
||||
- At least 2GB RAM and 20GB storage
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/wedding-photo-sharing.git
|
||||
cd wedding-photo-sharing
|
||||
```
|
||||
|
||||
2. **Set up environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
```
|
||||
|
||||
3. **Create required directories**
|
||||
```bash
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
4. **Deploy**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **Check logs**
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Essential Environment Variables
|
||||
|
||||
Generate secure values:
|
||||
```bash
|
||||
# JWT Secret
|
||||
openssl rand -base64 64
|
||||
|
||||
# Database Password
|
||||
openssl rand -base64 32
|
||||
|
||||
# Redis Password
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Update `.env` with:
|
||||
- `JWT_SECRET` - Authentication secret
|
||||
- `DB_PASSWORD` - PostgreSQL password
|
||||
- `REDIS_PASSWORD` - Redis password
|
||||
- `SMTP_*` - Email configuration
|
||||
- `FRONTEND_URL` - Your domain URL
|
||||
- `ADMIN_URL` - Backend admin URL
|
||||
- `VITE_API_URL` - API URL for frontend
|
||||
|
||||
### Email Configuration Examples
|
||||
|
||||
#### Gmail
|
||||
```env
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-specific-password
|
||||
```
|
||||
|
||||
#### SendGrid
|
||||
```env
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
```
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Build and Start Services
|
||||
|
||||
```bash
|
||||
# Build images
|
||||
docker compose build
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Access Points
|
||||
|
||||
By default, services are exposed on:
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend/API: http://localhost:3001
|
||||
- PostgreSQL: localhost:5432 (if needed)
|
||||
- Redis: localhost:6379 (if needed)
|
||||
|
||||
### Initial Admin Setup
|
||||
|
||||
The admin credentials are generated during first startup. Check the logs:
|
||||
|
||||
```bash
|
||||
docker compose logs backend | grep -A 5 "Admin user created"
|
||||
```
|
||||
|
||||
Or use the helper script:
|
||||
```bash
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js
|
||||
|
||||
# To reset password
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
```
|
||||
|
||||
## 🔒 Reverse Proxy Setup
|
||||
|
||||
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
|
||||
|
||||
### Option 1: Nginx
|
||||
|
||||
Install nginx and create `/etc/nginx/sites-available/picpeak`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Protected photos and uploads
|
||||
location ~ ^/(photos|thumbnails|uploads) {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Admin routes
|
||||
location /admin {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/picpeak /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Option 2: Traefik
|
||||
|
||||
Add labels to `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak.rule=Host(`your-domain.com`)"
|
||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||
|
||||
backend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
|
||||
```
|
||||
|
||||
### Option 3: Caddy
|
||||
|
||||
Create a `Caddyfile`:
|
||||
|
||||
```caddyfile
|
||||
your-domain.com {
|
||||
# Frontend
|
||||
handle /* {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
# Backend API and admin
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /admin/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Protected resources
|
||||
handle /photos/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /thumbnails/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /uploads/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SSL Certificates
|
||||
|
||||
For any reverse proxy, you can use Let's Encrypt:
|
||||
|
||||
```bash
|
||||
# With Certbot
|
||||
sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
|
||||
|
||||
# Or use your reverse proxy's built-in ACME support
|
||||
```
|
||||
|
||||
## 🔧 Maintenance
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
```
|
||||
|
||||
### Backup
|
||||
|
||||
#### Manual Backup
|
||||
```bash
|
||||
# Database backup
|
||||
docker exec picpeak-postgres pg_dump -U picpeak picpeak_prod > backup/db_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# Files backup
|
||||
tar -czf backup/photos_$(date +%Y%m%d_%H%M%S).tar.gz events/
|
||||
```
|
||||
|
||||
#### Automated Backup
|
||||
The application includes a built-in backup service. Configure it in the admin panel:
|
||||
1. Login to admin panel
|
||||
2. Go to Settings → Backup
|
||||
3. Configure destination and schedule
|
||||
4. Enable backup service
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose down
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Migrations run automatically on startup, but you can run them manually:
|
||||
|
||||
```bash
|
||||
docker exec picpeak-backend npm run migrate
|
||||
```
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Port Already in Use
|
||||
```bash
|
||||
# Check what's using the port
|
||||
sudo lsof -i :3000
|
||||
sudo lsof -i :3001
|
||||
|
||||
# Change ports in .env
|
||||
FRONTEND_PORT=3002
|
||||
BACKEND_PORT=3003
|
||||
```
|
||||
|
||||
#### Permission Errors
|
||||
```bash
|
||||
# Fix ownership
|
||||
sudo chown -R 1000:1000 events data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
#### Database Connection Issues
|
||||
```bash
|
||||
# Check if database is running
|
||||
docker compose ps
|
||||
docker compose logs postgres
|
||||
|
||||
# Test connection
|
||||
docker exec picpeak-postgres pg_isready
|
||||
```
|
||||
|
||||
#### Email Not Sending
|
||||
- Verify SMTP settings in .env
|
||||
- Check email queue: `docker exec picpeak-backend psql -U picpeak -d picpeak_prod -c "SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;"`
|
||||
- For Gmail, use app-specific password
|
||||
- Check logs: `docker compose logs backend | grep email`
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Backend health
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Frontend health
|
||||
curl http://localhost:3000
|
||||
|
||||
# Database health
|
||||
docker exec picpeak-postgres pg_isready
|
||||
```
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# Enter backend container
|
||||
docker exec -it picpeak-backend sh
|
||||
|
||||
# Enter database
|
||||
docker exec -it picpeak-postgres psql -U picpeak picpeak_prod
|
||||
|
||||
# Reset admin password
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
|
||||
# Check disk usage
|
||||
df -h
|
||||
du -sh events/ storage/ backup/
|
||||
|
||||
# View running processes
|
||||
docker compose top
|
||||
```
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
1. **Use HTTPS**: Always use a reverse proxy with SSL in production
|
||||
2. **Firewall**: Only expose necessary ports (80, 443)
|
||||
3. **Secure passwords**: Use strong, unique passwords for all services
|
||||
4. **Regular updates**: Keep Docker images and system packages updated
|
||||
5. **Backup strategy**: Set up automated backups and test restoration
|
||||
6. **Monitor logs**: Regularly check logs for suspicious activity
|
||||
7. **Rate limiting**: The app includes built-in rate limiting, configure as needed
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
- Check logs first: `docker compose logs`
|
||||
- Review documentation in the repository
|
||||
- Check existing issues on GitHub
|
||||
- Create a new issue with:
|
||||
- Error messages
|
||||
- Log output
|
||||
- Environment details (without secrets)
|
||||
- Steps to reproduce
|
||||
@@ -1,5 +1,13 @@
|
||||
# 📸 PicPeak - Open Source Photo Sharing for Events
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **PicPeak has moved to its own GitHub organization.**
|
||||
>
|
||||
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
|
||||
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
|
||||
>
|
||||
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||
|
||||
@@ -7,12 +15,28 @@
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://buymeacoffee.com/theluap)
|
||||
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support the project ☕](https://buymeacoffee.com/theluap)
|
||||
</div>
|
||||
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||
|
||||

|
||||
|
||||
## 🎮 Live Demo
|
||||
|
||||
Try PicPeak without installing anything:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
|
||||
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
|
||||
| **Email** | `demo@picpeak.app` |
|
||||
| **Password** | `Demo2026!` |
|
||||
|
||||
> The demo resets periodically. Uploaded content may be removed without notice.
|
||||
|
||||
## 🌟 Why Choose PicPeak?
|
||||
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
@@ -28,11 +52,14 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
|
||||
### For Photographers
|
||||
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
|
||||
- 🔗 **External Media (Reference Mode)** - Browse and import from a read‑only external folder library without copying originals
|
||||
- ⏰ **Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
|
||||
- 🔐 **Password Protection** - Secure client galleries
|
||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||
- 📽️ **Live Slideshow** - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options ([guide](docs/live-slideshow.md))
|
||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
|
||||
|
||||
### For Clients
|
||||
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
||||
@@ -40,43 +67,139 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
- ⬇️ **Bulk Downloads** - Download all photos with one click
|
||||
- 🔍 **Smart Search** - Find photos quickly
|
||||
- 📤 **Guest Uploads** - Optional client photo uploads
|
||||
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
|
||||
|
||||
### Technical Excellence
|
||||
- 🐳 **Docker Ready** - Deploy in minutes
|
||||
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
||||
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
|
||||
- 💾 **Smart Storage** - Automatic archiving of expired galleries
|
||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||
- 📈 **Scalable** - From small studios to large agencies
|
||||
|
||||
### For Studios — CRM & Accounting (Beta · off by default)
|
||||
- 📝 **Quotes → Contracts → Invoices** - One deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
|
||||
- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
|
||||
- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
|
||||
- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only
|
||||
- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
|
||||
- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
Get PicPeak running in under 5 minutes:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
git clone https://github.com/PicPeak/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy environment template
|
||||
# Copy the environment template — the defaults work out of the box.
|
||||
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
|
||||
# admin account is created in the browser (see below). Edit .env only to
|
||||
# customise (domain, SMTP, storage paths, …) — nothing is required.
|
||||
cp .env.example .env
|
||||
|
||||
# Edit configuration (required: JWT_SECRET)
|
||||
nano .env
|
||||
|
||||
# Start with Docker Compose
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
|
||||
# Access at http://localhost:3005
|
||||
# Access at http://localhost:3000
|
||||
```
|
||||
|
||||
### First run — create your admin account
|
||||
|
||||
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. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
|
||||
```bash
|
||||
docker compose logs backend | grep -i "setup token"
|
||||
```
|
||||
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
|
||||
|
||||
> 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`).
|
||||
|
||||
Note on Docker file permissions
|
||||
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
|
||||
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
|
||||
|
||||
**ARM64 (aarch64) systems:** Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](docker-compose.amd64.override.yml) for a transitional fallback.
|
||||
|
||||
## 🔄 Release Channels
|
||||
|
||||
PicPeak offers two release channels for different needs. Stable promotions are cut from a known-good beta point every 4–6 weeks — see [RELEASING.md](RELEASING.md) for the maintainer's promotion criteria and cadence policy.
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
|
||||
### Switching Channels
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
|
||||
```bash
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
Then update your containers:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
|
||||
|
||||
```bash
|
||||
UPDATE_CHECK_ENABLED=false
|
||||
```
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT.md) - Detailed installation instructions
|
||||
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:
|
||||
|
||||
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
|
||||
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
|
||||
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
|
||||
- 📽️ [**Live Slideshow**](https://docs.picpeak.app/features/live-slideshow) - Fullscreen projector view that auto-updates during live events
|
||||
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Backup configuration, restore wizard, full disaster recovery
|
||||
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
|
||||
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
|
||||
|
||||
Project meta:
|
||||
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||
|
||||
## 🌐 Public Landing Page
|
||||
|
||||
Spotlight your studio with a customizable marketing page at `/`:
|
||||
|
||||
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
|
||||
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
|
||||
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
|
||||
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
|
||||
- Use **Reset to default** anytime to restore the bundled template.
|
||||
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
|
||||
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
Perfect for:
|
||||
@@ -85,20 +208,127 @@ Perfect for:
|
||||
- 📸 **Portrait Studios** - Client galleries with download limits
|
||||
- 🏢 **Corporate Events** - Internal photo sharing with branding
|
||||
- 🎓 **School Photography** - Secure parent access with expiration
|
||||
- 📽️ **Live Events** - Put a [Live Slideshow](docs/live-slideshow.md) on the venue projector that updates as you shoot
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
|
||||
- **Backend**: Node.js, Express, SQLite/PostgreSQL
|
||||
- **Frontend**: React, Tailwind CSS, Framer Motion
|
||||
- **Storage**: File-based with automatic archiving
|
||||
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](#storage-backends)
|
||||
- **Email**: SMTP with customizable templates
|
||||
- **Analytics**: Privacy-focused with Umami integration
|
||||
|
||||
## 💾 Storage Backends
|
||||
|
||||
PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
|
||||
|
||||
| Capability | `STORAGE_BACKEND=local` (default) | `STORAGE_BACKEND=s3` |
|
||||
|---|---|---|
|
||||
| Photo / thumbnail / hero storage | Local filesystem under `STORAGE_PATH` | Bucket on any S3-compatible service |
|
||||
| Admin UI upload | ✅ | ✅ |
|
||||
| Filesystem auto-import (chokidar watcher) | ✅ | ❌ — disabled (use the upload API) |
|
||||
| Watermarks, fingerprinting, fragmentation | ✅ | ✅ (materialized to a tmp file just-in-time) |
|
||||
| Bulk download zips (cached + on-the-fly) | ✅ | ✅ |
|
||||
| Backups | ✅ | ✅ |
|
||||
| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
|
||||
|
||||
### Switching to an S3-compatible backend
|
||||
|
||||
1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
|
||||
2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
|
||||
3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
|
||||
4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
|
||||
|
||||
Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
|
||||
|
||||
## 🔔 Webhooks
|
||||
|
||||
PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
|
||||
|
||||
### Event types
|
||||
|
||||
| Event | Fires when |
|
||||
|---|---|
|
||||
| `event.created` | Gallery created (admin or API) |
|
||||
| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
|
||||
| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
|
||||
| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
|
||||
| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
|
||||
| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
|
||||
|
||||
### Payload shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "delivery-uuid",
|
||||
"type": "event.published",
|
||||
"created_at": "2026-04-28T05:25:00.000Z",
|
||||
"data": {
|
||||
"event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also sent on every request:
|
||||
- `X-PicPeak-Signature` — `HMAC-SHA256(secret, raw_body)` as hex
|
||||
- `X-PicPeak-Event` — the event type (handy for routing without parsing the body)
|
||||
- `X-PicPeak-Delivery` — UUID for idempotency on the receiver side
|
||||
- `User-Agent: PicPeak-Webhooks/1.0`
|
||||
|
||||
### Verifying signatures
|
||||
|
||||
**Node.js**
|
||||
```js
|
||||
const crypto = require('crypto');
|
||||
function verify(secret, rawBody, signature) {
|
||||
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const a = Buffer.from(expected, 'hex');
|
||||
const b = Buffer.from(signature, 'hex');
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
```
|
||||
|
||||
**Python**
|
||||
```python
|
||||
import hmac, hashlib
|
||||
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
|
||||
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
```
|
||||
|
||||
**curl + openssl** (one-liner for a quick replay)
|
||||
```sh
|
||||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
|
||||
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH
|
||||
```
|
||||
|
||||
### Retries + observability
|
||||
|
||||
- `2xx` → success, recorded with latency
|
||||
- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
|
||||
- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
|
||||
- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
|
||||
- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
|
||||
|
||||
The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
|
||||
|
||||
### SSRF protection
|
||||
|
||||
Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
|
||||
|
||||
For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
|
||||
|
||||
## 💻 System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
- **CPU**: 2 CPU cores
|
||||
- **RAM**: 2GB minimum
|
||||
- **RAM**: **4 GB minimum** for a normal photo-upload workload — sharp/libvips
|
||||
decodes the full uncompressed frame before resize, and the default two
|
||||
worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a
|
||||
batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the
|
||||
backend mid-batch (surfaces as 503s on thumbnails — see [Low-memory
|
||||
hosts](#low-memory-hosts) below for the recipe to run on 2 GB).
|
||||
- **Storage**: 20GB minimum (plus photo storage needs)
|
||||
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
|
||||
- **Node.js**: v18.0.0 or higher
|
||||
@@ -108,6 +338,51 @@ Perfect for:
|
||||
- **Docker**: v20.10.0+
|
||||
- **Docker Compose**: v2.0.0+
|
||||
|
||||
### Low-memory hosts
|
||||
|
||||
Running on 2 GB RAM (e.g. an entry-level VPS) is workable but requires
|
||||
tuning the upload-processor concurrency down. The backend auto-detects
|
||||
total RAM at startup via `os.totalmem()` — on a host that reports < 3 GB,
|
||||
it defaults `UPLOAD_PROCESSOR_CONCURRENCY` to **1** instead of 2 and logs
|
||||
a one-shot warning. You can pin the value explicitly in `.env`:
|
||||
|
||||
```env
|
||||
# Single worker loop — slower batch processing, lower peak RSS
|
||||
UPLOAD_PROCESSOR_CONCURRENCY=1
|
||||
```
|
||||
|
||||
The trade-off is throughput: a single worker processes one photo at a
|
||||
time, so a 100-photo batch takes ~2× as long but won't OOM. **Health-check
|
||||
note**: if the backend dies under memory pressure, the gallery serves
|
||||
`503 Service Unavailable` on thumbnails until Docker's
|
||||
`restart: unless-stopped` brings the container back. Persistent 503s
|
||||
during/after an upload batch on a low-memory host are almost always this.
|
||||
|
||||
### Video Support Requirements
|
||||
When enabling video uploads, consider these additional resources:
|
||||
|
||||
| Resource | Recommendation | Notes |
|
||||
|----------|----------------|-------|
|
||||
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
|
||||
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
|
||||
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
|
||||
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
|
||||
|
||||
**Technical Notes:**
|
||||
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
|
||||
- Maximum upload size: **10GB per video file**
|
||||
- Chunked upload support for files >100MB (resumable uploads)
|
||||
- Supported formats: MP4, WebM, MOV, AVI
|
||||
- Video thumbnails are automatically generated from the first few seconds
|
||||
|
||||
**For Nginx/Reverse Proxy:**
|
||||
If using Nginx, increase the client max body size:
|
||||
```nginx
|
||||
client_max_body_size 10G;
|
||||
proxy_read_timeout 3600;
|
||||
proxy_send_timeout 3600;
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
||||
@@ -116,17 +391,23 @@ See our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
## 📊 Comparison with Alternatives
|
||||
|
||||
| Feature | PicPeak | PicDrop | Scrapbook.de |
|
||||
|---------|---------|---------|--------------|
|
||||
| Self-Hosted | ✅ | ❌ | ❌ |
|
||||
| Custom Branding | ✅ Full | Limited | Limited |
|
||||
| Monthly Cost | $0 | $29-199 | €19-99 |
|
||||
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
|
||||
| Client Uploads | ✅ | ✅ | ✅ |
|
||||
| API Access | ✅ | Paid | ❌ |
|
||||
| Open Source | ✅ | ❌ | ❌ |
|
||||
| Feature | PicPeak | PicDrop | Scrapbook.de | Pixieset |
|
||||
|---------|---------|---------|--------------|----------|
|
||||
| Self-Hosted | ✅ | ❌ | ❌ | ❌ |
|
||||
| Custom Branding | ✅ Full | Limited | Limited | ✅ (paid) |
|
||||
| Monthly Cost | $0* | $29-199 | €19-99 | ~$60 |
|
||||
| Storage Limit | Unlimited** | 50-500GB | 100-1000GB | 3GB–Unlimited*** |
|
||||
| Client Uploads | ✅ | ✅ | ✅ | Limited |
|
||||
| API Access | ✅ | Paid | ❌ | ❌ |
|
||||
| Open Source | ✅ | ❌ | ❌ | ❌ |
|
||||
| Customer Accounts | ✅ | ❌ | ❌ | ✅ |
|
||||
| Quotes / Contracts / Invoices | 🧪 Beta | ❌ | ❌ | ✅ |
|
||||
| Incoming Invoices & Accounting | 🧪 Beta | ❌ | ❌ | ❌ |
|
||||
|
||||
*Limited only by your server storage
|
||||
*You still bring your own server (own hardware or a VPS) and, if you want one, a domain.
|
||||
**Limited only by your server storage.
|
||||
***Pixieset's "unlimited" is photos only; video is capped by plan (roughly 0–10 h depending on tier).
|
||||
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
|
||||
|
||||
## 🛡️ Security
|
||||
|
||||
@@ -138,7 +419,7 @@ PicPeak takes security seriously:
|
||||
- 📝 Activity logging
|
||||
- 🔒 Secure file access
|
||||
|
||||
Found a security issue? Please open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
@@ -177,21 +458,57 @@ Organize and manage your photo galleries with intuitive event management tools.
|
||||
|
||||
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
|
||||
|
||||
### 🚧 Beta Features (Use at your own risk)
|
||||
|
||||
These features are currently in beta testing and may have limited functionality or stability:
|
||||
|
||||
| Feature | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
|
||||
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
|
||||
|
||||
### 📋 Future Enhancements
|
||||
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|---------|
|
||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
|
||||
| **External Media Library (Reference Mode)** | Use an external folder library as a read‑only source with import and on‑demand thumbnail generation | High | ✅ Implemented |
|
||||
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
|
||||
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
|
||||
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented (not tested) |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
|
||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
|
||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
|
||||
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
|
||||
|
||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||
|
||||
## ☕ Support the Project
|
||||
|
||||
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
|
||||
|
||||
<p align="left">
|
||||
<a href="https://buymeacoffee.com/theluap" target="_blank">
|
||||
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
|
||||
|
||||
### 👥 Contributors
|
||||
|
||||
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
|
||||
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
|
||||
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
|
||||
|
||||
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
|
||||
|
||||
### 🤖 AI-Assisted Development
|
||||
|
||||
This project was generated with the assistance of AI technology, but has been:
|
||||
@@ -202,6 +519,34 @@ This project was generated with the assistance of AI technology, but has been:
|
||||
|
||||
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
|
||||
|
||||
## ⚠️ CRM & Accounting disclaimers — examples only, verify locally
|
||||
|
||||
The CRM & accounting modules (contracts, invoices, QR-bills, the tax
|
||||
report and the accountant exports) ship seeded content and computed
|
||||
figures that are intended as a **starting point only**:
|
||||
|
||||
- **Contract blocks** (image rights, NDA, model release, cancellation,
|
||||
jurisdiction, …) are written by the maintainer, **not by a lawyer**.
|
||||
Every operator must have their lawyer review and adapt them before
|
||||
sending any contract to a customer.
|
||||
- **QR-bills and SEPA EPC payloads** are rendered from the data you
|
||||
typed. Picpeak is open source — please scan a test invoice with your
|
||||
bank's app to check the QR actually works. We are not responsible for
|
||||
any mistakes that come from sending an invoice with bad data on it.
|
||||
- **Tax, VAT & accounting figures** (the tax report, VAT-payable, the
|
||||
per-rate breakdown, the Treuhänder / Banana export, etc.) are computed
|
||||
from the data you enter and the defaults you configure. They are
|
||||
**guidance only and jurisdiction-specific** — tax rules, VAT rates,
|
||||
deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat
|
||||
rate) and filing duties differ by country and change over time. **Every
|
||||
operator must check their own tax / VAT regulations and verify the
|
||||
numbers with their accountant / Treuhänder / tax authority before
|
||||
relying on any figure or export.** Picpeak makes no warranty that the
|
||||
output is correct for your jurisdiction or situation.
|
||||
|
||||
Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before
|
||||
enabling the Contracts, Invoices or Accounting features.
|
||||
|
||||
## 📄 License
|
||||
|
||||
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
|
||||
@@ -209,7 +554,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
## 🚀 Ready to Get Started?
|
||||
|
||||
1. ⭐ **Star this repository** to show your support
|
||||
2. 📖 Read the [Deployment Guide](DEPLOYMENT.md)
|
||||
2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app)
|
||||
3. 🐛 Report issues or request features
|
||||
4. 🤝 Join our community and contribute!
|
||||
|
||||
@@ -218,7 +563,9 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
<p align="center">
|
||||
Made with ❤️ by photographers, for photographers
|
||||
<br>
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="DEPLOYMENT.md">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
</p>
|
||||
<a href="https://www.picpeak.app">Homepage</a> •
|
||||
<a href="https://demo.picpeak.app">Live Demo</a> •
|
||||
<a href="https://github.com/PicPeak/picpeak">GitHub</a> •
|
||||
<a href="https://docs.picpeak.app">Documentation</a> •
|
||||
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Release Process
|
||||
|
||||
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs pre-release) see the [Release Channels section in README.md](README.md#-release-channels).
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **`main` branch** receives all merged work (active development). Every push triggers a `release-please` PR that proposes the next `vX.Y.Z-beta.N` pre-release. Merging that PR tags the pre-release and publishes Docker images under the `:main` rolling tag + the version-specific tag.
|
||||
- **`stable` branch** holds the curated stable channel. Stable releases are cut from a known-good `main` point via a `release/X.Y.Z-merge-from-main` branch and a manual PR to `stable`. Merging that PR triggers `release-please` to propose the stable release.
|
||||
- Target cadence: **a stable release every 4–6 weeks**, or sooner if `main` has been quiet and ready for promotion.
|
||||
|
||||
> **Branch model background** — `main` (active dev) was previously called `beta`, and `stable` (curated channel) was previously called `main`. The rename happened with #669 to match the convention every other open-source project uses. The mechanics below all reference the post-rename names.
|
||||
|
||||
## Cadence target
|
||||
|
||||
4–6 weeks between stable releases is the working target. Reasoning:
|
||||
|
||||
- Long enough that each stable carries meaningful changes worth the upgrade burden.
|
||||
- Short enough that pre-release users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
|
||||
- Aligns with how release-please surfaces pre-releases (multiple pre-release points usually accumulate inside a 4–6 week window, which gives natural promotion candidates).
|
||||
|
||||
This is a target, not a hard rule. Cut sooner if `main` has been quiet and stable longer than usual. Cut later if `main` is in flux for security or migration reasons.
|
||||
|
||||
## Promotion criteria
|
||||
|
||||
A `main` tip is eligible for promotion to `stable` when **all** of the following hold:
|
||||
|
||||
1. **CI green on the candidate `main` tip.** Specifically: `schema-drift` (`upgrade-from-bootstrap`), `fresh-install`, `Tests` (backend Jest + frontend Vitest), the four `Build and Push Docker Images` arch matrices, and `GitGuardian Security Checks`.
|
||||
2. **No open `bug`-labelled issues against the candidate for at least 7 days.** Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidate `main` tip before closing them out.
|
||||
3. **An upgrade walk has been done on real production-shaped data** — apply the candidate's migration chain to a snapshot of the previous stable's DB and verify no manual intervention is required. CI proves fresh-install works; the upgrade walk is what proves the upgrade path works.
|
||||
4. **Operator-time smoke** on the candidate: log in, create event, upload photos, share gallery, open as a customer, log out. Catches binary-incompatibility regressions and UI-level breaks that unit tests don't see.
|
||||
|
||||
If any of the four fail, the promotion waits. File any blockers as `bug`-labelled issues and let them bake on `main` before re-evaluating.
|
||||
|
||||
## How a stable release is cut
|
||||
|
||||
The actual mechanics, in order:
|
||||
|
||||
1. **Pick the `main` tip.** Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting.
|
||||
|
||||
2. **Create the release branch from the `main` tip.**
|
||||
```bash
|
||||
git push origin <main-tip-sha>:refs/heads/release/X.Y.Z-merge-from-main
|
||||
```
|
||||
Naming convention: `release/X.Y.Z-merge-from-main`, where `X.Y.Z` is the stable version you intend to land. release-please will write the actual `X.Y.Z` on merge — the branch name is just a human label.
|
||||
|
||||
3. **Open a PR to `stable`.** Title: `chore(release): promote main → stable as vX.Y.Z`. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example (predates the rename; the mechanics are unchanged).
|
||||
|
||||
4. **Resolve conflicts.** `stable` almost always has commits `main` doesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:
|
||||
- **`backend/package.json` / `package-lock.json` + `frontend/package.json` / `package-lock.json`** — usually take `main`'s version (superset), but verify any security-pinned deps (`axios`, `nodemailer`, `i18next-http-backend`, `multer`, `tar`) on `main` are `>=` the pinned versions on `stable`. If `stable` has a newer pinned version (e.g. an emergency CVE backport `main` hasn't picked up), take `stable`'s pin.
|
||||
- **`README.md`** — keep `stable`'s version if it has had a recent rewrite that `main` didn't pick up; otherwise take `main`'s.
|
||||
- **`CHANGELOG.md`** — keep `stable`'s; release-please regenerates entries on its next stable cut from the commits going forward.
|
||||
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
|
||||
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
|
||||
|
||||
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
|
||||
|
||||
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
|
||||
|
||||
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
|
||||
|
||||
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
|
||||
|
||||
## Hotfix path (backport to current stable)
|
||||
|
||||
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.
|
||||
|
||||
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
|
||||
|
||||
## Versioning
|
||||
|
||||
PicPeak follows [Semantic Versioning](https://semver.org/) with one project-specific convention:
|
||||
|
||||
- **MAJOR** bumps are reserved for breaking schema changes that require operator action on upgrade (e.g. a migration that's not safe to auto-apply, an env-var rename that can't be auto-detected).
|
||||
- **MINOR** bumps for new features, additive schema changes, and any change to the public HTTP API surface.
|
||||
- **PATCH** bumps for bug fixes and operator-invisible internal changes.
|
||||
- **Pre-release suffix** (`-beta.N`) for every `main`-channel cut; the `N` counter resets on each new MINOR or MAJOR target. The suffix kept the historical `-beta` literal even after the branch rename — operators were already pinning to `v3.x.y-beta.N` and changing the literal would have broken those pins.
|
||||
|
||||
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
|
||||
|
||||
## Things that don't go through this process
|
||||
|
||||
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
|
||||
- **Test-only changes** — same.
|
||||
- **CI / workflow changes** — same, but be aware they take effect on the branch they land on, so a CI fix targeting `main` won't fix a broken stable-channel workflow until the next promotion.
|
||||
|
||||
## When this doc is wrong
|
||||
|
||||
If you find yourself working around something here, update the doc before doing the workaround. The point of a written process is that future-you doesn't have to remember the workaround.
|
||||
+7
-7
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Currently supported versions:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
| 2.x.x | :white_check_mark: |
|
||||
| < 2.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -15,9 +15,9 @@ We take the security of PicPeak seriously. If you have discovered a security vul
|
||||
|
||||
### 1. **Do NOT create a public GitHub issue**
|
||||
|
||||
### 2. Report the vulnerability by:
|
||||
- Opening a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
- Mark it clearly as "SECURITY" in the title
|
||||
### 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
|
||||
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: [Create a security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
|
||||
- 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!
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
# 🚀 PicPeak Simple Setup Guide
|
||||
|
||||
This guide provides easy installation instructions for PicPeak on Linux servers with both Docker and non-Docker options.
|
||||
|
||||
## 📋 Quick Start
|
||||
|
||||
### One-Line Installation
|
||||
|
||||
```bash
|
||||
# Download and run the unified setup script
|
||||
curl -fsSL https://raw.githubusercontent.com/PicPeak/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will automatically detect your environment and recommend the best installation method.
|
||||
|
||||
## 🎯 Installation Methods
|
||||
|
||||
### Method 1: Docker Installation (Recommended)
|
||||
Best for: Most users, easy updates, isolated environment
|
||||
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --docker
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Easier installation and updates
|
||||
- ✅ Better isolation from system
|
||||
- ✅ Consistent environment across platforms
|
||||
- ✅ Built-in PostgreSQL and Redis
|
||||
|
||||
**Cons:**
|
||||
- ❌ Requires more resources (~4GB RAM recommended)
|
||||
- ❌ Additional Docker overhead
|
||||
|
||||
### Method 2: Native Installation
|
||||
Best for: Resource-constrained systems, Raspberry Pi, direct control
|
||||
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --native
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Lower resource usage (~1GB RAM minimum)
|
||||
- ✅ Direct system control
|
||||
- ✅ No Docker overhead
|
||||
- ✅ Better for ARM devices
|
||||
|
||||
**Cons:**
|
||||
- ❌ More complex setup
|
||||
- ❌ System dependencies required
|
||||
- ❌ Manual update process
|
||||
|
||||
## 📋 System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
- **OS**: Ubuntu 20.04+, Debian 11+, Fedora 38+, RHEL/CentOS 8+, Raspberry Pi OS
|
||||
- **RAM**:
|
||||
- Docker: 2GB minimum (4GB recommended)
|
||||
- Native: 1GB minimum (2GB recommended)
|
||||
- **Storage**: 2GB for application + space for photos
|
||||
- **Network**: Port 3001 (or 80/443 with proxy)
|
||||
|
||||
### Supported Platforms
|
||||
- ✅ Ubuntu 20.04, 22.04, 24.04
|
||||
- ✅ Debian 11, 12
|
||||
- ✅ Raspberry Pi OS (32-bit and 64-bit)
|
||||
- ✅ Fedora 38, 39, 40
|
||||
- ✅ RHEL/CentOS/Rocky/AlmaLinux 8, 9
|
||||
|
||||
## 🛠️ Installation Options
|
||||
|
||||
### Interactive Mode (Default)
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will prompt you to choose:
|
||||
1. Installation method (Docker or Native)
|
||||
2. Admin email and password
|
||||
3. Domain configuration (optional)
|
||||
4. Email server settings (optional)
|
||||
5. SSL/HTTPS setup (optional)
|
||||
|
||||
### Unattended Installation
|
||||
|
||||
#### Docker with full configuration:
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --docker --unattended \
|
||||
--domain photos.example.com \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123 \
|
||||
--smtp-host smtp.gmail.com \
|
||||
--smtp-port 587 \
|
||||
--smtp-user your-email@gmail.com \
|
||||
--smtp-pass your-app-password \
|
||||
--enable-ssl
|
||||
```
|
||||
|
||||
#### Native with minimal configuration:
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --native --unattended \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
|
||||
| Option | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `--docker` | Use Docker installation | `--docker` |
|
||||
| `--native` | Use native installation | `--native` |
|
||||
| `--unattended` | Run without prompts | `--unattended` |
|
||||
| `--domain` | Domain for HTTPS setup | `--domain photos.example.com` |
|
||||
| `--email` | Admin email address | `--email admin@example.com` |
|
||||
| `--admin-password` | Set admin password | `--admin-password MySecurePass` |
|
||||
| `--smtp-host` | SMTP server hostname | `--smtp-host smtp.gmail.com` |
|
||||
| `--smtp-port` | SMTP server port | `--smtp-port 587` |
|
||||
| `--smtp-user` | SMTP username | `--smtp-user user@gmail.com` |
|
||||
| `--smtp-pass` | SMTP password | `--smtp-pass app-password` |
|
||||
| `--enable-ssl` | Enable HTTPS with Let's Encrypt | `--enable-ssl` |
|
||||
| `--port` | Custom port (native only) | `--port 8080` |
|
||||
| `--update` | Update existing installation | `--update` |
|
||||
| `--uninstall` | Remove installation | `--uninstall` |
|
||||
| `--help` | Show help message | `--help` |
|
||||
|
||||
## 🏗️ What Gets Installed
|
||||
|
||||
### Docker Installation
|
||||
```
|
||||
~/picpeak/ # Or custom directory
|
||||
├── docker-compose.yml # Service definitions
|
||||
├── .env # Configuration
|
||||
├── storage/
|
||||
│ └── events/ # Photo storage
|
||||
│ ├── active/ # Current galleries
|
||||
│ └── archived/ # Expired galleries
|
||||
├── logs/ # Application logs
|
||||
└── backup/ # Backup directory
|
||||
```
|
||||
|
||||
**Services:**
|
||||
- PicPeak Backend (Node.js application)
|
||||
- PostgreSQL Database
|
||||
- Redis Cache
|
||||
- Nginx Reverse Proxy (optional)
|
||||
- Background Workers
|
||||
|
||||
### Native Installation
|
||||
```
|
||||
/opt/picpeak/ # Installation directory
|
||||
├── backend/ # Application code
|
||||
├── events/ # Photo storage
|
||||
│ ├── active/ # Current galleries
|
||||
│ └── archived/ # Expired galleries
|
||||
├── logs/ # Application logs
|
||||
└── config/ # Configuration files
|
||||
```
|
||||
|
||||
**Services (systemd):**
|
||||
- `picpeak-backend` - Main application
|
||||
- `picpeak-workers` - Background workers
|
||||
- `caddy` - Web server (optional)
|
||||
|
||||
## 🔑 First Login — Create Your Admin
|
||||
|
||||
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
|
||||
|
||||
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. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
|
||||
```bash
|
||||
docker compose logs backend | grep -i "setup token"
|
||||
```
|
||||
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
|
||||
|
||||
## 🌐 Access Methods
|
||||
|
||||
### Direct Access (Simplest)
|
||||
- Docker: `http://your-server:3000` (frontend and admin at `/admin`)
|
||||
- Backend/API: `http://your-server:3001` (API only; no UI routes)
|
||||
|
||||
For native installs, serve the built frontend (e.g., with nginx or Caddy) and access the admin at `/admin` on the frontend domain.
|
||||
|
||||
### With Domain & HTTPS
|
||||
If configured during setup:
|
||||
- `https://your-domain.com` - Gallery frontend
|
||||
- `https://your-domain.com/admin` - Admin panel
|
||||
|
||||
### Behind Existing Proxy
|
||||
Add to your Nginx/Apache configuration (split frontend vs backend):
|
||||
```nginx
|
||||
# Frontend (UI + /admin/*)
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend API and protected resources
|
||||
location /api {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
location ~ ^/(photos|thumbnails|uploads) {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
## 📁 Managing Galleries
|
||||
|
||||
### Creating a Gallery
|
||||
|
||||
#### Via Admin Panel
|
||||
1. Login to admin panel at `/admin`
|
||||
2. Click "Create New Event"
|
||||
3. Configure settings (name, date, password, customer email)
|
||||
4. Upload photos via drag & drop in the Photos tab
|
||||
5. Publish the gallery when ready
|
||||
|
||||
#### Adding Photos via File System
|
||||
|
||||
> **Important:** You must first create the event in the admin panel. The file watcher only detects new photos for events that already exist in the database. You cannot create a gallery by copying files alone.
|
||||
|
||||
Once an event exists, you can add photos by copying them into the event's folder. PicPeak's built-in file watcher will automatically detect the new files, create database records, and generate thumbnails.
|
||||
|
||||
```bash
|
||||
# Docker installation — copy photos into an existing event's folder
|
||||
cp /path/to/photos/*.jpg ~/picpeak/storage/events/active/<event-slug>/
|
||||
|
||||
# Native installation
|
||||
sudo cp /path/to/photos/*.jpg /opt/picpeak/events/active/<event-slug>/
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/<event-slug>
|
||||
```
|
||||
|
||||
The event slug is visible in the admin panel URL or share link (e.g. `wedding-smith-2024`). Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`. The file watcher has a 2-second stability delay before processing new files.
|
||||
|
||||
### Gallery Structure
|
||||
```
|
||||
<event-slug>/
|
||||
├── collages/ # Group photos (optional subfolder)
|
||||
├── individual/ # Individual photos (optional subfolder)
|
||||
└── photo.jpg # Photos at root level also work
|
||||
```
|
||||
|
||||
## 🔧 Service Management
|
||||
|
||||
### Docker Installation
|
||||
|
||||
```bash
|
||||
cd ~/picpeak
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker compose down
|
||||
|
||||
# Start services
|
||||
docker compose up -d
|
||||
|
||||
# Restart services
|
||||
docker compose restart
|
||||
|
||||
# Update PicPeak
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Native Installation
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
sudo systemctl status picpeak-backend
|
||||
sudo systemctl status picpeak-workers
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u picpeak-backend -f
|
||||
sudo journalctl -u picpeak-workers -f
|
||||
|
||||
# Start services
|
||||
sudo systemctl start picpeak-backend picpeak-workers
|
||||
|
||||
# Stop services
|
||||
sudo systemctl stop picpeak-backend picpeak-workers
|
||||
|
||||
# Restart services
|
||||
sudo systemctl restart picpeak-backend picpeak-workers
|
||||
|
||||
# Update PicPeak
|
||||
# (reruns migrations to pick up schema fixes for native installs)
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Docker Configuration
|
||||
Edit `~/picpeak/.env`:
|
||||
```bash
|
||||
nano ~/picpeak/.env
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### Native Configuration
|
||||
Edit `/opt/picpeak/app/backend/.env`:
|
||||
```bash
|
||||
sudo nano /opt/picpeak/app/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
### Key Settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| `JWT_SECRET` | Token signing secret | Auto-generated |
|
||||
| `ADMIN_EMAIL` | Admin email | admin@example.com |
|
||||
| `ADMIN_PASSWORD` | Admin password | Auto-generated |
|
||||
| `PHOTOS_DIR` | Photo storage path | Varies by method |
|
||||
| `SMTP_ENABLED` | Email notifications | false |
|
||||
| `DEFAULT_EXPIRY_DAYS` | Gallery expiration | 30 |
|
||||
|
||||
## 📧 Email Configuration
|
||||
|
||||
### Gmail Setup
|
||||
1. Enable 2-Factor Authentication
|
||||
2. Generate App Password
|
||||
3. Configure:
|
||||
```env
|
||||
SMTP_ENABLED=true
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
### SendGrid Setup
|
||||
1. Sign up at sendgrid.com (100 emails/day free)
|
||||
2. Create API key
|
||||
3. Configure:
|
||||
```env
|
||||
SMTP_ENABLED=true
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
SMTP_FROM=verified-sender@yourdomain.com
|
||||
```
|
||||
|
||||
## 🔄 Maintenance
|
||||
|
||||
### Backups
|
||||
|
||||
#### Docker:
|
||||
```bash
|
||||
# Backup script included
|
||||
cd ~/picpeak
|
||||
./backup.sh
|
||||
|
||||
# Manual backup
|
||||
docker exec picpeak-postgres pg_dump -U picpeak picpeak > backup.sql
|
||||
tar -czf photos-backup.tar.gz storage/events/
|
||||
```
|
||||
|
||||
#### Native:
|
||||
```bash
|
||||
# Database backup
|
||||
sudo cp /opt/picpeak/app/backend/data/photo_sharing.db /backup/database-$(date +%Y%m%d).sqlite
|
||||
|
||||
# Photos backup
|
||||
sudo tar -czf /backup/photos-$(date +%Y%m%d).tar.gz /opt/picpeak/events/
|
||||
```
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
cd ~/picpeak
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Native
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
# Will prompt for confirmation and data removal options
|
||||
sudo ./picpeak-setup.sh --uninstall
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Service Won't Start
|
||||
```bash
|
||||
# Docker
|
||||
docker compose logs backend
|
||||
docker compose down && docker compose up -d
|
||||
|
||||
# Native
|
||||
sudo journalctl -u picpeak-backend -n 50
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
#### Can't Access Admin Panel
|
||||
1. Check firewall:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 3001
|
||||
|
||||
# RHEL/CentOS
|
||||
sudo firewall-cmd --add-port=3001/tcp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
2. Verify service:
|
||||
```bash
|
||||
# Docker
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Native
|
||||
sudo systemctl is-active picpeak-backend
|
||||
```
|
||||
|
||||
#### Photos Not Showing
|
||||
```bash
|
||||
# Check permissions (Native)
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/
|
||||
sudo chmod -R 755 /opt/picpeak/events/
|
||||
|
||||
# Check permissions (Docker)
|
||||
ls -la ~/picpeak/storage/events/
|
||||
```
|
||||
|
||||
#### Reset Admin Password
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker exec picpeak-backend node scripts/reset-admin-password.js
|
||||
|
||||
# Native
|
||||
cd /opt/picpeak/app/backend
|
||||
sudo -u picpeak node scripts/reset-admin-password.js
|
||||
```
|
||||
|
||||
> **Note:** The new password will be displayed in the console output and saved to `ADMIN_PASSWORD_RESET.txt`. Save it immediately!
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. **Check logs:**
|
||||
- Docker: `docker compose logs -f`
|
||||
- Native: `sudo journalctl -u picpeak-backend -f`
|
||||
- Installation: `/tmp/picpeak-setup-*.log`
|
||||
|
||||
2. **Documentation:**
|
||||
- [Full Documentation](https://docs.picpeak.app)
|
||||
- [Deployment Guide](https://docs.picpeak.app/deployment)
|
||||
|
||||
3. **Support:**
|
||||
- [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
|
||||
- Include: Error messages, system info (`uname -a`), installation method
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
### Essential Security
|
||||
1. **Change default admin password immediately**
|
||||
2. **Use HTTPS for production** (Let's Encrypt included)
|
||||
3. **Configure firewall** (only open necessary ports)
|
||||
4. **Regular updates** (system and PicPeak)
|
||||
5. **Automated backups** (configure in admin panel)
|
||||
|
||||
### Advanced Security
|
||||
- Use VPN for admin panel access
|
||||
- Configure fail2ban for brute force protection
|
||||
- Enable audit logging
|
||||
- Regular security scans
|
||||
- Implement IP whitelisting
|
||||
|
||||
## 📊 Performance Optimization
|
||||
|
||||
### Docker Optimization
|
||||
```yaml
|
||||
# Adjust in docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
```
|
||||
|
||||
### Native Optimization
|
||||
```bash
|
||||
# Increase Node.js memory
|
||||
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/app/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
## 🎯 Quick Setup Examples
|
||||
|
||||
### Home/Office Network
|
||||
```bash
|
||||
# Simple local setup without domain
|
||||
sudo ./picpeak-setup.sh --native --email admin@local.com
|
||||
```
|
||||
|
||||
### Public Website with HTTPS
|
||||
```bash
|
||||
# Full production setup
|
||||
sudo ./picpeak-setup.sh --docker \
|
||||
--domain photos.company.com \
|
||||
--email admin@company.com \
|
||||
--enable-ssl
|
||||
```
|
||||
|
||||
### Raspberry Pi Setup
|
||||
```bash
|
||||
# Optimized for ARM devices
|
||||
sudo ./picpeak-setup.sh --native \
|
||||
--port 8080 \
|
||||
--email pi@local.com
|
||||
```
|
||||
|
||||
## ✅ Post-Installation Checklist
|
||||
|
||||
- [ ] Admin password changed
|
||||
- [ ] Email configuration tested
|
||||
- [ ] First test gallery created
|
||||
- [ ] Backup schedule configured
|
||||
- [ ] Firewall rules applied
|
||||
- [ ] SSL certificate working (if applicable)
|
||||
- [ ] Monitoring setup
|
||||
- [ ] Documentation bookmarked
|
||||
|
||||
---
|
||||
|
||||
**PicPeak Setup v1.0** | [Documentation](https://github.com/PicPeak/picpeak) | [Support](https://github.com/PicPeak/picpeak/issues)
|
||||
@@ -9,11 +9,67 @@ PORT=3001
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
|
||||
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
|
||||
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
|
||||
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
|
||||
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
|
||||
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
|
||||
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
|
||||
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
# Generate with: openssl rand -base64 32
|
||||
#MFA_ENCRYPTION_KEY=
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: 'auto' in production, false in dev (#427)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
||||
# login appears to succeed but the browser silently drops the
|
||||
# cookie, leaving you in a redirect loop. Only set this if you
|
||||
# ALWAYS reach the site via HTTPS)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP. Reads
|
||||
# req.secure from Express which respects X-Forwarded-Proto from a
|
||||
# trusted reverse proxy. This is the default and is the right
|
||||
# choice for most deployments.
|
||||
#
|
||||
# Why 'auto' is the default in production:
|
||||
# - On real HTTPS (reverse proxy with X-Forwarded-Proto), req.secure is
|
||||
# true → Secure flag is still emitted. No security regression vs. true.
|
||||
# - On plain HTTP (LAN access, first-time install before reverse proxy is
|
||||
# wired up), req.secure is false → Secure flag is omitted → login works
|
||||
# instead of silently looping back to /admin/login.
|
||||
#
|
||||
# When you'd set this explicitly:
|
||||
# - COOKIE_SECURE=true → strict HTTPS-only deployments where you want
|
||||
# defense in depth against accidentally serving over HTTP.
|
||||
# - COOKIE_SECURE=false → you intentionally only ever serve over HTTP and
|
||||
# don't want the per-request check (rare).
|
||||
#
|
||||
# Requirements for 'auto' mode to detect HTTPS correctly:
|
||||
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
|
||||
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
|
||||
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
|
||||
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
|
||||
# 192.168.x, link-local). Proxies outside those ranges need custom
|
||||
# trust proxy configuration.
|
||||
# COOKIE_SECURE=auto
|
||||
|
||||
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
|
||||
# COOKIE_SAMESITE=Lax
|
||||
|
||||
# Cookie Domain — set this if serving auth cookies across subdomains.
|
||||
# Leave unset for same-origin setups.
|
||||
# COOKIE_DOMAIN=.example.com
|
||||
|
||||
# URLs (adjust for your domain)
|
||||
ADMIN_URL=https://photos.example.com
|
||||
FRONTEND_URL=https://photos.example.com
|
||||
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
|
||||
|
||||
# API URL for email assets (logos, images in emails)
|
||||
# This must be the publicly accessible URL where recipients can load images
|
||||
# If not set, defaults to http://localhost:3001 which will break images in production emails
|
||||
API_URL=https://photos.example.com/api
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=localhost
|
||||
@@ -39,6 +95,7 @@ SMTP_PASS=your-sendgrid-api-key
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
# Storage Paths
|
||||
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
|
||||
# Docker deployment:
|
||||
STORAGE_PATH=/app/storage
|
||||
EVENTS_PATH=/app/storage/events
|
||||
|
||||
+69
-10
@@ -1,26 +1,68 @@
|
||||
FROM node:18-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Add build argument for cache busting
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
# Add labels for GitHub Container Registry
|
||||
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
# Install dependencies (--omit=dev replaces deprecated --only=production)
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
||||
RUN apk add --no-cache dumb-init postgresql-client
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# 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
|
||||
# privilege drop in wait-for-db.sh (see #484: container starts as root so it
|
||||
# can chown bind-mounted host volumes to UID 1001, then re-execs as nodejs
|
||||
# before running the app). Alpine's ffmpeg package ships both `ffmpeg` and
|
||||
# `ffprobe` built natively against musl libc — the npm
|
||||
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
|
||||
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
|
||||
# pipeline calls via fluent-ffmpeg.ffprobe()).
|
||||
# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that
|
||||
# contain live <text> for the CRM PDFs. Without any font installed, librsvg
|
||||
# renders text as tofu boxes (□) while the vector artwork still draws — i.e.
|
||||
# a "corrupted" logo on invoices/quotes. DejaVu/Liberation provide a broad
|
||||
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
|
||||
# PDFKit + the web UI use) are registered with fontconfig further down so the
|
||||
# logo's text renders in its actual typeface, not a fallback.
|
||||
# poppler-utils provides `pdftoppm`, used to rasterise inbound supplier-invoice
|
||||
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
|
||||
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
|
||||
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
|
||||
# documents (see docs/accounting-inbound-invoices.md).
|
||||
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
|
||||
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
|
||||
fc-cache -f
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
@@ -29,16 +71,33 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||
COPY --chown=nodejs:nodejs . .
|
||||
|
||||
# Make wait script executable
|
||||
RUN chmod +x wait-for-db.sh
|
||||
# Ensure all source files are readable and wait script is executable
|
||||
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
|
||||
|
||||
# Register picpeak's bundled brand fonts (assets/fonts/<Family>/*.ttf — the
|
||||
# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg
|
||||
# rasterises an SVG logo its <text> renders in the actual brand typeface
|
||||
# rather than a DejaVu/Liberation fallback. fontconfig indexes by each font's
|
||||
# internal family name and recurses into the per-family subdirectories.
|
||||
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
|
||||
fc-cache -f /app/assets/fonts
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
|
||||
chown -R nodejs:nodejs storage data logs
|
||||
|
||||
USER nodejs
|
||||
# No USER directive — the container starts as root so wait-for-db.sh can
|
||||
# chown bind-mounted host directories to UID 1001 before dropping privs
|
||||
# via su-exec. See #484 for the fresh-install restart loop this avoids.
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Healthcheck hits the same /health endpoint already used by the e2e
|
||||
# runner and by the docker-compose `depends_on: condition: service_healthy`
|
||||
# checks. wget is part of the Alpine base image. Long start-period covers
|
||||
# the wait-for-db.sh delay before the Node process starts listening.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["./wait-for-db.sh", "node", "server.js"]
|
||||
|
||||
+11
-3
@@ -1,9 +1,14 @@
|
||||
FROM node:18-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
|
||||
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
|
||||
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
|
||||
RUN apk add --no-cache dumb-init ffmpeg
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
@@ -25,5 +30,8 @@ USER nodejs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -0,0 +1,184 @@
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('Admin settings logo upload flow', () => {
|
||||
let tmpDir;
|
||||
let router;
|
||||
let app;
|
||||
let settingsStore;
|
||||
|
||||
const resetModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetModules();
|
||||
|
||||
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
|
||||
settingsStore = new Map();
|
||||
|
||||
const buildQuery = (table) => {
|
||||
const filters = [];
|
||||
const applyFilters = (rows) => {
|
||||
if (filters.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) =>
|
||||
filters.every(({ column, value }) => row[column] === value)
|
||||
);
|
||||
};
|
||||
|
||||
const makeRow = (row) => ({ ...row });
|
||||
|
||||
return {
|
||||
where(column, value) {
|
||||
filters.push({ column, value });
|
||||
return this;
|
||||
},
|
||||
first() {
|
||||
if (table === 'app_settings') {
|
||||
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
|
||||
return Promise.resolve(rows[0]);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
sum() {
|
||||
return Promise.resolve({ total: 0 });
|
||||
},
|
||||
join() {
|
||||
return this;
|
||||
},
|
||||
groupBy() {
|
||||
return this;
|
||||
},
|
||||
orderBy() {
|
||||
return this;
|
||||
},
|
||||
limit() {
|
||||
return this;
|
||||
},
|
||||
insert(payload) {
|
||||
const rows = Array.isArray(payload) ? payload : [payload];
|
||||
const upsert = (row, overrides = {}) => {
|
||||
if (table === 'app_settings') {
|
||||
const key = row.setting_key;
|
||||
const existing = settingsStore.get(key) || {};
|
||||
settingsStore.set(key, { ...existing, ...row, ...overrides });
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
return {
|
||||
onConflict() {
|
||||
return {
|
||||
merge(overrides) {
|
||||
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const dbMock = jest.fn((table) => buildQuery(table));
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.transaction = async (handler) => handler({
|
||||
commit: async () => {},
|
||||
rollback: async () => {}
|
||||
});
|
||||
|
||||
jest.doMock('../src/database/db', () => ({
|
||||
db: dbMock,
|
||||
logActivity: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/auth', () => ({
|
||||
adminAuth: (req, res, next) => {
|
||||
req.admin = { id: 1, username: 'tester' };
|
||||
next();
|
||||
}
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/publicSiteService', () => ({
|
||||
clearPublicSiteCache: jest.fn(),
|
||||
getDefaultPublicSitePayload: jest.fn(),
|
||||
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/rateLimitService', () => ({
|
||||
clearSettingsCache: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/maintenance', () => ({
|
||||
maintenanceMiddleware: (req, res, next) => next(),
|
||||
clearMaintenanceCache: jest.fn()
|
||||
}));
|
||||
|
||||
router = require('../src/routes/adminSettings');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/settings', router);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetModules();
|
||||
if (tmpDir) {
|
||||
await fsPromises.rm(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = null;
|
||||
}
|
||||
delete process.env.STORAGE_PATH;
|
||||
});
|
||||
|
||||
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
|
||||
const fileBuffer = Buffer.from('fake image data');
|
||||
|
||||
const uploadResponse = await request(app)
|
||||
.post('/api/admin/settings/logo')
|
||||
.attach('logo', fileBuffer, 'logo.png');
|
||||
|
||||
expect(uploadResponse.status).toBe(200);
|
||||
expect(uploadResponse.body).toHaveProperty('logoUrl');
|
||||
const logoUrl = uploadResponse.body.logoUrl;
|
||||
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
|
||||
|
||||
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
|
||||
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
|
||||
|
||||
await request(app)
|
||||
.put('/api/admin/settings/branding')
|
||||
.send({
|
||||
company_name: 'Test Co',
|
||||
company_tagline: 'Tagline',
|
||||
support_email: 'test@example.com',
|
||||
footer_text: 'Footer',
|
||||
watermark_enabled: false,
|
||||
watermark_position: 'bottom-right',
|
||||
watermark_opacity: 0.5,
|
||||
watermark_size: 'medium',
|
||||
favicon_url: null,
|
||||
logo_url: '',
|
||||
watermark_logo_url: null,
|
||||
logo_size: 'medium',
|
||||
logo_max_height: 120,
|
||||
logo_position: 'left',
|
||||
logo_display_header: true,
|
||||
logo_display_hero: false,
|
||||
logo_display_mode: 'default'
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await expect(fsPromises.access(storedPath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Integration test for GET /api/admin/system-health/backup-coverage.
|
||||
*
|
||||
* Pins the Stage C diagnostic that tells admins what the next
|
||||
* "Run Backup Now" will include, skip, or silently miss.
|
||||
*
|
||||
* Test surface:
|
||||
* 1. Empty / fresh install → default seed (7 paths), inline mode,
|
||||
* no DB dump on file yet, no drift
|
||||
* 2. Toggle `include_in_default=false` → coverage flips to
|
||||
* 'skipped-by-toggle'
|
||||
* 3. Feature_flag gating reflects the actual app_settings value
|
||||
* (events/archived ⇄ backup_include_archived)
|
||||
* 4. Drift detection: a top-level subdir on disk with no
|
||||
* `backup_paths` row is flagged in `unconfiguredOnDisk`
|
||||
* 5. Allow-list: `backups/` and `tmp/` are never flagged as drift
|
||||
* 6. Scheduled-only mode + recent dump → `database.ok = true`
|
||||
* 7. Scheduled-only mode + stale (>26h) dump → `database.ok = false`
|
||||
* and `lastDumpStale = true`
|
||||
*
|
||||
* Same auth/permission pass-through strategy as
|
||||
* adminBackupIntegrity.test.js — we exercise the route's logic,
|
||||
* not the auth middleware.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
|
||||
customerAuth: (_req, _res, next) => next(),
|
||||
galleryAuth: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-coverage', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
|
||||
const route = require('../../src/routes/adminSystemHealth');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/system-health', route);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function mkdir(rel) {
|
||||
fs.mkdirSync(path.join(storagePath, rel), { recursive: true });
|
||||
}
|
||||
|
||||
function rmdir(rel) {
|
||||
fs.rmSync(path.join(storagePath, rel), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function restoreDefaultPaths() {
|
||||
await db('backup_paths').del();
|
||||
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
|
||||
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await restoreDefaultPaths();
|
||||
await db('database_backup_runs').del().catch(() => {});
|
||||
await db('app_settings').where('setting_type', 'backup').del().catch(() => {});
|
||||
});
|
||||
|
||||
it('returns the canonical 7 paths + database block on a fresh install', async () => {
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('report');
|
||||
|
||||
const { report } = res.body;
|
||||
expect(report.paths.map((p) => p.path)).toEqual([
|
||||
'events/active',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'previews',
|
||||
'heroes',
|
||||
'uploads',
|
||||
'business-docs',
|
||||
]);
|
||||
|
||||
// Default mode is inline — no inline_dump setting present means
|
||||
// "inline is ON" (matches ensureDatabaseDumpForBackup semantics).
|
||||
expect(report.database.mode).toBe('inline');
|
||||
expect(report.database.ok).toBe(true);
|
||||
|
||||
expect(report.summary).toMatchObject({
|
||||
configuredCount: 7,
|
||||
tableMissingFallbackInUse: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('flips a path to skipped-by-toggle when include_in_default=false', async () => {
|
||||
await db('backup_paths').where('path', 'thumbnails').update({
|
||||
include_in_default: false,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const thumbnails = res.body.report.paths.find((p) => p.path === 'thumbnails');
|
||||
expect(thumbnails.coverage).toBe('skipped-by-toggle');
|
||||
expect(thumbnails.includeInDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('feature_flag gating reflects app_settings (archived path off vs on)', async () => {
|
||||
// backup_include_archived not set → archived skipped via flag
|
||||
const off = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const archivedOff = off.body.report.paths.find((p) => p.path === 'events/archived');
|
||||
expect(archivedOff.coverage).toBe('skipped-by-feature-flag');
|
||||
expect(archivedOff.featureFlag).toBe('backup_include_archived');
|
||||
expect(archivedOff.featureFlagValue).toBe(null); // unset
|
||||
|
||||
// Now set the flag — but path is missing on disk, so coverage
|
||||
// resolves to 'missing-on-disk', proving the flag was honoured.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_include_archived',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const on = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const archivedOn = on.body.report.paths.find((p) => p.path === 'events/archived');
|
||||
expect(archivedOn.featureFlagValue).toBe(true);
|
||||
// No on-disk dir → 'missing-on-disk' (not 'skipped-by-feature-flag')
|
||||
expect(['missing-on-disk', 'will-scan']).toContain(archivedOn.coverage);
|
||||
});
|
||||
|
||||
it('detects unconfigured top-level subdirs as drift', async () => {
|
||||
mkdir('events/active'); // configured
|
||||
mkdir('plugin-store/cache'); // DRIFT
|
||||
mkdir('shiny-new-feature/data'); // DRIFT
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).toEqual(expect.arrayContaining([
|
||||
'plugin-store',
|
||||
'shiny-new-feature',
|
||||
]));
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('events');
|
||||
|
||||
rmdir('plugin-store');
|
||||
rmdir('shiny-new-feature');
|
||||
});
|
||||
|
||||
it('never flags backups/ or tmp/ as drift (allow-list)', async () => {
|
||||
mkdir('backups');
|
||||
mkdir('tmp');
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('backups');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('tmp');
|
||||
expect(res.body.report.drift.expectedNonBackupDirs).toEqual(
|
||||
expect.arrayContaining(['backups', 'tmp']),
|
||||
);
|
||||
|
||||
rmdir('backups');
|
||||
rmdir('tmp');
|
||||
});
|
||||
|
||||
it('scheduled-only mode + recent dump → database.ok=true, not stale', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const recentDump = path.join(storagePath, 'backups', 'recent.sql.gz');
|
||||
fs.mkdirSync(path.dirname(recentDump), { recursive: true });
|
||||
fs.writeFileSync(recentDump, 'pretend dump');
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(), // just now
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: recentDump,
|
||||
file_size_bytes: fs.statSync(recentDump).size,
|
||||
destination_path: recentDump,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.database.mode).toBe('scheduled-only');
|
||||
expect(res.body.report.database.inlineDumpExplicitlyDisabled).toBe(true);
|
||||
expect(res.body.report.database.lastDumpStale).toBe(false);
|
||||
expect(res.body.report.database.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduled-only mode + stale dump → database.ok=false, lastDumpStale=true', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const oldDump = path.join(storagePath, 'backups', 'old.sql.gz');
|
||||
fs.mkdirSync(path.dirname(oldDump), { recursive: true });
|
||||
fs.writeFileSync(oldDump, 'pretend old dump');
|
||||
// 48 hours ago — well past the 26h staleness threshold. ISO
|
||||
// string instead of a Date object because knex-sqlite's datetime
|
||||
// serialisation has a quirk where some Date instances coerce to
|
||||
// '[object Object]' on insert (the test 6 "recent dump" case
|
||||
// passes only because `new Date()` happens to round-trip safely;
|
||||
// arithmetic Dates don't).
|
||||
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: stale,
|
||||
completed_at: stale,
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: oldDump,
|
||||
file_size_bytes: fs.statSync(oldDump).size,
|
||||
destination_path: oldDump,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.database.lastDumpStale).toBe(true);
|
||||
expect(res.body.report.database.ok).toBe(false);
|
||||
// Top-level summary reflects the failed DB check.
|
||||
expect(res.body.report.summary.databaseOk).toBe(false);
|
||||
expect(res.body.report.summary.overallOk).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Integration test for GET /api/admin/system-health/backup-integrity.
|
||||
*
|
||||
* Auth + permission middleware are mocked to pass-through so the test
|
||||
* focuses on the route's own behaviour: scope-param validation, the
|
||||
* successResponse envelope, and that the underlying service report
|
||||
* surfaces correctly in the JSON body.
|
||||
*
|
||||
* The verifier service itself is exercised against the real schema
|
||||
* (bootCrmDb) and real filesystem — only the auth gate is stubbed.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Pass-through auth so we don't need to mint JWTs.
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
|
||||
customerAuth: (_req, _res, next) => next(),
|
||||
galleryAuth: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// Pass-through permissions so settings.view always allows.
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-integrity', () => {
|
||||
let cleanup;
|
||||
let db;
|
||||
let customerId;
|
||||
let app;
|
||||
let storagePath;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
|
||||
// Mount the route on a minimal Express app. Cold-require after
|
||||
// bootCrmDb so the route's downstream `require('../database/db')`
|
||||
// sees the same db instance.
|
||||
const route = require('../../src/routes/adminSystemHealth');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/system-health', route);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('contracts').del().catch(() => {});
|
||||
await db('invoices').del().catch(() => {});
|
||||
await db('quotes').del().catch(() => {});
|
||||
});
|
||||
|
||||
it('returns a report envelope when nothing references any path', async () => {
|
||||
const res = await request(app).get('/api/admin/system-health/backup-integrity');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('report');
|
||||
expect(res.body.report.summary).toMatchObject({
|
||||
totalRows: 0,
|
||||
missingFiles: 0,
|
||||
hashMismatches: 0,
|
||||
verifiedOk: 0,
|
||||
existsButNoHash: 0,
|
||||
});
|
||||
expect(res.body.report.scopes).toEqual(expect.arrayContaining([
|
||||
'quote', 'contract', 'contract-signature', 'invoice',
|
||||
]));
|
||||
});
|
||||
|
||||
it('surfaces a missing file in the response payload', async () => {
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-B7-MISSING',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-B7-MISSING.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-integrity');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.report.summary.missingFiles).toBe(1);
|
||||
expect(res.body.report.missing[0]).toMatchObject({
|
||||
table: 'contracts',
|
||||
column: 'signed_pdf_path',
|
||||
expectedPath: 'business-docs/contract/2026/C-B7-MISSING.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('honours the ?scope=invoice filter', async () => {
|
||||
// Seed both an invoice and a contract with missing files. With
|
||||
// scope=invoice the contract row must not appear.
|
||||
await db('invoices').insert({
|
||||
customer_account_id: customerId,
|
||||
invoice_number: 'INV-B7-SCOPE',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
due_date: '2026-01-31',
|
||||
pdf_path: 'business-docs/invoice/2026/INV-B7-SCOPE.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-B7-SCOPE',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-B7-SCOPE.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/system-health/backup-integrity')
|
||||
.query({ scope: 'invoice' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.report.scopes).toEqual(['invoice']);
|
||||
expect(res.body.report.missing.every((m) => m.table === 'invoices')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown scope with 400 + a code', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/system-health/backup-integrity')
|
||||
.query({ scope: 'gallery' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('BACKUP_INTEGRITY_UNKNOWN_SCOPE');
|
||||
expect(res.body.validScopes).toEqual(expect.arrayContaining([
|
||||
'quote', 'contract', 'contract-signature', 'invoice',
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('Admin photos in reference mode', () => {
|
||||
let tmpDir;
|
||||
let storagePath;
|
||||
let db;
|
||||
let app;
|
||||
let categoryId;
|
||||
|
||||
const resetModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-admin-photos-'));
|
||||
storagePath = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'photo_sharing_test.db');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
try {
|
||||
await fs.promises.unlink(process.env.TEST_DATABASE_PATH);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
process.env.STORAGE_PATH = storagePath;
|
||||
|
||||
resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => {
|
||||
req.admin = { id: 1, username: 'tester' };
|
||||
next();
|
||||
}
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
|
||||
ensureThumbnail: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/middleware/uploadValidation', () => ({
|
||||
validateUploadedFiles: (_req, _res, next) => next()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/utils/fileSecurityUtils', () => {
|
||||
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
|
||||
return {
|
||||
...actual,
|
||||
validateFileType: () => true,
|
||||
createFileUploadValidator: () => (_req, _res, next) => next()
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
}));
|
||||
|
||||
const dbModule = require('../../src/database/db');
|
||||
db = dbModule.db;
|
||||
|
||||
await db.schema.dropTableIfExists('photo_feedback');
|
||||
await db.schema.dropTableIfExists('photos');
|
||||
await db.schema.dropTableIfExists('photo_categories');
|
||||
await db.schema.dropTableIfExists('events');
|
||||
|
||||
await db.schema.createTable('events', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('slug').notNullable();
|
||||
table.string('event_name').notNullable();
|
||||
table.string('source_mode').notNullable();
|
||||
table.string('external_path');
|
||||
});
|
||||
|
||||
await db.schema.createTable('photo_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.string('slug').notNullable();
|
||||
table.boolean('is_global').defaultTo(true);
|
||||
table.integer('event_id');
|
||||
});
|
||||
|
||||
await db.schema.createTable('photos', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').notNullable();
|
||||
table.string('filename').notNullable();
|
||||
table.string('path').notNullable();
|
||||
table.string('thumbnail_path');
|
||||
table.string('type').notNullable();
|
||||
table.integer('size_bytes');
|
||||
table.integer('category_id');
|
||||
table.string('source_origin');
|
||||
table.string('external_relpath');
|
||||
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
||||
table.float('average_rating').defaultTo(0);
|
||||
table.integer('like_count').defaultTo(0);
|
||||
table.integer('favorite_count').defaultTo(0);
|
||||
});
|
||||
|
||||
await db.schema.createTable('photo_feedback', (table) => {
|
||||
table.increments('id');
|
||||
table.integer('photo_id');
|
||||
table.string('feedback_type');
|
||||
table.boolean('is_approved');
|
||||
table.boolean('is_hidden');
|
||||
});
|
||||
|
||||
await db('events').insert({
|
||||
id: 1,
|
||||
slug: 'test-event',
|
||||
event_name: 'Test Event',
|
||||
source_mode: 'reference',
|
||||
external_path: 'external/library'
|
||||
});
|
||||
|
||||
const insertedCategory = await db('photo_categories').insert({
|
||||
name: 'Highlights',
|
||||
slug: 'highlights',
|
||||
is_global: true
|
||||
});
|
||||
categoryId = Array.isArray(insertedCategory) ? insertedCategory[0] : insertedCategory;
|
||||
|
||||
const router = require('../../src/routes/adminPhotos');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/events', router);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.destroy();
|
||||
}
|
||||
resetModules();
|
||||
delete process.env.TEST_DATABASE_PATH;
|
||||
delete process.env.STORAGE_PATH;
|
||||
if (tmpDir) {
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('stores managed uploads with category information and managed origin', async () => {
|
||||
const uploadResponse = await request(app)
|
||||
.post(`/api/admin/events/1/upload`)
|
||||
.field('category_id', String(categoryId))
|
||||
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
|
||||
|
||||
expect(uploadResponse.status).toBe(200);
|
||||
expect(uploadResponse.body).toHaveProperty('photos');
|
||||
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
|
||||
|
||||
const photo = await db('photos').first();
|
||||
expect(photo).toBeTruthy();
|
||||
expect(photo.category_id).toBe(categoryId);
|
||||
expect(photo.source_origin).toBe('managed');
|
||||
expect(photo.external_relpath).toBeNull();
|
||||
});
|
||||
|
||||
it('returns numeric category metadata when listing photos', async () => {
|
||||
await db('photos').insert({
|
||||
event_id: 1,
|
||||
filename: 'external.jpg',
|
||||
path: 'test-event/external.jpg',
|
||||
thumbnail_path: null,
|
||||
type: 'individual',
|
||||
size_bytes: 123,
|
||||
source_origin: 'external',
|
||||
external_relpath: 'individual/external.jpg'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/admin/events/1/photos`)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(response.body.photos)).toBe(true);
|
||||
const managedPhoto = response.body.photos.find((p) => p.category_id === categoryId);
|
||||
expect(managedPhoto).toBeTruthy();
|
||||
expect(managedPhoto.category_name).toBe('Highlights');
|
||||
|
||||
const filtered = await request(app)
|
||||
.get(`/api/admin/events/1/photos`)
|
||||
.query({ category_id: String(categoryId) })
|
||||
.expect(200);
|
||||
|
||||
expect(filtered.body.photos.every((p) => p.category_id === categoryId)).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes category updates', async () => {
|
||||
const photo = await db('photos').first();
|
||||
|
||||
await request(app)
|
||||
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||
.send({ category_id: '0' })
|
||||
.expect(200);
|
||||
|
||||
const updated = await db('photos').where({ id: photo.id }).first();
|
||||
expect(updated.category_id).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
@@ -7,12 +7,15 @@ const crypto = require('crypto');
|
||||
// Load services
|
||||
const backupService = require('../../src/services/backupService');
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { db, initialize: initDb } = require('../../src/database/db');
|
||||
const { db, initializeDatabase: initDb } = require('../../src/database/db');
|
||||
const logger = require('../../src/utils/logger');
|
||||
|
||||
// Test configuration
|
||||
// Defaults match the dev MinIO container in docker-compose.dev.yml (port 7104).
|
||||
// Override via TEST_S3_ENDPOINT / TEST_S3_ACCESS_KEY / TEST_S3_SECRET_KEY when running
|
||||
// against a different S3 endpoint (CI, hosted MinIO, real AWS, etc.).
|
||||
const TEST_CONFIG = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:9000',
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
bucket: 'test-backup-bucket-' + Date.now(),
|
||||
@@ -56,9 +59,17 @@ describe('S3 Backup Integration Tests', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
await initDb();
|
||||
await db.migrate.latest();
|
||||
// Schema is expected to already be applied by `npm run migrate` against
|
||||
// the dev database. db.migrate.latest() can't be used here because
|
||||
// PicPeak's custom run-migrations.js tracks state in the `migrations`
|
||||
// table (not knex's `knex_migrations`), so knex would try to re-apply
|
||||
// every migration and crash on duplicate-table errors.
|
||||
const ok = await db.schema.hasTable('events')
|
||||
&& await db.schema.hasTable('app_settings')
|
||||
&& await db.schema.hasTable('backup_runs');
|
||||
if (!ok) {
|
||||
throw new Error('Required tables missing — run `npm run migrate` against the dev DB first.');
|
||||
}
|
||||
|
||||
// Create test storage directory
|
||||
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
|
||||
@@ -69,10 +80,12 @@ describe('S3 Backup Integration Tests', () => {
|
||||
await setupTestData();
|
||||
|
||||
// Mock logger to reduce noise
|
||||
logger.info = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
if (process.env.UNMOCK_LOGGER !== 'true') {
|
||||
logger.info = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -165,8 +178,9 @@ describe('S3 Backup Integration Tests', () => {
|
||||
.first();
|
||||
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(backupRun.files_backed_up).toBeGreaterThan(0);
|
||||
expect(backupRun.total_size_bytes).toBeGreaterThan(0);
|
||||
// pg driver returns bigint columns as strings; coerce for the size assertion.
|
||||
expect(Number(backupRun.files_backed_up)).toBeGreaterThan(0);
|
||||
expect(Number(backupRun.total_size_bytes)).toBeGreaterThan(0);
|
||||
|
||||
// Verify files in S3
|
||||
const s3Objects = await listS3Objects();
|
||||
@@ -269,13 +283,16 @@ describe('S3 Backup Integration Tests', () => {
|
||||
.first();
|
||||
|
||||
expect(secondRun.id).not.toBe(firstRun.id);
|
||||
expect(secondRun.files_backed_up).toBe(1); // Only modified file
|
||||
expect(Number(secondRun.files_backed_up)).toBe(1); // Only modified file
|
||||
|
||||
// Check manifest indicates incremental
|
||||
// Check manifest indicates incremental. The current manifest schema
|
||||
// groups counts under `incremental.changes.*` (added/modified/deleted/
|
||||
// unchanged + size_difference) — see backupManifest.generateIncrementalManifest.
|
||||
if (secondRun.manifest_path) {
|
||||
const manifest = await backupService.getBackupManifest(secondRun.id);
|
||||
expect(manifest.manifest.incremental).toBeDefined();
|
||||
expect(manifest.manifest.incremental.modified_files_count).toBe(1);
|
||||
expect(manifest.manifest.incremental.changes).toBeDefined();
|
||||
expect(manifest.manifest.incremental.changes.modified_files_count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -468,15 +485,16 @@ describe('S3 Backup Integration Tests', () => {
|
||||
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
|
||||
];
|
||||
|
||||
// Schema drift: app_settings has no created_at column anymore and the
|
||||
// unique constraint is on setting_key alone, not (setting_type, key).
|
||||
for (const setting of settings) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_type: 'backup',
|
||||
...setting,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.onConflict(['setting_type', 'setting_key'])
|
||||
.onConflict('setting_key')
|
||||
.merge();
|
||||
}
|
||||
}
|
||||
@@ -503,4 +521,4 @@ describe('S3 Backup Integration Tests', () => {
|
||||
console.error('Failed to cleanup S3 objects:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Regression net for the business-docs coverage gap fixed in this PR.
|
||||
*
|
||||
* Prior to the fix, `getFilesToBackupInternal()` enumerated a fixed
|
||||
* list of storage subdirectories (events/active, events/archived,
|
||||
* thumbnails, previews, heroes, uploads) and silently omitted the
|
||||
* entire `business-docs/` tree. That meant every CRM PDF + signature
|
||||
* drawing — quotes, contracts (system-rendered + wet uploads),
|
||||
* invoices, Storno, imported historical invoices, and the customer
|
||||
* signature PNG/JPG drawn on the public signing page — fell outside
|
||||
* the in-app scheduled backup, leaving every `*_path` column on
|
||||
* `quotes` / `contracts` / `invoices` as a broken FK after restore.
|
||||
*
|
||||
* The fix is a single `scanDirectory(business-docs, ...)` call. This
|
||||
* suite pins the contract so a future refactor of the walker cannot
|
||||
* silently drop business-docs again.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('backupService — business-docs is in the backup walker', () => {
|
||||
let cleanup;
|
||||
let backupService;
|
||||
let storagePath;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
// Cold-require after bootCrmDb so backupService picks up the same
|
||||
// db instance + STORAGE_PATH the test harness configured.
|
||||
backupService = require('../../src/services/backupService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function seed(relPath, content = 'dummy bytes for backup test') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
|
||||
it('does not error when business-docs is absent', async () => {
|
||||
// Fresh harness has no business-docs/ tree at all. The walker
|
||||
// must short-circuit on ENOENT rather than throw — installs that
|
||||
// never used CRM features have to keep backing up fine.
|
||||
await expect(backupService.getFilesToBackup(false)).resolves.toEqual(expect.any(Array));
|
||||
});
|
||||
|
||||
it('picks up every CRM-relevant business-docs subdirectory', async () => {
|
||||
// Seed one file in each of the five subpaths the renderer + import
|
||||
// routes write to. The signature path is the one most prone to be
|
||||
// forgotten — it lives one level deeper than the others (per-
|
||||
// contract subfolder, not per-year).
|
||||
seed('business-docs/quote/2026/Q-001.pdf');
|
||||
seed('business-docs/contract/2026/C-001.pdf');
|
||||
seed('business-docs/contract/signatures/42/customer-1700000000000.png');
|
||||
seed('business-docs/invoice/2026/INV-001.pdf');
|
||||
seed('business-docs/invoice-imports/2026/scan.pdf');
|
||||
|
||||
const files = await backupService.getFilesToBackup(false);
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toEqual(expect.arrayContaining([
|
||||
'business-docs/quote/2026/Q-001.pdf',
|
||||
'business-docs/contract/2026/C-001.pdf',
|
||||
'business-docs/contract/signatures/42/customer-1700000000000.png',
|
||||
'business-docs/invoice/2026/INV-001.pdf',
|
||||
'business-docs/invoice-imports/2026/scan.pdf',
|
||||
]));
|
||||
});
|
||||
|
||||
it('walks newly-created business-docs files without needing a restart', async () => {
|
||||
// The walker reads the filesystem live on every call; this guards
|
||||
// against a future "cache the scan result at boot" optimisation
|
||||
// that would miss freshly-written PDFs (which is exactly what
|
||||
// happens during normal operation — every send writes a new file).
|
||||
seed('business-docs/invoice/2027/INV-NEW.pdf');
|
||||
|
||||
const files = await backupService.getFilesToBackup(false);
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
expect(rels).toContain('business-docs/invoice/2027/INV-NEW.pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Pins the Stage-B refactor that lifted the file-backup walker's
|
||||
* subdirectory list out of hard-coded JS into the `backup_paths`
|
||||
* table seeded by migration 109.
|
||||
*
|
||||
* Scenarios:
|
||||
* 1. Walker reads canonical seed → all 7 default subdirs walked
|
||||
* 2. include_in_default=false on one row → that subdir is skipped
|
||||
* 3. New row inserted at runtime → walker picks it up without restart
|
||||
* 4. feature_flag gating → row only walked when the named app_settings
|
||||
* boolean is truthy (mirrors historical `includeArchived` behavior)
|
||||
* 5. Empty table → walker falls back to LEGACY_BACKUP_PATHS (defense
|
||||
* in depth — never silently scans nothing)
|
||||
*
|
||||
* Why not stub `db('backup_paths')`: the whole point of Stage B is
|
||||
* that the walker is now data-driven, so the test has to actually
|
||||
* mutate the table and observe the walker's output change. Stubs
|
||||
* would re-introduce the hard-coding the refactor is meant to remove.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — configurable walker (backup_paths)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function seedFile(relPath, content = 'dummy bytes') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Restore canonical seed before every test. Tests mutate this table
|
||||
// freely; the next test starts from a known state.
|
||||
await db('backup_paths').del();
|
||||
const {
|
||||
DEFAULT_PATHS,
|
||||
} = require('../../migrations/core/109_add_backup_paths');
|
||||
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})));
|
||||
});
|
||||
|
||||
it('migration 109 seeds the canonical 7 paths', async () => {
|
||||
const rows = await db('backup_paths').orderBy('display_order', 'asc').select();
|
||||
expect(rows.map((r) => r.path)).toEqual([
|
||||
'events/active',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'previews',
|
||||
'heroes',
|
||||
'uploads',
|
||||
'business-docs',
|
||||
]);
|
||||
// Only events/archived is gated by a feature flag.
|
||||
expect(rows.filter((r) => r.feature_flag).map((r) => r.path)).toEqual([
|
||||
'events/archived',
|
||||
]);
|
||||
});
|
||||
|
||||
it('walks every default subdir when files are present', async () => {
|
||||
seedFile('events/active/E1/a.jpg');
|
||||
seedFile('thumbnails/E1/a.jpg');
|
||||
seedFile('previews/E1/a.jpg');
|
||||
seedFile('heroes/E1/hero.jpg');
|
||||
seedFile('uploads/intake/x.bin');
|
||||
seedFile('business-docs/quote/2026/Q-001.pdf');
|
||||
// events/archived is gated — left out of this test; covered below.
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toEqual(expect.arrayContaining([
|
||||
'events/active/E1/a.jpg',
|
||||
'thumbnails/E1/a.jpg',
|
||||
'previews/E1/a.jpg',
|
||||
'heroes/E1/hero.jpg',
|
||||
'uploads/intake/x.bin',
|
||||
'business-docs/quote/2026/Q-001.pdf',
|
||||
]));
|
||||
});
|
||||
|
||||
it('skips a path when include_in_default is toggled off', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
await db('backup_paths').where('path', 'thumbnails').update({
|
||||
include_in_default: false,
|
||||
});
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
|
||||
});
|
||||
|
||||
it('picks up a new path inserted at runtime — no restart needed', async () => {
|
||||
// Simulates a future feature shipping its own subdirectory and
|
||||
// self-healing a `backup_paths` row at boot.
|
||||
await db('backup_paths').insert({
|
||||
path: 'plugin-store',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 200,
|
||||
description: 'Hypothetical future feature payload',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
seedFile('plugin-store/cache/payload.bin');
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('plugin-store/cache/payload.bin');
|
||||
});
|
||||
|
||||
it('respects feature_flag gating (events/archived ⇄ backup_include_archived)', async () => {
|
||||
seedFile('events/active/E1/active.jpg');
|
||||
seedFile('events/archived/E2/archived.jpg');
|
||||
|
||||
// backup_include_archived=false → archived/ is skipped.
|
||||
const filesOff = await backupService.getFilesToBackup({ backup_include_archived: false });
|
||||
const relsOff = filesOff.map((f) => f.relativePath);
|
||||
expect(relsOff).toContain('events/active/E1/active.jpg');
|
||||
expect(relsOff).not.toContain('events/archived/E2/archived.jpg');
|
||||
|
||||
// backup_include_archived=true → archived/ is included.
|
||||
const filesOn = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const relsOn = filesOn.map((f) => f.relativePath);
|
||||
expect(relsOn).toContain('events/archived/E2/archived.jpg');
|
||||
});
|
||||
|
||||
it('falls back to LEGACY_BACKUP_PATHS when the table is empty', async () => {
|
||||
// Defense in depth: even if seed-and-self-heal both failed, the
|
||||
// walker must still cover the historical set so "Run Backup Now"
|
||||
// cannot silently degrade to no-op.
|
||||
await db('backup_paths').del();
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
seedFile('business-docs/quote/2026/Q-002.pdf');
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).toContain('business-docs/quote/2026/Q-002.pdf');
|
||||
});
|
||||
|
||||
it('legacy boolean call signature still works (backward compat)', async () => {
|
||||
// Existing call sites (and the businessDocs regression test) pass
|
||||
// a boolean for `includeArchived`. Refactor must not break them.
|
||||
seedFile('events/archived/E3/legacy.jpg');
|
||||
|
||||
const filesOff = await backupService.getFilesToBackup(false);
|
||||
expect(filesOff.map((f) => f.relativePath)).not.toContain('events/archived/E3/legacy.jpg');
|
||||
|
||||
const filesOn = await backupService.getFilesToBackup(true);
|
||||
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Pins the inline-DB-dump + fail-loud guard added to `runBackupInternal`.
|
||||
*
|
||||
* The previous behaviour was: file-backup looked up an existing dump via
|
||||
* `getDatabaseBackupInfo()` and silently shipped a files-only manifest
|
||||
* when none was found. Admins clicking "Run Backup Now" got an apparent
|
||||
* success that omitted every customer / quote / invoice / contract row —
|
||||
* the data-loss footgun that this commit closes.
|
||||
*
|
||||
* Five scenarios under test:
|
||||
* 1. Default (inline dump enabled), dump succeeds → backup proceeds
|
||||
* 2. Default, dump throws → run aborts, backup_runs row marked failed
|
||||
* 3. Opt-out + recent DB dump available → backup proceeds
|
||||
* 4. Opt-out + no DB dump available → fail loud
|
||||
* 5. Opt-out + DB dump file is 0 bytes on disk → fail loud
|
||||
*
|
||||
* Mocking strategy: the underlying `databaseBackupService.backup()` and
|
||||
* the local-destination writer are stubbed so the test exercises just
|
||||
* the new guard logic without depending on `pg_dump` / `sqlite3` CLI
|
||||
* binaries being available in the test environment.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
// Set up mocks BEFORE bootCrmDb so backupService picks them up at require time.
|
||||
const mockBackupFn = jest.fn();
|
||||
jest.mock('../../src/services/databaseBackup', () => ({
|
||||
databaseBackupService: { backup: mockBackupFn },
|
||||
startScheduledBackups: jest.fn(),
|
||||
stopScheduledBackups: jest.fn(),
|
||||
DatabaseBackupService: class {},
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — inline DB dump + fail-loud guard', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
let dumpFileAbs;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
|
||||
// Seed backup destination settings so the run can proceed past the
|
||||
// "destination not configured" guard.
|
||||
const dest = path.join(storagePath, 'backups');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
// getBackupConfigInternal filters by setting_type='backup', so the
|
||||
// tests have to seed with that type or the resolver returns
|
||||
// `{ ... }` with the keys missing — runBackup then sees
|
||||
// `backup_destination_type === undefined` and bails before our
|
||||
// new guard runs.
|
||||
await db('app_settings').insert([
|
||||
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(dest), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
|
||||
]).onConflict('setting_key').merge();
|
||||
|
||||
// Pre-create a dump file that getDatabaseBackupInfo can resolve to.
|
||||
// Reused/mutated per-test via the database_backup_runs seed below.
|
||||
dumpFileAbs = path.join(storagePath, 'backups', 'fake-dump.sql.gz');
|
||||
fs.writeFileSync(dumpFileAbs, 'pretend this is a pg_dump'.repeat(100));
|
||||
|
||||
// Neutralise the file-scan step: we don't care which files would
|
||||
// be backed up, just whether the run reaches that stage at all.
|
||||
backupService.getFilesToBackup = jest.fn(async () => []);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockBackupFn.mockReset();
|
||||
// Default to "dump produced this file with this size" — the per-test
|
||||
// setup overrides as needed.
|
||||
mockBackupFn.mockResolvedValue({
|
||||
success: true,
|
||||
path: dumpFileAbs,
|
||||
size: fs.statSync(dumpFileAbs).size,
|
||||
duration: 1,
|
||||
checksum: 'abc',
|
||||
});
|
||||
|
||||
// Re-seed the database_backup_runs row that getDatabaseBackupInfo
|
||||
// resolves against (its query is `status='completed'` + most recent).
|
||||
await db('database_backup_runs').del();
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: dumpFileAbs,
|
||||
file_size_bytes: fs.statSync(dumpFileAbs).size,
|
||||
destination_path: dumpFileAbs,
|
||||
});
|
||||
});
|
||||
|
||||
it('default behaviour: inline dump runs, then file backup proceeds', async () => {
|
||||
// Inline-dump setting is unset (undefined) — default is ON.
|
||||
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
expect(mockBackupFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('completed');
|
||||
expect(run.error_message).toBeNull();
|
||||
});
|
||||
|
||||
it('aborts the run when the inline dump throws', async () => {
|
||||
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
|
||||
mockBackupFn.mockRejectedValueOnce(new Error('pg_dump segfaulted'));
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('failed');
|
||||
expect(run.error_message).toMatch(/pg_dump segfaulted/);
|
||||
});
|
||||
|
||||
it('opt-out: skips inline dump but proceeds when a recent dump exists', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
expect(mockBackupFn).not.toHaveBeenCalled();
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('opt-out + no recent dump: fails loud with a clear error', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
// Wipe the dump row so getDatabaseBackupInfo returns backupFile=null.
|
||||
await db('database_backup_runs').del();
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('failed');
|
||||
expect(run.error_message).toMatch(/No database backup available/);
|
||||
});
|
||||
|
||||
it('opt-out + 0-byte dump file: fails loud', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const emptyDump = path.join(storagePath, 'backups', 'empty-dump.sql.gz');
|
||||
fs.writeFileSync(emptyDump, '');
|
||||
await db('database_backup_runs').del();
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: emptyDump,
|
||||
file_size_bytes: 0,
|
||||
destination_path: emptyDump,
|
||||
});
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('failed');
|
||||
expect(run.error_message).toMatch(/is empty/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Per-Stage-B-path tally — Tier 3 of tonight's backup hardening.
|
||||
*
|
||||
* Pins the new `computePerPathStats` logic that the Backup History
|
||||
* "Content Backed Up" pane reads via `backup_runs.statistics.per_path`.
|
||||
*
|
||||
* Three scenarios:
|
||||
* 1. Single file under one path — straightforward attribution
|
||||
* 2. Multiple paths with overlapping prefixes — longest-prefix wins
|
||||
* (e.g. `events/active/E1/x.jpg` should attribute to
|
||||
* `events/active`, not `events`)
|
||||
* 3. File outside any configured path — silently dropped, doesn't
|
||||
* throw or contaminate other buckets
|
||||
*
|
||||
* Tests exercise the EXPORTED side: write a backup_runs row via the
|
||||
* service entry point and assert the statistics JSON shape. We don't
|
||||
* stub `computePerPathStats` directly — the integration view is what
|
||||
* the frontend actually consumes.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — per-Stage-B-path statistics', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function mkFile(rel, content = 'x'.repeat(100)) {
|
||||
const abs = path.join(storagePath, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean slate of any artefacts from prior tests
|
||||
await db('backup_runs').del();
|
||||
await db('app_settings').where('setting_type', 'backup').del();
|
||||
await db('app_settings').insert([
|
||||
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(path.join(storagePath, 'destination')), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_include_archived', setting_value: JSON.stringify(true), setting_type: 'backup' },
|
||||
]).onConflict('setting_key').merge();
|
||||
fs.mkdirSync(path.join(storagePath, 'destination'), { recursive: true });
|
||||
|
||||
// Restore canonical backup_paths from migration 109
|
||||
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
|
||||
await db('backup_paths').del();
|
||||
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})));
|
||||
|
||||
// Wipe leftover files between tests
|
||||
for (const dir of ['events', 'business-docs', 'thumbnails', 'previews', 'heroes', 'uploads']) {
|
||||
const p = path.join(storagePath, dir);
|
||||
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('attributes files to their owning backup_paths row', async () => {
|
||||
mkFile('events/active/E1/photo-a.jpg', 'X'.repeat(1000));
|
||||
mkFile('events/active/E1/photo-b.jpg', 'X'.repeat(2000));
|
||||
mkFile('business-docs/quote/2026/Q-1.pdf', 'X'.repeat(500));
|
||||
mkFile('thumbnails/E1/photo-a.jpg', 'X'.repeat(50));
|
||||
|
||||
// Disable the inline DB dump so we don't need pg_dump in tests;
|
||||
// the file walker is what produces per_path.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
// Seed a fake DB-backup row so the fail-loud guard is satisfied.
|
||||
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
|
||||
fs.writeFileSync(fakeDump, 'pretend dump');
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: fakeDump,
|
||||
file_size_bytes: fs.statSync(fakeDump).size,
|
||||
destination_path: fakeDump,
|
||||
});
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('completed');
|
||||
|
||||
const statsRaw = typeof run.statistics === 'string'
|
||||
? JSON.parse(run.statistics)
|
||||
: run.statistics;
|
||||
expect(statsRaw.per_path).toBeDefined();
|
||||
|
||||
// events/active should have 2 files (3000 bytes)
|
||||
expect(statsRaw.per_path['events/active']).toEqual({ count: 2, size: 3000 });
|
||||
// business-docs should have 1 file (500 bytes)
|
||||
expect(statsRaw.per_path['business-docs']).toEqual({ count: 1, size: 500 });
|
||||
// thumbnails should have 1 file (50 bytes)
|
||||
expect(statsRaw.per_path['thumbnails']).toEqual({ count: 1, size: 50 });
|
||||
|
||||
// No spurious buckets for paths that had nothing
|
||||
expect(statsRaw.per_path['previews']).toBeUndefined();
|
||||
expect(statsRaw.per_path['heroes']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('archived path attributed separately from active when both have files', async () => {
|
||||
mkFile('events/active/E1/active.jpg', 'X'.repeat(100));
|
||||
mkFile('events/archived/E2/archived.jpg', 'X'.repeat(200));
|
||||
|
||||
// backup_include_archived already set true in beforeEach so the
|
||||
// archived walker fires; same opt-out for inline DB dump.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
|
||||
fs.writeFileSync(fakeDump, 'pretend dump');
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: fakeDump,
|
||||
file_size_bytes: fs.statSync(fakeDump).size,
|
||||
destination_path: fakeDump,
|
||||
});
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
const statsRaw = typeof run.statistics === 'string'
|
||||
? JSON.parse(run.statistics)
|
||||
: run.statistics;
|
||||
|
||||
// events/active and events/archived attribute separately —
|
||||
// longest-prefix match prevents `events/active/...` from claiming
|
||||
// an `events/archived/...` file or vice versa.
|
||||
expect(statsRaw.per_path['events/active']).toEqual({ count: 1, size: 100 });
|
||||
expect(statsRaw.per_path['events/archived']).toEqual({ count: 1, size: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE on walker duplication
|
||||
//
|
||||
// If two `backup_paths` rows overlap (e.g. one row at `events` AND
|
||||
// another at `events/active`), the walker scans the same files twice
|
||||
// — once via each path. Per-path stats then attribute the file to the
|
||||
// longest-prefix-matching path BOTH times, producing inflated counts.
|
||||
//
|
||||
// The canonical seed in migration 109 contains no overlapping pairs,
|
||||
// so this isn't exercised in practice. But an admin who hand-adds a
|
||||
// broad row that overlaps an existing nested one will see double
|
||||
// counts in their next backup's statistics + the destination will
|
||||
// receive duplicate copies (wasting space). Worth flagging if anyone
|
||||
// reports it — the fix is to de-dupe `files` in
|
||||
// `getFilesToBackupInternal` before returning, OR to skip walking a
|
||||
// path if a longer one has already covered it.
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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(30000);
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Booking cutover — prepare_invoice's draft seam. convertToInvoiceOnly({draft})
|
||||
* must create the invoice(s) but leave scheduled_send_at NULL so the scheduler
|
||||
* never auto-sends them before the workflow's review gate + explicit
|
||||
* send_document.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('booking cutover — draft invoices on hold', () => {
|
||||
let db; let cleanup; let adminId; let customerId; let quoteService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
quoteService = require('../../src/services/quoteService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
async function acceptedQuote() {
|
||||
const dealUuid = crypto.randomUUID();
|
||||
const [id] = await db('quotes').insert({
|
||||
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
status: 'accepted',
|
||||
currency: 'CHF',
|
||||
issue_date: '2026-01-01',
|
||||
net_amount_minor: 100000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 100000,
|
||||
// A non-delivery installment so the contrast (scheduled date vs null) is meaningful.
|
||||
payment_term_snapshot: JSON.stringify({ installments: [{ percent: 100, trigger: 'quote_accepted', offset_days: 0, label: 'Total' }], net_days: 30 }),
|
||||
deal_uuid: dealUuid,
|
||||
created_by_admin_id: adminId,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
it('draft mode creates the invoice with scheduled_send_at = NULL (held), and returns its id', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
|
||||
expect(Array.isArray(res.invoiceIds)).toBe(true);
|
||||
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
||||
expect(inv.status).toBe('scheduled'); // editable + sendInvoice can issue it
|
||||
expect(inv.scheduled_send_at == null).toBe(true); // held — scheduler won't auto-send
|
||||
});
|
||||
|
||||
it('without draft, the same installment IS scheduled (scheduled_send_at set)', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId);
|
||||
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
||||
expect(inv.status).toBe('scheduled');
|
||||
expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set
|
||||
});
|
||||
|
||||
it('prepare_event path (convertToEvent hold) creates a DRAFT event with held invoices', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true });
|
||||
expect(res.eventId).toBeGreaterThanOrEqual(1);
|
||||
expect(Array.isArray(res.invoiceIds)).toBe(true);
|
||||
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const ev = await db('events').where({ id: res.eventId }).first();
|
||||
expect(ev.is_draft == true || ev.is_draft === 1).toBe(true); // created as a draft gallery
|
||||
|
||||
// Every invoice the event scheduled is held (no auto-send before the gate).
|
||||
const invs = await db('invoices').whereIn('id', res.invoiceIds);
|
||||
for (const inv of invs) expect(inv.scheduled_send_at == null).toBe(true);
|
||||
|
||||
// Quote is now linked to the event — convertToInvoiceOnly must NOT be called
|
||||
// again for it (the flow's prepare_invoice adopts these ids instead).
|
||||
const q = await db('quotes').where({ id: quoteId }).first();
|
||||
expect(q.converted_event_id).toBe(res.eventId);
|
||||
});
|
||||
|
||||
it('draft mode with the DEFAULT (after_delivery) payment term yields a SENDABLE scheduled invoice, not pending_delivery', async () => {
|
||||
// Reproduces the booking_invoice_only flow on a quote with no explicit
|
||||
// payment timing: the default installment is after_delivery, which would
|
||||
// otherwise be pending_delivery — a status sendInvoice (send_document) rejects.
|
||||
const dealUuid = crypto.randomUUID();
|
||||
const [quoteId] = await db('quotes').insert({
|
||||
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
status: 'accepted',
|
||||
currency: 'CHF',
|
||||
issue_date: '2026-01-01',
|
||||
net_amount_minor: 50000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 50000,
|
||||
// No payment_term_snapshot → spawnInstallmentInvoices falls back to a single
|
||||
// 100% after_delivery installment.
|
||||
deal_uuid: dealUuid,
|
||||
created_by_admin_id: adminId,
|
||||
});
|
||||
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
|
||||
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
||||
expect(inv.status).toBe('scheduled'); // sendInvoice accepts this
|
||||
expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send
|
||||
});
|
||||
|
||||
it('finalizeQuoteResponses only fires once the 15-min response window has locked', async () => {
|
||||
const mk = async (lockOffsetMs) => {
|
||||
const dealUuid = crypto.randomUUID();
|
||||
const [id] = await db('quotes').insert({
|
||||
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
status: 'accepted',
|
||||
currency: 'CHF', issue_date: '2026-01-01',
|
||||
net_amount_minor: 1000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 1000,
|
||||
responded_at: new Date().toISOString(),
|
||||
response_locked_at: new Date(Date.now() + lockOffsetMs).toISOString(),
|
||||
accepted_at: new Date().toISOString(),
|
||||
deal_uuid: dealUuid,
|
||||
created_by_admin_id: adminId,
|
||||
});
|
||||
return id;
|
||||
};
|
||||
const openId = await mk(15 * 60 * 1000); // still inside the window
|
||||
const lockedId = await mk(-60 * 1000); // window already closed
|
||||
|
||||
const emitted = await quoteService.finalizeQuoteResponses();
|
||||
expect(emitted).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const open = await db('quotes').where({ id: openId }).first();
|
||||
const locked = await db('quotes').where({ id: lockedId }).first();
|
||||
expect(open.workflow_response_emitted_at == null).toBe(true); // deferred — not yet fired
|
||||
expect(locked.workflow_response_emitted_at == null).toBe(false); // fired + stamped
|
||||
|
||||
// Idempotent: a second sweep doesn't re-fire the already-stamped one.
|
||||
const again = await db('quotes').where({ id: lockedId })
|
||||
.whereNull('workflow_response_emitted_at').update({ workflow_response_emitted_at: new Date() });
|
||||
expect(again).toBe(0);
|
||||
});
|
||||
|
||||
it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true });
|
||||
expect(res.eventId).toBeGreaterThanOrEqual(1);
|
||||
expect(res.invoiceIds).toEqual([]);
|
||||
const invCount = await db('invoices').where({ event_id: res.eventId }).count({ c: '*' }).first();
|
||||
expect(Number(invCount.c)).toBe(0); // pure date hold — no money documents
|
||||
});
|
||||
|
||||
it('prepare_quote path (duplicateQuote) creates a new DRAFT quote — no in-trx deadlock', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const newId = await quoteService.duplicateQuote(quoteId, adminId);
|
||||
expect(newId).toBeGreaterThanOrEqual(1);
|
||||
expect(newId).not.toBe(quoteId);
|
||||
const q = await db('quotes').where({ id: newId }).first();
|
||||
expect(q.status).toBe('draft');
|
||||
});
|
||||
|
||||
it('registers prepare_gallery / reserve_date / prepare_quote as real actions', () => {
|
||||
const { registry } = require('../../src/services/workflows'); // loads actions.js (side-effect registration)
|
||||
for (const a of ['prepare_gallery', 'reserve_date', 'prepare_quote', 'prepare_event', 'prepare_invoice', 'send_document']) {
|
||||
expect(typeof registry.getAction(a)).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => {
|
||||
const contractService = require('../../src/services/contractService');
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await contractService.createFromQuote(quoteId, adminId);
|
||||
expect(res.contractId).toBeGreaterThanOrEqual(1);
|
||||
expect(res.alreadyConverted).toBe(false);
|
||||
const c = await db('contracts').where({ id: res.contractId }).first();
|
||||
expect(c).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Schema-shape regression net for the CRM consolidated migration.
|
||||
*
|
||||
* Pins the table/column layout that the route + service layer expect
|
||||
* after `migrations/core/107_crm_consolidated.js` runs. The schema-
|
||||
* drift workflow (#530) catches Postgres-only FK ordering bugs (the
|
||||
* forward-reference deferral added in this PR), but it doesn't notice
|
||||
* if a future edit silently drops a column the service code reads —
|
||||
* SQLite would just return undefined and the broken behavior would
|
||||
* land on beta.
|
||||
*
|
||||
* Touches the lineage chain (deal_uuid + back-pointer FKs) explicitly
|
||||
* so a rename or removal there fails the test instead of silently
|
||||
* breaking the lineage card.
|
||||
*/
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
describe('CRM schema after core migrations', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('table layout', () => {
|
||||
const expectedTables = [
|
||||
'admin_users', 'customer_accounts', 'business_profile', 'business_bank_accounts',
|
||||
'events', 'document_sequences',
|
||||
'quotes', 'quote_line_items', 'quote_line_item_presets', 'quote_action_tokens',
|
||||
'contracts', 'contract_blocks', 'contract_block_inclusions', 'contract_action_tokens',
|
||||
'invoices', 'invoice_line_items', 'invoice_payment_log', 'invoice_payment_check_tokens',
|
||||
'customer_hour_entries',
|
||||
'payment_term_templates', 'payment_net_days_templates', 'payment_timing_templates',
|
||||
'event_payment_plans',
|
||||
];
|
||||
|
||||
it.each(expectedTables)('has table %s', async (table) => {
|
||||
expect(await db.schema.hasTable(table)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deal_uuid lineage columns', () => {
|
||||
// Every document in one engagement shares a deal_uuid — the
|
||||
// lineage card joins on it. Drop the column anywhere in the chain
|
||||
// and the card silently returns partial data.
|
||||
it.each(['quotes', 'contracts', 'invoices'])(
|
||||
'%s has deal_uuid column',
|
||||
async (table) => {
|
||||
expect(await db.schema.hasColumn(table, 'deal_uuid')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
// The back-pointer FKs were the source of the schema-drift bug
|
||||
// we fixed in this PR (forward references). Pin them.
|
||||
it('quotes has converted_contract_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('quotes', 'converted_contract_id')).toBe(true);
|
||||
});
|
||||
it('invoices has source_contract_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'source_contract_id')).toBe(true);
|
||||
});
|
||||
it('invoices has source_quote_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'source_quote_id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storno discriminator columns', () => {
|
||||
// kind='storno' + cancels_invoice_id + negative totals are the
|
||||
// shape every aggregate filter relies on (feedback_storno_filter_
|
||||
// everywhere). Pin the columns so a rename doesn't silently break
|
||||
// every revenue report.
|
||||
it('invoices has kind discriminator', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'kind')).toBe(true);
|
||||
});
|
||||
it('invoices has cancels_invoice_id self-ref', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'cancels_invoice_id')).toBe(true);
|
||||
});
|
||||
it('invoices has replaces_invoice_id self-ref', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'replaces_invoice_id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event time columns (migration 137)', () => {
|
||||
// The admin calendar reads these to render timed vs. full-day
|
||||
// tiles. Per the feedback_migration_preserve_visuals rule, the
|
||||
// default has to be `is_full_day=true` so existing rows keep
|
||||
// their pre-migration visual.
|
||||
it('events has event_time_start', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'event_time_start')).toBe(true);
|
||||
});
|
||||
it('events has event_time_end', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'event_time_end')).toBe(true);
|
||||
});
|
||||
it('events has is_full_day', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'is_full_day')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seed paths', () => {
|
||||
it('admin + customer seed inserts cleanly', async () => {
|
||||
const { adminId, customerId } = await seedMinimal(db);
|
||||
expect(adminId).toBeTruthy();
|
||||
expect(customerId).toBeTruthy();
|
||||
|
||||
const admin = await db('admin_users').where({ id: adminId }).first();
|
||||
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||
expect(admin.email).toBe('tester@example.com');
|
||||
expect(customer.email).toBe('customer@example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Negative line items (Rabatt / manual discount lines) are accepted
|
||||
* end-to-end as long as the resulting total stays ≥ 0. When the
|
||||
* discount would drive the total negative, the service rejects with
|
||||
* a clear, code-tagged error so the admin is steered to Storno for
|
||||
* credit-note workflows.
|
||||
*
|
||||
* Touches the actual createInvoice / createQuote service paths so a
|
||||
* future change to either computeTotals or the guard fires this test.
|
||||
*/
|
||||
|
||||
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(30000);
|
||||
|
||||
describe('discount line items (negative unit_price_minor)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminId;
|
||||
let customerId;
|
||||
let invoiceService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
invoiceService = require('../../src/services/invoiceService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
// Quote-side coverage of the symmetric validator + guard is
|
||||
// deliberately omitted: createQuote's init path takes ~30 s under
|
||||
// this harness (something in pdfService / emailProcessor cold-
|
||||
// require), which would push the suite well past CI's per-test
|
||||
// budget. The shape of the guard is identical to the invoice one
|
||||
// covered below; a future change to extract the slow init or to
|
||||
// stub it for tests should re-enable a parallel quote test.
|
||||
|
||||
describe('invoices', () => {
|
||||
it('accepts a negative-price line and computes the net correctly', async () => {
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 20000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, description: 'Treuerabatt', unit_price_minor: -5000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId);
|
||||
|
||||
expect(Array.isArray(invoiceIds)).toBe(true);
|
||||
expect(invoiceIds.length).toBe(1);
|
||||
|
||||
const row = await db('invoices').where({ id: invoiceIds[0] }).first();
|
||||
expect(row.net_amount_minor).toBe(15000);
|
||||
expect(row.total_amount_minor).toBe(15000);
|
||||
});
|
||||
|
||||
it('rejects when the discount drives the total negative', async () => {
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 10000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, description: 'Übergroßer Rabatt', unit_price_minor: -50000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId)).rejects.toMatchObject({
|
||||
code: 'INVOICE_TOTAL_NEGATIVE',
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Boot-time email-template self-heal:
|
||||
* 1. Seeds the CRM / contract / event-reminder templates on an
|
||||
* install that's never had them before.
|
||||
* 2. Recovers email_queue rows that previously exhausted their
|
||||
* retries because their template was missing.
|
||||
*
|
||||
* The failure that triggered this fix (2026-05-27) had Ralf's beta
|
||||
* box failing every `quote_sent` / `invoice_sent` send for ~14h
|
||||
* because crmEmailTemplates.ensureCrmEmailTemplatesSeeded was
|
||||
* defined but never called. After 3 retries the rows sat in
|
||||
* status='pending' forever; nothing in the admin UI signalled the
|
||||
* problem. Both halves of that regression are covered here.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('email template self-heal at boot', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('seeds crm/contract/event-reminder templates and recovers stuck queue rows', async () => {
|
||||
// Sanity: a fresh CRM-migrated DB does NOT carry CRM templates —
|
||||
// 107_crm_consolidated documents the deliberate split (templates
|
||||
// are self-healed at runtime, not inserted by the migration).
|
||||
const before = await db('email_templates')
|
||||
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
|
||||
.pluck('template_key');
|
||||
expect(before).toEqual([]);
|
||||
|
||||
// Seed a stuck queue row that mirrors what we found on Ralf's box:
|
||||
// quote_sent send attempted 3 times, each time failed because the
|
||||
// template didn't exist, queue processor gave up.
|
||||
const queueRowIds = await db('email_queue').insert({
|
||||
recipient_email: 'customer@example.com',
|
||||
email_type: 'quote_sent',
|
||||
email_data: JSON.stringify({ quote_number: 'Q-2026-0001' }),
|
||||
status: 'pending',
|
||||
retry_count: 3,
|
||||
error_message: "Email template 'quote_sent' not found",
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const queueRowId = typeof queueRowIds[0] === 'object' ? queueRowIds[0].id : queueRowIds[0];
|
||||
|
||||
// Also seed an UNRELATED stuck row (different template, NOT one
|
||||
// we're going to insert) to confirm the recovery is targeted —
|
||||
// it must not blanket-reset every retry-exhausted row.
|
||||
const unrelatedIds = await db('email_queue').insert({
|
||||
recipient_email: 'someone@example.com',
|
||||
email_type: 'some_other_template',
|
||||
email_data: JSON.stringify({}),
|
||||
status: 'pending',
|
||||
retry_count: 3,
|
||||
error_message: 'SMTP timeout',
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const unrelatedId = typeof unrelatedIds[0] === 'object' ? unrelatedIds[0].id : unrelatedIds[0];
|
||||
|
||||
// The seeders use module-level caches (`_seeded = true`). When
|
||||
// jest runs this test in isolation that cache starts fresh; in
|
||||
// the full suite no other test currently calls these seeders, so
|
||||
// the first call here also runs the real work. Reset the cache
|
||||
// defensively in case a future test changes that.
|
||||
jest.resetModules();
|
||||
const { seedEmailTemplatesAndRecoverQueue } = require('../../src/services/_emailTemplateBoot');
|
||||
|
||||
const result = await seedEmailTemplatesAndRecoverQueue(db, null);
|
||||
|
||||
// Templates landed.
|
||||
expect(result.seeded).toEqual(expect.arrayContaining([
|
||||
'quote_sent', 'invoice_sent', 'storno_issued',
|
||||
]));
|
||||
const after = await db('email_templates')
|
||||
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
|
||||
.pluck('template_key');
|
||||
expect(after.sort()).toEqual(['invoice_sent', 'quote_sent', 'storno_issued']);
|
||||
|
||||
// Stuck quote_sent row was recovered.
|
||||
expect(result.recovered).toBeGreaterThanOrEqual(1);
|
||||
const recoveredRow = await db('email_queue').where({ id: queueRowId }).first();
|
||||
expect(recoveredRow.retry_count).toBe(0);
|
||||
expect(recoveredRow.error_message).toBeNull();
|
||||
expect(recoveredRow.status).toBe('pending'); // ready for the next tick
|
||||
|
||||
// Unrelated stuck row was NOT touched.
|
||||
const unrelatedRow = await db('email_queue').where({ id: unrelatedId }).first();
|
||||
expect(unrelatedRow.retry_count).toBe(3);
|
||||
expect(unrelatedRow.error_message).toBe('SMTP timeout');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Renaming an event type's slug_prefix must CASCADE to everything keyed on the
|
||||
* old slug, so a rename behaves like a rename rather than silently detaching
|
||||
* existing events/quotes and orphaning the per-type pre-event reminder template.
|
||||
*/
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// bootCrmDb runs the full core-migration set in beforeAll.
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('event type slug rename cascade', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let customerId;
|
||||
let eventTypeService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('re-points events + quotes + the reminder template from old slug to new', async () => {
|
||||
// A non-system event type with slug 'party'.
|
||||
const [typeId] = await db('event_types').insert({ name: 'Party', slug_prefix: 'party', is_active: true });
|
||||
|
||||
// An authored per-type reminder template + an event + a quote, all on 'party'.
|
||||
await db('email_templates').insert({ template_key: 'event_reminder_party', subject_en: 'Party reminder' });
|
||||
await db('events').insert({
|
||||
event_type: 'party', password_hash: 'x', expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug: 'party-ev', share_link: 'party-ev',
|
||||
event_name: 'A party', event_date: '2026-09-01',
|
||||
});
|
||||
await db('quotes').insert({
|
||||
quote_number: 'Q-PARTY-1', customer_account_id: customerId, issue_date: '2026-01-01', event_type: 'party',
|
||||
});
|
||||
|
||||
// Rename the slug.
|
||||
await eventTypeService.updateEventType(typeId, { slug_prefix: 'concert' });
|
||||
|
||||
// Event + quote follow the rename.
|
||||
expect((await db('events').where({ slug: 'party-ev' }).first()).event_type).toBe('concert');
|
||||
expect((await db('quotes').where({ quote_number: 'Q-PARTY-1' }).first()).event_type).toBe('concert');
|
||||
// The authored reminder template moved (subject/body preserved), old key gone.
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_party' }).first()).toBeUndefined();
|
||||
const moved = await db('email_templates').where({ template_key: 'event_reminder_concert' }).first();
|
||||
expect(moved).toBeTruthy();
|
||||
expect(moved.subject_en).toBe('Party reminder');
|
||||
});
|
||||
|
||||
it('does not clobber an existing template for the new slug', async () => {
|
||||
const [typeId] = await db('event_types').insert({ name: 'Gala', slug_prefix: 'gala', is_active: true });
|
||||
await db('email_templates').insert({ template_key: 'event_reminder_gala', subject_en: 'old gala' });
|
||||
await db('email_templates').insert({ template_key: 'event_reminder_soiree', subject_en: 'existing soiree' });
|
||||
|
||||
await eventTypeService.updateEventType(typeId, { slug_prefix: 'soiree' });
|
||||
|
||||
// Target already existed → left intact; source not force-merged over it.
|
||||
expect((await db('email_templates').where({ template_key: 'event_reminder_soiree' }).first()).subject_en)
|
||||
.toBe('existing soiree');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* HTTP-level tests for the `/s/:shortSlug` public resolver (#699).
|
||||
*
|
||||
* Verifies the contract the public route is expected to honour:
|
||||
* - Browser UA → 302 to target_path
|
||||
* - Social crawler UA → 200 with OG <meta>, canonical = /s/<slug>
|
||||
* - Soft-deleted slug → 410 Gone (intentional-delete signal)
|
||||
* - Unknown slug → 404 Not Found
|
||||
* - Hit count increments after successful resolutions (both shapes)
|
||||
*
|
||||
* Mirrors the production server.js wiring but doesn't load the whole
|
||||
* server — the surrounding middleware (CORS, helmet, rate limiters)
|
||||
* isn't part of this route's contract.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db; let cleanup; let service; let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
// Persist a business_profile + business_name so buildOgMetadata's
|
||||
// settings-based fields populate consistently.
|
||||
const { upsertAppSetting } = require('../../src/utils/appSettings');
|
||||
await upsertAppSetting('branding_company_name', JSON.stringify('Test Studio'), 'string');
|
||||
|
||||
service = require('../../src/services/galleryShortUrlService');
|
||||
const {
|
||||
isSocialCrawler, buildOgMetadata, renderOgHtml,
|
||||
} = require('../../src/services/galleryOgService');
|
||||
|
||||
app = express();
|
||||
app.get('/s/:shortSlug', async (req, res) => {
|
||||
try {
|
||||
const row = await service.findByShortSlug(req.params.shortSlug);
|
||||
if (!row) return res.status(404).type('text/plain').send('Short URL not found');
|
||||
if (row.deleted_at) return res.status(410).type('text/plain').send('Short URL has been removed');
|
||||
|
||||
if (isSocialCrawler(req.get('user-agent'))) {
|
||||
const event = await db('events').where({ id: row.event_id }).first('slug');
|
||||
if (event?.slug) {
|
||||
const meta = await buildOgMetadata(event.slug, req.originalUrl);
|
||||
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
meta.url = `${base}/s/${row.short_slug}`;
|
||||
res.set('Cache-Control', 'public, max-age=300');
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(renderOgHtml(meta));
|
||||
service.recordHit(row.id).catch(() => {});
|
||||
return;
|
||||
}
|
||||
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
|
||||
}
|
||||
|
||||
service.recordHit(row.id).catch(() => {});
|
||||
return res.redirect(302, row.target_path);
|
||||
} catch (err) {
|
||||
return res.status(500).type('text/plain').send(err.message);
|
||||
}
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
async function seedEventAndShortUrl({ slug = `evt-${Date.now()}`, shortSlug }) {
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [eventId] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Event',
|
||||
event_date: '2026-06-05',
|
||||
password_hash: 'x',
|
||||
expires_at: farFuture,
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
share_link: slug,
|
||||
share_token: `tok${Math.random().toString(36).slice(2, 12)}`,
|
||||
welcome_message: null,
|
||||
});
|
||||
const row = await service.createShortUrl({
|
||||
eventId, customSlug: shortSlug,
|
||||
});
|
||||
return { eventId, shortUrl: row };
|
||||
}
|
||||
|
||||
// User-agent strings the production `isSocialCrawler` helper matches.
|
||||
// Snapshot known-true samples here so the test stays in sync if the
|
||||
// helper's allowlist evolves.
|
||||
const BOT_UA_WHATSAPP = 'WhatsApp/2.23.20.0';
|
||||
const BOT_UA_FACEBOOK = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
|
||||
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
|
||||
|
||||
describe('GET /s/:shortSlug — browser (302 redirect)', () => {
|
||||
it('redirects to the snapshotted target_path with a 302', async () => {
|
||||
const { shortUrl } = await seedEventAndShortUrl({
|
||||
slug: 'browser-redirect', shortSlug: 'go-here',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/go-here')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe(shortUrl.target_path);
|
||||
expect(res.headers.location).toMatch(/^\/gallery\//);
|
||||
});
|
||||
|
||||
it('increments hit_count on a browser hit (fire-and-forget — wait briefly)', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'hit-browser', shortSlug: 'hit-from-browser',
|
||||
});
|
||||
await request(app).get('/s/hit-from-browser').set('User-Agent', BROWSER_UA);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const row = await service.findByShortSlug('hit-from-browser');
|
||||
expect(row.hit_count).toBe(1);
|
||||
expect(row.last_hit_at).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /s/:shortSlug — social crawler (OG metadata)', () => {
|
||||
it('returns 200 with OG HTML for WhatsApp UA', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'whatsapp-og', shortSlug: 'wa-preview',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/wa-preview')
|
||||
.set('User-Agent', BOT_UA_WHATSAPP);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/html/);
|
||||
expect(res.text).toContain('<meta');
|
||||
expect(res.text).toMatch(/og:title/);
|
||||
expect(res.text).toMatch(/og:url/);
|
||||
});
|
||||
|
||||
it('og:url canonical points at /s/<slug>, not the underlying gallery URL', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'canonical-test', shortSlug: 'canonical-short',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/canonical-short')
|
||||
.set('User-Agent', BOT_UA_FACEBOOK);
|
||||
expect(res.status).toBe(200);
|
||||
// The og:url meta tag must contain the short-URL path, not the
|
||||
// /gallery/<slug> path — this is the cache-key invariant from #699.
|
||||
expect(res.text).toMatch(/property="og:url"\s+content="[^"]*\/s\/canonical-short"/);
|
||||
expect(res.text).not.toMatch(
|
||||
/property="og:url"\s+content="[^"]*\/gallery\/canonical-test"/
|
||||
);
|
||||
});
|
||||
|
||||
it('sets a short cache header so scrapers can re-fetch when admin rotates the preview', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'cache-header', shortSlug: 'cache-test',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/cache-test')
|
||||
.set('User-Agent', BOT_UA_WHATSAPP);
|
||||
expect(res.headers['cache-control']).toMatch(/public/);
|
||||
expect(res.headers['cache-control']).toMatch(/max-age=300/);
|
||||
});
|
||||
|
||||
it('increments hit_count on a crawler hit as well', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'hit-bot', shortSlug: 'hit-from-bot',
|
||||
});
|
||||
await request(app).get('/s/hit-from-bot').set('User-Agent', BOT_UA_WHATSAPP);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const row = await service.findByShortSlug('hit-from-bot');
|
||||
expect(row.hit_count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /s/:shortSlug — error states', () => {
|
||||
it('404 for an unknown slug', async () => {
|
||||
const res = await request(app)
|
||||
.get('/s/never-existed')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('410 for a soft-deleted slug (intentional-delete signal)', async () => {
|
||||
const { shortUrl } = await seedEventAndShortUrl({
|
||||
slug: 'gone-test', shortSlug: 'gone-slug',
|
||||
});
|
||||
await service.softDelete(shortUrl.id, null);
|
||||
const res = await request(app)
|
||||
.get('/s/gone-slug')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
|
||||
it('410 if the event was hard-deleted but the short URL row somehow survives', async () => {
|
||||
const { eventId } = await seedEventAndShortUrl({
|
||||
slug: 'orphan-test', shortSlug: 'orphan-slug',
|
||||
});
|
||||
// Hard-delete the event row (FK CASCADE would normally clean up the
|
||||
// short URL too — but if CASCADE didn't fire for whatever reason
|
||||
// (e.g. SQLite foreign_keys pragma off in a particular runtime), the
|
||||
// resolver should still degrade safely).
|
||||
// SQLite's foreign_keys pragma is OFF by default; the migration
|
||||
// doesn't toggle it, so this delete leaves the short URL row.
|
||||
await db('events').where({ id: eventId }).delete();
|
||||
const res = await request(app)
|
||||
.get('/s/orphan-slug')
|
||||
.set('User-Agent', BOT_UA_WHATSAPP);
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
|
||||
it('404 for a malformed slug (rejected at validation, no DB hit)', async () => {
|
||||
const res = await request(app)
|
||||
.get('/s/UPPER_CASE')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression — existing URL paths must still respond the same', () => {
|
||||
// The /s/* namespace is additive: it must NOT shadow /gallery/*
|
||||
// or any of the OG routes. We don't load the whole app here, but we
|
||||
// can at least pin that the route param doesn't accept slashes —
|
||||
// i.e. /s/foo/bar must NOT be matched by our handler.
|
||||
it('the /s/:shortSlug route does not match nested paths', async () => {
|
||||
const res = await request(app)
|
||||
.get('/s/foo/bar')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
// Express returns its default 404 when no route matches the path.
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Integration tests for the branded short-URL service (#699).
|
||||
*
|
||||
* Exercises createShortUrl + findByShortSlug + listForEvent + softDelete
|
||||
* + recordHit against a real SQLite DB, including the contracts that
|
||||
* matter for production correctness:
|
||||
*
|
||||
* - Custom slug + collision detection (409 with `suggested`)
|
||||
* - Auto-generated slug from event slug + year
|
||||
* - Soft-delete preserves the row (admin can audit)
|
||||
* - target_path snapshots at create time (toggling the global
|
||||
* "Use short gallery URLs" setting later doesn't change existing
|
||||
* short URLs — backward-compat invariant from #699)
|
||||
* - hit_count increments idempotently
|
||||
* - findByShortSlug returns soft-deleted rows (caller decides 410 vs 404)
|
||||
*
|
||||
* Boots one DB for the whole file (cheap on SQLite); each test seeds
|
||||
* its own event row to keep scope clean.
|
||||
*/
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db; let cleanup; let service; let adminId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
// Minimal admin for created_by audit.
|
||||
const adminInsert = await db('admin_users').insert({
|
||||
username: 'shorturl-test',
|
||||
email: 'shorturl@example.com',
|
||||
password_hash: 'x',
|
||||
must_change_password: false,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
adminId = adminInsert[0]?.id ?? adminInsert[0];
|
||||
|
||||
service = require('../../src/services/galleryShortUrlService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
// Each test seeds a fresh event so collisions / counter state don't leak.
|
||||
async function seedEvent(overrides = {}) {
|
||||
const slug = overrides.slug || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [id] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: overrides.event_name || 'Test Wedding',
|
||||
event_date: overrides.event_date || '2026-06-05',
|
||||
password_hash: 'x',
|
||||
expires_at: farFuture,
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
share_link: slug,
|
||||
share_token: overrides.share_token || `tok${Math.random().toString(36).slice(2, 12)}`,
|
||||
welcome_message: null,
|
||||
});
|
||||
const event = await db('events').where({ id }).first();
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('createShortUrl — custom slug', () => {
|
||||
it('creates with a custom slug', async () => {
|
||||
const event = await seedEvent({ slug: 'sofia-grad-1' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'sofia-graduation-1',
|
||||
createdBy: adminId,
|
||||
});
|
||||
expect(row.short_slug).toBe('sofia-graduation-1');
|
||||
expect(row.target_path).toBe(`/gallery/${event.slug}`);
|
||||
expect(row.event_id).toBe(event.id);
|
||||
expect(row.hit_count).toBe(0);
|
||||
});
|
||||
|
||||
it('lowercases the input — operators pasting mixed-case still get a clean slug', async () => {
|
||||
const event = await seedEvent({ slug: 'sofia-grad-2' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'Sofia-GraduAtion-2', // mixed case
|
||||
createdBy: adminId,
|
||||
});
|
||||
expect(row.short_slug).toBe('sofia-graduation-2');
|
||||
});
|
||||
|
||||
it('rejects an invalid slug with INVALID_SLUG code', async () => {
|
||||
const event = await seedEvent({ slug: 'invalid-test' });
|
||||
await expect(service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'invalid slug with spaces',
|
||||
createdBy: adminId,
|
||||
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
|
||||
});
|
||||
|
||||
it('rejects a reserved slug with INVALID_SLUG code', async () => {
|
||||
const event = await seedEvent({ slug: 'reserved-test' });
|
||||
await expect(service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'admin',
|
||||
createdBy: adminId,
|
||||
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
|
||||
});
|
||||
|
||||
it('rejects a duplicate slug with SLUG_TAKEN + suggested fallback', async () => {
|
||||
const event1 = await seedEvent({ slug: 'dup-test-1' });
|
||||
const event2 = await seedEvent({ slug: 'dup-test-2' });
|
||||
await service.createShortUrl({ eventId: event1.id, customSlug: 'collide-me' });
|
||||
await expect(service.createShortUrl({
|
||||
eventId: event2.id, customSlug: 'collide-me',
|
||||
})).rejects.toMatchObject({
|
||||
code: 'SLUG_TAKEN',
|
||||
suggested: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws EVENT_NOT_FOUND when the event id does not exist', async () => {
|
||||
await expect(service.createShortUrl({
|
||||
eventId: 9999999, customSlug: 'no-event',
|
||||
})).rejects.toMatchObject({ code: 'EVENT_NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createShortUrl — auto-generated slug', () => {
|
||||
it('uses event slug + year when no custom slug provided', async () => {
|
||||
const event = await seedEvent({
|
||||
slug: 'autogen-wedding', event_date: '2026-06-05',
|
||||
});
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
createdBy: adminId,
|
||||
});
|
||||
// First-choice candidate is just the slug; takes that.
|
||||
expect(row.short_slug).toBe('autogen-wedding');
|
||||
});
|
||||
|
||||
it('falls back to slug-year when the bare slug is already taken', async () => {
|
||||
// Both events SHARE the same canonical slug so the first-choice
|
||||
// bare-slug candidate is burned, forcing autoGen to try the
|
||||
// year-suffixed variant.
|
||||
const event1 = await seedEvent({
|
||||
slug: 'collide-base', event_date: '2026-07-01',
|
||||
});
|
||||
await service.createShortUrl({
|
||||
eventId: event1.id, customSlug: 'collide-base',
|
||||
});
|
||||
const event2 = await seedEvent({
|
||||
slug: 'collide-base-2', event_date: '2026-07-01',
|
||||
});
|
||||
// Force the bare candidate of event2 to also collide by burning it.
|
||||
await service.createShortUrl({
|
||||
eventId: event1.id, customSlug: 'collide-base-2',
|
||||
});
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event2.id, // No custom — auto-gen from event2.slug
|
||||
});
|
||||
// Bare candidate `collide-base-2` is taken → year-suffixed picks.
|
||||
expect(row.short_slug).toBe('collide-base-2-2026');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createShortUrl — target_path snapshotting (#699 backward-compat)', () => {
|
||||
it('uses /gallery/<slug> when the global short-URLs setting is OFF (default)', async () => {
|
||||
const event = await seedEvent({ slug: 'snapshot-off' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'snap-off',
|
||||
});
|
||||
expect(row.target_path).toBe(`/gallery/${event.slug}`);
|
||||
});
|
||||
|
||||
it('uses /gallery/<share_token> when the global setting is ON at create time', async () => {
|
||||
// Persist the setting.
|
||||
const { upsertAppSetting } = require('../../src/utils/appSettings');
|
||||
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(true), 'system');
|
||||
try {
|
||||
const event = await seedEvent({ slug: 'snapshot-on', share_token: 'tokenAbc123' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'snap-on',
|
||||
});
|
||||
expect(row.target_path).toBe(`/gallery/${event.share_token}`);
|
||||
|
||||
// CRITICAL backward-compat invariant: now flip the setting OFF.
|
||||
// Existing short URLs must still resolve to the same target_path
|
||||
// they were created with — operator's existing share links don't
|
||||
// silently change behaviour.
|
||||
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
|
||||
const refetched = await service.findByShortSlug('snap-on');
|
||||
expect(refetched.target_path).toBe(`/gallery/${event.share_token}`);
|
||||
} finally {
|
||||
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByShortSlug + listForEvent', () => {
|
||||
it('returns null for an unknown slug', async () => {
|
||||
expect(await service.findByShortSlug('does-not-exist-xyz')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a malformed slug (no DB hit)', async () => {
|
||||
expect(await service.findByShortSlug('UPPER_CASE')).toBeNull();
|
||||
expect(await service.findByShortSlug('with spaces')).toBeNull();
|
||||
expect(await service.findByShortSlug('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns soft-deleted rows (caller decides 410 vs 404)', async () => {
|
||||
const event = await seedEvent({ slug: 'softdel-find' });
|
||||
const created = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'find-deleted',
|
||||
});
|
||||
await service.softDelete(created.id, adminId);
|
||||
const fetched = await service.findByShortSlug('find-deleted');
|
||||
expect(fetched).not.toBeNull();
|
||||
expect(fetched.deleted_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('listForEvent excludes soft-deleted rows', async () => {
|
||||
const event = await seedEvent({ slug: 'list-test' });
|
||||
const live = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'list-live',
|
||||
});
|
||||
const deleted = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'list-deleted',
|
||||
});
|
||||
await service.softDelete(deleted.id, adminId);
|
||||
const list = await service.listForEvent(event.id);
|
||||
const ids = list.map((r) => r.id);
|
||||
expect(ids).toContain(live.id);
|
||||
expect(ids).not.toContain(deleted.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDelete', () => {
|
||||
it('returns true on first call, false on second (idempotent admin clicks)', async () => {
|
||||
const event = await seedEvent({ slug: 'softdel-idem' });
|
||||
const created = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'idem-delete',
|
||||
});
|
||||
expect(await service.softDelete(created.id, adminId)).toBe(true);
|
||||
expect(await service.softDelete(created.id, adminId)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unknown id (caller maps to 404)', async () => {
|
||||
expect(await service.softDelete(9999999, adminId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createShortUrl after soft-delete — slug rotation', () => {
|
||||
it('re-creating a soft-deleted slug succeeds (purges the deleted row)', async () => {
|
||||
const event = await seedEvent({ slug: 'rotate' });
|
||||
const first = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'rotate-me',
|
||||
});
|
||||
await service.softDelete(first.id, adminId);
|
||||
// The slug is now reclaimable for a fresh row.
|
||||
const second = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'rotate-me',
|
||||
});
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(second.short_slug).toBe('rotate-me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordHit', () => {
|
||||
it('increments hit_count + stamps last_hit_at', async () => {
|
||||
const event = await seedEvent({ slug: 'hit-counter' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'count-me',
|
||||
});
|
||||
await service.recordHit(row.id);
|
||||
await service.recordHit(row.id);
|
||||
const fetched = await service.findByShortSlug('count-me');
|
||||
expect(fetched.hit_count).toBe(2);
|
||||
expect(fetched.last_hit_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is fire-and-forget — invalid id does not throw', async () => {
|
||||
await expect(service.recordHit(9999999)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Test harness for CRM integration tests.
|
||||
*
|
||||
* Boots a temp-SQLite database, runs every `migrations/core/*.up()`
|
||||
* directly (bypassing knex's Migrator — its exclusive write lock
|
||||
* deadlocks 001_init's nested `initializeDatabase()` call), and
|
||||
* exposes a small helper for seeding the minimal row set that the
|
||||
* quote/contract/invoice services need to operate.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
*
|
||||
* beforeAll(async () => {
|
||||
* ({ db, cleanup } = await bootCrmDb());
|
||||
* ({ adminId, customerId } = await seedMinimal(db));
|
||||
* });
|
||||
* afterAll(async () => { await cleanup(); });
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
async function runCoreMigrations(db) {
|
||||
await db.schema.createTable('migrations', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('filename').unique().notNullable();
|
||||
t.timestamp('applied_at').defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
const coreDir = path.resolve(__dirname, '..', '..', '..', 'migrations', 'core');
|
||||
const files = (await fs.promises.readdir(coreDir))
|
||||
.filter((f) => f.endsWith('.js'))
|
||||
.sort();
|
||||
|
||||
for (const f of files) {
|
||||
const mod = require(path.join(coreDir, f));
|
||||
if (typeof mod.up === 'function') {
|
||||
await mod.up(db);
|
||||
}
|
||||
await db('migrations').insert({ filename: f });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a clean test DB. Returns { db, cleanup, tmpDir }.
|
||||
* Caller must invoke cleanup() in afterAll to release the SQLite file
|
||||
* and the temp directory.
|
||||
*/
|
||||
async function bootCrmDb() {
|
||||
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-crm-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'crm.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
|
||||
// No jest.resetModules() — every service the test later requires
|
||||
// must share THIS db instance. Two module copies on one SQLite file
|
||||
// each open their own knex pool and the SQLite write lock deadlocks
|
||||
// the second one acquiring a connection. Caller is responsible for
|
||||
// setting TEST_DATABASE_PATH before the first require of db.js
|
||||
// (which knexfile reads at module-init time); bootCrmDb only works
|
||||
// when invoked before any service import.
|
||||
const { db } = require('../../../src/database/db');
|
||||
|
||||
await runCoreMigrations(db);
|
||||
|
||||
return {
|
||||
db,
|
||||
tmpDir,
|
||||
cleanup: async () => {
|
||||
try { await db.destroy(); } catch (_) {}
|
||||
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the minimal row set that quote/contract/invoice services
|
||||
* dereference on creation: an admin user, an active customer, a
|
||||
* business_profile row, and the app_settings keys the services read.
|
||||
*
|
||||
* Returns the ids the caller will pass into service calls.
|
||||
*/
|
||||
async function seedMinimal(db) {
|
||||
const passwordHash = await bcrypt.hash('test-pass', 4); // low rounds = fast
|
||||
|
||||
const adminInsert = await db('admin_users').insert({
|
||||
username: 'tester', email: 'tester@example.com',
|
||||
password_hash: passwordHash, must_change_password: false,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const adminId = adminInsert[0]?.id ?? adminInsert[0];
|
||||
|
||||
// business_profile is a singleton; the row is seeded by migration 107
|
||||
// for fresh installs. Defensive: insert if missing.
|
||||
const profile = await db('business_profile').first();
|
||||
if (!profile) {
|
||||
await db('business_profile').insert({
|
||||
legal_name: 'Test Studio',
|
||||
default_currency: 'CHF',
|
||||
default_locale: 'de',
|
||||
});
|
||||
}
|
||||
|
||||
const customerInsert = await db('customer_accounts').insert({
|
||||
email: 'customer@example.com',
|
||||
display_name: 'Test Customer',
|
||||
password_hash: passwordHash,
|
||||
preferred_language: 'de',
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const customerId = customerInsert[0]?.id ?? customerInsert[0];
|
||||
|
||||
return { adminId, customerId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Route-test helpers (#570) — building blocks for the CRM HTTP layer
|
||||
// tests. Kept here so every supertest suite shares the same minting +
|
||||
// app-wiring shape and a refactor lands in one place.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const crypto = require('crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
|
||||
/**
|
||||
* Promote a seeded admin into a role (default `super_admin`) so
|
||||
* `requirePermission(...)` checks pass. seedMinimal creates an admin
|
||||
* without a role — that's good for negative tests (expect 403) but
|
||||
* happy-path tests need the role assignment.
|
||||
*
|
||||
* Returns the role id the admin was assigned to.
|
||||
*/
|
||||
async function assignAdminRole(db, adminId, roleName = 'super_admin') {
|
||||
const role = await db('roles').where({ name: roleName }).first();
|
||||
if (!role) {
|
||||
throw new Error(`Role '${roleName}' not seeded — check the test DB`);
|
||||
}
|
||||
await db('admin_users').where({ id: adminId }).update({ role_id: role.id });
|
||||
return role.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint an admin JWT in the same shape adminAuth middleware expects.
|
||||
* The tests inject this via `Authorization: Bearer <token>`.
|
||||
*/
|
||||
function mintAdminToken(adminId, { expiresIn = '1h', extraClaims = {} } = {}) {
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
return jwt.sign(
|
||||
{ id: adminId, type: 'admin', iat: Math.floor(Date.now() / 1000), ...extraClaims },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn, issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a row into one of the public-token tables for testing the
|
||||
* loadActionToken guard outcomes. Returns the generated 64-hex token.
|
||||
*
|
||||
* Usage:
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id });
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: pastDate });
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, used_at: new Date() });
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: null });
|
||||
*/
|
||||
async function createPublicToken(db, tableName, opts = {}) {
|
||||
const token = opts.token || crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = opts.expires_at === null
|
||||
? null
|
||||
: (opts.expires_at || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000));
|
||||
// Serialise Date → ISO string. Bare Date objects round-tripped
|
||||
// inconsistently through knex+SQLite — sometimes as epoch ms,
|
||||
// sometimes via .toString() → literal "[object Object]" which then
|
||||
// parses back to NaN and silently defeats the expiry guard.
|
||||
const toStorable = (v) => (v instanceof Date ? v.toISOString() : v);
|
||||
const row = {
|
||||
...opts,
|
||||
token,
|
||||
expires_at: toStorable(expiresAt),
|
||||
created_at: toStorable(new Date()),
|
||||
};
|
||||
await db(tableName).insert(row);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an Express app with the requested route file mounted. Mirrors
|
||||
* the production app's middleware shape (json + cookies) but skips
|
||||
* everything else (CORS, helmet, rate limiters) — route tests pin the
|
||||
* handler's contract, not the surrounding cross-cutting concerns.
|
||||
*
|
||||
* Example:
|
||||
* const app = buildRouteApp('/api/public/quotes',
|
||||
* require('../../src/routes/publicQuotes'));
|
||||
*/
|
||||
function buildRouteApp(mount, router) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(mount, router);
|
||||
// Catch-all error handler. Mirrors the real middleware/errorHandler:
|
||||
// AppError subclasses (ValidationError, NotFoundError, etc.) use
|
||||
// `.statusCode` (NOT `.status` — getting that wrong silently maps
|
||||
// every 400 / 404 / 410 to 500 in tests).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
const statusCode = err.statusCode || err.status || 500;
|
||||
res.status(statusCode).json({
|
||||
error: err.message || 'Internal error',
|
||||
code: err.code,
|
||||
...(err.details ? { details: err.details } : {}),
|
||||
});
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bootCrmDb,
|
||||
seedMinimal,
|
||||
assignAdminRole,
|
||||
mintAdminToken,
|
||||
createPublicToken,
|
||||
buildRouteApp,
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
|
||||
const storageModule = require('../../src/services/storage');
|
||||
|
||||
// Stub out the DB so getThumbnailSettings falls into its catch and uses defaults.
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => {
|
||||
throw new Error('db disabled in this test');
|
||||
},
|
||||
}));
|
||||
|
||||
const TEST_S3 = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
region: 'us-east-1',
|
||||
};
|
||||
|
||||
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
|
||||
|
||||
function backendCases() {
|
||||
const cases = [
|
||||
{
|
||||
name: 'LocalFsStorage',
|
||||
async setup() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-'));
|
||||
const storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
return { storage, cleanup: () => fs.rm(root, { recursive: true, force: true }) };
|
||||
},
|
||||
},
|
||||
];
|
||||
if (!skipS3) {
|
||||
cases.push({
|
||||
name: 'S3StorageBackend (MinIO)',
|
||||
async setup() {
|
||||
const bucket = `picpeak-imgproc-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
|
||||
const s3Client = new S3Client({
|
||||
endpoint: TEST_S3.endpoint,
|
||||
region: TEST_S3.region,
|
||||
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
const storage = new S3StorageBackend({
|
||||
bucket,
|
||||
region: TEST_S3.region,
|
||||
endpoint: TEST_S3.endpoint,
|
||||
accessKeyId: TEST_S3.accessKeyId,
|
||||
secretAccessKey: TEST_S3.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false,
|
||||
});
|
||||
await storage.init();
|
||||
return {
|
||||
storage,
|
||||
async cleanup() {
|
||||
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
|
||||
if (list.Contents?.length) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
|
||||
}));
|
||||
}
|
||||
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
async function makeSourceJpeg(targetDir, name) {
|
||||
const localPath = path.join(targetDir, name);
|
||||
// 800x600 random RGB image so sharp has something realistic to thumbnail.
|
||||
const width = 800;
|
||||
const height = 600;
|
||||
const buf = Buffer.alloc(width * height * 3);
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
|
||||
await sharp(buf, { raw: { width, height, channels: 3 } })
|
||||
.jpeg({ quality: 90 })
|
||||
.toFile(localPath);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
|
||||
let storage;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let imageProcessor;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ storage, cleanup } = await setup());
|
||||
storageModule.setStorageForTesting(storage);
|
||||
// Require AFTER setStorageForTesting so the module sees our injection.
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-src-'));
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
test('generateThumbnail writes through storage and returns a relative key', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'sample.jpg');
|
||||
const key = await imageProcessor.generateThumbnail(src);
|
||||
expect(key).toBe('thumbnails/thumb_sample.jpg');
|
||||
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
const stat = await storage.stat(key);
|
||||
expect(stat.size).toBeGreaterThan(100);
|
||||
|
||||
// Verify the bytes are a valid JPEG by re-parsing with sharp on local mode.
|
||||
if (storage.kind() === 'local') {
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
expect(meta.format).toBe('jpeg');
|
||||
expect(meta.width).toBeLessThanOrEqual(300);
|
||||
}
|
||||
});
|
||||
|
||||
test('generateHeroImage writes through storage and returns a relative key', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'hero-source.jpg');
|
||||
const key = await imageProcessor.generateHeroImage(src);
|
||||
expect(key).toBe('heroes/hero_hero-source.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
});
|
||||
|
||||
test('generatePreviewImage writes to /previews and skips enlargement of small originals', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'preview-source.jpg');
|
||||
const key = await imageProcessor.generatePreviewImage(src);
|
||||
expect(key).toBe('previews/preview_preview-source.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
|
||||
if (storage.kind() === 'local') {
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
expect(meta.format).toBe('jpeg');
|
||||
// Source is 800x600 and default longEdge is 1920 with
|
||||
// withoutEnlargement: true → preview must NOT be upscaled.
|
||||
expect(meta.width).toBe(800);
|
||||
expect(meta.height).toBe(600);
|
||||
}
|
||||
});
|
||||
|
||||
test('generatePreviewImage shrinks oversized images to fit longEdge while preserving aspect', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'preview-shrink.jpg');
|
||||
const key = await imageProcessor.generatePreviewImage(src, { longEdge: 400 });
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
if (storage.kind() === 'local') {
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
// 800x600 → fit:'inside' inside 400×400 → 400×300.
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height).toBe(300);
|
||||
}
|
||||
});
|
||||
|
||||
test('isPreviewValid returns true for a real preview and false for a missing key', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'preview-valid.jpg');
|
||||
const key = await imageProcessor.generatePreviewImage(src);
|
||||
expect(await imageProcessor.isPreviewValid(key)).toBe(true);
|
||||
expect(await imageProcessor.isPreviewValid('previews/does-not-exist.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
|
||||
const key = await imageProcessor.generateThumbnail(src);
|
||||
expect(await imageProcessor.isThumbnailValid(key)).toBe(true);
|
||||
expect(await imageProcessor.isThumbnailValid('thumbnails/does-not-exist.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
test('generateVideoPlaceholder writes a thumbnail entirely from buffer', async () => {
|
||||
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4');
|
||||
expect(key).toBe('thumbnails/thumb_demo.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
});
|
||||
|
||||
test('withLocalCopy yields a usable local path on both backends', async () => {
|
||||
const sourceKey = 'fixture/withlocal.jpg';
|
||||
const src = await makeSourceJpeg(tmpDir, 'withlocal.jpg');
|
||||
const buf = await fs.readFile(src);
|
||||
await storage.put(sourceKey, buf, { contentType: 'image/jpeg' });
|
||||
|
||||
const seenSize = await imageProcessor.withLocalCopy(sourceKey, async (localPath) => {
|
||||
const meta = await sharp(localPath).metadata();
|
||||
return meta.width;
|
||||
});
|
||||
expect(seenSize).toBe(800);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Incoming-invoice categorisation + re-bill chain (expenseService) against a
|
||||
* real SQLite schema. Covers the bits unit tests can't: the disposition state
|
||||
* machine, re-categorisation unwind, the per-event PENDING pool + bundling, and
|
||||
* the monthly accumulator immediate-bill — i.e. that categorizeInbound /
|
||||
* billPendingRebills actually mint / amend invoice rows correctly.
|
||||
*
|
||||
* No date-range comparisons are exercised here, so it's safe on SQLite (the
|
||||
* usual PG-vs-SQLite date pitfall — [[feedback_pg_date_columns_serialize]] —
|
||||
* doesn't apply to this path).
|
||||
*/
|
||||
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(60000);
|
||||
|
||||
describe('incoming-invoice categorise / re-bill chain', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminId;
|
||||
let expenseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// logActivity writes to activity_logs via the GLOBAL db. createInvoice (and
|
||||
// appendToMonthlyDraft) call it INSIDE the transaction we pass them, and a
|
||||
// second write connection deadlocks against the held write lock on
|
||||
// SQLite. It's fire-and-forget audit noise, irrelevant to these
|
||||
// assertions, so stub it BEFORE the services destructure it at require
|
||||
// time. (Production runs Postgres, where the concurrent write is fine.)
|
||||
const dbModule = require('../../src/database/db');
|
||||
dbModule.logActivity = async () => {};
|
||||
({ adminId } = await seedMinimal(db));
|
||||
expenseService = require('../../src/services/expenseService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
|
||||
|
||||
async function captureDoc(overrides = {}) {
|
||||
const ins = await db('inbound_documents').insert({
|
||||
source: 'upload',
|
||||
status: 'unsorted',
|
||||
parse_status: 'pending',
|
||||
parse_method: 'none',
|
||||
supplier_name: 'ACME AG',
|
||||
currency: 'CHF',
|
||||
total_amount_minor: 10000,
|
||||
invoice_date: '2026-06-01',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return unwrapId(ins);
|
||||
}
|
||||
|
||||
let customerSeq = 0;
|
||||
async function makeCustomer(billingCadence) {
|
||||
customerSeq += 1;
|
||||
const ins = await db('customer_accounts').insert({
|
||||
email: `rebill-${billingCadence || 'event'}-${customerSeq}@example.com`,
|
||||
display_name: `Rebill ${billingCadence || 'event'} ${customerSeq}`,
|
||||
password_hash: 'x',
|
||||
preferred_language: 'de',
|
||||
is_active: 1,
|
||||
billing_cadence: billingCadence || null,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
return unwrapId(ins);
|
||||
}
|
||||
|
||||
it('company expense (eigener_aufwand) categorises with no invoice + no customer', async () => {
|
||||
const id = await captureDoc();
|
||||
const doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
|
||||
expect(doc.disposition).toBe('eigener_aufwand');
|
||||
expect(doc.status).toBe('categorized');
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
expect(doc.customerAccountId).toBeNull();
|
||||
});
|
||||
|
||||
it('rebill REQUIRES a customer', async () => {
|
||||
const id = await captureDoc();
|
||||
await expect(expenseService.categorizeInbound(id, { disposition: 'rebill' }, adminId))
|
||||
.rejects.toMatchObject({ code: 'CUSTOMER_REQUIRED' });
|
||||
});
|
||||
|
||||
it('per-event rebill stays PENDING (customer + markup stored, no invoice yet)', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const id = await captureDoc({ total_amount_minor: 10000 });
|
||||
const doc = await expenseService.categorizeInbound(id, {
|
||||
disposition: 'rebill', customerAccountId: customerId,
|
||||
markupType: 'percent', markupPercent: 10,
|
||||
}, adminId);
|
||||
expect(doc.disposition).toBe('rebill');
|
||||
expect(doc.customerAccountId).toBe(customerId);
|
||||
expect(doc.billedInvoiceId).toBeNull(); // pending — not billed until bundled
|
||||
expect(doc.markupType).toBe('percent');
|
||||
expect(Number(doc.markupPercent)).toBe(10);
|
||||
});
|
||||
|
||||
it('passthrough never carries a markup, even if one is sent', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const id = await captureDoc();
|
||||
const doc = await expenseService.categorizeInbound(id, {
|
||||
disposition: 'durchlaufend', customerAccountId: customerId,
|
||||
markupType: 'percent', markupPercent: 25, // should be ignored
|
||||
}, adminId);
|
||||
expect(doc.disposition).toBe('durchlaufend');
|
||||
expect(doc.customerAccountId).toBe(customerId);
|
||||
expect(doc.markupType).toBe('none');
|
||||
expect(doc.markupPercent).toBeNull();
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
});
|
||||
|
||||
it('billPendingRebills refuses monthly/manual customers (they auto-consolidate)', async () => {
|
||||
const customerId = await makeCustomer('monthly');
|
||||
await expect(expenseService.billPendingRebills(customerId, adminId))
|
||||
.rejects.toMatchObject({ code: 'CADENCE_MISMATCH' });
|
||||
});
|
||||
|
||||
// ── The actual invoice-MINTING paths (billPendingRebills bundling a per-event
|
||||
// customer's pool; monthly-customer immediate-bill onto the running draft)
|
||||
// both call invoiceService.createInvoice INSIDE a db.transaction. createInvoice
|
||||
// claims its sequence number via the global db, which DEADLOCKS against the
|
||||
// held write lock on a SQLite-backed harness (a second write connection blocks
|
||||
// — verified). Production runs Postgres where the concurrent write is fine, so
|
||||
// this is a harness limitation, not a product bug. The line-amount math is
|
||||
// covered by the buildInboundLineItem unit tests, and createInvoice itself by
|
||||
// discountLineItems.test.js. Below we test the UNWIND path against a
|
||||
// hand-crafted billed state so we don't have to mint through createInvoice. ──
|
||||
|
||||
// Build a billed state directly: an invoice with two lines, with the inbound
|
||||
// doc stamped onto the first line as a prior re-bill.
|
||||
async function makeBilledDoc(customerId, { status = 'scheduled', scheduledSendAt = null, isMonthlyDraft = false } = {}) {
|
||||
const invIns = await db('invoices').insert({
|
||||
invoice_number: `R-TEST-${customerSeq}-${Math.floor(Math.random() * 1e9)}`,
|
||||
customer_account_id: customerId,
|
||||
status,
|
||||
scheduled_send_at: scheduledSendAt,
|
||||
is_monthly_draft: isMonthlyDraft,
|
||||
currency: 'CHF',
|
||||
issue_date: '2026-06-01',
|
||||
due_date: '2026-07-01',
|
||||
vat_rate: 0,
|
||||
net_amount_minor: 7000, // 4000 (rebill line) + 3000 (sibling)
|
||||
vat_amount_minor: 0,
|
||||
total_amount_minor: 7000,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const invoiceId = unwrapId(invIns);
|
||||
const rebillLineIns = await db('invoice_line_items').insert({
|
||||
invoice_id: invoiceId, position: 1, quantity: 1, description: 'Rebill Co (Weiterverrechnung)',
|
||||
unit_price_minor: 4000, discount_percent: 0, line_total_minor: 4000,
|
||||
}).returning('id');
|
||||
const rebillLineId = unwrapId(rebillLineIns);
|
||||
await db('invoice_line_items').insert({
|
||||
invoice_id: invoiceId, position: 2, quantity: 1, description: 'Other line',
|
||||
unit_price_minor: 3000, discount_percent: 0, line_total_minor: 3000,
|
||||
});
|
||||
const id = await captureDoc({ total_amount_minor: 4000, supplier_name: 'Rebill Co' });
|
||||
await db('inbound_documents').where({ id }).update({
|
||||
disposition: 'rebill', status: 'categorized', customer_account_id: customerId,
|
||||
billed_invoice_id: invoiceId, billed_invoice_line_item_id: rebillLineId,
|
||||
});
|
||||
return { id, invoiceId, rebillLineId };
|
||||
}
|
||||
|
||||
it('re-categorising a billed doc UNWINDS its re-bill line + recomputes the (mutable) invoice', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const { id, invoiceId, rebillLineId } = await makeBilledDoc(customerId); // scheduled, no send-at → mutable
|
||||
|
||||
const recat = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
|
||||
expect(recat.disposition).toBe('eigener_aufwand');
|
||||
expect(recat.billedInvoiceId).toBeNull();
|
||||
expect(recat.customerAccountId).toBeNull();
|
||||
|
||||
// The re-bill line is gone; the sibling line remains and net recomputes.
|
||||
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeUndefined();
|
||||
const after = await db('invoices').where({ id: invoiceId }).first();
|
||||
expect(Number(after.net_amount_minor)).toBe(3000);
|
||||
});
|
||||
|
||||
it('re-categorising a doc billed on an ISSUED invoice is refused (Storno required)', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const { id, rebillLineId } = await makeBilledDoc(customerId, { status: 'sent' });
|
||||
|
||||
await expect(expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId))
|
||||
.rejects.toMatchObject({ code: 'INVOICE_LOCKED' });
|
||||
// Nothing was touched — the line survives.
|
||||
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeDefined();
|
||||
});
|
||||
|
||||
it('re-categorisation moves a pending item between dispositions without a stray invoice', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const id = await captureDoc();
|
||||
// passthrough → pending
|
||||
let doc = await expenseService.categorizeInbound(id, { disposition: 'durchlaufend', customerAccountId: customerId }, adminId);
|
||||
expect(doc.customerAccountId).toBe(customerId);
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
// → company expense: customer cleared, still no invoice
|
||||
doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId);
|
||||
expect(doc.disposition).toBe('eigener_aufwand');
|
||||
expect(doc.customerAccountId).toBeNull();
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Install-from-backup boot hook — pins the trigger-file convention.
|
||||
*
|
||||
* The hook itself depends on `restoreService.restore`, which is hard
|
||||
* to fully exercise in an integration test without a real PG cluster
|
||||
* (sequence resync, DROP/CREATE, etc.). So we stub the actual restore
|
||||
* and verify the BOOT HOOK logic:
|
||||
*
|
||||
* - No trigger file → no-op, ran=false
|
||||
* - Empty trigger file → picks newest manifest from manifests/
|
||||
* - Non-empty trigger file → uses the path inside
|
||||
* - DB not empty → refuses (no restore call)
|
||||
* - DB not empty + FORCE env → proceeds
|
||||
* - Successful restore → deletes trigger file
|
||||
* - Failed restore → leaves trigger file in place
|
||||
*
|
||||
* These are the surfaces an admin will hit when actually using the
|
||||
* feature — the docker-compose-on-real-PG end-to-end test belongs in
|
||||
* the follow-up CI work captured as task #7 earlier today.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
// Stub the heavy lifting so the test stays fast + portable.
|
||||
const mockRestore = jest.fn();
|
||||
jest.mock('../../src/services/restoreService', () => ({
|
||||
restoreService: {
|
||||
restore: (...args) => mockRestore(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('installFromBackupBoot', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupRoot;
|
||||
let manifestsDir;
|
||||
let tryInstallFromBackup;
|
||||
let originalBackupRootEnv;
|
||||
let originalForceEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupRoot = path.join(storagePath, 'backup');
|
||||
manifestsDir = path.join(backupRoot, 'manifests');
|
||||
fs.mkdirSync(manifestsDir, { recursive: true });
|
||||
|
||||
originalBackupRootEnv = process.env.BACKUP_ROOT;
|
||||
originalForceEnv = process.env.INSTALL_FROM_BACKUP_FORCE;
|
||||
process.env.BACKUP_ROOT = backupRoot;
|
||||
|
||||
({ tryInstallFromBackup } = require('../../src/services/_installFromBackupBoot'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (originalBackupRootEnv === undefined) {
|
||||
delete process.env.BACKUP_ROOT;
|
||||
} else {
|
||||
process.env.BACKUP_ROOT = originalBackupRootEnv;
|
||||
}
|
||||
if (originalForceEnv === undefined) {
|
||||
delete process.env.INSTALL_FROM_BACKUP_FORCE;
|
||||
} else {
|
||||
process.env.INSTALL_FROM_BACKUP_FORCE = originalForceEnv;
|
||||
}
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockRestore.mockReset();
|
||||
mockRestore.mockResolvedValue({ success: true });
|
||||
delete process.env.INSTALL_FROM_BACKUP_FORCE;
|
||||
|
||||
// Clean trigger files + manifests between tests
|
||||
for (const name of ['RESTORE_ON_INSTALL', 'RESTORE_ON_INSTALL.txt']) {
|
||||
const p = path.join(backupRoot, name);
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
}
|
||||
for (const f of fs.readdirSync(manifestsDir)) {
|
||||
fs.unlinkSync(path.join(manifestsDir, f));
|
||||
}
|
||||
|
||||
// Reset DB to fresh-install state
|
||||
await db('events').del();
|
||||
// Leave admin_users alone — fresh-install state has 1 row.
|
||||
});
|
||||
|
||||
it('no trigger file → no-op', async () => {
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(false);
|
||||
expect(mockRestore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('empty trigger file picks the newest manifest from manifests/', async () => {
|
||||
const older = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
const newer = path.join(manifestsDir, 'backup-manifest-002.json');
|
||||
fs.writeFileSync(older, '{}');
|
||||
// Set the newer file's mtime slightly later so it wins the sort
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
fs.utimesSync(older, past, past);
|
||||
fs.writeFileSync(newer, '{}');
|
||||
|
||||
// Empty trigger
|
||||
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(true);
|
||||
expect(result.manifestPath).toBe(newer);
|
||||
expect(mockRestore).toHaveBeenCalledWith(expect.objectContaining({
|
||||
source: 'local',
|
||||
manifestPath: newer,
|
||||
restoreType: 'full',
|
||||
force: true,
|
||||
skipPreBackup: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('non-empty trigger file uses the path inside', async () => {
|
||||
const specific = path.join(manifestsDir, 'backup-manifest-specific.json');
|
||||
fs.writeFileSync(specific, '{}');
|
||||
|
||||
// Relative to backupRoot
|
||||
fs.writeFileSync(
|
||||
path.join(backupRoot, 'RESTORE_ON_INSTALL'),
|
||||
'manifests/backup-manifest-specific.json\n',
|
||||
);
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(true);
|
||||
expect(result.manifestPath).toBe(specific);
|
||||
});
|
||||
|
||||
it('deletes the trigger file after a successful restore', async () => {
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
|
||||
fs.writeFileSync(triggerPath, '');
|
||||
|
||||
await tryInstallFromBackup(db);
|
||||
expect(fs.existsSync(triggerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the trigger file in place when restore throws', async () => {
|
||||
mockRestore.mockRejectedValueOnce(new Error('restore exploded'));
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
|
||||
fs.writeFileSync(triggerPath, '');
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(false);
|
||||
expect(result.error).toMatch(/restore exploded/);
|
||||
expect(fs.existsSync(triggerPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to run when the install already has events', async () => {
|
||||
// Simulate an install with existing data
|
||||
await db('events').insert({
|
||||
slug: 'existing-event',
|
||||
event_name: 'Existing Event',
|
||||
event_type: 'wedding',
|
||||
event_date: new Date(),
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'host@example.com',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
share_link: 'existing-event-token',
|
||||
password_hash: 'dummy-hash-for-test',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(false);
|
||||
expect(result.error).toMatch(/Database not empty/);
|
||||
expect(mockRestore).not.toHaveBeenCalled();
|
||||
|
||||
// Trigger file should NOT be deleted — admin needs to fix + retry
|
||||
expect(fs.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'))).toBe(true);
|
||||
});
|
||||
|
||||
it('proceeds when INSTALL_FROM_BACKUP_FORCE=true even with existing data', async () => {
|
||||
await db('events').insert({
|
||||
slug: 'existing-event-2',
|
||||
event_name: 'Existing Event 2',
|
||||
event_type: 'wedding',
|
||||
event_date: new Date(),
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'host@example.com',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
share_link: 'existing-event-2-token',
|
||||
password_hash: 'dummy-hash-for-test-2',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
|
||||
|
||||
process.env.INSTALL_FROM_BACKUP_FORCE = 'true';
|
||||
const result = await tryInstallFromBackup(db);
|
||||
|
||||
expect(result.ran).toBe(true);
|
||||
expect(mockRestore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Dunning / Mahngebühr logic — the tax-sensitive bits added in the dunning
|
||||
* rework. Covers the fee math (flat / percent), the VAT toggle gating
|
||||
* (incl. the "no-op when the org has no VAT rate" requirement), per-reminder
|
||||
* accumulation (2nd = 1×, 3rd = 2×), invoice immutability (the fee never
|
||||
* changes the issued invoice total), and the 3-reminder cap.
|
||||
*
|
||||
* The Mahnung PDF render is stubbed — PDF rendering (fonts) is flaky in CI and
|
||||
* is verified manually; here we assert the data/immutability behaviour.
|
||||
*/
|
||||
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(30000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let invoiceService;
|
||||
let ids;
|
||||
|
||||
async function setSetting(key, value) {
|
||||
const { upsertAppSetting } = require('../../src/utils/appSettings');
|
||||
await upsertAppSetting(key, JSON.stringify(value), 'crm');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
ids = await seedMinimal(db);
|
||||
try { await db('customer_accounts').where({ id: ids.customerId }).update({ feature_bills: true }); } catch (_) {}
|
||||
invoiceService = require('../../src/services/invoiceService');
|
||||
// Stub the (flaky) PDF render so applyReminder exercises its data path.
|
||||
// eslint-disable-next-line global-require
|
||||
const pdfService = require('../../src/services/pdfService');
|
||||
pdfService.renderInvoiceToBuffer = async () => Buffer.from('%PDF-stub');
|
||||
});
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
describe('dunning fee resolvers', () => {
|
||||
test('flat fee, no VAT', async () => {
|
||||
await setSetting('crm_invoices_late_fee_enabled', true);
|
||||
await setSetting('crm_invoices_late_fee_type', 'flat');
|
||||
await setSetting('crm_invoices_late_fee_minor', 2000);
|
||||
await setSetting('crm_invoices_late_fee_vat_enabled', false);
|
||||
const inv = { total_amount_minor: 100000 };
|
||||
expect(await invoiceService.resolveLateFeeNetMinor(inv)).toBe(2000);
|
||||
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
|
||||
expect(await invoiceService.resolvePerReminderFeeMinor(inv)).toBe(2000);
|
||||
});
|
||||
|
||||
test('percent fee = % of the invoice gross', async () => {
|
||||
await setSetting('crm_invoices_late_fee_type', 'percent');
|
||||
await setSetting('crm_invoices_late_fee_percent', 5);
|
||||
expect(await invoiceService.resolveLateFeeNetMinor({ total_amount_minor: 100000 })).toBe(5000);
|
||||
});
|
||||
|
||||
test('VAT toggle applies the org rate, but is a NO-OP when the org has no VAT rate', async () => {
|
||||
await setSetting('crm_invoices_late_fee_type', 'flat');
|
||||
await setSetting('crm_invoices_late_fee_minor', 2000);
|
||||
await setSetting('crm_invoices_late_fee_vat_enabled', true);
|
||||
|
||||
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 8.1 });
|
||||
expect(await invoiceService.resolveLateFeeVatRate()).toBeCloseTo(8.1);
|
||||
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 }))
|
||||
.toBe(2000 + Math.round(2000 * 8.1 / 100)); // net + VAT
|
||||
|
||||
// Org doesn't charge VAT → toggle adds nothing (Mara's requirement).
|
||||
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 0 });
|
||||
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
|
||||
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyReminder — dunning-document model', () => {
|
||||
let invoiceId;
|
||||
let originalTotal;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setSetting('crm_invoices_late_fee_enabled', true);
|
||||
await setSetting('crm_invoices_late_fee_type', 'flat');
|
||||
await setSetting('crm_invoices_late_fee_minor', 2000);
|
||||
await setSetting('crm_invoices_late_fee_vat_enabled', false);
|
||||
const res = await invoiceService.createInvoice({
|
||||
customerAccountId: ids.customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
lineItems: [{ description: 'Service', quantity: 1, unit_price_minor: 100000 }],
|
||||
}, ids.adminId);
|
||||
invoiceId = res.invoiceIds[0];
|
||||
originalTotal = Number((await db('invoices').where({ id: invoiceId }).first()).total_amount_minor);
|
||||
});
|
||||
|
||||
test('level 2 tracks one fee and leaves the invoice total immutable', async () => {
|
||||
const data = await invoiceService.getInvoiceById(invoiceId);
|
||||
await invoiceService.applyReminder(data.invoice, data.lineItems, 2, ids.adminId);
|
||||
const inv = await db('invoices').where({ id: invoiceId }).first();
|
||||
expect(inv.reminder_level).toBe(2);
|
||||
expect(Number(inv.late_fee_amount_minor)).toBe(2000);
|
||||
expect(Number(inv.total_amount_minor)).toBe(originalTotal); // never mutated
|
||||
});
|
||||
|
||||
test('level 3 accumulates the fee to 2×, total still immutable', async () => {
|
||||
const data = await invoiceService.getInvoiceById(invoiceId);
|
||||
await invoiceService.applyReminder(data.invoice, data.lineItems, 3, ids.adminId);
|
||||
const inv = await db('invoices').where({ id: invoiceId }).first();
|
||||
expect(Number(inv.late_fee_amount_minor)).toBe(4000);
|
||||
expect(Number(inv.total_amount_minor)).toBe(originalTotal);
|
||||
});
|
||||
|
||||
test('sendReminder refuses to exceed level 3', async () => {
|
||||
await expect(invoiceService.sendReminder(invoiceId, 4, ids.adminId)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
// Validates the engine-neutral .picpeak export: it must produce a real zip with
|
||||
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
|
||||
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
|
||||
// bootCrmDb MUST run before requiring the service (which transitively requires
|
||||
// db.js) so the export reads this test's DB, not the default path.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
async function readZip(filePath) {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const entries = Object.keys(await zip.entries());
|
||||
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
|
||||
await zip.close();
|
||||
return { entries, manifest };
|
||||
}
|
||||
|
||||
describe('picpeak export (.picpeak logical export)', () => {
|
||||
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
expect(filePath.endsWith('.picpeak')).toBe(true);
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
|
||||
expect(manifest.format).toBe(1);
|
||||
expect(manifest.kind).toBe('picpeak-backup');
|
||||
expect(manifest.database.engine).toBe('sqlite');
|
||||
expect(manifest.options.includePhotos).toBe(false);
|
||||
expect(manifest.contains_secrets).toBe(true);
|
||||
// Migrations seed real tables (e.g. app_settings) — expect several.
|
||||
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(manifest.tables)).toContain('app_settings');
|
||||
|
||||
const { entries, manifest: zipped } = await readZip(filePath);
|
||||
expect(entries).toContain('manifest.json');
|
||||
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
|
||||
expect(entries).toContain('data/app_settings.ndjson');
|
||||
// Manifest inside the zip matches the returned one.
|
||||
expect(zipped.tables).toEqual(manifest.tables);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never exports knex bookkeeping tables', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const names = Object.keys(manifest.tables);
|
||||
expect(names).not.toContain('knex_migrations');
|
||||
expect(names).not.toContain('knex_migrations_lock');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('row counts in the manifest match the NDJSON line counts', async () => {
|
||||
// Insert a couple of settings so at least one table is non-empty.
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const buf = await zip.entryData('data/app_settings.ndjson');
|
||||
await zip.close();
|
||||
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
|
||||
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
|
||||
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
'use strict';
|
||||
|
||||
// Full .picpeak roundtrip on a temp SQLite DB:
|
||||
// 1. seed a "backup" instance (admin A + a marker setting)
|
||||
// 2. export → .picpeak
|
||||
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
|
||||
// 4. import the backup with currentAdminId = B
|
||||
// 5. assert the backup data is restored AND the current account (B) survives,
|
||||
// while the backup's admin (A) is also present (different email → added).
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
let importFromPicpeak;
|
||||
let validateManifest;
|
||||
let superAdminRoleId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
superAdminRoleId = role.id;
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
const adminRow = (email, hash) => ({
|
||||
username: email,
|
||||
email,
|
||||
password_hash: hash,
|
||||
role_id: superAdminRoleId,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
async function setMarker(value) {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
}
|
||||
async function getMarker() {
|
||||
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
|
||||
return row ? JSON.parse(row.setting_value) : null;
|
||||
}
|
||||
|
||||
describe('.picpeak roundtrip (export → import)', () => {
|
||||
it('restores backup data and preserves the current account', async () => {
|
||||
// 1. Seed the "source" instance.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A'));
|
||||
await setMarker('from_backup');
|
||||
|
||||
// 2. Export.
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// 3. Simulate a reinstall: fresh current admin B, mutated data.
|
||||
await db('admin_users').del();
|
||||
const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id');
|
||||
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
|
||||
await setMarker('mutated_after_backup');
|
||||
|
||||
// 4. Import, preserving the current admin.
|
||||
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
|
||||
expect(result.restored).toBe(true);
|
||||
expect(result.tables).toBeGreaterThan(0);
|
||||
|
||||
// 5a. Backup data restored (marker reverted to the backup value).
|
||||
expect(await getMarker()).toBe('from_backup');
|
||||
|
||||
// 5b. The backup's admin is present (different email → added).
|
||||
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first();
|
||||
expect(a).toBeTruthy();
|
||||
expect(a.password_hash).toBe('HASH_A');
|
||||
|
||||
// 5c. The current account SURVIVES the override, with its own credentials.
|
||||
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first();
|
||||
expect(b).toBeTruthy();
|
||||
expect(b.password_hash).toBe('HASH_B');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('overwrites a backup admin that collides with the current account email', async () => {
|
||||
// Source has an admin at the SAME email the current operator will use.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH'));
|
||||
await setMarker('collision_case');
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// Reinstall: current admin uses the same email but a NEW password.
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
|
||||
// Exactly one admin at that email, and it keeps the CURRENT password.
|
||||
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].password_hash).toBe('NEW_HASH');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('restores files/ and reports filesRestored', async () => {
|
||||
// A business-doc that lives in storage → travels in the backup.
|
||||
const docDir = path.join(tmpDir, 'business-docs');
|
||||
const marker = path.join(docDir, 'roundtrip-doc.txt');
|
||||
fs.mkdirSync(docDir, { recursive: true });
|
||||
fs.writeFileSync(marker, 'hello');
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
fs.rmSync(marker); // delete on disk so the restore must bring it back
|
||||
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
|
||||
expect(fs.existsSync(marker)).toBe(true);
|
||||
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
fs.rmSync(docDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('.picpeak manifest validation', () => {
|
||||
it('rejects a database-engine mismatch', async () => {
|
||||
// Harness runs on SQLite, so a pg manifest must be refused.
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a backup from a newer schema (forward-only)', async () => {
|
||||
// validateManifest reads knex_migrations for the target's latest migration;
|
||||
// the harness has none, so create it with an older migration than the backup.
|
||||
await db.schema.createTable('knex_migrations', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name');
|
||||
t.integer('batch');
|
||||
t.timestamp('migration_time');
|
||||
});
|
||||
try {
|
||||
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1,
|
||||
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
|
||||
tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
|
||||
} finally {
|
||||
await db.schema.dropTableIfExists('knex_migrations');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a file that is not a PicPeak backup', async () => {
|
||||
const blockers = await validateManifest({ some: 'random-json' });
|
||||
expect(blockers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738).
|
||||
*
|
||||
* Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs
|
||||
* the script in a child process (--email <addr> --yes) pointed at the same
|
||||
* DB file, and asserts the four MFA columns are zeroed. The script runs in
|
||||
* its own process with its own knex connection; the parent connection is
|
||||
* idle during the spawn so the SQLite write lock isn't contended.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js');
|
||||
|
||||
async function seedEnrolledAdmin(email) {
|
||||
const inserted = await db('admin_users').insert({
|
||||
username: email.split('@')[0],
|
||||
email,
|
||||
password_hash: 'x',
|
||||
is_active: true,
|
||||
two_factor_enabled: true,
|
||||
two_factor_secret: 'iv.tag.ct',
|
||||
two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']),
|
||||
two_factor_enrolled_at: new Date(),
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
it('zeroes the four MFA columns for the targeted admin', async () => {
|
||||
const email = 'reset-me@example.com';
|
||||
const id = await seedEnrolledAdmin(email);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', email, '--yes'], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const row = await db('admin_users').where({ id }).first();
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
expect(row.two_factor_enrolled_at).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a different admin untouched', async () => {
|
||||
const targetEmail = 'target@example.com';
|
||||
const bystanderEmail = 'bystander@example.com';
|
||||
const targetId = await seedEnrolledAdmin(targetEmail);
|
||||
const bystanderId = await seedEnrolledAdmin(bystanderEmail);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], {
|
||||
env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const target = await db('admin_users').where({ id: targetId }).first();
|
||||
const bystander = await db('admin_users').where({ id: bystanderId }).first();
|
||||
expect(Number(target.two_factor_enabled)).toBe(0);
|
||||
expect(Number(bystander.two_factor_enabled)).toBe(1);
|
||||
expect(bystander.two_factor_secret).toBe('iv.tag.ct');
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Pins the fix for the PR #596 review blocker.
|
||||
*
|
||||
* **The bug**
|
||||
*
|
||||
* `preservedMeta` was declared with `let` INSIDE the PostgreSQL
|
||||
* `else` branch of `performDatabaseRestore`, then read AFTER the
|
||||
* `else` block closed at the shared replay site (~L1030). On every
|
||||
* real PG restore:
|
||||
*
|
||||
* ReferenceError: preservedMeta is not defined
|
||||
*
|
||||
* would fire — psql had already completed the data restore, but
|
||||
* the operator-meta replay never ran, the trigger file was left
|
||||
* in place by `_installFromBackupBoot.js` because the restore
|
||||
* "failed", and `combined.log` got a loud FAILED line even though
|
||||
* the data was back. Caught on PR #596 review by the maintainer.
|
||||
*
|
||||
* **Why CI missed it**
|
||||
*
|
||||
* The integration tests around `performFullRestore` only exercise
|
||||
* the SQLite branch via `this.dbType === 'sqlite'`. The PG branch
|
||||
* (~L827-984) requires a real PG connection + real `psql` binary,
|
||||
* neither of which are in the test environment. So the scope leak
|
||||
* sat untested until the maintainer ran a real DR cycle.
|
||||
*
|
||||
* **What this test does**
|
||||
*
|
||||
* Reads the source of `restoreService.js` and asserts the scope
|
||||
* contract: the `preservedMeta` declaration sits ABOVE the
|
||||
* SQLite/PG branch split, so the replay block at the bottom of the
|
||||
* try{} can read it on either branch.
|
||||
*
|
||||
* Source-inspection is uglier than a runtime test but it has two
|
||||
* advantages here: (a) it doesn't require a real PG cluster + psql
|
||||
* binary in CI, (b) it pins the EXACT contract — "the declaration
|
||||
* must be visible to the replay block" — which is the property
|
||||
* that broke, more directly than a runtime test would.
|
||||
*
|
||||
* The follow-up "real-PG integration test in CI" (separate task)
|
||||
* would replace this with an end-to-end exercise, at which point
|
||||
* this can be deleted.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('restoreService — PG branch scope contract (PR #596 review)', () => {
|
||||
let src;
|
||||
let lines;
|
||||
|
||||
beforeAll(() => {
|
||||
src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'services', 'restoreService.js'),
|
||||
'utf8',
|
||||
);
|
||||
lines = src.split(/\r?\n/);
|
||||
});
|
||||
|
||||
/** Return the 1-based line number of the FIRST line matching `re`. */
|
||||
function findFirst(re) {
|
||||
const idx = lines.findIndex((l) => re.test(l));
|
||||
return idx >= 0 ? idx + 1 : -1;
|
||||
}
|
||||
|
||||
/** Return the 1-based line number of the LAST line matching `re`. */
|
||||
function findLast(re) {
|
||||
let last = -1;
|
||||
lines.forEach((l, i) => { if (re.test(l)) last = i + 1; });
|
||||
return last;
|
||||
}
|
||||
|
||||
it('preservedMetaSnapshot lives on `this` and is initialised in the constructor', () => {
|
||||
// PR #596 round 3 moved the snapshot from a block-scoped local to
|
||||
// an instance variable so the replay can happen in `restore()`
|
||||
// AFTER post-restore verification — preventing the replay row
|
||||
// from inflating the row-count check.
|
||||
//
|
||||
// Contract:
|
||||
// 1. The constructor initialises `this.preservedMetaSnapshot = []`
|
||||
// 2. The `restore()` entry point resets it per call (no leak
|
||||
// across consecutive runs in the singleton service instance)
|
||||
// 3. `performDatabaseRestore` assigns to `this.preservedMetaSnapshot`
|
||||
// inside the PG branch (must run before DROP)
|
||||
// 4. The replay reads `this.preservedMetaSnapshot` — NOT a bare
|
||||
// `preservedMeta` local — so a future refactor can't
|
||||
// accidentally drop the snapshot half on the floor again.
|
||||
const constructorInit = lines.some((l) =>
|
||||
/this\.preservedMetaSnapshot\s*=\s*\[\s*\]/.test(l)
|
||||
);
|
||||
expect(constructorInit).toBe(true);
|
||||
|
||||
const assignmentSites = lines.filter((l) =>
|
||||
/this\.preservedMetaSnapshot\s*=\s*(\[\s*\]|await\s+db)/.test(l)
|
||||
);
|
||||
// Constructor init + restore() per-run reset + the PG-branch
|
||||
// assignment from db query. Three writes.
|
||||
expect(assignmentSites.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// No stray bare `preservedMeta` local-scoped declaration in
|
||||
// performDatabaseRestore — would indicate someone re-introduced
|
||||
// the round-1 footgun.
|
||||
const dangerousLocalDecl = lines.filter((l) =>
|
||||
/^\s*(let|const)\s+preservedMeta\s*=/.test(l)
|
||||
);
|
||||
expect(dangerousLocalDecl).toEqual([]);
|
||||
});
|
||||
|
||||
it('every .count() result is coerced to Number before comparison', () => {
|
||||
// PR #596 review caught a second PG-only landmine: pg-driver
|
||||
// returns COUNT(*) as a string ("16" not 16) to preserve bigint
|
||||
// precision. The original code compared `result.count !==
|
||||
// expected.rowCount` and every match flagged as a mismatch on PG.
|
||||
//
|
||||
// The fix coerces with `Number(...)` at every comparison +
|
||||
// interpolation site. This test catches a future regression where
|
||||
// a refactor uses `.count` directly in a `===` / `!==` / `>` /
|
||||
// `<` comparison without coercing.
|
||||
//
|
||||
// Heuristic: find every `.count` access in the file and make sure
|
||||
// the line either:
|
||||
// (a) wraps it in `Number(...)`, or
|
||||
// (b) is purely an interpolation that already coerced upstream
|
||||
// (e.g. `validation.warnings.push(`... ${eventCountN} ...`)`
|
||||
// where eventCountN is the coerced local), or
|
||||
// (c) is the docstring/comment line (filtered separately).
|
||||
//
|
||||
// We approximate this by listing every `.count` reference site
|
||||
// and asserting that lines doing comparisons (`===`/`!==`/`>`/
|
||||
// `<`/`>=`/`<=`) on a raw `.count` access without `Number(...)`
|
||||
// around it are zero.
|
||||
const dangerousLines = lines
|
||||
.map((l, i) => ({ line: i + 1, text: l }))
|
||||
// Filter to lines that compare a .count result
|
||||
.filter(({ text }) => {
|
||||
// Skip comments
|
||||
if (/^\s*(\/\/|\*)/.test(text)) return false;
|
||||
// Detect a `.count` (followed by `)` for `?.count` or by space/operator)
|
||||
// being directly compared via ===/!==/>/<.
|
||||
// Match the BAD pattern: `<something>.count <op> <something>`
|
||||
// where <op> is === / !== / > / < / >= / <=
|
||||
const bareCountInComparison = /\w+\??\.count\s*(?:!==|===|>=?|<=?)\s+/;
|
||||
// ALLOW if the .count is preceded by `Number(` in the same line
|
||||
const wrappedInNumber = /Number\(\s*\w+\??\.count/;
|
||||
return bareCountInComparison.test(text) && !wrappedInNumber.test(text);
|
||||
});
|
||||
|
||||
expect(dangerousLines).toEqual([]);
|
||||
});
|
||||
|
||||
it('the completed-restore update sets was_successful=true', () => {
|
||||
// Without this, every successful restore ends up with
|
||||
// status='completed', was_successful=false — the dashboard's
|
||||
// "last successful restore" widget then filters out the row +
|
||||
// any future audit query gating on was_successful misses it.
|
||||
// Caught locally + maintainer PR #596 review.
|
||||
//
|
||||
// Contract: the update payload that writes status='completed' on
|
||||
// the SUCCESS branch ALSO includes was_successful: true. We pin
|
||||
// it by source inspection so any future refactor of the success
|
||||
// payload keeps both fields together.
|
||||
// The success-branch update lives AFTER performPostRestoreVerification.
|
||||
// There's also a `status: 'completed'` in the dry-run / early-return
|
||||
// path (failure handling has its own block too) — we want the
|
||||
// SUCCESS-branch one specifically.
|
||||
const verifyLine = findFirst(/performPostRestoreVerification\s*\(/);
|
||||
expect(verifyLine).toBeGreaterThan(0);
|
||||
|
||||
const completedStatusLineIdx = lines
|
||||
.map((l, i) => ({ line: i + 1, text: l }))
|
||||
.find(({ line, text }) =>
|
||||
line > verifyLine && /status:\s*['"]completed['"]/.test(text)
|
||||
);
|
||||
expect(completedStatusLineIdx).toBeDefined();
|
||||
|
||||
// Look in the next ~10 lines for was_successful: true. The actual
|
||||
// payload is small (no nested objects between status and the
|
||||
// closing })), so a fixed-window search is reliable.
|
||||
const window = lines.slice(
|
||||
completedStatusLineIdx.line - 1,
|
||||
completedStatusLineIdx.line + 10,
|
||||
).join('\n');
|
||||
expect(window).toMatch(/was_successful:\s*true/);
|
||||
});
|
||||
|
||||
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 `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).
|
||||
//
|
||||
// Contract:
|
||||
// 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(/['"]migrate:safe['"]/);
|
||||
expect(migrateLine).toBeGreaterThan(0);
|
||||
|
||||
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
|
||||
expect(replayLine).toBeGreaterThan(0);
|
||||
expect(migrateLine).toBeGreaterThan(replayLine);
|
||||
|
||||
// Must NOT live inside performDatabaseRestore (same scope as the
|
||||
// replay check above).
|
||||
const dbRestoreStart = findFirst(/async\s+performDatabaseRestore\s*\(/);
|
||||
let dbRestoreEnd = -1;
|
||||
for (let i = dbRestoreStart; i < lines.length; i++) {
|
||||
if (/^ \}\s*$/.test(lines[i])) {
|
||||
dbRestoreEnd = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(migrateLine < dbRestoreStart || migrateLine > dbRestoreEnd).toBe(true);
|
||||
});
|
||||
|
||||
it('the replay site lives in restore() AFTER performPostRestoreVerification', () => {
|
||||
// PR #596 round 3 moved the replay out of performDatabaseRestore
|
||||
// and into the parent restore() method, sequenced AFTER the
|
||||
// post-restore verification. Otherwise the replay's upserted row
|
||||
// count was being flagged as a verification mismatch (e.g.
|
||||
// "expected 190, got 191" because the fresh-install seeded
|
||||
// `restore_allow_force_auto_upgraded` that wasn't in the backup).
|
||||
//
|
||||
// Contract: the line that drains `this.preservedMetaSnapshot`
|
||||
// must come AFTER `performPostRestoreVerification` AND must NOT
|
||||
// sit inside `performDatabaseRestore`.
|
||||
const verificationLine = findFirst(/performPostRestoreVerification\s*\(/);
|
||||
expect(verificationLine).toBeGreaterThan(0);
|
||||
|
||||
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
|
||||
expect(replayLine).toBeGreaterThan(0);
|
||||
expect(replayLine).toBeGreaterThan(verificationLine);
|
||||
|
||||
// `performDatabaseRestore` must not contain the replay drain.
|
||||
// Find the function bounds + assert no drain line falls inside.
|
||||
const dbRestoreStart = findFirst(/async\s+performDatabaseRestore\s*\(/);
|
||||
expect(dbRestoreStart).toBeGreaterThan(0);
|
||||
|
||||
// Find the closing brace of performDatabaseRestore. Lazy heuristic:
|
||||
// the first `^ \}\s*$` (two-space indent + }) after the function
|
||||
// start. Brittle to indent changes but unambiguous in this codebase.
|
||||
let dbRestoreEnd = -1;
|
||||
for (let i = dbRestoreStart; i < lines.length; i++) {
|
||||
if (/^ \}\s*$/.test(lines[i])) {
|
||||
dbRestoreEnd = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(dbRestoreEnd).toBeGreaterThan(dbRestoreStart);
|
||||
|
||||
// The replay drain line must be OUTSIDE [dbRestoreStart, dbRestoreEnd].
|
||||
expect(replayLine < dbRestoreStart || replayLine > dbRestoreEnd).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
'use strict';
|
||||
|
||||
// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service
|
||||
// so setupService shares this test's db instance (see crmDb.js note).
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let setupService;
|
||||
let getAppSetting;
|
||||
let upsertAppSetting;
|
||||
let app;
|
||||
|
||||
const VALID_PW = 'Str0ng-Passw0rd!';
|
||||
|
||||
// bootCrmDb MUST run before any require of db.js (directly or transitively via a
|
||||
// service/util), or db.js binds to the default path instead of the temp one.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
|
||||
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('admin_users').del();
|
||||
await db('app_settings').where({ setting_key: 'setup_token' }).del();
|
||||
});
|
||||
|
||||
describe('setupService (first-run bootstrap)', () => {
|
||||
it('reports needsAdmin while no admin exists', async () => {
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('generates and persists a one-time token while no admin exists', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
expect(token).toEqual(expect.any(String));
|
||||
expect(token.length).toBeGreaterThan(20);
|
||||
expect(await getAppSetting('setup_token')).toBe(token);
|
||||
// Idempotent — a second call returns the same token, not a fresh one.
|
||||
expect(await setupService.ensureSetupToken()).toBe(token);
|
||||
});
|
||||
|
||||
it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => {
|
||||
// Regression guard for the SQLite-only miss: a bare token string is rejected
|
||||
// by Postgres jsonb ("invalid input syntax for type json"). The raw column
|
||||
// value must be JSON-parseable and round-trip back to the token.
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const row = await db('app_settings').where({ setting_key: 'setup_token' }).first();
|
||||
expect(() => JSON.parse(row.setting_value)).not.toThrow();
|
||||
expect(JSON.parse(row.setting_value)).toBe(token);
|
||||
});
|
||||
|
||||
it('rejects a wrong token', async () => {
|
||||
await setupService.ensureSetupToken();
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token: 'nope', email: 'a@b.co', password: VALID_PW })
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('rejects a weak password', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token, email: 'a@b.co', password: 'weak' })
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const result = await setupService.createInitialAdmin({
|
||||
token, email: 'Owner@Example.com', password: VALID_PW, ip: '203.0.113.7',
|
||||
});
|
||||
|
||||
expect(result.user.email).toBe('owner@example.com'); // normalised
|
||||
expect(result.user.role.name).toBe('super_admin');
|
||||
expect(result.token).toEqual(expect.any(String));
|
||||
|
||||
const row = await db('admin_users').first();
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
expect(row.role_id).toBe(role.id);
|
||||
expect(row.password_hash).not.toBe(VALID_PW); // hashed
|
||||
|
||||
// One-time: token burned, status now complete.
|
||||
expect(await getAppSetting('setup_token')).toBeFalsy();
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
||||
});
|
||||
|
||||
it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => {
|
||||
const tokenFile = path.join(tmpDir, 'SETUP_TOKEN');
|
||||
const token = await setupService.ensureSetupToken();
|
||||
expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token);
|
||||
await setupService.createInitialAdmin({ token, email: 'owner@example.com', password: VALID_PW });
|
||||
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
|
||||
});
|
||||
|
||||
it('refuses to create a second admin (setup already complete)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token, email: 'second@example.com', password: VALID_PW })
|
||||
).rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
it('serialises a double-submit — two concurrent valid-token calls create only one admin', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const results = await Promise.allSettled([
|
||||
setupService.createInitialAdmin({ token, email: 'a@example.com', password: VALID_PW }),
|
||||
setupService.createInitialAdmin({ token, email: 'b@example.com', password: VALID_PW }),
|
||||
]);
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||
expect(fulfilled).toHaveLength(1); // the atomic token claim lets exactly one win
|
||||
const count = await db('admin_users').count({ c: '*' }).first();
|
||||
expect(Number(count.c)).toBe(1);
|
||||
});
|
||||
|
||||
it('ensureSetupToken clears any stale token once an admin exists', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
// Simulate a stale token left in settings, then re-run the boot hook.
|
||||
await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string');
|
||||
expect(await setupService.ensureSetupToken()).toBeNull();
|
||||
expect(await getAppSetting('setup_token')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup routes', () => {
|
||||
it('GET /api/setup/status reports needsAdmin', async () => {
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('POST /api/setup/verify-token accepts the right token without burning it (200)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const res = await request(app).post('/api/setup/verify-token').send({ token });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ valid: true });
|
||||
// Token is NOT consumed — it still works for the actual create.
|
||||
expect(await getAppSetting('setup_token')).toBe(token);
|
||||
});
|
||||
|
||||
it('POST /api/setup/verify-token rejects a wrong token (400, field token)', async () => {
|
||||
await setupService.ensureSetupToken();
|
||||
const res = await request(app).post('/api/setup/verify-token').send({ token: 'nope' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.field).toBe('token');
|
||||
});
|
||||
|
||||
it('POST /api/setup/verify-token is closed once an admin exists (409)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
const res = await request(app).post('/api/setup/verify-token').send({ token });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin rejects a wrong token (400)', async () => {
|
||||
await setupService.ensureSetupToken();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/admin')
|
||||
.send({ token: 'nope', email: 'a@b.co', password: VALID_PW });
|
||||
expect(res.status).toBe(400);
|
||||
expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true });
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/admin')
|
||||
.send({ token, email: 'owner@example.com', password: VALID_PW });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.user.role.name).toBe('super_admin');
|
||||
expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/);
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin is closed once an admin exists (409)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
const res = await request(app)
|
||||
.post('/api/setup/admin')
|
||||
.send({ token, email: 'second@example.com', password: VALID_PW });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { Readable } = require('stream');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
|
||||
|
||||
// MinIO defaults match docker-compose.dev.yml. Override via TEST_S3_* if needed.
|
||||
const TEST_S3 = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
region: 'us-east-1',
|
||||
};
|
||||
|
||||
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
|
||||
|
||||
// Build the matrix of backends to test. Local always runs; S3 runs against MinIO
|
||||
// unless SKIP_S3_TESTS=true (CI default). The same suite runs against both so
|
||||
// every consumer can rely on identical semantics.
|
||||
function backendCases() {
|
||||
const cases = [
|
||||
{
|
||||
name: 'LocalFsStorage',
|
||||
async setup() {
|
||||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-storage-'));
|
||||
const storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
return { storage, cleanup: () => fsp.rm(root, { recursive: true, force: true }) };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!skipS3) {
|
||||
cases.push({
|
||||
name: 'S3StorageBackend (MinIO)',
|
||||
async setup() {
|
||||
const bucket = `picpeak-test-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
|
||||
const s3Client = new S3Client({
|
||||
endpoint: TEST_S3.endpoint,
|
||||
region: TEST_S3.region,
|
||||
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
const storage = new S3StorageBackend({
|
||||
bucket,
|
||||
region: TEST_S3.region,
|
||||
endpoint: TEST_S3.endpoint,
|
||||
accessKeyId: TEST_S3.accessKeyId,
|
||||
secretAccessKey: TEST_S3.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false,
|
||||
});
|
||||
await storage.init();
|
||||
return {
|
||||
storage,
|
||||
async cleanup() {
|
||||
// Empty bucket then delete it.
|
||||
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
|
||||
if (list.Contents?.length) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
|
||||
}));
|
||||
}
|
||||
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return cases;
|
||||
}
|
||||
|
||||
async function readToString(stream) {
|
||||
const chunks = [];
|
||||
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
describe.each(backendCases())('StorageBackend contract: $name', ({ setup }) => {
|
||||
let storage;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ storage, cleanup } = await setup());
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
test('put + get + exists + stat + delete round-trip with a buffer body', async () => {
|
||||
const key = 'photos/event-a/IMG_0001.jpg';
|
||||
const body = Buffer.from('hello picpeak');
|
||||
|
||||
await storage.put(key, body, { contentType: 'image/jpeg' });
|
||||
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
|
||||
const stat = await storage.stat(key);
|
||||
expect(stat).not.toBeNull();
|
||||
expect(stat.size).toBe(body.length);
|
||||
|
||||
const stream = await storage.get(key);
|
||||
const text = await readToString(stream);
|
||||
expect(text).toBe('hello picpeak');
|
||||
|
||||
await storage.delete(key);
|
||||
expect(await storage.exists(key)).toBe(false);
|
||||
expect(await storage.stat(key)).toBeNull();
|
||||
});
|
||||
|
||||
test('put accepts a Readable stream body', async () => {
|
||||
const key = 'photos/event-b/streamed.bin';
|
||||
const body = Readable.from(Buffer.from('streamed payload'));
|
||||
|
||||
await storage.put(key, body);
|
||||
|
||||
const got = await readToString(await storage.get(key));
|
||||
expect(got).toBe('streamed payload');
|
||||
});
|
||||
|
||||
test('putFromFile + getToFile round-trip', async () => {
|
||||
const tmpIn = path.join(os.tmpdir(), `in-${Date.now()}.txt`);
|
||||
const tmpOut = path.join(os.tmpdir(), `out-${Date.now()}.txt`);
|
||||
await fsp.writeFile(tmpIn, 'file payload');
|
||||
|
||||
const key = 'thumbnails/thumb_x.jpg';
|
||||
await storage.putFromFile(key, tmpIn, { contentType: 'image/jpeg' });
|
||||
|
||||
await storage.getToFile(key, tmpOut);
|
||||
const text = await fsp.readFile(tmpOut, 'utf-8');
|
||||
expect(text).toBe('file payload');
|
||||
|
||||
await fsp.unlink(tmpIn).catch(() => {});
|
||||
await fsp.unlink(tmpOut).catch(() => {});
|
||||
});
|
||||
|
||||
test('list returns entries under a prefix with size + key', async () => {
|
||||
await storage.put('events/active/a/photo1.jpg', Buffer.from('a1'));
|
||||
await storage.put('events/active/a/photo2.jpg', Buffer.from('a22'));
|
||||
await storage.put('events/active/b/photo3.jpg', Buffer.from('b333'));
|
||||
|
||||
const entries = await storage.list('events/active/a');
|
||||
const keys = entries.map((e) => e.key).sort();
|
||||
expect(keys).toEqual(['events/active/a/photo1.jpg', 'events/active/a/photo2.jpg']);
|
||||
const sizes = Object.fromEntries(entries.map((e) => [e.key, e.size]));
|
||||
expect(sizes['events/active/a/photo1.jpg']).toBe(2);
|
||||
expect(sizes['events/active/a/photo2.jpg']).toBe(3);
|
||||
});
|
||||
|
||||
test('rename moves an object from src to dst (atomic on local; copy+delete on s3)', async () => {
|
||||
await storage.put('uploads/temp.jpg', Buffer.from('rename-me'));
|
||||
await storage.rename('uploads/temp.jpg', 'uploads/final.jpg');
|
||||
|
||||
expect(await storage.exists('uploads/temp.jpg')).toBe(false);
|
||||
expect(await storage.exists('uploads/final.jpg')).toBe(true);
|
||||
const text = await readToString(await storage.get('uploads/final.jpg'));
|
||||
expect(text).toBe('rename-me');
|
||||
});
|
||||
|
||||
test('copy duplicates an object without removing the source', async () => {
|
||||
await storage.put('events/source.jpg', Buffer.from('src'));
|
||||
await storage.copy('events/source.jpg', 'events/copied.jpg');
|
||||
|
||||
expect(await storage.exists('events/source.jpg')).toBe(true);
|
||||
expect(await storage.exists('events/copied.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
test('delete on a missing key is a no-op (does not throw)', async () => {
|
||||
await expect(storage.delete('does/not/exist.jpg')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('stat on a missing key returns null', async () => {
|
||||
expect(await storage.stat('still/not/here.jpg')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects path traversal attempts', async () => {
|
||||
await expect(storage.put('../escape.txt', Buffer.from('x'))).rejects.toThrow(/traversal/i);
|
||||
await expect(storage.get('../escape.txt')).rejects.toThrow(/traversal/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
// Worker reads WEBHOOK_ALLOW_PRIVATE_URLS at module-load. Set it BEFORE
|
||||
// requiring the worker so the local-stub URLs (127.0.0.1:<random>) pass
|
||||
// the SSRF check by default.
|
||||
process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
|
||||
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
|
||||
|
||||
const http = require('http');
|
||||
const { db } = require('../../src/database/db');
|
||||
const webhookService = require('../../src/services/webhookService');
|
||||
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
|
||||
|
||||
// Local-only test stub: matches what dev/webhook-receiver/server.js does
|
||||
// in the docker-compose flow but spun up inside the Jest process so the
|
||||
// suite is self-contained.
|
||||
function makeStub({ status = 200, delayMs = 0, bodyOverride = null } = {}) {
|
||||
const requests = [];
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
requests.push({ method: req.method, url: req.url, headers: req.headers, body });
|
||||
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
|
||||
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
||||
res.end(bodyOverride !== null ? bodyOverride : (status >= 200 && status < 300 ? 'ok' : 'forced'));
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
resolve({ url: `http://127.0.0.1:${port}/`, requests, close: () => new Promise((r) => server.close(r)) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function insertWebhook(url, events = ['event.published'], extras = {}) {
|
||||
// Tests need the WORKER to bypass SSRF on 127.0.0.1 stubs, but the
|
||||
// route layer's allowlist check is bypassed here since we insert
|
||||
// straight into the DB.
|
||||
const { plaintext, preview } = webhookService.generateSecret();
|
||||
const insert = await db('webhooks').insert({
|
||||
name: extras.name || 'test',
|
||||
url,
|
||||
secret: plaintext,
|
||||
secret_preview: preview,
|
||||
events: JSON.stringify(events),
|
||||
active: extras.active !== false,
|
||||
created_by: 1,
|
||||
}).returning('id');
|
||||
const id = insert[0]?.id || insert[0];
|
||||
return { id, secret: plaintext };
|
||||
}
|
||||
|
||||
async function clearWebhooks() {
|
||||
await db('webhook_deliveries').del();
|
||||
await db('webhooks').del();
|
||||
}
|
||||
|
||||
describe('webhook delivery worker (#327)', () => {
|
||||
beforeAll(async () => {
|
||||
// Schema is expected to already be applied by `npm run migrate`. We
|
||||
// just verify the webhooks tables exist; if not, the test harness has
|
||||
// missed running migration 082.
|
||||
const ok = await db.schema.hasTable('webhooks');
|
||||
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
stopWebhookDeliveryWorker();
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearWebhooks();
|
||||
});
|
||||
|
||||
test('signs the body with HMAC-SHA256 and the receiver can verify', async () => {
|
||||
const stub = await makeStub({ status: 200 });
|
||||
try {
|
||||
const { id, secret } = await insertWebhook(stub.url);
|
||||
await webhookService.fire('event.published', { event: { id: 1, slug: 'sig-test' } });
|
||||
await __test.tick();
|
||||
|
||||
expect(stub.requests).toHaveLength(1);
|
||||
const got = stub.requests[0];
|
||||
const sig = got.headers['x-picpeak-signature'];
|
||||
expect(sig).toBeTruthy();
|
||||
// Receiver-side verification using the SAME helper we ship in the README.
|
||||
expect(webhookService.verifySignature(secret, got.body, sig)).toBe(true);
|
||||
// Tampering must fail.
|
||||
expect(webhookService.verifySignature(secret, got.body + 'x', sig)).toBe(false);
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('success');
|
||||
expect(row.attempt_count).toBe(1);
|
||||
expect(row.response_status).toBe(200);
|
||||
expect(row.latency_ms).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('headers include event type and a unique delivery id', async () => {
|
||||
const stub = await makeStub({ status: 200 });
|
||||
try {
|
||||
await insertWebhook(stub.url, ['photo.uploaded']);
|
||||
await webhookService.fire('photo.uploaded', { photo: { id: 7 } });
|
||||
await __test.tick();
|
||||
|
||||
const got = stub.requests[0];
|
||||
expect(got.headers['x-picpeak-event']).toBe('photo.uploaded');
|
||||
expect(got.headers['x-picpeak-delivery']).toBeTruthy();
|
||||
expect(got.headers['user-agent']).toMatch(/PicPeak-Webhooks/);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('on 5xx, schedules a retry with exponential backoff and stays pending', async () => {
|
||||
const stub = await makeStub({ status: 500 });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url);
|
||||
await webhookService.fire('event.published', { event: { id: 2 } });
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('pending');
|
||||
expect(row.attempt_count).toBe(1);
|
||||
expect(row.response_status).toBe(500);
|
||||
// BACKOFF_MS[0] = 60s; next_retry_at should be ~60s in the future.
|
||||
const dueIn = new Date(row.next_retry_at).getTime() - Date.now();
|
||||
expect(dueIn).toBeGreaterThan(50_000);
|
||||
expect(dueIn).toBeLessThan(70_000);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('after MAX_ATTEMPTS failures, status flips to failed and the row is closed', async () => {
|
||||
const stub = await makeStub({ status: 500 });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url);
|
||||
// Pre-seed a delivery already at attempt_count = 4 so a single tick
|
||||
// takes it to 5 → failed (avoids waiting through backoffs).
|
||||
await db('webhook_deliveries').insert({
|
||||
webhook_id: id,
|
||||
event_type: 'event.published',
|
||||
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
||||
attempt_count: 4,
|
||||
status: 'pending',
|
||||
next_retry_at: new Date(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('failed');
|
||||
expect(row.attempt_count).toBe(5);
|
||||
expect(row.completed_at).toBeTruthy();
|
||||
expect(row.next_retry_at).toBeNull();
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('truncates response body to 1KB before storing', async () => {
|
||||
const big = 'x'.repeat(5000);
|
||||
const stub = await makeStub({ status: 200, bodyOverride: big });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url);
|
||||
await webhookService.fire('event.published', { event: {} });
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('success');
|
||||
expect(Buffer.byteLength(row.response_body || '', 'utf8')).toBeLessThanOrEqual(1024);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not deliver to disabled webhooks (post-mortem state captured)', async () => {
|
||||
const stub = await makeStub({ status: 200 });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url, ['event.published'], { active: false });
|
||||
// fire enqueues regardless of active state at fire-time, but we
|
||||
// disabled BEFORE firing so nothing is enqueued. Direct insert to
|
||||
// exercise the worker's mid-flight disable check:
|
||||
await db('webhook_deliveries').insert({
|
||||
webhook_id: id,
|
||||
event_type: 'event.published',
|
||||
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
||||
attempt_count: 0,
|
||||
status: 'pending',
|
||||
next_retry_at: new Date(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
await __test.tick();
|
||||
|
||||
expect(stub.requests).toHaveLength(0);
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('failed');
|
||||
expect(row.last_error).toMatch(/disabled/i);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects loopback URLs when WEBHOOK_ALLOW_PRIVATE_URLS=false', async () => {
|
||||
__test.setAllowPrivateUrls(false);
|
||||
try {
|
||||
const { id } = await insertWebhook('http://127.0.0.1:9/');
|
||||
await db('webhook_deliveries').insert({
|
||||
webhook_id: id,
|
||||
event_type: 'event.published',
|
||||
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
||||
attempt_count: 0,
|
||||
status: 'pending',
|
||||
next_retry_at: new Date(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('failed');
|
||||
expect(row.last_error).toMatch(/private|internal/i);
|
||||
} finally {
|
||||
__test.setAllowPrivateUrls(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('worker can be started + stopped without leaking timers', async () => {
|
||||
startWebhookDeliveryWorker();
|
||||
startWebhookDeliveryWorker(); // idempotent
|
||||
stopWebhookDeliveryWorker();
|
||||
stopWebhookDeliveryWorker(); // idempotent
|
||||
// If timers leaked the test runner would warn after force-exit; assertion
|
||||
// is just "no throw".
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,661 @@
|
||||
/**
|
||||
* Workflow engine — graph execution integration tests.
|
||||
*
|
||||
* Exercises the engine against a real (temp SQLite) DB with migration 142
|
||||
* applied: branching, bounded loops, wait pauses + scheduler-style resume,
|
||||
* gate pauses + confirm/deny resume, dedup idempotency, and step recording.
|
||||
*/
|
||||
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(30000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let engine;
|
||||
|
||||
async function makeWorkflow({ nodes, edges, trigger = 'test.event', enabled = true }) {
|
||||
const ins = await db('workflows').insert({ name: 'wf', trigger_type: trigger, version: 1, enabled });
|
||||
const workflowId = ins[0];
|
||||
for (const n of nodes) {
|
||||
await db('workflow_nodes').insert({
|
||||
workflow_id: workflowId, version: 1, node_key: n.key, type: n.type,
|
||||
config: JSON.stringify(n.config || {}),
|
||||
});
|
||||
}
|
||||
for (const e of edges) {
|
||||
await db('workflow_edges').insert({
|
||||
workflow_id: workflowId, version: 1, from_node: e.from, from_handle: e.handle || null, to_node: e.to,
|
||||
loop_back: e.loopBack || false,
|
||||
});
|
||||
}
|
||||
return workflowId;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Engine requires the singleton db — require AFTER bootCrmDb wired the test path.
|
||||
engine = require('../../src/services/workflows');
|
||||
// Enable the workflows flag so emitWorkflowEvent doesn't fail closed.
|
||||
await db('feature_flags').insert({ key: 'workflows', value: true });
|
||||
});
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
describe('workflow engine', () => {
|
||||
test('condition + bounded loop + wait pauses, resumes to completion', async () => {
|
||||
// trigger → set paid=false → condition(paid?) --no--> loop(max2)
|
||||
// loop --loop--> reminder(noop) → wait → (back to condition)
|
||||
// loop --exit--> lateFee(noop) → end
|
||||
// condition --yes--> lateFee (paid path, not taken here)
|
||||
const wfId = await makeWorkflow({
|
||||
nodes: [
|
||||
{ key: 'n1', type: 'trigger' },
|
||||
{ key: 'n2', type: 'action', config: { action: 'set_context', set: { paid: false } } },
|
||||
{ key: 'n3', type: 'condition', config: { condition: 'expr', field: 'paid', op: 'truthy' } },
|
||||
{ key: 'n4', type: 'loop', config: { maxIterations: 2 } },
|
||||
{ key: 'n5', type: 'action', config: { action: 'noop' } },
|
||||
{ key: 'n6', type: 'wait', config: { delayMinutes: 0 } },
|
||||
{ key: 'n7', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'n1', to: 'n2' },
|
||||
{ from: 'n2', to: 'n3' },
|
||||
{ from: 'n3', handle: 'no', to: 'n4' },
|
||||
{ from: 'n3', handle: 'yes', to: 'n7' },
|
||||
{ from: 'n4', handle: 'loop', to: 'n5' },
|
||||
{ from: 'n4', handle: 'exit', to: 'n7' },
|
||||
{ from: 'n5', to: 'n6' },
|
||||
{ from: 'n6', to: 'n3', loopBack: true },
|
||||
],
|
||||
});
|
||||
|
||||
const runIds = await engine.emitWorkflowEvent('test.event', { entityType: 'invoice', entityId: 1 });
|
||||
expect(runIds.length).toBe(1);
|
||||
const runId = runIds[0];
|
||||
|
||||
let run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting'); // paused at first wait (loop iter 1)
|
||||
expect(run.current_node).toBe('n6');
|
||||
|
||||
await engine.resumeRun(runId);
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting'); // paused again (loop iter 2)
|
||||
|
||||
await engine.resumeRun(runId);
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done'); // loop exhausted → exit → end
|
||||
|
||||
const ctx = JSON.parse(run.context);
|
||||
expect(ctx.vars.__loop_n4).toBe(3); // counter incremented past the cap
|
||||
void wfId;
|
||||
|
||||
const steps = await db('workflow_run_steps').where({ run_id: runId });
|
||||
expect(steps.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('emit is idempotent on dedup_key', async () => {
|
||||
await makeWorkflow({
|
||||
trigger: 'dedup.event',
|
||||
nodes: [{ key: 'n1', type: 'trigger' }, { key: 'n2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'n1', to: 'n2' }],
|
||||
});
|
||||
const first = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
|
||||
const second = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
|
||||
expect(first.length).toBe(1);
|
||||
expect(second.length).toBe(0); // same entity → no duplicate run
|
||||
});
|
||||
|
||||
test('gate pauses and resumes via the confirm edge', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'gate.event',
|
||||
nodes: [
|
||||
{ key: 'g1', type: 'trigger' },
|
||||
{ key: 'g2', type: 'gate', config: { type: 'payment_confirm' } },
|
||||
{ key: 'g3', type: 'action', config: { action: 'noop' } },
|
||||
{ key: 'g4', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'g1', to: 'g2' },
|
||||
{ from: 'g2', handle: 'confirm', to: 'g3' },
|
||||
{ from: 'g2', handle: 'deny', to: 'g4' },
|
||||
],
|
||||
});
|
||||
// create + start a run directly
|
||||
await db('workflow_runs').insert({
|
||||
workflow_id: wfId, version: 1, trigger_event: 'gate.event', status: 'pending',
|
||||
context: JSON.stringify({ vars: {} }), dedup_key: 'gate-test',
|
||||
});
|
||||
const run0 = await db('workflow_runs').where({ dedup_key: 'gate-test' }).first();
|
||||
await engine.startRun(run0.id);
|
||||
|
||||
let run = await db('workflow_runs').where({ id: run0.id }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
expect(run.current_node).toBe('g2');
|
||||
|
||||
await engine.resumeRun(run0.id, { decisionHandle: 'confirm' });
|
||||
run = await db('workflow_runs').where({ id: run0.id }).first();
|
||||
expect(run.status).toBe('done');
|
||||
});
|
||||
|
||||
test('runDueWaits resumes only elapsed wait nodes', async () => {
|
||||
await makeWorkflow({
|
||||
trigger: 'wait.event',
|
||||
nodes: [
|
||||
{ key: 'w1', type: 'trigger' },
|
||||
{ key: 'w2', type: 'wait', config: { delayMinutes: 60 } },
|
||||
{ key: 'w3', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [{ from: 'w1', to: 'w2' }, { from: 'w2', to: 'w3' }],
|
||||
});
|
||||
const runIds = await engine.emitWorkflowEvent('wait.event', { entityType: 'e', entityId: 7 });
|
||||
const runId = runIds[0];
|
||||
let run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
|
||||
expect(await engine.runDueWaits()).toBe(0); // wake_at ~60min out → not due
|
||||
|
||||
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
|
||||
const resumed = await engine.runDueWaits();
|
||||
expect(resumed).toBeGreaterThanOrEqual(1);
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done');
|
||||
});
|
||||
|
||||
test('send_email queues a customer mail with business-hours routing', async () => {
|
||||
await makeWorkflow({
|
||||
trigger: 'mail.event',
|
||||
nodes: [
|
||||
{ key: 'm1', type: 'trigger' },
|
||||
{ key: 'm2', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'workflow_test' } },
|
||||
],
|
||||
edges: [{ from: 'm1', to: 'm2' }],
|
||||
});
|
||||
const runIds = await engine.emitWorkflowEvent('mail.event', {
|
||||
entityType: 'invoice', entityId: 3, payload: { customerEmail: 'cust@example.com' },
|
||||
});
|
||||
const run = await db('workflow_runs').where({ id: runIds[0] }).first();
|
||||
expect(run.status).toBe('done');
|
||||
const queued = await db('email_queue').where({ recipient_email: 'cust@example.com' }).first();
|
||||
expect(queued).toBeTruthy();
|
||||
const step = await db('workflow_run_steps').where({ run_id: runIds[0], node_key: 'm2' }).first();
|
||||
expect(JSON.parse(step.result).respectBusinessHours).toBe(true);
|
||||
});
|
||||
|
||||
test('invoice_paid condition reads the entity', async () => {
|
||||
const registry = require('../../src/services/workflows/registry');
|
||||
const cond = registry.getCondition('invoice_paid');
|
||||
const makeCtx = (row) => ({ run: { entity_id: 1 }, db: () => ({ where: () => ({ first: async () => row }) }) });
|
||||
expect(await cond(makeCtx({ paid_at: '2026-01-01', status: 'sent' }))).toBe(true);
|
||||
expect(await cond(makeCtx({ paid_at: null, status: 'paid' }))).toBe(true);
|
||||
expect(await cond(makeCtx({ paid_at: null, status: 'sent', paid_amount_minor: 0, total_amount_minor: 1000 }))).toBe(false);
|
||||
});
|
||||
|
||||
test('gate creates a pending approval + admin email, token confirm resumes the run', async () => {
|
||||
await makeWorkflow({
|
||||
trigger: 'approval.event',
|
||||
nodes: [
|
||||
{ key: 'a1', type: 'trigger' },
|
||||
{ key: 'a2', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment yet?' } },
|
||||
{ key: 'a3', type: 'action', config: { action: 'noop' } }, // confirm path
|
||||
{ key: 'a4', type: 'action', config: { action: 'noop' } }, // deny path
|
||||
],
|
||||
edges: [
|
||||
{ from: 'a1', to: 'a2' },
|
||||
{ from: 'a2', handle: 'confirm', to: 'a3' },
|
||||
{ from: 'a2', handle: 'deny', to: 'a4' },
|
||||
],
|
||||
});
|
||||
const runIds = await engine.emitWorkflowEvent('approval.event', {
|
||||
entityType: 'invoice', entityId: 42, payload: { adminEmail: 'admin@example.com' },
|
||||
});
|
||||
const runId = runIds[0];
|
||||
|
||||
let run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
expect(run.current_node).toBe('a2');
|
||||
|
||||
const approval = await db('workflow_approvals').where({ run_id: runId }).first();
|
||||
expect(approval).toBeTruthy();
|
||||
expect(approval.status).toBe('pending');
|
||||
|
||||
const adminMail = await db('email_queue').where({ recipient_email: 'admin@example.com' }).first();
|
||||
expect(adminMail).toBeTruthy();
|
||||
|
||||
// Extract the raw token from the emailed confirm link and act on it.
|
||||
const data = JSON.parse(adminMail.email_data);
|
||||
const rawToken = data.confirm_url.split('/').slice(-2)[0];
|
||||
const res = await engine.actByToken(rawToken, 'confirm');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.status).toBe('confirmed');
|
||||
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done');
|
||||
|
||||
// A second click is idempotent (already recorded).
|
||||
const again = await engine.actByToken(rawToken, 'confirm');
|
||||
expect(again.already).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
|
||||
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(6);
|
||||
|
||||
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
|
||||
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
|
||||
expect(nodes.some((n) => n.type === 'gate')).toBe(false); // payment-check email IS the gate
|
||||
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'queue_payment_check')).toBe(true);
|
||||
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'escalate_to_collections')).toBe(true);
|
||||
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent at current seed version
|
||||
const all = await db('workflows').where({ builtin_key: DUNNING_KEY });
|
||||
expect(all.length).toBe(1);
|
||||
});
|
||||
|
||||
test('re-seeds a stale built-in on version bump, but never an admin-owned one', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
|
||||
const noopLogger = { info() {}, warn() {} };
|
||||
|
||||
// Simulate an older, never-touched seed (v1, with a legacy gate node).
|
||||
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
|
||||
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: null, trigger_config: JSON.stringify({ seedVersion: 1 }) });
|
||||
await db('workflow_nodes').insert({ workflow_id: wf.id, version: wf.version, node_key: 'legacyGate', type: 'gate', config: '{}', pos_x: 0, pos_y: 0 });
|
||||
|
||||
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(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
|
||||
|
||||
// Admin-owned (admin_toggled_at set) + stale → must NOT be touched.
|
||||
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: new Date().toISOString(), trigger_config: JSON.stringify({ seedVersion: 1 }) });
|
||||
const before = await db('workflows').where({ id: wf.id }).first();
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||
const after = await db('workflows').where({ id: wf.id }).first();
|
||||
expect(after.version).toBe(before.version); // unchanged
|
||||
expect(!!after.enabled).toBe(true); // admin's choice preserved
|
||||
});
|
||||
|
||||
test('seeds the gallery, pre-event + booking built-ins (all disabled for first beta)', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
|
||||
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
|
||||
|
||||
// First beta: cutover flows ship DISABLED (legacy paths run until enabled);
|
||||
// they delegate to the proven send functions once turned on.
|
||||
const expiring = await db('workflows').where({ builtin_key: 'gallery_expiring' }).first();
|
||||
expect(expiring).toBeTruthy();
|
||||
expect(!!expiring.enabled).toBe(false);
|
||||
expect(expiring.trigger_type).toBe('gallery.expiring');
|
||||
const expiringNodes = await db('workflow_nodes').where({ workflow_id: expiring.id, version: expiring.version });
|
||||
expect(expiringNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expiring')).toBe(true);
|
||||
|
||||
const expired = await db('workflows').where({ builtin_key: 'gallery_expired' }).first();
|
||||
expect(expired).toBeTruthy();
|
||||
expect(!!expired.enabled).toBe(false);
|
||||
expect(expired.trigger_type).toBe('gallery.expired');
|
||||
const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
|
||||
expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
|
||||
|
||||
// Invoice-only booking variant (quote → invoice, no gallery).
|
||||
const invoiceOnly = await db('workflows').where({ builtin_key: 'booking_invoice_only' }).first();
|
||||
expect(invoiceOnly).toBeTruthy();
|
||||
expect(!!invoiceOnly.enabled).toBe(false);
|
||||
expect(invoiceOnly.trigger_type).toBe('quote.accepted');
|
||||
const ioNodes = await db('workflow_nodes').where({ workflow_id: invoiceOnly.id, version: invoiceOnly.version });
|
||||
expect(ioNodes.some((n) => n.type === 'wait')).toBe(false); // no event wait — sends on approval
|
||||
expect(ioNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_event')).toBe(false); // no gallery
|
||||
|
||||
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
|
||||
expect(bookingFull).toBeTruthy();
|
||||
expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
|
||||
expect(bookingFull.trigger_type).toBe('quote.accepted');
|
||||
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
|
||||
expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
|
||||
// Admin review gate guards BOTH document sends (adjust line items, then OK).
|
||||
const fullGateKeys = fullNodes.filter((n) => n.type === 'gate').map((n) => n.node_key);
|
||||
expect(fullGateKeys).toEqual(expect.arrayContaining(['reviewContract', 'reviewInvoice']));
|
||||
const fullEdges = await db('workflow_edges').where({ workflow_id: bookingFull.id, version: bookingFull.version });
|
||||
// reviewContract --confirm--> sendContract. The invoice is prepared + approved
|
||||
// EARLY; reviewInvoice --confirm--> waitEvent, and the wait --> sendInvoice, so
|
||||
// dispatch is held until the event date after the admin's early OK.
|
||||
expect(fullEdges.some((e) => e.from_node === 'reviewContract' && e.from_handle === 'confirm' && e.to_node === 'sendContract')).toBe(true);
|
||||
expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
|
||||
expect(fullEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
|
||||
|
||||
const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first();
|
||||
expect(bookingSimple).toBeTruthy();
|
||||
expect(bookingSimple.trigger_type).toBe('quote.accepted');
|
||||
const simpleEdges = await db('workflow_edges').where({ workflow_id: bookingSimple.id, version: bookingSimple.version });
|
||||
expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
|
||||
expect(simpleEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
|
||||
|
||||
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
|
||||
expect(preEvent).toBeTruthy();
|
||||
expect(!!preEvent.enabled).toBe(false); // first beta: ships disabled
|
||||
expect(preEvent.trigger_type).toBe('event.date_approaching');
|
||||
expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(2); // default when global setting unset
|
||||
const preNodes = await db('workflow_nodes').where({ workflow_id: preEvent.id, version: preEvent.version });
|
||||
expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_pre_event')).toBe(true);
|
||||
});
|
||||
|
||||
test('emitDueEventReminders starts a run for an event inside the lead window', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'event.date_approaching',
|
||||
enabled: true,
|
||||
nodes: [{ key: 'pe1', type: 'trigger' }, { key: 'pe2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'pe1', to: 'pe2' }],
|
||||
});
|
||||
// Park the workflow's trigger window at 5 days so our event (2 days out) is in range.
|
||||
await db('workflows').where({ id: wfId }).update({ trigger_config: JSON.stringify({ daysBefore: 5 }) });
|
||||
|
||||
const inWindow = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
|
||||
const tooFar = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const evt = { event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false, customer_email: 'c@x.test' };
|
||||
await db('events').insert({ ...evt, slug: 'pe-soon', share_link: 'pe-soon', event_name: 'Soon', event_date: inWindow });
|
||||
await db('events').insert({ ...evt, slug: 'pe-far', share_link: 'pe-far', event_name: 'Far', event_date: tooFar });
|
||||
|
||||
const emitted = await engine.emitDueEventReminders();
|
||||
expect(emitted).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const runs = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
|
||||
expect(runs.length).toBe(1); // only the in-window event, not the far one
|
||||
|
||||
// Idempotent: a second pass dedups (no duplicate run for the same event).
|
||||
await engine.emitDueEventReminders();
|
||||
const runs2 = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
|
||||
expect(runs2.length).toBe(1);
|
||||
});
|
||||
|
||||
test('notify_pre_event / sendReminderForEvent sends to an event with a direct email (no CRM account)', async () => {
|
||||
// Regression: the reminder query used events.customer_account_id, which does
|
||||
// not exist — so an event with only customer_email/host_email got no mail.
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x', expires_at: farFuture,
|
||||
is_active: true, is_archived: false,
|
||||
slug: 'rem-direct', share_link: 'rem-direct', event_name: 'Direct',
|
||||
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
|
||||
customer_email: 'direct@x.test', // event-level email, NOT a customer_account
|
||||
});
|
||||
const ev = await db('events').where({ slug: 'rem-direct' }).first();
|
||||
|
||||
const res = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
|
||||
expect(res.sent).toBe(1);
|
||||
const mail = await db('email_queue').where({ event_id: ev.id }).first();
|
||||
expect(mail).toBeTruthy();
|
||||
expect(mail.recipient_email).toBe('direct@x.test');
|
||||
// Idempotent: sent_at stamped → a second call is a no-op.
|
||||
const again = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
|
||||
expect(again.sent).toBe(0);
|
||||
expect(again.reason).toBe('already_sent');
|
||||
});
|
||||
|
||||
test('reminder template resolves per event type within the chosen group, else group default', async () => {
|
||||
const { _internal } = require('../../src/services/eventReminderService');
|
||||
// Per-type template exists within a custom group → used.
|
||||
await db('email_templates').insert({ template_key: 'promo_wedding' });
|
||||
expect(await _internal.resolveTemplateKey('wedding', 'promo')).toBe('promo_wedding');
|
||||
// A type with no authored template (in any group) → the group's default.
|
||||
expect(await _internal.resolveTemplateKey('zzznotype', 'promo')).toBe('promo_default');
|
||||
// Blank group → the default event_reminder group.
|
||||
expect(await _internal.resolveTemplateKey('zzznotype')).toBe('event_reminder_default');
|
||||
// Trailing underscore on the group is tolerated.
|
||||
expect(await _internal.resolveTemplateKey('zzznotype', 'promo_')).toBe('promo_default');
|
||||
});
|
||||
|
||||
test('pre-event payload passes the RAW event_date (processor formats it — no "Invalid Date")', async () => {
|
||||
const { _internal } = require('../../src/services/eventReminderService');
|
||||
const p = _internal.composePayload({
|
||||
event: { id: 1, event_name: 'X', event_date: '2026-06-25', customer_name: 'A' },
|
||||
recipientEmail: 'a@x.test', daysBefore: 2, businessName: 'Biz',
|
||||
});
|
||||
expect(p.event_date).toBe('2026-06-25'); // raw, not pre-formatted DD.MM.YYYY
|
||||
expect(p.event_date).not.toMatch(/invalid/i);
|
||||
});
|
||||
|
||||
test('webhook action enqueues a delivery for a configured subscription (full pipeline)', async () => {
|
||||
const webhook = engine.registry.getAction('webhook');
|
||||
expect(typeof webhook).toBe('function'); // registered — no longer a silent no-op
|
||||
const ctx = (config, vars = {}) => ({
|
||||
run: { id: 1, workflow_id: 1, version: 1, trigger_event: 'invoice.sent', entity_type: 'invoice', entity_id: 5 },
|
||||
node: { config }, vars, db, logger: { warn() {} },
|
||||
});
|
||||
// No webhook selected → observable skip, not a crash.
|
||||
expect(await webhook(ctx({}))).toMatchObject({ skipped: true });
|
||||
|
||||
// A configured, active webhook subscription.
|
||||
const [adminId] = await db('admin_users').insert({ username: 'wfhook', email: 'wf@x.test', password_hash: 'x' });
|
||||
const [whId] = await db('webhooks').insert({
|
||||
name: 'Flow hook', url: 'https://example.com/hook', secret: 'whsec_test',
|
||||
events: JSON.stringify([]), active: true, created_by: adminId,
|
||||
});
|
||||
|
||||
// Dry run does not enqueue.
|
||||
expect(await webhook(ctx({ webhookId: whId }, { __dryRun: true }))).toMatchObject({ dryRun: true, would: 'webhook' });
|
||||
expect(await db('webhook_deliveries').where({ webhook_id: whId }).count('id as c').first()).toMatchObject({ c: 0 });
|
||||
|
||||
// Real run → a pending delivery is enqueued for the worker (which does the
|
||||
// signing + SSRF re-validation + retries).
|
||||
const res = await webhook(ctx({ webhookId: whId }));
|
||||
expect(res.webhook_enqueued).toBe(whId);
|
||||
const del = await db('webhook_deliveries').where({ webhook_id: whId }).first();
|
||||
expect(del).toBeTruthy();
|
||||
expect(del.status).toBe('pending');
|
||||
expect(del.event_type).toBe('workflow.invoice.sent');
|
||||
|
||||
// Inactive / missing subscription → skip.
|
||||
await db('webhooks').where({ id: whId }).update({ active: false });
|
||||
expect((await webhook(ctx({ webhookId: whId }))).skipped).toBe(true);
|
||||
});
|
||||
|
||||
test('pre-event falls back to the assigned customer account when the event has no inline email', async () => {
|
||||
const eventReminderService = require('../../src/services/eventReminderService');
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [custId] = await db('customer_accounts').insert({
|
||||
email: 'assigned@x.test', preferred_language: 'en', is_active: true, created_at: new Date(),
|
||||
});
|
||||
// Event with NO inline customer_email / host_email.
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false,
|
||||
slug: 'rem-assigned', share_link: 'rem-assigned', event_name: 'Assigned',
|
||||
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
|
||||
});
|
||||
const ev = await db('events').where({ slug: 'rem-assigned' }).first();
|
||||
await db('event_customer_assignments').insert({ event_id: ev.id, customer_account_id: custId, assigned_at: new Date() });
|
||||
|
||||
const res = await eventReminderService.sendReminderForEvent(ev.id);
|
||||
expect(res.sent).toBe(1);
|
||||
const mail = await db('email_queue').where({ recipient_email: 'assigned@x.test' }).first();
|
||||
expect(mail).toBeTruthy();
|
||||
// Queued WITHOUT event_id so the resolver uses the customer's preferred_language.
|
||||
expect(mail.event_id == null).toBe(true);
|
||||
});
|
||||
|
||||
test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
|
||||
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
|
||||
// All built-ins ship disabled → inactive until the admin enables one.
|
||||
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(false);
|
||||
expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false);
|
||||
// Enable one → now active.
|
||||
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: true });
|
||||
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(true);
|
||||
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: false }); // restore
|
||||
});
|
||||
|
||||
test('legacy event-reminder pass stands down ONLY when the pre_event_email flow is enabled', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
|
||||
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); // pre_event_email seeded DISABLED
|
||||
// crm_event_reminders_enabled must be on to reach the mutex guard.
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' })
|
||||
.onConflict('setting_key').merge();
|
||||
const eventReminderService = require('../../src/services/eventReminderService');
|
||||
|
||||
// Flow disabled → guard does NOT fire (legacy pass owns reminders).
|
||||
expect(await engine.isBuiltinFlowActive('pre_event_email')).toBe(false);
|
||||
|
||||
// Flow enabled → the pass stands down before doing any work (byWorkflow).
|
||||
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: true });
|
||||
const after = await eventReminderService.runEventReminderPass();
|
||||
expect(after.byWorkflow).toBe(true);
|
||||
expect(after.sent).toBe(0);
|
||||
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: false }); // restore
|
||||
});
|
||||
|
||||
test('targetWorkflowId runs only the selected flow, not every matching one', async () => {
|
||||
// Two enabled flows on the same trigger — the quote picks one.
|
||||
const chosen = await makeWorkflow({
|
||||
trigger: 'pick.event', enabled: true,
|
||||
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'c1', to: 'c2' }],
|
||||
});
|
||||
const other = await makeWorkflow({
|
||||
trigger: 'pick.event', enabled: true,
|
||||
nodes: [{ key: 'o1', type: 'trigger' }, { key: 'o2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'o1', to: 'o2' }],
|
||||
});
|
||||
|
||||
const runIds = await engine.emitWorkflowEvent('pick.event', { entityType: 'quote', entityId: 99, targetWorkflowId: chosen });
|
||||
expect(runIds.length).toBe(1);
|
||||
const chosenRuns = await db('workflow_runs').where({ workflow_id: chosen, entity_id: 99 });
|
||||
const otherRuns = await db('workflow_runs').where({ workflow_id: other, entity_id: 99 });
|
||||
expect(chosenRuns.length).toBe(1); // only the selected flow ran
|
||||
expect(otherRuns.length).toBe(0); // the other matching flow did NOT
|
||||
});
|
||||
|
||||
test('gate decision with no matching edge FAILS the run (not a silent done)', async () => {
|
||||
// Gate has a confirm edge but the deny edge was lost (e.g. a bad import).
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'noedge.event', enabled: true,
|
||||
nodes: [
|
||||
{ key: 'g0', type: 'trigger' },
|
||||
{ key: 'g1', type: 'gate', config: {} },
|
||||
{ key: 'g2', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'g0', to: 'g1' },
|
||||
{ from: 'g1', handle: 'confirm', to: 'g2' }, // no deny edge
|
||||
],
|
||||
});
|
||||
const [runId] = await engine.emitWorkflowEvent('noedge.event', { entityType: 'x', entityId: 1 });
|
||||
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
|
||||
await engine.actById(approval.id, 'deny'); // deny has no edge
|
||||
const run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('failed'); // loud failure, not a green 'done'
|
||||
expect(run.error).toMatch(/deny.*no matching edge/i);
|
||||
});
|
||||
|
||||
test('admin confirms a gate early; the following wait holds dispatch until its date', async () => {
|
||||
// The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The
|
||||
// admin can approve at the gate whenever; the run then parks at the wait and
|
||||
// the scheduler dispatches when the date arrives.
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'gatewait.event',
|
||||
nodes: [
|
||||
{ key: 'g0', type: 'trigger' },
|
||||
{ key: 'g1', type: 'gate', config: { prompt: 'Approve invoice?' } },
|
||||
{ key: 'g2', type: 'wait', config: { delayDays: 5 } },
|
||||
{ key: 'g3', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'g0', to: 'g1' },
|
||||
{ from: 'g1', handle: 'confirm', to: 'g2' },
|
||||
{ from: 'g2', to: 'g3' },
|
||||
],
|
||||
});
|
||||
const [runId] = await engine.emitWorkflowEvent('gatewait.event', { entityType: 'invoice', entityId: 7 });
|
||||
let run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
expect(run.current_node).toBe('g1'); // parked at the review gate
|
||||
|
||||
// Admin confirms EARLY (before the wait date).
|
||||
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
|
||||
await engine.actById(approval.id, 'confirm');
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
expect(run.current_node).toBe('g2'); // now holding at the wait, not yet dispatched
|
||||
|
||||
// Date arrives → scheduler dispatches.
|
||||
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
|
||||
await engine.runDueWaits();
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done');
|
||||
});
|
||||
|
||||
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'recover.event',
|
||||
nodes: [{ key: 'r1', type: 'trigger' }, { key: 'r2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'r1', to: 'r2' }],
|
||||
});
|
||||
// Simulate a run left 'running' at r2 with a stale heartbeat (crash mid-flow).
|
||||
await db('workflow_runs').insert({
|
||||
workflow_id: wfId, version: 1, trigger_event: 'recover.event', status: 'running', current_node: 'r2',
|
||||
context: JSON.stringify({ vars: {} }), dedup_key: 'recover-1',
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
});
|
||||
const run0 = await db('workflow_runs').where({ dedup_key: 'recover-1' }).first();
|
||||
const n = await engine.recoverStaleRuns({ staleMs: 1000 });
|
||||
expect(n).toBeGreaterThanOrEqual(1);
|
||||
const run = await db('workflow_runs').where({ id: run0.id }).first();
|
||||
expect(run.status).toBe('done');
|
||||
});
|
||||
|
||||
test('recoverStaleRuns abandons a crash-looping run after the attempts cap', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'crashloop.event',
|
||||
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'c1', to: 'c2' }],
|
||||
});
|
||||
await db('workflow_runs').insert({
|
||||
workflow_id: wfId, version: 1, trigger_event: 'crashloop.event', status: 'running', current_node: 'c2',
|
||||
context: JSON.stringify({ vars: {} }), dedup_key: 'crash-1', attempts: 5,
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
});
|
||||
const run0 = await db('workflow_runs').where({ dedup_key: 'crash-1' }).first();
|
||||
await engine.recoverStaleRuns({ staleMs: 1000 });
|
||||
const run = await db('workflow_runs').where({ id: run0.id }).first();
|
||||
expect(run.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('testRun dry-run walks the whole flow (waits skipped, gate auto-confirmed, actions mocked)', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'testfire.event',
|
||||
nodes: [
|
||||
{ key: 't', type: 'trigger' },
|
||||
{ key: 'w', type: 'wait', config: { delayDays: 14 } },
|
||||
{ key: 'g', type: 'gate', config: { type: 'payment_confirm' } },
|
||||
{ key: 'a', type: 'action', config: { action: 'send_email', recipientClass: 'customer' } },
|
||||
{ key: 'end', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 't', to: 'w' },
|
||||
{ from: 'w', to: 'g' },
|
||||
{ from: 'g', handle: 'confirm', to: 'a' },
|
||||
{ from: 'g', handle: 'deny', to: 'end' },
|
||||
{ from: 'a', to: 'end' },
|
||||
],
|
||||
});
|
||||
const runId = await engine.testRun(wfId, { dryRun: true });
|
||||
const run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done'); // walked to completion — no parking at the wait/gate
|
||||
|
||||
const steps = await db('workflow_run_steps').where({ run_id: runId });
|
||||
expect(steps.find((s) => s.node_key === 'w').status).toBe('skipped'); // wait passed through
|
||||
const emailStep = steps.find((s) => s.node_key === 'a');
|
||||
expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Admin workflow API — route tests (CRUD, versioning, RBAC gate, approvals).
|
||||
*/
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = 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(30000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let token;
|
||||
let noPermToken;
|
||||
|
||||
const sampleGraph = {
|
||||
name: 'Test flow',
|
||||
trigger_type: 'invoice.sent',
|
||||
enabled: false,
|
||||
nodes: [
|
||||
{ node_key: 'n1', type: 'trigger' },
|
||||
{ node_key: 'n2', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [{ from_node: 'n1', to_node: 'n2' }],
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
const ins = await db('admin_users').insert({
|
||||
username: 'norole', email: 'nr@example.com', password_hash: 'x',
|
||||
must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
noPermToken = mintAdminToken(ins[0]?.id ?? ins[0]);
|
||||
|
||||
await db('feature_flags').insert({ key: 'workflows', value: true });
|
||||
app = buildRouteApp('/api/admin/workflows', require('../../src/routes/adminWorkflows'));
|
||||
});
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
const auth = (t) => ({ Authorization: `Bearer ${t}` });
|
||||
|
||||
describe('admin workflows API', () => {
|
||||
let createdId;
|
||||
|
||||
test('create → 201 with id', async () => {
|
||||
const res = await request(app).post('/api/admin/workflows').set(auth(token)).send(sampleGraph);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toBeGreaterThan(0);
|
||||
createdId = res.body.id;
|
||||
});
|
||||
|
||||
test('rejects a graph without exactly one trigger', async () => {
|
||||
const res = await request(app).post('/api/admin/workflows').set(auth(token))
|
||||
.send({ ...sampleGraph, nodes: [{ node_key: 'x', type: 'action' }], edges: [] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects an unknown node type', async () => {
|
||||
const res = await request(app).post('/api/admin/workflows').set(auth(token))
|
||||
.send({ ...sampleGraph, nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'x', type: 'actoin' }], edges: [] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/unknown node type/i);
|
||||
});
|
||||
|
||||
test('refuses to enable a flow that uses an unregistered action', async () => {
|
||||
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
|
||||
name: 'Stub flow', trigger_type: 'quote.accepted', enabled: false,
|
||||
nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'totally_not_a_real_action' } }],
|
||||
edges: [{ from_node: 't', to_node: 'a' }],
|
||||
});
|
||||
expect(create.status).toBe(201);
|
||||
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/not.*implemented|totally_not_a_real_action/i);
|
||||
});
|
||||
|
||||
test('allows enabling a flow using the now-implemented booking invoice actions', async () => {
|
||||
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
|
||||
name: 'Invoice-only booking', trigger_type: 'quote.accepted', enabled: false,
|
||||
nodes: [
|
||||
{ node_key: 't', type: 'trigger' },
|
||||
{ node_key: 'p', type: 'action', config: { action: 'prepare_invoice' } },
|
||||
{ node_key: 'g', type: 'gate', config: {} },
|
||||
{ node_key: 's', type: 'action', config: { action: 'send_document', document: 'invoice' } },
|
||||
],
|
||||
edges: [
|
||||
{ from_node: 't', to_node: 'p' },
|
||||
{ from_node: 'p', to_node: 'g' },
|
||||
{ from_node: 'g', from_handle: 'confirm', to_node: 's' },
|
||||
],
|
||||
});
|
||||
expect(create.status).toBe(201);
|
||||
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('get one returns the graph', async () => {
|
||||
const res = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodes).toHaveLength(2);
|
||||
expect(res.body.edges).toHaveLength(1);
|
||||
expect(res.body.version).toBe(1);
|
||||
});
|
||||
|
||||
test('list includes it', async () => {
|
||||
const res = await request(app).get('/api/admin/workflows').set(auth(token));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.some((w) => w.id === createdId)).toBe(true);
|
||||
});
|
||||
|
||||
test('update bumps the version', async () => {
|
||||
const res = await request(app).put(`/api/admin/workflows/${createdId}`).set(auth(token))
|
||||
.send({ ...sampleGraph, name: 'Renamed' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.version).toBe(2);
|
||||
const get = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
|
||||
expect(get.body.name).toBe('Renamed');
|
||||
expect(get.body.version).toBe(2);
|
||||
});
|
||||
|
||||
test('enable toggle', async () => {
|
||||
const res = await request(app).patch(`/api/admin/workflows/${createdId}/enabled`).set(auth(token)).send({ enabled: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('approvals inbox returns an array', async () => {
|
||||
const res = await request(app).get('/api/admin/workflows/approvals').set(auth(token));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
test('a role without workflows.manage is forbidden from writing', async () => {
|
||||
const res = await request(app).post('/api/admin/workflows').set(auth(noPermToken)).send(sampleGraph);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Regression test for the cross-event thumbnail enumeration leak.
|
||||
*
|
||||
* Thumbnails are served flat from /thumbnails/thumb_<name> with
|
||||
* deterministic, enumerable filenames. photoAuth previously granted any
|
||||
* holder of a gallery token for ANY active event access to ANY thumbnail
|
||||
* (it set eventSlug=null and returned next() as long as the token's event
|
||||
* existed), so a visitor to one gallery could pull another (password-
|
||||
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
|
||||
* access to the token's event by matching the requested file against
|
||||
* photos.thumbnail_path for that event_id.
|
||||
*/
|
||||
|
||||
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Two events, each owning one thumbnail. The photos mock resolves a row
|
||||
// only when BOTH event_id and thumbnail_path match — i.e. it models the
|
||||
// real ownership query.
|
||||
const EVENTS = [
|
||||
{ id: 10, slug: 'event-a', is_active: 1 },
|
||||
{ id: 20, slug: 'event-b', is_active: 1 },
|
||||
];
|
||||
const PHOTOS = [
|
||||
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
|
||||
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: (table) => ({
|
||||
_cond: null,
|
||||
where(cond) { this._cond = cond; return this; },
|
||||
first() {
|
||||
if (table === 'events') {
|
||||
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
|
||||
}
|
||||
if (table === 'photos') {
|
||||
return Promise.resolve(
|
||||
PHOTOS.find((p) => p.event_id === this._cond.event_id
|
||||
&& p.thumbnail_path === this._cond.thumbnail_path) || null
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const photoAuth = require('../../src/middleware/photoAuth');
|
||||
|
||||
function galleryToken(eventId) {
|
||||
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
}
|
||||
|
||||
function makeReqRes(token, thumbPath) {
|
||||
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
describe('photoAuth — thumbnail ownership scoping', () => {
|
||||
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
// Access denied: middleware must not pass the request through.
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.event).toMatchObject({ id: 20 });
|
||||
});
|
||||
|
||||
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Unit test for the non-mutating isSessionExpired() helper added to
|
||||
* middleware/sessionTimeout.js. Used by GET /auth/session to mirror the
|
||||
* timeout enforcement that sessionTimeoutMiddleware applies to /api/admin
|
||||
* endpoints — closing the asymmetry that surfaced as the redirect-loop
|
||||
* recurrence on v3.39.1-beta.0 (issue #350).
|
||||
*
|
||||
* The helper has two branches:
|
||||
* 1. In-memory `lastActivity` exists for this token → expired iff
|
||||
* now - lastActivity > timeout.
|
||||
* 2. No in-memory entry (post-restart, or first request) → expired
|
||||
* iff token's iat is older than the timeout (post-restart guard
|
||||
* that the existing middleware already implements at line ~101).
|
||||
*
|
||||
* Both branches must NOT mutate the in-memory `sessions` Map — the
|
||||
* middleware is the only place that tracks activity. We assert that.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({
|
||||
where: () => ({
|
||||
first: () => ({
|
||||
timeout: () => Promise.resolve(null),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Speed up the cached-timeout reads. The module reads
|
||||
// `security_session_timeout_minutes` from app_settings and falls back to
|
||||
// DEFAULT_SESSION_TIMEOUT (60 min) when the row is null.
|
||||
const SIXTY_MINUTES_MS = 60 * 60 * 1000;
|
||||
|
||||
const sessionTimeout = require('../../src/middleware/sessionTimeout');
|
||||
const { isSessionExpired } = sessionTimeout;
|
||||
|
||||
function makeDecodedToken({ id = 1, iatSecondsAgo = 0 } = {}) {
|
||||
return { id, iat: Math.floor((Date.now() - iatSecondsAgo * 1000) / 1000) };
|
||||
}
|
||||
|
||||
describe('isSessionExpired (sessionTimeout helper)', () => {
|
||||
it('returns false for a freshly-issued token with no in-memory record', async () => {
|
||||
const decoded = makeDecodedToken({ id: 1, iatSecondsAgo: 60 });
|
||||
expect(await isSessionExpired('fresh-token-1', decoded)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when iat is older than the timeout (post-restart guard)', async () => {
|
||||
const decoded = makeDecodedToken({
|
||||
id: 2,
|
||||
// 90 minutes > 60 minute default timeout
|
||||
iatSecondsAgo: 90 * 60,
|
||||
});
|
||||
expect(await isSessionExpired('stale-token-2', decoded)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false / true based on lastActivity when one exists', async () => {
|
||||
// Drive the in-memory map by running the actual middleware once to
|
||||
// record activity for the token, then check the helper.
|
||||
const decoded = makeDecodedToken({ id: 3 });
|
||||
|
||||
// Drive the actual middleware once with a real signed token so it
|
||||
// records this token in the in-memory `sessions` Map. Then check the
|
||||
// helper sees that recent activity and reports "not expired".
|
||||
const res = { status: jest.fn(() => res), json: jest.fn() };
|
||||
const jwt = require('jsonwebtoken');
|
||||
process.env.JWT_SECRET = 'session-timeout-helper-test-secret';
|
||||
const realToken = jwt.sign(decoded, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
});
|
||||
const realReq = {
|
||||
headers: { authorization: `Bearer ${realToken}` },
|
||||
cookies: {},
|
||||
};
|
||||
await sessionTimeout.sessionTimeoutMiddleware(realReq, res, () => {});
|
||||
|
||||
const decodedReal = jwt.decode(realToken);
|
||||
// Just-recorded → not expired
|
||||
expect(await isSessionExpired(realToken, decodedReal)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when token / decoded is missing (defensive)', async () => {
|
||||
expect(await isSessionExpired(null, { id: 1 })).toBe(false);
|
||||
expect(await isSessionExpired('tok', null)).toBe(false);
|
||||
expect(await isSessionExpired('tok', {})).toBe(false);
|
||||
});
|
||||
|
||||
// Sanity: the helper must not poke the `sessions` Map. Indirectly check
|
||||
// by counting active sessions before/after a call with a never-seen
|
||||
// token — should not change.
|
||||
it('does not mutate the in-memory sessions map', async () => {
|
||||
const before = sessionTimeout.getActiveSessions();
|
||||
await isSessionExpired('never-seen-token-99', makeDecodedToken({ id: 99 }));
|
||||
const after = sessionTimeout.getActiveSessions();
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('uses the default 60-minute timeout when no DB setting exists', async () => {
|
||||
// 59 minutes → not expired
|
||||
const fresh = makeDecodedToken({ id: 4, iatSecondsAgo: 59 * 60 });
|
||||
expect(await isSessionExpired('fresh-4', fresh)).toBe(false);
|
||||
|
||||
// 61 minutes → expired (just past the default)
|
||||
const stale = makeDecodedToken({ id: 5, iatSecondsAgo: 61 * 60 });
|
||||
expect(await isSessionExpired('stale-5', stale)).toBe(true);
|
||||
});
|
||||
|
||||
// Document the constant the test relies on so a future timeout change
|
||||
// makes this assertion explicit rather than mysterious.
|
||||
it('default timeout is 60 minutes (constant under test)', () => {
|
||||
expect(SIXTY_MINUTES_MS).toBe(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* HTTP route auth-gate tests for the CRM admin surface (P1 / P2 — #570).
|
||||
*
|
||||
* Bundled into one file rather than nine because the contract is the
|
||||
* same for every CRM admin route:
|
||||
* - No token → 401 (adminAuth at the router level)
|
||||
* - Valid token, missing permission → 403 (requirePermission middleware)
|
||||
* - Valid token + super_admin role → 2xx / 404 (resource-based)
|
||||
*
|
||||
* Deeper service-layer behaviour (PDF generation, send, Storno,
|
||||
* countersign, integrity hash) is covered by the existing service
|
||||
* unit tests in __tests__/services/. This file pins the contract
|
||||
* between the HTTP layer and the auth+permission middleware so a
|
||||
* misconfigured route ("forgot requirePermission") can never ship
|
||||
* unnoticed.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admincrm-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole,
|
||||
mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
// One row per admin CRM route. `mount` matches server.js's app.use,
|
||||
// `loader` is the require()'d router, `getPath` is one path on the
|
||||
// router we'll exercise. The path should be a GET-shaped read where
|
||||
// possible — listing endpoints (`/`) are safest because they don't
|
||||
// require pre-seeded resource ids.
|
||||
const ROUTES = [
|
||||
{ name: 'adminQuotes', mount: '/api/admin/quotes', loader: () => require('../../src/routes/adminQuotes'), getPath: '/' },
|
||||
{ name: 'adminContracts', mount: '/api/admin/contracts', loader: () => require('../../src/routes/adminContracts'), getPath: '/' },
|
||||
{ name: 'adminInvoices', mount: '/api/admin/invoices', loader: () => require('../../src/routes/adminInvoices'), getPath: '/' },
|
||||
{ name: 'adminCalendar', mount: '/api/admin/calendar', loader: () => require('../../src/routes/adminCalendar'), getPath: '/items?from=2026-01-01&to=2026-12-31' },
|
||||
{ name: 'adminDeals', mount: '/api/admin/deals', loader: () => require('../../src/routes/adminDeals'), getPath: '/' },
|
||||
{ name: 'adminTaxReport', mount: '/api/admin/tax-report', loader: () => require('../../src/routes/adminTaxReport'), getPath: '/?period=2026-Q1' },
|
||||
{ name: 'adminBusinessProfile', mount: '/api/admin/business-profile', loader: () => require('../../src/routes/adminBusinessProfile'), getPath: '/' },
|
||||
];
|
||||
|
||||
describe('admin CRM routes — auth + permission gate', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminId;
|
||||
let customerId;
|
||||
let superAdminToken;
|
||||
let invalidToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
|
||||
// Super-admin: assign the seeded super_admin role (created by
|
||||
// migration 057). requirePermission lookups short-circuit because
|
||||
// super_admin role inherits every permission via role_permissions
|
||||
// rows seeded by mig 107 and earlier.
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
superAdminToken = mintAdminToken(adminId);
|
||||
|
||||
// CRM routes have a feature-flag gate that runs INSIDE the route
|
||||
// handler — even a super-admin gets 403 (`QUOTES_DISABLED` /
|
||||
// similar) when the flag is off. The flag check is independent
|
||||
// of permissions, so for happy-path tests we flip every CRM flag
|
||||
// on. Negative tests (no-token, bad-signature) hit adminAuth
|
||||
// first and never reach the flag check, so they're unaffected.
|
||||
// `accounting` is the master flag the tax-report route now requires
|
||||
// (tax export moved out of CRM into Accounting, independent of bills).
|
||||
const crmFlags = ['quotes', 'bills', 'contracts', 'hoursLogging', 'calendar', 'taxReport', 'clients', 'accounting'];
|
||||
for (const key of crmFlags) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db('feature_flags').where({ key }).update({ value: 1 });
|
||||
}
|
||||
|
||||
// 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' });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe.each(ROUTES)('$name', ({ mount, loader, getPath }) => {
|
||||
let app;
|
||||
|
||||
beforeAll(() => {
|
||||
app = buildRouteApp(mount, loader());
|
||||
});
|
||||
|
||||
it('returns 401 with no Authorization header', async () => {
|
||||
const res = await request(app).get(`${mount}${getPath}`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 with an invalid JWT signature', async () => {
|
||||
const res = await request(app)
|
||||
.get(`${mount}${getPath}`)
|
||||
.set('Authorization', `Bearer ${invalidToken}`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 2xx (or resource-shaped 4xx) with a valid super-admin token', async () => {
|
||||
const res = await request(app)
|
||||
.get(`${mount}${getPath}`)
|
||||
.set('Authorization', `Bearer ${superAdminToken}`);
|
||||
// 200 if listing succeeds (likely empty list), 400 if a
|
||||
// validator complains about query shape, 404 if the route
|
||||
// doesn't have a list endpoint at `/`. What MUST NOT happen:
|
||||
// 401 (auth gate failed) or 403 (permission gate failed).
|
||||
expect(res.status).not.toBe(401);
|
||||
expect(res.status).not.toBe(403);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminCustomers — CRM additions (hour-entries / bill / trigger-monthly-bill)', () => {
|
||||
let app;
|
||||
beforeAll(() => {
|
||||
app = buildRouteApp('/api/admin/customers', require('../../src/routes/adminCustomers'));
|
||||
});
|
||||
|
||||
it('GET /:id/hour-entries — 401 without token', async () => {
|
||||
const res = await request(app).get(`/api/admin/customers/${customerId}/hour-entries`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET /:id/hour-entries — 2xx with super-admin token', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/admin/customers/${customerId}/hour-entries`)
|
||||
.set('Authorization', `Bearer ${superAdminToken}`);
|
||||
expect(res.status).not.toBe(401);
|
||||
expect(res.status).not.toBe(403);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('POST /:id/hour-entries/bill — 401 without token', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/customers/${customerId}/hour-entries/bill`)
|
||||
.send({});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /:id/trigger-monthly-bill — 401 without token', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/customers/${customerId}/trigger-monthly-bill`)
|
||||
.send({});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* HTTP-level tests for the admin TOTP MFA feature (#738).
|
||||
*
|
||||
* Two surfaces:
|
||||
* 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable,
|
||||
* GET /mfa/status, POST /mfa/disable — mounted like server.js at
|
||||
* /api/admin/auth (src/routes/adminAuth.js).
|
||||
* 2. Login challenge — POST /admin/login + POST /admin/login/mfa
|
||||
* (src/routes/auth.js, mounted /api/auth).
|
||||
*
|
||||
* Uses the same real-SQLite harness as the CRM route tests
|
||||
* (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are
|
||||
* generated in-test via otplib's authenticator against the secret the
|
||||
* /setup endpoint returns in plaintext.
|
||||
*
|
||||
* NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the
|
||||
* first require of db.js — mirror adminCrmAuth.test.js exactly.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret';
|
||||
// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login
|
||||
// tests don't need a token. Be explicit so a leaked env can't flip it on.
|
||||
delete process.env.RECAPTCHA_SECRET_KEY;
|
||||
|
||||
const request = require('supertest');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminApp; // /api/admin/auth (enrollment)
|
||||
let authApp; // /api/auth (login challenge)
|
||||
|
||||
/**
|
||||
* Seed a bare admin (password known) and return its id + login creds.
|
||||
* seedMinimal always creates username 'tester'; we need distinct rows per
|
||||
* scenario, so insert directly with a unique username/email.
|
||||
*/
|
||||
async function seedAdmin({ username, superAdmin = false } = {}) {
|
||||
const password = 'correct-horse';
|
||||
const passwordHash = await bcrypt.hash(password, 4);
|
||||
const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const row = {
|
||||
username: uname,
|
||||
email: `${uname}@example.com`,
|
||||
password_hash: passwordHash,
|
||||
must_change_password: false,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
};
|
||||
if (superAdmin) {
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
if (!role) throw new Error('super_admin role not seeded');
|
||||
row.role_id = role.id;
|
||||
}
|
||||
const inserted = await db('admin_users').insert(row).returning('id');
|
||||
const id = inserted[0]?.id ?? inserted[0];
|
||||
return { id, username: uname, password };
|
||||
}
|
||||
|
||||
/** Run the full setup→enable enrollment against the live app. Returns
|
||||
* the plaintext TOTP secret (for later login codes) and recovery codes. */
|
||||
async function enroll(adminId) {
|
||||
const token = mintAdminToken(adminId);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(setup.status).toBe(200);
|
||||
const secret = setup.body.secret;
|
||||
|
||||
const enable = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(enable.status).toBe(200);
|
||||
return { secret, recoveryCodes: enable.body.recoveryCodes, token };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
|
||||
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('MFA enrollment — /api/admin/auth/mfa/*', () => {
|
||||
it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.secret).toEqual(expect.any(String));
|
||||
expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(res.body.qr).toMatch(/^data:image\/png;base64,/);
|
||||
|
||||
// Not yet enabled: status must still report disabled.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
|
||||
// And the row stores an encrypted secret (not the plaintext one).
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeTruthy();
|
||||
expect(row.two_factor_secret).not.toBe(res.body.secret);
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
});
|
||||
|
||||
it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
|
||||
expect(Array.isArray(recoveryCodes)).toBe(true);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(10);
|
||||
expect(status.body.enrolledAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('enable with a WRONG code is rejected (400) and MFA stays off', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
const valid = authenticator.generate(setup.body.secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: wrong });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enable before setup is rejected', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '123456' });
|
||||
// No provisional secret → ValidationError (400).
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('all enrollment endpoints require a valid admin token (401 without one)', async () => {
|
||||
const noToken = await request(adminApp).get('/api/admin/auth/mfa/status');
|
||||
expect(noToken.status).toBe(401);
|
||||
const setup = await request(adminApp).post('/api/admin/auth/mfa/setup');
|
||||
expect(setup.status).toBe(401);
|
||||
});
|
||||
|
||||
// Regression guard for #735: super_admin used to be blocked from enrolling.
|
||||
// Enrollment operates on req.admin.id and is role-agnostic — assert a
|
||||
// super_admin can complete the full setup→enable flow.
|
||||
it('#735 regression — a super_admin can enroll in MFA', async () => {
|
||||
const admin = await seedAdmin({ superAdmin: true });
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA disable — /api/admin/auth/mfa/disable', () => {
|
||||
it('requires a valid code; a wrong code is rejected and state persists', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { token } = await enroll(admin.id);
|
||||
|
||||
const bad = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '000000' });
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
const stillOn = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(stillOn.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('a valid TOTP disables MFA and clears the stored secret', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(0);
|
||||
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBe(true);
|
||||
expect(res.body.mfaToken).toEqual(expect.any(String));
|
||||
expect(res.body.user).toBeUndefined(); // no completed session
|
||||
// No admin auth cookie should have been set on the challenge response.
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.join(';')).not.toMatch(/adminToken/i);
|
||||
});
|
||||
|
||||
it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBeUndefined();
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.username).toBe(admin.username);
|
||||
});
|
||||
|
||||
it('login/mfa with a valid TOTP completes the session', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const { mfaToken } = challenge.body;
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken, code: authenticator.generate(secret) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
const valid = authenticator.generate(secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: challenge.body.mfaToken, code: wrong });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('MFA_INVALID');
|
||||
expect(res.body.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a recovery code logs in and is then single-use (second use fails)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes } = await enroll(admin.id);
|
||||
const recovery = recoveryCodes[0];
|
||||
|
||||
// First challenge + recovery-code exchange succeeds.
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const first = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c1.body.mfaToken, code: recovery });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.user).toBeDefined();
|
||||
|
||||
// recoveryCodesRemaining dropped by one.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${mintAdminToken(admin.id)}`);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(9);
|
||||
|
||||
// Second use of the SAME recovery code must fail.
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const second = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c2.body.mfaToken, code: recovery });
|
||||
expect(second.status).toBe(401);
|
||||
expect(second.body.code).toBe('MFA_INVALID');
|
||||
});
|
||||
|
||||
it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: mintAdminToken(admin.id), code: '123456' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Pin the date-field normalisation in adminUsers transformer (#485).
|
||||
*
|
||||
* The Users page crashed on native/SQLite installs because Postgres
|
||||
* returned ISO strings while SQLite returned epoch-millisecond
|
||||
* integers, and the frontend `parseISO()` blew up on numbers with
|
||||
* "e.split is not a function". The transformer now coerces every
|
||||
* shape to an ISO 8601 string before serialising.
|
||||
*
|
||||
* These tests guard the contract so a future refactor can't quietly
|
||||
* regress and re-break the same page on the same DB.
|
||||
*/
|
||||
|
||||
const adminUsersRoute = require('../../src/routes/adminUsers');
|
||||
const { toIso, transformUser, transformInvitation } = adminUsersRoute.__test;
|
||||
|
||||
describe('toIso', () => {
|
||||
it('passes null and undefined through unchanged', () => {
|
||||
expect(toIso(null)).toBeNull();
|
||||
expect(toIso(undefined)).toBeUndefined();
|
||||
// Empty string also short-circuits — important so an unset
|
||||
// last_login renders as "Never" instead of 1970-01-01T00:00:00Z.
|
||||
expect(toIso('')).toBe('');
|
||||
});
|
||||
|
||||
it('coerces an integer epoch (SQLite shape) to an ISO 8601 string', () => {
|
||||
// 2026-05-14T10:00:00.000Z, in epoch ms.
|
||||
const epochMs = 1778752800000;
|
||||
expect(toIso(epochMs)).toBe('2026-05-14T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('coerces a stringified large integer to an ISO 8601 string', () => {
|
||||
// Some SQLite drivers stringify large integers because they
|
||||
// overflow JS safe-integer in the driver's serialiser. Re-coerce
|
||||
// so the frontend doesn't try to parseISO('1778752800000').
|
||||
expect(toIso('1778752800000')).toBe('2026-05-14T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('coerces a Date instance via toISOString', () => {
|
||||
const d = new Date('2026-01-01T12:34:56.000Z');
|
||||
expect(toIso(d)).toBe('2026-01-01T12:34:56.000Z');
|
||||
});
|
||||
|
||||
it('passes an existing ISO string through unchanged', () => {
|
||||
const iso = '2026-05-14T10:00:00.000Z';
|
||||
expect(toIso(iso)).toBe(iso);
|
||||
});
|
||||
|
||||
it('passes a non-numeric short string (e.g. truncated date) through unchanged', () => {
|
||||
// Defensive: anything that isn't a 10+ digit integer string is
|
||||
// treated as already-stringified — the date library will surface
|
||||
// the failure cleanly if it's malformed, rather than the
|
||||
// transformer silently rewriting it.
|
||||
expect(toIso('2026-05-14')).toBe('2026-05-14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformUser', () => {
|
||||
it('normalises last_login, created_at, updated_at coming from SQLite', () => {
|
||||
const sqliteRow = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
is_active: 1,
|
||||
last_login: 1778752800000, // epoch ms
|
||||
last_login_ip: '127.0.0.1',
|
||||
created_at: 1778751144600, // epoch ms
|
||||
updated_at: 1778751242320, // epoch ms
|
||||
role_id: 1,
|
||||
role_name: 'super_admin',
|
||||
role_display_name: 'Super Admin',
|
||||
created_by_username: null,
|
||||
};
|
||||
|
||||
const out = transformUser(sqliteRow);
|
||||
|
||||
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
|
||||
expect(out.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(out.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
// Other fields untouched.
|
||||
expect(out.username).toBe('admin');
|
||||
expect(out.lastLoginIp).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('leaves Postgres ISO strings intact', () => {
|
||||
const pgRow = {
|
||||
id: 2,
|
||||
username: 'second',
|
||||
email: 'second@example.com',
|
||||
is_active: true,
|
||||
last_login: '2026-05-14T10:00:00.000Z',
|
||||
created_at: '2026-05-13T08:00:00.000Z',
|
||||
updated_at: '2026-05-14T09:00:00.000Z',
|
||||
};
|
||||
const out = transformUser(pgRow);
|
||||
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
|
||||
expect(out.createdAt).toBe('2026-05-13T08:00:00.000Z');
|
||||
expect(out.updatedAt).toBe('2026-05-14T09:00:00.000Z');
|
||||
});
|
||||
|
||||
it('keeps last_login null when the user has never logged in', () => {
|
||||
const out = transformUser({
|
||||
id: 3, username: 'fresh', email: 'fresh@example.com',
|
||||
is_active: 1, last_login: null,
|
||||
});
|
||||
expect(out.lastLogin).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformInvitation', () => {
|
||||
it('normalises expires_at and created_at from SQLite epoch-ms', () => {
|
||||
const out = transformInvitation({
|
||||
id: 9,
|
||||
email: 'invitee@example.com',
|
||||
expires_at: 1779357600000,
|
||||
created_at: 1778752800000,
|
||||
role_name: 'admin',
|
||||
invited_by: 'admin',
|
||||
});
|
||||
expect(out.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(out.createdAt).toBe('2026-05-14T10:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Regression test for the /admin/login → /admin/dashboard → /admin/login
|
||||
* redirect loop reported on v3.32.4-beta.0.
|
||||
*
|
||||
* Cause: GET /auth/session was less strict than the adminAuth middleware.
|
||||
* The session endpoint accepted tokens that the protected endpoints
|
||||
* subsequently rejected with 401, which the frontend's interceptor
|
||||
* translated into a hard redirect to /admin/login. /auth/session then
|
||||
* said "valid: true" again on the next page load and the cycle closed.
|
||||
*
|
||||
* /auth/session must reject the same admin tokens adminAuth would
|
||||
* reject, specifically: deactivated admin user, deleted admin user,
|
||||
* password changed since iat. Same for gallery: archived event.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
process.env.JWT_SECRET = 'session-symmetry-test-secret';
|
||||
|
||||
const fakeDb = {
|
||||
adminUsers: [],
|
||||
events: [],
|
||||
revokedTokens: [],
|
||||
};
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const formatBoolean = (v) => (v ? 1 : 0);
|
||||
void formatBoolean;
|
||||
function dbFn(table) {
|
||||
if (table === 'admin_users') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
rowFilter = (row) => {
|
||||
return Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
};
|
||||
return this;
|
||||
},
|
||||
select(...cols) {
|
||||
this._cols = cols;
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
const row = fakeDb.adminUsers.find(rowFilter);
|
||||
if (!row) return undefined;
|
||||
if (!this._cols) return row;
|
||||
const out = {};
|
||||
for (const c of this._cols) out[c] = row[c];
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === 'events') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
rowFilter = (row) =>
|
||||
Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return fakeDb.events.find(rowFilter);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`);
|
||||
}
|
||||
return { db: dbFn, formatBoolean: () => 1 };
|
||||
});
|
||||
|
||||
jest.mock('../../src/utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
|
||||
revokeToken: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenUtils', () => ({
|
||||
getAdminTokenFromRequest: (req) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
|
||||
return null;
|
||||
},
|
||||
getGalleryTokenFromRequest: () => null,
|
||||
setAdminAuthCookie: jest.fn(),
|
||||
setGalleryAuthCookies: jest.fn(),
|
||||
clearAdminAuthCookie: jest.fn(),
|
||||
clearGalleryAuthCookies: jest.fn(),
|
||||
buildCookieOptionsWithExpiry: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
|
||||
// Mock sessionTimeout's isSessionExpired so each test controls the return.
|
||||
// Default: not expired (so existing tests keep passing without setup).
|
||||
jest.mock('../../src/middleware/sessionTimeout', () => ({
|
||||
endSession: jest.fn(),
|
||||
isSessionExpired: jest.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth', authRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
|
||||
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
|
||||
// Note: do NOT pass noTimestamp:true here — that strips iat from the
|
||||
// payload entirely, defeating the password-change comparison. Provide
|
||||
// iat (and exp) via the payload directly instead.
|
||||
return jwt.sign(
|
||||
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
|
||||
process.env.JWT_SECRET,
|
||||
{ issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
|
||||
return jwt.sign(
|
||||
{ eventId, eventSlug, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
describe('GET /auth/session — symmetry with protected middleware', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.adminUsers = [];
|
||||
fakeDb.events = [];
|
||||
fakeDb.revokedTokens = [];
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.type).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user has been deactivated', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: false,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user no longer exists', async () => {
|
||||
// adminUsers is empty
|
||||
const token = signAdminToken({ id: 999 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when password was changed after the token was issued', async () => {
|
||||
// iat must be in the past, exp must be in the future so jwt.verify
|
||||
// doesn't reject the token before /auth/session even gets to look
|
||||
// at password_changed_at.
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true when password was changed BEFORE the token was issued', async () => {
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60;
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is archived', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: true,
|
||||
expires_at: null,
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is expired', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() - 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true for an active gallery token', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid:false when the token is revoked', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
fakeDb.revokedTokens.push(1);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
// Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0.
|
||||
// sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat
|
||||
// tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't.
|
||||
// The new isSessionExpired helper closes that asymmetry.
|
||||
describe('session-timeout symmetry', () => {
|
||||
const { isSessionExpired } = require('../../src/middleware/sessionTimeout');
|
||||
|
||||
beforeEach(() => {
|
||||
isSessionExpired.mockReset();
|
||||
// Default to "active session" so the other admin checks above also
|
||||
// pass when this branch runs.
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when isSessionExpired reports the token has timed out', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(true);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.error).toBe('Session expired');
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token (helper says not expired)', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call isSessionExpired for gallery tokens', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through (treats as valid) if the helper itself throws', async () => {
|
||||
// Defensive: the require() in auth.js is wrapped in try/catch so a
|
||||
// missing/broken helper doesn't fail-closed during early bootstrap.
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockRejectedValue(new Error('boom'));
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* HTTP route tests for backend/src/routes/publicContracts (P0 — #570).
|
||||
*
|
||||
* Four endpoints on the customer-facing surface:
|
||||
* GET /:token — load contract for signing
|
||||
* POST /:token/sign — in-browser canvas signature submission
|
||||
* POST /:token/upload-signed-pdf — wet-signed PDF upload
|
||||
* GET /:token/pdf — download the contract PDF
|
||||
*
|
||||
* Tests pin the publicTokenGuards.loadActionToken contract per
|
||||
* endpoint and a few endpoint-specific shape assertions. Deeper
|
||||
* service-layer behaviour (PDF generation, signature attachment,
|
||||
* integrity-hash compute) is covered by the contractService unit
|
||||
* tests; here we only assert the HTTP contract.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubcontracts-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const tokenGuards = require('../../src/utils/publicTokenGuards');
|
||||
|
||||
describe('publicContracts routes', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let customerId;
|
||||
let contractId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
const inserted = await db('contracts').insert({
|
||||
contract_number: 'K-TEST-0001',
|
||||
customer_account_id: customerId,
|
||||
title: 'Test Booking Confirmation',
|
||||
issue_date: new Date().toISOString().slice(0, 10),
|
||||
status: 'sent',
|
||||
language: 'de',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
contractId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
if (tokenGuards._internal?.badAttempts) tokenGuards._internal.badAttempts.clear();
|
||||
});
|
||||
|
||||
describe('GET /:token', () => {
|
||||
it('returns 404 for an unknown well-formed token', async () => {
|
||||
const fakeToken = 'a'.repeat(64);
|
||||
const res = await request(app).get(`/api/public/contracts/${fakeToken}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects malformed tokens with 400 before reaching the guard', async () => {
|
||||
const res = await request(app).get('/api/public/contracts/short');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 410 for an expired token', async () => {
|
||||
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId, expires_at: past,
|
||||
});
|
||||
const res = await request(app).get(`/api/public/contracts/${token}`);
|
||||
expect(res.status).toBe(410);
|
||||
expect(res.body.code).toBe('TOKEN_EXPIRED');
|
||||
});
|
||||
|
||||
it('returns 200 with the contract payload for a valid token', async () => {
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId,
|
||||
});
|
||||
const res = await request(app).get(`/api/public/contracts/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.contract).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:token/sign', () => {
|
||||
it('rejects missing required fields (name, accepted) with 400', async () => {
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId,
|
||||
});
|
||||
const res = await request(app)
|
||||
.post(`/api/public/contracts/${token}/sign`)
|
||||
.send({}); // missing name + accepted
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown token on sign', async () => {
|
||||
const fakeToken = 'b'.repeat(64);
|
||||
const res = await request(app)
|
||||
.post(`/api/public/contracts/${fakeToken}/sign`)
|
||||
.send({ name: 'Jane Doe', accepted: true });
|
||||
// Either 404 (token not found) or service-level error mapped to
|
||||
// 4xx — what matters is the request didn't slip past validation.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:token/upload-signed-pdf', () => {
|
||||
it('rejects malformed tokens with 400 before multer runs', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/public/contracts/bad-token/upload-signed-pdf')
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown but well-formed token', async () => {
|
||||
const fakeToken = 'c'.repeat(64);
|
||||
const res = await request(app)
|
||||
.post(`/api/public/contracts/${fakeToken}/upload-signed-pdf`)
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:token/pdf', () => {
|
||||
it('returns 404 for an unknown token on PDF download', async () => {
|
||||
const fakeToken = 'd'.repeat(64);
|
||||
const res = await request(app).get(`/api/public/contracts/${fakeToken}/pdf`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 410 for an expired token on PDF download', async () => {
|
||||
const past = new Date(Date.now() - 1000);
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId, expires_at: past,
|
||||
});
|
||||
const res = await request(app).get(`/api/public/contracts/${token}/pdf`);
|
||||
expect(res.status).toBe(410);
|
||||
expect(res.body.code).toBe('TOKEN_EXPIRED');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* HTTP route tests for backend/src/routes/publicPaymentCheck (P0 — #570).
|
||||
*
|
||||
* Two endpoints:
|
||||
* GET /:token — load invoice payment-check view
|
||||
* POST /:token — record customer's "paid / unpaid / partial" claim
|
||||
*
|
||||
* Unlike the quote / contract public routes, payment-check goes
|
||||
* through invoiceService rather than the shared publicTokenGuards.
|
||||
* Tests focus on the validator gates and the unknown-token edge.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paymentcheck-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('publicPaymentCheck routes', () => {
|
||||
let cleanup;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
let db;
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('GET /:token', () => {
|
||||
it('rejects malformed tokens with 400', async () => {
|
||||
const res = await request(app).get('/api/public/payment-check/short');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns a service-level error for an unknown well-formed token (4xx, not 500)', async () => {
|
||||
const fakeToken = 'a'.repeat(64);
|
||||
const res = await request(app).get(`/api/public/payment-check/${fakeToken}`);
|
||||
// Service throws NotFound or similar — what matters is the
|
||||
// request reaches the service AND isn't an unhandled 500.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:token', () => {
|
||||
it('rejects malformed tokens with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/public/payment-check/short')
|
||||
.send({ action: 'paid_full' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an invalid action with 400', async () => {
|
||||
const validToken = 'b'.repeat(64);
|
||||
const res = await request(app)
|
||||
.post(`/api/public/payment-check/${validToken}`)
|
||||
.send({ action: 'maybe' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts the canonical four actions through the validator', async () => {
|
||||
// Each action passes validator (token is well-formed); service
|
||||
// then rejects unknown token with a 4xx — what we're pinning is
|
||||
// the validator doesn't reject any of the canonical actions.
|
||||
const validToken = 'c'.repeat(64);
|
||||
for (const action of ['paid_full', 'paid_with_skonto', 'partial', 'unpaid']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await request(app)
|
||||
.post(`/api/public/payment-check/${validToken}`)
|
||||
.send({ action });
|
||||
// Either succeeds (rare — no real invoice) or service-level
|
||||
// 4xx for unknown token. Must NOT be 400 (which would mean
|
||||
// the validator rejected the action).
|
||||
expect(res.status).not.toBe(400);
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(600);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects negative amountMinor with 400', async () => {
|
||||
// Validator chain: optional({ values: 'falsy' }) means
|
||||
// amountMinor=0 / null / undefined gets skipped (allowed). For
|
||||
// any actually-supplied integer, isInt({ min: 1 }) takes over —
|
||||
// pin the negative-rejection so a future refactor can't loosen
|
||||
// the lower bound silently.
|
||||
const validToken = 'd'.repeat(64);
|
||||
const res = await request(app)
|
||||
.post(`/api/public/payment-check/${validToken}`)
|
||||
.send({ action: 'partial', amountMinor: -100 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* HTTP route tests for backend/src/routes/publicQuotes (P0 — #570).
|
||||
*
|
||||
* Public token guards (publicTokenGuards.loadActionToken) are the most
|
||||
* security-sensitive surface in the CRM module — these are the routes
|
||||
* a customer hits via the link in the quote email, reachable from any
|
||||
* IP with the raw token. A regression here means leaked tokens become
|
||||
* permanently usable, or worse, an expired token starts working again.
|
||||
*
|
||||
* Tests pin the contract documented in publicTokenGuards.js:
|
||||
* - 404 on unknown token (and IP bad-attempt counter ticks)
|
||||
* - 410 on expired token
|
||||
* - 410 on NULL expiry (defensive — historical bug)
|
||||
* - 429 after 20 invalid attempts from one IP
|
||||
* - 200 + sanitised payload on valid token
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// MUST set the test DB env BEFORE the first require of anything that
|
||||
// pulls in db.js — knexfile reads TEST_DATABASE_PATH at module-init
|
||||
// time. The helper's bootCrmDb also has to be called once per file
|
||||
// because the db module is cached; calling it from a second describe
|
||||
// would silently reuse (or kill) the first instance's connection pool.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubquotes-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const tokenGuards = require('../../src/utils/publicTokenGuards');
|
||||
|
||||
describe('publicQuotes routes', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let customerId;
|
||||
let quoteId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
const inserted = await db('quotes').insert({
|
||||
quote_number: 'Q-TEST-0001',
|
||||
customer_account_id: customerId,
|
||||
currency: 'CHF',
|
||||
issue_date: new Date().toISOString().slice(0, 10),
|
||||
net_amount_minor: 10000,
|
||||
vat_amount_minor: 0,
|
||||
total_amount_minor: 10000,
|
||||
status: 'sent',
|
||||
language: 'de',
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
quoteId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
// Clear the in-memory IP bad-attempts map between scenarios so the
|
||||
// lockout test starts from a known state — and so it doesn't bleed
|
||||
// 429s into the unrelated tests that follow.
|
||||
beforeEach(() => {
|
||||
if (tokenGuards._internal?.badAttempts) {
|
||||
tokenGuards._internal.badAttempts.clear();
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /:token', () => {
|
||||
it('returns 404 for an unknown but well-formed token', async () => {
|
||||
const fakeToken = 'a'.repeat(64);
|
||||
const res = await request(app).get(`/api/public/quotes/${fakeToken}`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects malformed (non-64-hex) tokens with 400', async () => {
|
||||
const res = await request(app).get('/api/public/quotes/not-a-real-token');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 410 for a token whose expires_at is in the past', async () => {
|
||||
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const token = await createPublicToken(db, 'quote_action_tokens', {
|
||||
quote_id: quoteId, expires_at: past,
|
||||
});
|
||||
const res = await request(app).get(`/api/public/quotes/${token}`);
|
||||
expect(res.status).toBe(410);
|
||||
expect(res.body.code).toBe('TOKEN_EXPIRED');
|
||||
});
|
||||
|
||||
// The NULL-expiry guard in loadActionToken is intentionally
|
||||
// defensive but the current schema declares
|
||||
// quote_action_tokens.expires_at NOT NULL — so the defensive
|
||||
// branch is unreachable at the route level. Test it directly
|
||||
// against loadActionToken in a unit suite if you want coverage.
|
||||
|
||||
it('returns 200 with a sanitised quote payload for a valid token', async () => {
|
||||
const token = await createPublicToken(db, 'quote_action_tokens', {
|
||||
quote_id: quoteId,
|
||||
});
|
||||
const res = await request(app).get(`/api/public/quotes/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.quote).toBeDefined();
|
||||
// API uses camelCase on the public view (see publicQuoteView in
|
||||
// the route handler).
|
||||
expect(res.body.quote.quoteNumber).toBe('Q-TEST-0001');
|
||||
// Internal IDs / admin metadata must NOT appear on the public payload
|
||||
expect(res.body.quote.customer_account_id).toBeUndefined();
|
||||
expect(res.body.quote.customerAccountId).toBeUndefined();
|
||||
expect(res.body.quote.createdByAdminId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('locks the IP after 20 invalid token lookups (429 TOKEN_LOOKUP_LOCKED)', async () => {
|
||||
const fakeToken = 'b'.repeat(64);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const r = await request(app)
|
||||
.get(`/api/public/quotes/${fakeToken}`)
|
||||
.set('X-Forwarded-For', '203.0.113.10');
|
||||
expect(r.status).toBe(404);
|
||||
}
|
||||
const locked = await request(app)
|
||||
.get(`/api/public/quotes/${fakeToken}`)
|
||||
.set('X-Forwarded-For', '203.0.113.10');
|
||||
expect(locked.status).toBe(429);
|
||||
expect(locked.body.code).toBe('TOKEN_LOOKUP_LOCKED');
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('POST /:token/respond', () => {
|
||||
it('rejects an invalid action (must be accept|decline) with 400', async () => {
|
||||
const token = await createPublicToken(db, 'quote_action_tokens', { quote_id: quoteId });
|
||||
const res = await request(app)
|
||||
.post(`/api/public/quotes/${token}/respond`)
|
||||
.send({ action: 'maybe' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown token on respond', async () => {
|
||||
const fakeToken = 'c'.repeat(64);
|
||||
const res = await request(app)
|
||||
.post(`/api/public/quotes/${fakeToken}/respond`)
|
||||
.send({ action: 'accept' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 410 when the token has expired (service-side check)', async () => {
|
||||
// The POST path goes through quoteService.recordResponse rather
|
||||
// than loadActionToken, so the error shape can differ from the
|
||||
// GET expiry response — what matters is the HTTP status.
|
||||
const past = new Date(Date.now() - 1000);
|
||||
const token = await createPublicToken(db, 'quote_action_tokens', {
|
||||
quote_id: quoteId, expires_at: past,
|
||||
});
|
||||
const res = await request(app)
|
||||
.post(`/api/public/quotes/${token}/respond`)
|
||||
.send({ action: 'accept' });
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* HTTP route tests for the ADMIN Live Slideshow endpoints:
|
||||
* POST /api/admin/events/:id/slideshow/generate
|
||||
* POST /api/admin/events/:id/slideshow/disable
|
||||
* PATCH /api/admin/events/:id/slideshow
|
||||
* PUT /api/admin/settings/slideshow (global preset + watermark + fit)
|
||||
*
|
||||
* Pins the contracts + the two regressions hit during the build:
|
||||
* - the events table has NO `updated_at` column, so these writes must NOT set
|
||||
* it (else every call 500s — that was the original "Generate" failure);
|
||||
* - the `slideshow` feature flag gates these endpoints (403 when off);
|
||||
* - PUT /admin/settings/slideshow validates + clamps every key.
|
||||
*/
|
||||
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-show-admin-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
|
||||
|
||||
async function setFlag(db, key, on) {
|
||||
await db('feature_flags').where({ key }).del();
|
||||
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
|
||||
invalidateFeatureFlagCache();
|
||||
}
|
||||
|
||||
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 Live Slideshow endpoints', () => {
|
||||
let db; let cleanup; let app; let adminId; let token;
|
||||
|
||||
// Match slideshowPublic.test.js — bootCrmDb's full migration run intermittently
|
||||
// exceeds Jest's default 5s `beforeAll` timeout on slower CI runners; raise
|
||||
// it so this doesn't block PRs.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('events').del();
|
||||
await db('app_settings').del();
|
||||
await setFlag(db, 'slideshow', true);
|
||||
});
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
describe('generate / disable', () => {
|
||||
it('mints a share token (no updated_at column → must not 500)', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.show_share_token).toBe('string');
|
||||
expect(res.body.show_share_token).toHaveLength(64);
|
||||
expect(res.body.slideshow_url).toContain(`/show/${res.body.show_share_token}`);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.show_share_token).toBe(res.body.show_share_token);
|
||||
});
|
||||
|
||||
it('regenerate rotates the token', async () => {
|
||||
const id = await insertEvent(db, adminId, { show_share_token: 'old-token' });
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.show_share_token).not.toBe('old-token');
|
||||
});
|
||||
|
||||
it('disable nulls the token', async () => {
|
||||
const id = await insertEvent(db, adminId, { show_share_token: 'live-token' });
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/disable`));
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.show_share_token == null).toBe(true);
|
||||
});
|
||||
|
||||
it('403 when the slideshow feature is off', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
await setFlag(db, 'slideshow', false);
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('401 without an admin token', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await request(app).post(`/api/admin/events/${id}/slideshow/generate`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /:id/slideshow', () => {
|
||||
it('persists display + watermark mode (no updated_at column → must not 500)', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({
|
||||
show_interval_ms: 9000,
|
||||
show_transition: 'cut',
|
||||
show_transition_ms: 300,
|
||||
show_watermark: true,
|
||||
show_colorfilter: 'bw',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.show_interval_ms).toBe(9000);
|
||||
expect(row.show_transition).toBe('cut');
|
||||
expect(row.show_transition_ms).toBe(300);
|
||||
expect(row.show_colorfilter).toBe('bw');
|
||||
expect(row.show_watermark === 1 || row.show_watermark === true).toBe(true);
|
||||
});
|
||||
|
||||
it('show_watermark=null sets the column to NULL (inherit global)', async () => {
|
||||
const id = await insertEvent(db, adminId, { show_watermark: 1 });
|
||||
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_watermark: null });
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.show_watermark == null).toBe(true);
|
||||
});
|
||||
|
||||
it('400 on an invalid transition', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_transition: 'wormhole' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/admin/settings/slideshow', () => {
|
||||
const getSetting = async (key) => {
|
||||
const row = await db('app_settings').where({ setting_key: key }).first();
|
||||
return row ? JSON.parse(row.setting_value) : undefined;
|
||||
};
|
||||
|
||||
it('persists the global preset + watermark + fit, clamping out-of-range values', async () => {
|
||||
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
|
||||
slideshow_fit: 'contain',
|
||||
slideshow_interval_ms: 9000,
|
||||
slideshow_transition: 'slide',
|
||||
slideshow_transition_ms: 250,
|
||||
slideshow_colorfilter: 'sepia',
|
||||
slideshow_watermark_enabled: true,
|
||||
slideshow_watermark_opacity: 999, // clamp -> 100
|
||||
slideshow_watermark_size: 99, // clamp -> 40
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await getSetting('slideshow_fit')).toBe('contain');
|
||||
expect(await getSetting('slideshow_interval_ms')).toBe(9000);
|
||||
expect(await getSetting('slideshow_transition')).toBe('slide');
|
||||
expect(await getSetting('slideshow_transition_ms')).toBe(250);
|
||||
expect(await getSetting('slideshow_colorfilter')).toBe('sepia');
|
||||
expect(await getSetting('slideshow_watermark_enabled')).toBe(true);
|
||||
expect(await getSetting('slideshow_watermark_opacity')).toBe(100);
|
||||
expect(await getSetting('slideshow_watermark_size')).toBe(40);
|
||||
});
|
||||
|
||||
it('coerces an invalid fit / transition to the safe default', async () => {
|
||||
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
|
||||
slideshow_fit: 'banana',
|
||||
slideshow_transition: 'wormhole',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await getSetting('slideshow_fit')).toBe('cover');
|
||||
expect(await getSetting('slideshow_transition')).toBe('crossfade');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* HTTP route tests for the PUBLIC Live Slideshow surface (backend/src/routes/gallery.js):
|
||||
* GET /:slug/show/:token/state (cheap settings + photo-count poll)
|
||||
* GET /:slug/show/:token/session (mints the gallery JWT + cookie)
|
||||
*
|
||||
* These pin the two pieces of logic where real bugs lived during the build:
|
||||
* - resolveSlideshow: the `slideshow` feature flag is a MASTER kill-switch
|
||||
* (404 when off), plus token / expiry / draft / archived / inactive guards.
|
||||
* - slideshowSettings: the watermark cascade (global look + per-event on/off),
|
||||
* image fit, and the fact that globals are read from `app_settings`
|
||||
* (regression for the getSetting→nonexistent-`settings`-table bug).
|
||||
*/
|
||||
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-show-pub-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
|
||||
const { invalidateSlideshowGlobals } = require('../../src/utils/slideshowGlobals');
|
||||
|
||||
const SLUG = 'wedding-test';
|
||||
const TOKEN = 'show-tok-abcdef';
|
||||
|
||||
async function setFlag(db, key, on) {
|
||||
await db('feature_flags').where({ key }).del();
|
||||
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
|
||||
invalidateFeatureFlagCache();
|
||||
}
|
||||
|
||||
async function setSetting(db, key, value, type = 'slideshow') {
|
||||
await db('app_settings').where({ setting_key: key }).del();
|
||||
await db('app_settings').insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: type, updated_at: new Date() });
|
||||
}
|
||||
|
||||
async function insertEvent(db, over = {}) {
|
||||
const base = {
|
||||
slug: SLUG,
|
||||
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/${SLUG}/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,
|
||||
show_share_token: TOKEN,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('public Live Slideshow routes', () => {
|
||||
let db; let cleanup; let app;
|
||||
|
||||
// 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);
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
// Both routers mount under /api/gallery in production; the display-only
|
||||
// guard lives on download routes (gallery) + the feedback POST (galleryFeedback).
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
||||
// 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 });
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('events').del();
|
||||
await db('app_settings').del();
|
||||
await db('feature_flags').del();
|
||||
invalidateFeatureFlagCache();
|
||||
invalidateSlideshowGlobals();
|
||||
await setFlag(db, 'slideshow', true);
|
||||
});
|
||||
|
||||
const stateUrl = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state`;
|
||||
|
||||
describe('resolveSlideshow guards', () => {
|
||||
it('200 + per-event display settings on a live link', async () => {
|
||||
await insertEvent(db, {
|
||||
show_interval_ms: 8000,
|
||||
show_transition: 'kenburns',
|
||||
show_transition_ms: 1200,
|
||||
show_colorfilter: 'sepia',
|
||||
});
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
interval_ms: 8000,
|
||||
transition: 'kenburns',
|
||||
transition_ms: 1200,
|
||||
colorfilter: 'sepia',
|
||||
fit: 'cover',
|
||||
photo_count: 0,
|
||||
watermark: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the slideshow feature flag is OFF (master kill-switch)', async () => {
|
||||
await insertEvent(db);
|
||||
await setFlag(db, 'slideshow', false);
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404 on an unknown token', async () => {
|
||||
await insertEvent(db);
|
||||
const res = await request(app).get(stateUrl('not-the-token'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when the share token is null (link never minted / disabled)', async () => {
|
||||
await insertEvent(db, { show_share_token: null });
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when the event has expired', async () => {
|
||||
await insertEvent(db, { expires_at: new Date(Date.now() - 1000).toISOString() });
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when the event is a draft', async () => {
|
||||
await insertEvent(db, { is_draft: 1 });
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when the event is archived', async () => {
|
||||
await insertEvent(db, { is_archived: 1 });
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slideshowSettings — image fit (global, live)', () => {
|
||||
it('reflects the global slideshow_fit setting', async () => {
|
||||
await insertEvent(db);
|
||||
await setSetting(db, 'slideshow_fit', 'contain');
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.fit).toBe('contain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('slideshowSettings — watermark cascade (global look + per-event on/off)', () => {
|
||||
async function enableGlobalWatermark() {
|
||||
await setSetting(db, 'slideshow_watermark_enabled', true);
|
||||
await setSetting(db, 'slideshow_watermark_source', 'logo');
|
||||
await setSetting(db, 'slideshow_watermark_position', 'top-left');
|
||||
await setSetting(db, 'slideshow_watermark_opacity', 40);
|
||||
await setSetting(db, 'slideshow_watermark_style', 'original');
|
||||
await setSetting(db, 'slideshow_watermark_size', 9);
|
||||
await setSetting(db, 'branding_logo_url', '/uploads/logos/light.svg', 'branding');
|
||||
}
|
||||
|
||||
it('inherits the global watermark when show_watermark is NULL', async () => {
|
||||
await insertEvent(db, { show_watermark: null });
|
||||
await enableGlobalWatermark();
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.body.watermark).toEqual({
|
||||
url: '/uploads/logos/light.svg',
|
||||
position: 'top-left',
|
||||
opacity: 40,
|
||||
style: 'original',
|
||||
size: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the dark logo / favicon sources', async () => {
|
||||
await insertEvent(db, { show_watermark: null });
|
||||
await enableGlobalWatermark();
|
||||
await setSetting(db, 'slideshow_watermark_source', 'favicon');
|
||||
await setSetting(db, 'branding_favicon_url', '/uploads/favicons/f.png', 'branding');
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.body.watermark.url).toBe('/uploads/favicons/f.png');
|
||||
});
|
||||
|
||||
it('per-event OFF override hides the watermark even when the global is on', async () => {
|
||||
await insertEvent(db, { show_watermark: 0 });
|
||||
await enableGlobalWatermark();
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.body.watermark).toBeNull();
|
||||
});
|
||||
|
||||
it('per-event ON override shows the watermark even when the global is off', async () => {
|
||||
await insertEvent(db, { show_watermark: 1 });
|
||||
await enableGlobalWatermark();
|
||||
await setSetting(db, 'slideshow_watermark_enabled', false);
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.body.watermark).not.toBeNull();
|
||||
expect(res.body.watermark.url).toBe('/uploads/logos/light.svg');
|
||||
});
|
||||
|
||||
it('null when enabled but no logo URL is configured', async () => {
|
||||
await insertEvent(db, { show_watermark: null });
|
||||
await setSetting(db, 'slideshow_watermark_enabled', true);
|
||||
// no branding_logo_url set
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.body.watermark).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('display-only token guards (#646 review concern 1)', () => {
|
||||
// Mint a real slideshow JWT, then prove it is denied on the
|
||||
// download / upload / feedback routes (display-only contract).
|
||||
async function slideshowJwt() {
|
||||
await insertEvent(db);
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.token;
|
||||
}
|
||||
|
||||
it('403 on whole-gallery download', async () => {
|
||||
const jwt = await slideshowJwt();
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`).set('Authorization', `Bearer ${jwt}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 on single-photo download', async () => {
|
||||
const jwt = await slideshowJwt();
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download/1`).set('Authorization', `Bearer ${jwt}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 on bulk download-selected', async () => {
|
||||
const jwt = await slideshowJwt();
|
||||
const res = await request(app).post(`/api/gallery/${SLUG}/download-selected`).set('Authorization', `Bearer ${jwt}`).send({ photoIds: [1] });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 on feedback POST', async () => {
|
||||
const jwt = await slideshowJwt();
|
||||
const res = await request(app).post(`/api/gallery/${SLUG}/photos/1/feedback`).set('Authorization', `Bearer ${jwt}`).send({ feedback_type: 'like' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /session', () => {
|
||||
it('mints a token + sets the gallery cookie on a valid link', async () => {
|
||||
await insertEvent(db);
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
expect(res.body.token.length).toBeGreaterThan(20);
|
||||
expect(res.body.event).toMatchObject({ event_name: 'Test Wedding' });
|
||||
expect(res.body).toHaveProperty('settings');
|
||||
expect(res.body).toHaveProperty('photo_count', 0);
|
||||
expect(res.headers['set-cookie']).toBeDefined();
|
||||
});
|
||||
|
||||
it('404 when the feature is off', async () => {
|
||||
await insertEvent(db);
|
||||
await setFlag(db, 'slideshow', false);
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Unit tests for backgroundProcessor.claimNextPhoto.
|
||||
*
|
||||
* Mocks the db so we don't need a live postgres/sqlite — focuses on
|
||||
* the claim contract: returns null when no rows, returns row + flips
|
||||
* status to 'processing' when one is available, returns null when a
|
||||
* race loses the UPDATE-with-guard.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/services/photoProcessor', () => ({
|
||||
processPhoto: jest.fn(),
|
||||
processUploadedPhotos: jest.fn(),
|
||||
queueFilesForProcessing: jest.fn(),
|
||||
}));
|
||||
|
||||
// Build a fake knex instance whose .transaction() takes a callback we can
|
||||
// drive from the test, and whose query-builder records calls.
|
||||
function makeFakeDb({ pendingRow = null, updateResult = 1, clientName = 'pg' } = {}) {
|
||||
const queries = [];
|
||||
|
||||
const builder = () => {
|
||||
const recorded = { wheres: [], updates: null, ordered: false, locked: false, skipped: false };
|
||||
queries.push(recorded);
|
||||
const chain = {
|
||||
where: jest.fn(function (...args) {
|
||||
recorded.wheres.push(args);
|
||||
return chain;
|
||||
}),
|
||||
orderBy: jest.fn(function () {
|
||||
recorded.ordered = true;
|
||||
return chain;
|
||||
}),
|
||||
forUpdate: jest.fn(function () {
|
||||
recorded.locked = true;
|
||||
return chain;
|
||||
}),
|
||||
skipLocked: jest.fn(function () {
|
||||
recorded.skipped = true;
|
||||
return chain;
|
||||
}),
|
||||
first: jest.fn(async function () {
|
||||
// Only the SELECT chain returns the pending row; the UPDATE chain
|
||||
// never calls .first().
|
||||
return pendingRow ? { ...pendingRow } : null;
|
||||
}),
|
||||
update: jest.fn(async function (data) {
|
||||
recorded.updates = data;
|
||||
return updateResult;
|
||||
}),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
const trxFn = (table) => builder(table);
|
||||
trxFn.client = { config: { client: clientName } };
|
||||
trxFn.transaction = async (cb) => cb(trxFn);
|
||||
|
||||
// Top-level db('photos') returns same builder for the janitor test path.
|
||||
const db = trxFn;
|
||||
return { db, queries };
|
||||
}
|
||||
|
||||
describe('backgroundProcessor.claimNextPhoto', () => {
|
||||
function loadProcessor(db) {
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
return require('../../src/services/backgroundProcessor');
|
||||
}
|
||||
|
||||
it('returns null when there are no pending photos (postgres path)', async () => {
|
||||
const { db } = makeFakeDb({ pendingRow: null, clientName: 'pg' });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the claimed row and flips status (postgres path)', async () => {
|
||||
const pendingRow = { id: 42, processing_status: 'pending' };
|
||||
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'pg' });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toEqual(pendingRow);
|
||||
// The first query is the SELECT FOR UPDATE SKIP LOCKED.
|
||||
expect(queries[0].locked).toBe(true);
|
||||
expect(queries[0].skipped).toBe(true);
|
||||
// The second query is the status update.
|
||||
expect(queries[1].updates.processing_status).toBe('processing');
|
||||
expect(queries[1].updates.processing_started_at).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns null when the SQLite UPDATE-with-guard loses the race', async () => {
|
||||
const pendingRow = { id: 7 };
|
||||
const { db } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 0 });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the row when SQLite UPDATE-with-guard wins', async () => {
|
||||
const pendingRow = { id: 7 };
|
||||
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 1 });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toEqual(pendingRow);
|
||||
// SQLite path: no FOR UPDATE / SKIP LOCKED.
|
||||
expect(queries[0].locked).toBe(false);
|
||||
expect(queries[0].skipped).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Verifies the backup-integrity check covers every CRM document
|
||||
* artefact column and correctly buckets each row into:
|
||||
* - verifiedOk — file exists AND hash matches (when hash is stored)
|
||||
* - missing — `*_path` set but file is not on disk
|
||||
* - hashMismatches — file exists but bytes don't hash to `*_sha256`
|
||||
* - existsButNoHash — file exists, no `*_sha256` column for this row
|
||||
*
|
||||
* Uses the CRM integration harness (bootCrmDb) so the schema +
|
||||
* STORAGE_PATH wiring exactly mirrors production behaviour.
|
||||
*
|
||||
* Background: this service is the diagnostic for the
|
||||
* `storage/business-docs/` gap fixed in the same PR — without it,
|
||||
* a restored install would have audit-trail columns referencing
|
||||
* files that no longer exist, but admins would have no way to see
|
||||
* the breakage until a customer asked for their contract back.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupIntegrityService.verifyDocumentArtefacts', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let customerId;
|
||||
let storagePath;
|
||||
let backupIntegrityService;
|
||||
|
||||
function seedFile(relPath, content) {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
return { abs, relPath, sha: sha256(content) };
|
||||
}
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupIntegrityService = require('../../src/services/backupIntegrityService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Wipe CRM rows between tests so each scenario sees a clean slate.
|
||||
// Order matters: child tables before parents.
|
||||
await db('invoice_line_items').del().catch(() => {});
|
||||
await db('invoice_payment_log').del().catch(() => {});
|
||||
await db('invoices').del().catch(() => {});
|
||||
await db('quote_line_items').del().catch(() => {});
|
||||
await db('quotes').del().catch(() => {});
|
||||
await db('contracts').del().catch(() => {});
|
||||
});
|
||||
|
||||
it('returns an empty report when no documents reference any path', async () => {
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts();
|
||||
expect(report.summary.totalRows).toBe(0);
|
||||
expect(report.summary.verifiedOk).toBe(0);
|
||||
expect(report.missing).toEqual([]);
|
||||
expect(report.hashMismatches).toEqual([]);
|
||||
expect(report.existsButNoHash).toEqual([]);
|
||||
expect(report.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(report.scopes).toEqual(expect.arrayContaining(['quote', 'contract', 'contract-signature', 'invoice']));
|
||||
});
|
||||
|
||||
it('flags a contract whose signed_pdf_path file is missing', async () => {
|
||||
// Reference a file that we deliberately never create on disk.
|
||||
// knex's `.returning('id')` returns `[{ id: N }]` on Postgres and
|
||||
// newer SQLite, but `[N]` (plain int) on some SQLite versions —
|
||||
// unwrap both shapes the same way the crmDb test harness does.
|
||||
const inserted = await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-MISSING',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-2026-MISSING.pdf',
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
const hit = report.missing.find((m) => m.rowId === contractId);
|
||||
expect(hit).toMatchObject({
|
||||
table: 'contracts',
|
||||
column: 'signed_pdf_path',
|
||||
expectedPath: 'business-docs/contract/2026/C-2026-MISSING.pdf',
|
||||
});
|
||||
expect(report.summary.missingFiles).toBe(1);
|
||||
});
|
||||
|
||||
it('verifies a contract whose file exists AND hash matches', async () => {
|
||||
const { relPath, sha } = seedFile(
|
||||
'business-docs/contract/2026/C-2026-OK.pdf',
|
||||
'this is the signed contract content',
|
||||
);
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-OK',
|
||||
status: 'fully_signed',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: relPath,
|
||||
signed_pdf_sha256: sha,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
expect(report.summary.verifiedOk).toBeGreaterThanOrEqual(1);
|
||||
expect(report.summary.missingFiles).toBe(0);
|
||||
expect(report.summary.hashMismatches).toBe(0);
|
||||
});
|
||||
|
||||
it('flags a hash mismatch when the file exists but bytes differ from signed_pdf_sha256', async () => {
|
||||
const { relPath } = seedFile(
|
||||
'business-docs/contract/2026/C-2026-TAMPER.pdf',
|
||||
'tampered bytes on disk',
|
||||
);
|
||||
const inserted = await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-TAMPER',
|
||||
status: 'fully_signed',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: relPath,
|
||||
// Hash for completely different content — simulates tampering or
|
||||
// bit-rot between sign-time and now.
|
||||
signed_pdf_sha256: sha256('the ORIGINAL bytes the customer signed'),
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
const hit = report.hashMismatches.find((m) => m.rowId === contractId);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit.expectedSha).not.toBe(hit.actualSha);
|
||||
expect(hit.column).toBe('signed_pdf_path');
|
||||
});
|
||||
|
||||
it('buckets signature PNGs into existsButNoHash (no hash column)', async () => {
|
||||
const { relPath } = seedFile(
|
||||
'business-docs/contract/signatures/99/customer-1700000000000.png',
|
||||
'\x89PNG\r\n\x1a\n', // doesn't have to be a real PNG, just bytes
|
||||
);
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-SIG',
|
||||
status: 'fully_signed',
|
||||
issue_date: '2026-01-01',
|
||||
signed_customer_signature_path: relPath,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({
|
||||
scope: ['contract-signature'],
|
||||
});
|
||||
expect(report.summary.existsButNoHash).toBeGreaterThanOrEqual(1);
|
||||
expect(report.summary.verifiedOk).toBe(0); // no hash → not "verified ok"
|
||||
expect(report.summary.missingFiles).toBe(0);
|
||||
const hit = report.existsButNoHash.find((r) => r.column === 'signed_customer_signature_path');
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it('respects the scope filter — contract scope skips quote/invoice tables', async () => {
|
||||
// Seed an invoice with a missing pdf_path AND a contract with a
|
||||
// missing signed_pdf_path. Scoping to contract should only flag
|
||||
// the contract.
|
||||
await db('invoices').insert({
|
||||
customer_account_id: customerId,
|
||||
invoice_number: 'INV-2026-SCOPE',
|
||||
status: 'sent',
|
||||
pdf_path: 'business-docs/invoice/2026/INV-2026-SCOPE.pdf',
|
||||
issue_date: '2026-01-01',
|
||||
due_date: '2026-01-31',
|
||||
created_at: new Date(),
|
||||
});
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-SCOPE',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-2026-SCOPE.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
expect(report.scopes).toEqual(['contract']);
|
||||
expect(report.missing.every((m) => m.table === 'contracts')).toBe(true);
|
||||
expect(report.missing.some((m) => m.table === 'invoices')).toBe(false);
|
||||
});
|
||||
|
||||
it('covers invoices.imported_pdf_path (admin-uploaded historical scans)', async () => {
|
||||
// Imported invoices are the most catastrophic case — there's no
|
||||
// renderer that can reproduce them. Verifier must check this column
|
||||
// alongside invoices.pdf_path.
|
||||
await db('invoices').insert({
|
||||
customer_account_id: customerId,
|
||||
invoice_number: 'IMP-2025-001',
|
||||
status: 'sent',
|
||||
imported_pdf_path: 'business-docs/invoice-imports/2025/legacy.pdf',
|
||||
issue_date: '2025-06-01',
|
||||
due_date: '2025-07-01',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['invoice'] });
|
||||
const hit = report.missing.find((m) => m.column === 'imported_pdf_path');
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
|
||||
const mockFs = require('mock-fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
@@ -748,4 +748,4 @@ describe('Enhanced Backup Service Tests', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Unit tests for the recipient resolver that routes invoice / Storno /
|
||||
* reminder emails to a bookkeeper address when one is configured,
|
||||
* while keeping the decision-maker (primary email) on CC.
|
||||
*
|
||||
* Pure helper, no DB, no side effects.
|
||||
*/
|
||||
|
||||
const { resolveBillingRecipients } = require('../../src/services/_billingRecipients');
|
||||
|
||||
describe('resolveBillingRecipients', () => {
|
||||
it('routes to the primary email when no billing_email is set', () => {
|
||||
expect(resolveBillingRecipients({ email: 'bride@example.com' }, null))
|
||||
.toEqual({ to: 'bride@example.com', cc: undefined });
|
||||
});
|
||||
|
||||
it('routes to billing_email and CCs the primary when both are set', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'bride@example.com',
|
||||
billing_email: 'books@example.com',
|
||||
}, null)).toEqual({
|
||||
to: 'books@example.com',
|
||||
cc: ['bride@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('folds the per-document cc_pdf_email into the CC list', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'bride@example.com',
|
||||
billing_email: 'books@example.com',
|
||||
}, 'advisor@example.com')).toEqual({
|
||||
to: 'books@example.com',
|
||||
cc: ['bride@example.com', 'advisor@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses cc_pdf_email alone when there is no billing_email', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'bride@example.com',
|
||||
}, 'advisor@example.com')).toEqual({
|
||||
to: 'bride@example.com',
|
||||
cc: ['advisor@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not CC the primary onto itself when billing_email equals email', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'same@example.com',
|
||||
billing_email: 'same@example.com',
|
||||
}, null)).toEqual({
|
||||
to: 'same@example.com',
|
||||
cc: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('is case-insensitive when deduping addresses', () => {
|
||||
// RFC 5321 says mailbox local-parts MAY be case sensitive, but in
|
||||
// practice every mail server treats them as insensitive — and the
|
||||
// admin entering "BRIDE@example.com" in one field and
|
||||
// "bride@example.com" in another should not produce two copies.
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'BRIDE@example.com',
|
||||
billing_email: 'books@example.com',
|
||||
}, 'bride@example.com')).toEqual({
|
||||
to: 'books@example.com',
|
||||
cc: ['BRIDE@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace around the addresses', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: ' bride@example.com ',
|
||||
billing_email: ' books@example.com\n',
|
||||
}, '\tadvisor@example.com ')).toEqual({
|
||||
to: 'books@example.com',
|
||||
cc: ['bride@example.com', 'advisor@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('treats empty-string billing_email as not set', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'bride@example.com',
|
||||
billing_email: '',
|
||||
}, null)).toEqual({
|
||||
to: 'bride@example.com',
|
||||
cc: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty To when neither email nor billing_email is set', () => {
|
||||
// Caller is responsible for surfacing this — emailProcessor's own
|
||||
// validation will reject the empty recipient. The helper just
|
||||
// refuses to crash.
|
||||
expect(resolveBillingRecipients({}, null))
|
||||
.toEqual({ to: '', cc: undefined });
|
||||
});
|
||||
|
||||
it('tolerates a null customer without throwing', () => {
|
||||
// Per-doc cc alone is never promoted to To: — it stays
|
||||
// supplemental. A missing customer is a caller bug; we just refuse
|
||||
// to crash and let emailProcessor reject the empty recipient.
|
||||
expect(resolveBillingRecipients(null, 'a@b.com'))
|
||||
.toEqual({ to: '', cc: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Unit tests for the pure helpers in contractService (migration 130).
|
||||
*
|
||||
* The DB-bound CRUD paths (createContract / sendContract /
|
||||
* recordCustomerSignature / attachSignedPdfUpload) are exercised in
|
||||
* manual QA via the admin + public routes. This file covers the
|
||||
* deterministic helpers so regressions in placeholder substitution or
|
||||
* section ordering surface before they leak into a rendered contract.
|
||||
*
|
||||
* The service pulls in DB-bound peers (businessProfileService,
|
||||
* pdfService, emailProcessor) at the top level. We stub the DB layer
|
||||
* + the side-effect peers so the require chain doesn't try to connect
|
||||
* to anything; the helpers under test are pure.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'contractService');
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: jest.fn(),
|
||||
logActivity: jest.fn(),
|
||||
withRetry: (fn) => fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderContractToBuffer: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/utils/frontendUrl', () => ({
|
||||
getFrontendBaseUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
const { _internal } = require(servicePath);
|
||||
const { renderTemplatedBody, SECTIONS_ORDER } = _internal;
|
||||
|
||||
describe('renderTemplatedBody', () => {
|
||||
it('substitutes simple {{var}} placeholders', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'Hello {{name}}, due in {{net_days}} days.',
|
||||
{ name: 'Alice', net_days: 30 },
|
||||
)).toBe('Hello Alice, due in 30 days.');
|
||||
});
|
||||
|
||||
it('preserves unknown placeholders literally so admins notice missing fields', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'Bill from {{issuer}} to {{customer_name}}',
|
||||
{ issuer: 'PicPeak GmbH' },
|
||||
)).toBe('Bill from PicPeak GmbH to {{customer_name}}');
|
||||
});
|
||||
|
||||
it('keeps {{#if var}}…{{/if}} block when var is truthy', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'{{#if has_skonto}}Skonto: {{pct}} %{{/if}} on early payment',
|
||||
{ has_skonto: true, pct: 2 },
|
||||
)).toBe('Skonto: 2 % on early payment');
|
||||
});
|
||||
|
||||
it('drops {{#if var}}…{{/if}} block when var is falsy', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'Net {{net_days}} d{{#if has_skonto}}, Skonto {{pct}}%{{/if}}.',
|
||||
{ net_days: 30, has_skonto: false, pct: 2 },
|
||||
)).toBe('Net 30 d.');
|
||||
});
|
||||
|
||||
it('treats missing variables in {{#if}} as falsy', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'A{{#if missing}}B{{/if}}C',
|
||||
{ unrelated: 'foo' },
|
||||
)).toBe('AC');
|
||||
});
|
||||
|
||||
it('handles empty strings and missing variables map gracefully', () => {
|
||||
expect(renderTemplatedBody('', { x: 1 })).toBe('');
|
||||
expect(renderTemplatedBody('plain text', null)).toBe('plain text');
|
||||
expect(renderTemplatedBody('plain text', undefined)).toBe('plain text');
|
||||
});
|
||||
|
||||
it('passes through non-string input unchanged', () => {
|
||||
expect(renderTemplatedBody(null, { x: 1 })).toBeNull();
|
||||
expect(renderTemplatedBody(undefined, { x: 1 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('substitutes numeric and falsy variable values as strings', () => {
|
||||
expect(renderTemplatedBody('count: {{n}}', { n: 0 })).toBe('count: 0');
|
||||
expect(renderTemplatedBody('flag: {{flag}}', { flag: false })).toBe('flag: false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SECTIONS_ORDER', () => {
|
||||
it('matches the canonical six-section order locked in the spec', () => {
|
||||
expect(SECTIONS_ORDER).toEqual([
|
||||
'basics', 'scope', 'privacy', 'commercial', 'nda', 'closing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('stays in sync with contractBlocksService.ALLOWED_SECTIONS', () => {
|
||||
const blocksService = require('../../src/services/contractBlocksService');
|
||||
expect([...SECTIONS_ORDER].sort()).toEqual(
|
||||
[...blocksService.ALLOWED_SECTIONS].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for the custom-tracker HTML sanitiser (#663 Phase 1).
|
||||
*
|
||||
* The field accepts admin-pasted `<head>`-style snippets for arbitrary
|
||||
* trackers (Plausible / Matomo / Pirsch / GA4 / GoatCounter / Fathom /
|
||||
* Cloudflare Web Analytics). We sanitise on save with a narrow allowlist
|
||||
* tuned for tracker scripts — defence-in-depth, even though the field is
|
||||
* admin-only.
|
||||
*/
|
||||
|
||||
const { sanitizeTrackerSnippet } = require('../../src/services/trackers/customScriptSanitiser');
|
||||
|
||||
describe('sanitizeTrackerSnippet (#663)', () => {
|
||||
test('returns empty string for non-string / empty / whitespace input', () => {
|
||||
expect(sanitizeTrackerSnippet(null)).toBe('');
|
||||
expect(sanitizeTrackerSnippet(undefined)).toBe('');
|
||||
expect(sanitizeTrackerSnippet(42)).toBe('');
|
||||
expect(sanitizeTrackerSnippet('')).toBe('');
|
||||
expect(sanitizeTrackerSnippet(' ')).toBe('');
|
||||
});
|
||||
|
||||
test('passes through a Plausible-style script tag with data-domain', () => {
|
||||
const input = '<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('src="https://plausible.io/js/script.js"');
|
||||
expect(out).toContain('data-domain="example.com"');
|
||||
expect(out).toContain('defer');
|
||||
});
|
||||
|
||||
test('passes through a Umami-style script with data-website-id', () => {
|
||||
const input = '<script async defer src="https://analytics.example.com/script.js" data-website-id="aaa-bbb-ccc"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('src="https://analytics.example.com/script.js"');
|
||||
expect(out).toContain('data-website-id="aaa-bbb-ccc"');
|
||||
});
|
||||
|
||||
test('passes through inline script body unchanged', () => {
|
||||
const input = '<script>window.GA = "x"; window.tracker = function() { console.log("init"); };</script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('window.GA = "x"');
|
||||
expect(out).toContain('console.log("init")');
|
||||
});
|
||||
|
||||
test('allows <noscript> fallback', () => {
|
||||
const input = '<noscript><img src="https://t.example/?nojs=1" /></noscript>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('<noscript>');
|
||||
});
|
||||
|
||||
test('allows <link rel="preconnect"> and <link rel="dns-prefetch">', () => {
|
||||
const out = sanitizeTrackerSnippet(
|
||||
'<link rel="preconnect" href="https://t.example.com">'
|
||||
+ '<link rel="dns-prefetch" href="https://t.example.com">',
|
||||
);
|
||||
expect(out).toContain('rel="preconnect"');
|
||||
expect(out).toContain('rel="dns-prefetch"');
|
||||
expect(out).toContain('href="https://t.example.com"');
|
||||
});
|
||||
|
||||
test('strips <link rel="stylesheet"> (not tracker-related)', () => {
|
||||
const out = sanitizeTrackerSnippet('<link rel="stylesheet" href="https://evil.example/x.css">');
|
||||
expect(out).not.toContain('stylesheet');
|
||||
expect(out).not.toContain('href');
|
||||
});
|
||||
|
||||
test('strips disallowed tags entirely', () => {
|
||||
const input = '<div><iframe src="https://evil.example/x.html"></iframe><h1>hi</h1></div>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('iframe');
|
||||
expect(out).not.toContain('<div');
|
||||
expect(out).not.toContain('<h1');
|
||||
});
|
||||
|
||||
test('strips javascript: URLs from script src', () => {
|
||||
const input = '<script src="javascript:alert(1)"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('strips data: URLs from script src', () => {
|
||||
const input = '<script src="data:text/javascript,alert(1)"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('data:text/javascript');
|
||||
});
|
||||
|
||||
test('strips on* event-handler attributes (defence-in-depth)', () => {
|
||||
// event-handler attrs are not in our allowlist; sanitize-html strips them.
|
||||
const input = '<script src="https://t.example/x.js" onload="evil()"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('onload');
|
||||
expect(out).toContain('src="https://t.example/x.js"');
|
||||
});
|
||||
|
||||
test('returns empty string on unparseable input rather than throwing', () => {
|
||||
// sanitize-html is fault-tolerant — pass deliberately malformed and
|
||||
// confirm we don't blow up.
|
||||
expect(typeof sanitizeTrackerSnippet('<<<>>>')).toBe('string');
|
||||
expect(typeof sanitizeTrackerSnippet('<script')).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Tests for the passive-customer surface:
|
||||
*
|
||||
* - createDirect inserts a customer with password_hash=null,
|
||||
* queueEmail is never called, race-guard rejects duplicates
|
||||
* - createInvitation allows passing through when the existing
|
||||
* customer is passive (promotion path); still rejects when the
|
||||
* existing customer is active (real duplicate)
|
||||
* - acceptInvitation upserts into an existing passive customer
|
||||
* row (preserving id) when one exists; inserts a fresh row
|
||||
* otherwise; still rejects when the existing customer is active
|
||||
*
|
||||
* Pure unit tests — db is mocked via a thenable chain so we can
|
||||
* inspect every insert / update payload without spinning up SQLite.
|
||||
*/
|
||||
|
||||
// ----- mock db chain --------------------------------------------------
|
||||
//
|
||||
// We need fine-grained control over which row each table-name returns
|
||||
// for `.first()`, what `.insert(...).returning('id')` resolves to, and
|
||||
// what `.update(...)` resolves to. The chain is a thenable proxy that
|
||||
// terminates on the call we care about.
|
||||
|
||||
const tableSeeds = {}; // table → first-row return value
|
||||
const insertResults = {}; // table → array of inserted rows (auto-id from a counter)
|
||||
const updateCalls = []; // [{ table, where, updates }]
|
||||
let nextInsertId = 1000;
|
||||
|
||||
function resetMockDb() {
|
||||
for (const k of Object.keys(tableSeeds)) delete tableSeeds[k];
|
||||
for (const k of Object.keys(insertResults)) delete insertResults[k];
|
||||
updateCalls.length = 0;
|
||||
nextInsertId = 1000;
|
||||
}
|
||||
|
||||
function makeChain(tableName) {
|
||||
const chain = {
|
||||
_whereClauses: [],
|
||||
where(...args) { this._whereClauses.push(args); return this; },
|
||||
whereNull() { return this; },
|
||||
whereNot() { return this; },
|
||||
andWhere() { return this; },
|
||||
orderBy() { return this; },
|
||||
leftJoin() { return this; },
|
||||
groupBy() { return this; },
|
||||
select(...args) {
|
||||
// listCustomers / search → return seeded array
|
||||
const seeded = tableSeeds[`${tableName}__select`];
|
||||
return Promise.resolve(seeded || []);
|
||||
},
|
||||
first() {
|
||||
const seeded = tableSeeds[tableName];
|
||||
return Promise.resolve(seeded);
|
||||
},
|
||||
insert(payload) {
|
||||
const id = nextInsertId++;
|
||||
insertResults[tableName] = insertResults[tableName] || [];
|
||||
insertResults[tableName].push({ ...payload, id });
|
||||
const result = { id };
|
||||
return {
|
||||
returning() { return Promise.resolve([result]); },
|
||||
then(resolve) { return Promise.resolve(undefined).then(resolve); },
|
||||
};
|
||||
},
|
||||
update(updates) {
|
||||
updateCalls.push({ table: tableName, where: this._whereClauses, updates });
|
||||
return Promise.resolve(1);
|
||||
},
|
||||
del() { return Promise.resolve(1); },
|
||||
raw() { return this; },
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((tableName) => makeChain(tableName));
|
||||
mockDbFn.raw = jest.fn();
|
||||
mockDbFn.transaction = async (cb) => cb(mockDbFn);
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const mockQueueEmail = jest.fn(async () => {});
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: mockQueueEmail,
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({
|
||||
profile: { default_locale: 'de' },
|
||||
bankAccounts: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/frontendUrl', () => ({
|
||||
getFrontendBaseUrl: jest.fn(async () => 'https://test.example'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const customerAccountsService = require('../../src/services/customerAccountsService');
|
||||
|
||||
beforeEach(() => {
|
||||
resetMockDb();
|
||||
mockQueueEmail.mockClear();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// createDirect
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
describe('createDirect', () => {
|
||||
it('inserts a customer with password_hash=null, is_active=true', async () => {
|
||||
tableSeeds.customer_accounts = undefined; // no duplicate
|
||||
const result = await customerAccountsService.createDirect({
|
||||
email: 'test@example.com',
|
||||
prefill: { first_name: 'Anna', company_name: 'ACME GmbH' },
|
||||
createdByAdminId: 5,
|
||||
});
|
||||
expect(result.id).toBeDefined();
|
||||
const inserted = insertResults.customer_accounts[0];
|
||||
expect(inserted.email).toBe('test@example.com');
|
||||
expect(inserted.password_hash).toBeNull();
|
||||
expect(inserted.created_by_admin_id).toBe(5);
|
||||
expect(inserted.first_name).toBe('Anna');
|
||||
expect(inserted.company_name).toBe('ACME GmbH');
|
||||
// is_active should be truthy (could be 1 or true depending on formatBoolean impl)
|
||||
expect([true, 1, '1']).toContain(inserted.is_active);
|
||||
});
|
||||
|
||||
it('defaults preferred_language from the business profile', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await customerAccountsService.createDirect({
|
||||
email: 'de@example.com',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
});
|
||||
expect(insertResults.customer_accounts[0].preferred_language).toBe('de');
|
||||
});
|
||||
|
||||
it('honours preferred_language when the admin pre-fills it', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await customerAccountsService.createDirect({
|
||||
email: 'fr@example.com',
|
||||
prefill: { preferred_language: 'fr' },
|
||||
createdByAdminId: 1,
|
||||
});
|
||||
expect(insertResults.customer_accounts[0].preferred_language).toBe('fr');
|
||||
});
|
||||
|
||||
it('rejects when a customer with the email already exists', async () => {
|
||||
tableSeeds.customer_accounts = { id: 7, email: 'dup@example.com', password_hash: 'whatever' };
|
||||
await expect(customerAccountsService.createDirect({
|
||||
email: 'dup@example.com',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
})).rejects.toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it('rejects when only an EMAIL is supplied without anything else (still valid)', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await expect(customerAccountsService.createDirect({
|
||||
email: '',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
})).rejects.toThrow(/Email is required/);
|
||||
});
|
||||
|
||||
it('NEVER queues an invitation email (regression guard)', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await customerAccountsService.createDirect({
|
||||
email: 'silent@example.com',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
});
|
||||
expect(mockQueueEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// createInvitation passive-allowance behaviour
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
describe('createInvitation — duplicate-email guard', () => {
|
||||
it('still rejects when the existing customer has a password (real duplicate)', async () => {
|
||||
tableSeeds.customer_accounts = { id: 1, email: 'active@example.com', password_hash: 'hash' };
|
||||
await expect(customerAccountsService.createInvitation({
|
||||
email: 'active@example.com',
|
||||
invitedById: 5,
|
||||
prefill: null,
|
||||
})).rejects.toThrow(/already exists/);
|
||||
expect(mockQueueEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ALLOWS through when the existing customer is passive (promote path)', async () => {
|
||||
tableSeeds.customer_accounts = { id: 7, email: 'passive@example.com', password_hash: null };
|
||||
// no pending invitation
|
||||
// The chain returns `tableSeeds.customer_invitations` for .first()
|
||||
// and we haven't seeded one, so it's undefined → allowed through.
|
||||
const out = await customerAccountsService.createInvitation({
|
||||
email: 'passive@example.com',
|
||||
invitedById: 9,
|
||||
prefill: { first_name: 'Anna' },
|
||||
});
|
||||
expect(out.id).toBeDefined();
|
||||
expect(out.token).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(mockQueueEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Unit tests for the pure helpers in customerHoursService (migration
|
||||
* 129). The CRUD paths themselves are exercised end-to-end via the
|
||||
* admin/customers routes during manual QA; this file covers the
|
||||
* deterministic logic so regressions in the rate / duration / lock
|
||||
* resolution show up before they hit a real invoice.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'customerHoursService');
|
||||
|
||||
// The service imports invoiceService which pulls in the DB. We don't
|
||||
// need either for the pure helpers — stub the DB layer so the
|
||||
// require chain doesn't try to connect to anything.
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: jest.fn(),
|
||||
logActivity: jest.fn(),
|
||||
withRetry: (fn) => fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/invoiceService', () => ({}));
|
||||
|
||||
const { _internal } = require(servicePath);
|
||||
const { computeDurationMinutes, resolveEffectiveRate, isEntryLocked, buildLineItemFromEntry } = _internal;
|
||||
|
||||
describe('computeDurationMinutes', () => {
|
||||
it('returns minute count for a basic window', () => {
|
||||
expect(computeDurationMinutes('09:00', '11:30')).toBe(150);
|
||||
});
|
||||
|
||||
it('handles single-minute precision', () => {
|
||||
expect(computeDurationMinutes('09:30', '11:00')).toBe(90);
|
||||
expect(computeDurationMinutes('14:15', '14:30')).toBe(15);
|
||||
});
|
||||
|
||||
it('rejects malformed input', () => {
|
||||
expect(() => computeDurationMinutes('9:00', '11:00')).toThrow(/Invalid start_time/);
|
||||
expect(() => computeDurationMinutes('09:00', '25:00')).toThrow(/Invalid end_time/);
|
||||
});
|
||||
|
||||
it('rejects zero or negative duration', () => {
|
||||
expect(() => computeDurationMinutes('09:00', '09:00')).toThrow(/must be after/);
|
||||
expect(() => computeDurationMinutes('11:00', '09:00')).toThrow(/must be after/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEffectiveRate', () => {
|
||||
it('prefers the per-entry override when set', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: 20000 },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
)).toBe(20000);
|
||||
});
|
||||
|
||||
it('falls back to the customer default when no override', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
)).toBe(15000);
|
||||
});
|
||||
|
||||
it('throws when override, customer rate, AND install default are all unset', () => {
|
||||
expect(() => resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: null },
|
||||
null,
|
||||
)).toThrow(/No hourly rate/);
|
||||
});
|
||||
|
||||
it('falls back to the install-wide default when override + customer rate are unset', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: null },
|
||||
12000,
|
||||
)).toBe(12000);
|
||||
});
|
||||
|
||||
it('customer rate wins over the install-wide default', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
12000,
|
||||
)).toBe(15000);
|
||||
});
|
||||
|
||||
it('treats override=0 as "explicitly zero" (not null)', () => {
|
||||
// Override === 0 is unusual but legal — pro bono blocks, internal
|
||||
// tracking. Must NOT fall through to the customer default.
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: 0 },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEntryLocked', () => {
|
||||
it('unbilled entry → not locked', () => {
|
||||
expect(isEntryLocked({ invoice_id: null }, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('monthly draft → not locked (still accumulating)', () => {
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: true, status: 'scheduled', scheduled_send_at: null },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('standalone draft with no send time → not locked', () => {
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: null },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('future-scheduled draft → not locked', () => {
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: future },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('armed (scheduled_send_at in the past, status still scheduled) → locked', () => {
|
||||
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: past },
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('sent / paid / overdue / cancelled → locked', () => {
|
||||
for (const status of ['sent', 'paid', 'overdue', 'cancelled']) {
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status, scheduled_send_at: null },
|
||||
)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('entry references a deleted invoice (null) → treat as unbilled', () => {
|
||||
expect(isEntryLocked({ invoice_id: 42 }, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLineItemFromEntry', () => {
|
||||
const baseEntry = {
|
||||
entry_date: '2026-05-20',
|
||||
start_time: '09:00',
|
||||
end_time: '11:30',
|
||||
duration_minutes: 150,
|
||||
description: 'Editing wedding photos',
|
||||
};
|
||||
|
||||
it('formats the description per spec', () => {
|
||||
const li = buildLineItemFromEntry(baseEntry, 15000);
|
||||
expect(li.description).toBe('2026-05-20 09:00–11:30 (2.50h): Editing wedding photos');
|
||||
});
|
||||
|
||||
it('omits the colon when no description', () => {
|
||||
const li = buildLineItemFromEntry({ ...baseEntry, description: null }, 15000);
|
||||
expect(li.description).toBe('2026-05-20 09:00–11:30 (2.50h)');
|
||||
});
|
||||
|
||||
it('quantity is decimal hours with 2 places', () => {
|
||||
const li = buildLineItemFromEntry(baseEntry, 15000);
|
||||
expect(li.quantity).toBeCloseTo(2.5, 5);
|
||||
});
|
||||
|
||||
it('line_total rounds correctly for non-clean durations', () => {
|
||||
// 15 minutes at CHF 100/h = CHF 25.00 = 2500 minor
|
||||
const li = buildLineItemFromEntry(
|
||||
{ ...baseEntry, start_time: '14:00', end_time: '14:15', duration_minutes: 15 },
|
||||
10000,
|
||||
);
|
||||
expect(li.line_total_minor).toBe(2500);
|
||||
});
|
||||
|
||||
it('zero-rate line items produce a zero total without exploding', () => {
|
||||
const li = buildLineItemFromEntry(baseEntry, 0);
|
||||
expect(li.line_total_minor).toBe(0);
|
||||
expect(li.unit_price_minor).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Pure-logic tests for the #493 download-filename helpers that don't depend
|
||||
* on the DB (those are covered by the route integration suite).
|
||||
*/
|
||||
|
||||
const {
|
||||
pickRawDownloadName,
|
||||
getZipEntryNames,
|
||||
} = require('../../src/services/downloadFilenameService');
|
||||
|
||||
describe('pickRawDownloadName', () => {
|
||||
it('returns the storage filename when the toggle is off', () => {
|
||||
expect(
|
||||
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, false)
|
||||
).toBe('slug_001.jpg');
|
||||
});
|
||||
|
||||
it('returns original_filename when the toggle is on', () => {
|
||||
expect(
|
||||
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, true)
|
||||
).toBe('DSC_1.jpg');
|
||||
});
|
||||
|
||||
it('falls back to storage filename when original_filename is missing', () => {
|
||||
expect(
|
||||
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: null }, true)
|
||||
).toBe('slug_001.jpg');
|
||||
});
|
||||
|
||||
it('produces a stable last-resort name when both are missing', () => {
|
||||
expect(pickRawDownloadName({ id: 42 }, true)).toBe('photo-42.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getZipEntryNames', () => {
|
||||
it('uses original filenames with deterministic suffixes on collision', () => {
|
||||
const photos = [
|
||||
{ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1234.jpg' },
|
||||
{ id: 2, filename: 'slug_002.jpg', original_filename: 'DSC_1234.jpg' },
|
||||
{ id: 3, filename: 'slug_003.jpg', original_filename: 'DSC_1235.jpg' },
|
||||
];
|
||||
expect(getZipEntryNames(photos, true)).toEqual([
|
||||
'DSC_1234.jpg',
|
||||
'DSC_1234_1.jpg',
|
||||
'DSC_1235.jpg',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to storage filename per-photo when original is missing', () => {
|
||||
const photos = [
|
||||
{ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' },
|
||||
{ id: 2, filename: 'slug_002.jpg', original_filename: null },
|
||||
];
|
||||
expect(getZipEntryNames(photos, true)).toEqual([
|
||||
'DSC_1.jpg',
|
||||
'slug_002.jpg',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns storage filenames when the toggle is off, dedup still applies', () => {
|
||||
const photos = [
|
||||
{ id: 1, filename: 'a.jpg', original_filename: 'DSC_1.jpg' },
|
||||
{ id: 2, filename: 'a.jpg', original_filename: 'DSC_2.jpg' },
|
||||
];
|
||||
expect(getZipEntryNames(photos, false)).toEqual(['a.jpg', 'a_1.jpg']);
|
||||
});
|
||||
|
||||
it('sanitizes path-traversal attempts that sneak into original_filename', () => {
|
||||
const photos = [
|
||||
{ id: 1, filename: 'slug_001.jpg', original_filename: '../etc/passwd' },
|
||||
];
|
||||
const [name] = getZipEntryNames(photos, true);
|
||||
expect(name).not.toContain('..');
|
||||
expect(name).not.toContain('/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Unit tests for emailProcessor.htmlToText.
|
||||
*
|
||||
* Regression: when a template ships without a body_text, sendTemplateEmail
|
||||
* used `htmlBody.replace(/<[^>]*>/g, '')` to derive the plain-text fallback.
|
||||
* That regex strips angle-bracket tags but leaves the *contents* of <style>
|
||||
* and <script> blocks intact — so any HTML wrapped by wrapEmailHtml() (which
|
||||
* embeds a 100+ line <style> block) produced a "plain-text" email starting
|
||||
* with `body { margin: 0; padding: 0; … }`. htmlToText fixes that.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
|
||||
const { htmlToText } = require('../../src/services/emailProcessor');
|
||||
|
||||
describe('htmlToText', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(htmlToText('')).toBe('');
|
||||
expect(htmlToText(null)).toBe('');
|
||||
expect(htmlToText(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('strips <style> blocks and their contents', () => {
|
||||
const html = '<html><head><style>body { margin: 0; color: red; }</style></head><body>Hello</body></html>';
|
||||
const out = htmlToText(html);
|
||||
expect(out).toBe('Hello');
|
||||
expect(out).not.toMatch(/margin/);
|
||||
expect(out).not.toMatch(/color/);
|
||||
});
|
||||
|
||||
it('strips <script> blocks and their contents', () => {
|
||||
const html = '<body><script>alert("x")</script>Hi</body>';
|
||||
expect(htmlToText(html)).toBe('Hi');
|
||||
});
|
||||
|
||||
it('converts <br> tags to newlines', () => {
|
||||
expect(htmlToText('a<br>b<br />c<BR/>d')).toBe('a\nb\nc\nd');
|
||||
});
|
||||
|
||||
it('keeps a paragraph break between adjacent <p> tags', () => {
|
||||
expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\n\ntwo');
|
||||
});
|
||||
|
||||
it('decodes the common HTML entities', () => {
|
||||
expect(htmlToText('Tom & Jerry <3 "hi"'))
|
||||
.toBe('Tom & Jerry <3 "hi"');
|
||||
});
|
||||
|
||||
it('handles a fully-wrapped email body without leaking CSS rules', () => {
|
||||
// Shape mirrors what wrapEmailHtml() produces: a <style> block with many
|
||||
// CSS rules followed by the actual content.
|
||||
const wrapped = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; background-color: #f5f5f5; }
|
||||
.email-container { max-width: 600px; }
|
||||
.button { background-color: #5C8762; color: white !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Galerie erfolgreich erstellt</h2>
|
||||
<p>Liebe(r) Natalie,</p>
|
||||
</body>
|
||||
</html>`;
|
||||
const out = htmlToText(wrapped);
|
||||
expect(out).toContain('Galerie erfolgreich erstellt');
|
||||
expect(out).toContain('Liebe(r) Natalie');
|
||||
expect(out).not.toMatch(/margin/);
|
||||
expect(out).not.toMatch(/font-family/);
|
||||
expect(out).not.toMatch(/background-color/);
|
||||
expect(out).not.toMatch(/\.button/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Unit tests for emailProcessor.safeTemplateReplace.
|
||||
*
|
||||
* Covers the two regressions that hit picpeak.nothaft.cloud on the
|
||||
* 3.32.x betas:
|
||||
* - {{#if VAR}}…{{/if}} blocks rendered as literal text in the email
|
||||
* because the renderer only handled {{var}} substitution and the
|
||||
* shipped templates use Handlebars-style conditionals.
|
||||
* - {{var}} substitution inside a kept conditional block.
|
||||
*
|
||||
* The publish-from-draft password localisation lives inside the wider
|
||||
* processTemplate() pipeline (DB-backed), so it isn't covered here — the
|
||||
* sentinel string '(set at creation)' is asserted only at the i18n-map
|
||||
* level by integration in adminEvents.js.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
|
||||
const { safeTemplateReplace } = require('../../src/services/emailProcessor');
|
||||
|
||||
describe('safeTemplateReplace', () => {
|
||||
describe('flat variable substitution', () => {
|
||||
it('replaces {{var}} with the variable value', () => {
|
||||
expect(safeTemplateReplace('Hello {{name}}!', { name: 'Paul' }))
|
||||
.toBe('Hello Paul!');
|
||||
});
|
||||
|
||||
it('leaves unknown variables untouched', () => {
|
||||
expect(safeTemplateReplace('Hello {{name}}!', {}))
|
||||
.toBe('Hello {{name}}!');
|
||||
});
|
||||
|
||||
it('coerces non-string values to string', () => {
|
||||
expect(safeTemplateReplace('Count: {{n}}', { n: 42 }))
|
||||
.toBe('Count: 42');
|
||||
});
|
||||
|
||||
it('handles empty templates and missing variables map', () => {
|
||||
expect(safeTemplateReplace('', { x: 1 })).toBe('');
|
||||
expect(safeTemplateReplace('plain text', undefined)).toBe('plain text');
|
||||
expect(safeTemplateReplace(null, {})).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('{{#if VAR}}…{{/if}} blocks', () => {
|
||||
it('strips the block when the variable is missing', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, {})).toBe('before after');
|
||||
});
|
||||
|
||||
it('strips the block when the variable is an empty string', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, { welcome: '' })).toBe('before after');
|
||||
});
|
||||
|
||||
it('strips the block when the variable is null', () => {
|
||||
const tpl = '{{#if x}}kept{{/if}}';
|
||||
expect(safeTemplateReplace(tpl, { x: null })).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the block and substitutes inside it when truthy', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, { welcome: 'world' }))
|
||||
.toBe('before HELLO world after');
|
||||
});
|
||||
|
||||
it('handles multi-line conditional blocks', () => {
|
||||
const tpl = [
|
||||
'Liebe(r) {{host_name}},',
|
||||
'',
|
||||
'{{#if welcome_message}}',
|
||||
'Persönliche Nachricht:',
|
||||
'{{welcome_message}}',
|
||||
'{{/if}}',
|
||||
'Galerie-Details:',
|
||||
].join('\n');
|
||||
|
||||
const withMsg = safeTemplateReplace(tpl, {
|
||||
host_name: 'Natalie',
|
||||
welcome_message: 'Schön, dass ihr da seid!',
|
||||
});
|
||||
expect(withMsg).toContain('Persönliche Nachricht:');
|
||||
expect(withMsg).toContain('Schön, dass ihr da seid!');
|
||||
expect(withMsg).not.toContain('{{#if');
|
||||
expect(withMsg).not.toContain('{{/if');
|
||||
|
||||
const withoutMsg = safeTemplateReplace(tpl, {
|
||||
host_name: 'Natalie',
|
||||
welcome_message: '',
|
||||
});
|
||||
expect(withoutMsg).not.toContain('Persönliche Nachricht');
|
||||
expect(withoutMsg).not.toContain('{{#if');
|
||||
expect(withoutMsg).not.toContain('{{/if');
|
||||
expect(withoutMsg).toContain('Liebe(r) Natalie,');
|
||||
expect(withoutMsg).toContain('Galerie-Details:');
|
||||
});
|
||||
|
||||
it('handles multiple sibling conditionals independently', () => {
|
||||
const tpl = '{{#if a}}A{{/if}}|{{#if b}}B{{/if}}|{{#if c}}C{{/if}}';
|
||||
expect(safeTemplateReplace(tpl, { a: 1, c: 'yes' })).toBe('A||C');
|
||||
});
|
||||
|
||||
it('treats numeric 0 as falsy', () => {
|
||||
expect(safeTemplateReplace('{{#if n}}has-n{{/if}}', { n: 0 })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML escaping (escapeHtml: true)', () => {
|
||||
it('does not escape by default', () => {
|
||||
const tpl = 'Welcome to {{event_name}}';
|
||||
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>' }))
|
||||
.toBe('Welcome to Test <script>');
|
||||
});
|
||||
|
||||
it('escapes admin-supplied values when opted in', () => {
|
||||
const tpl = 'Welcome to {{event_name}}';
|
||||
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>alert(1)</script>' }, { escapeHtml: true }))
|
||||
.toBe('Welcome to Test <script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('escapes both the < > and & characters and quotes', () => {
|
||||
expect(safeTemplateReplace('{{x}}', { x: '<a href="evil">A & B\'s</a>' }, { escapeHtml: true }))
|
||||
.toBe('<a href="evil">A & B's</a>');
|
||||
});
|
||||
|
||||
it('passes welcome_message through unescaped (already HTML from formatWelcomeMessage)', () => {
|
||||
const tpl = '<p>{{welcome_message}}</p>';
|
||||
expect(safeTemplateReplace(tpl, { welcome_message: 'Hi<br />there' }, { escapeHtml: true }))
|
||||
.toBe('<p>Hi<br />there</p>');
|
||||
});
|
||||
|
||||
it('passes server-generated URLs through unescaped', () => {
|
||||
const tpl = '<a href="{{gallery_link}}">link</a>';
|
||||
expect(safeTemplateReplace(tpl, { gallery_link: 'https://example.com/g/abc?token=xyz&u=1' }, { escapeHtml: true }))
|
||||
.toBe('<a href="https://example.com/g/abc?token=xyz&u=1">link</a>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Unit tests for `normaliseEventTimeTriple` — the pure validator that
|
||||
* gates the migration-137 calendar time columns on events.
|
||||
*
|
||||
* The DB-bound CRUD paths (createEvent/updateEvent) inline this
|
||||
* helper and write through hasColumnCached guards; those are
|
||||
* exercised in manual QA. This file pins the contract so a future
|
||||
* tweak to the validation rules doesn't silently break it.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'eventService');
|
||||
|
||||
// Stub every DB-bound peer so the require chain doesn't try to open
|
||||
// a knex connection. The helper under test is pure.
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
jest.mock('../../src/utils/schemaCache', () => ({ hasColumnCached: jest.fn() }));
|
||||
jest.mock('bcrypt', () => ({ hash: jest.fn() }));
|
||||
|
||||
const { normaliseEventTimeTriple } = require(servicePath);
|
||||
|
||||
describe('normaliseEventTimeTriple', () => {
|
||||
it('defaults to full-day when is_full_day is undefined', () => {
|
||||
expect(normaliseEventTimeTriple({})).toEqual({
|
||||
event_time_start: null,
|
||||
event_time_end: null,
|
||||
is_full_day: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('forces times to null when is_full_day is true even if times are supplied', () => {
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: true,
|
||||
event_time_start: '10:00',
|
||||
event_time_end: '12:00',
|
||||
})).toEqual({
|
||||
event_time_start: null,
|
||||
event_time_end: null,
|
||||
is_full_day: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a valid timed range when is_full_day is false', () => {
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '09:30',
|
||||
event_time_end: '17:00',
|
||||
})).toEqual({
|
||||
event_time_start: '09:30',
|
||||
event_time_end: '17:00',
|
||||
is_full_day: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when is_full_day is false and start is missing/malformed', () => {
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_end: '12:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '25:00',
|
||||
event_time_end: '12:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '9:00',
|
||||
event_time_end: '12:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
});
|
||||
|
||||
it('throws when end is missing or malformed', () => {
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '10:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '10:00',
|
||||
event_time_end: '12:99',
|
||||
})).toThrow(/HH:MM/);
|
||||
});
|
||||
|
||||
it('throws when end is at or before start', () => {
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '10:00',
|
||||
event_time_end: '10:00',
|
||||
})).toThrow(/after/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '15:00',
|
||||
event_time_end: '10:00',
|
||||
})).toThrow(/after/);
|
||||
});
|
||||
|
||||
it('parses string boolean flag', () => {
|
||||
// `parseBooleanInput` accepts "true" / "false" / "1" / "0" — verify
|
||||
// the helper consumes them transparently.
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: 'false',
|
||||
event_time_start: '08:00',
|
||||
event_time_end: '09:00',
|
||||
})).toEqual({
|
||||
event_time_start: '08:00',
|
||||
event_time_end: '09:00',
|
||||
is_full_day: false,
|
||||
});
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: '1',
|
||||
event_time_start: '08:00',
|
||||
event_time_end: '09:00',
|
||||
})).toEqual({
|
||||
event_time_start: null,
|
||||
event_time_end: null,
|
||||
is_full_day: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Unit tests for the accounting money logic — re-bill markup (incoming
|
||||
* invoices) and internal-expense amount/build. Pure functions via _internal.
|
||||
*/
|
||||
const expenseService = require('../../src/services/expenseService');
|
||||
|
||||
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment } = expenseService._internal;
|
||||
|
||||
describe('computeMarkupMinor', () => {
|
||||
it('percent of base, rounded', () => {
|
||||
expect(computeMarkupMinor(10000, { type: 'percent', percent: 10 })).toBe(1000);
|
||||
expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33);
|
||||
expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34);
|
||||
});
|
||||
it('flat / none', () => {
|
||||
expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: 500 })).toBe(500);
|
||||
expect(computeMarkupMinor(10000, { type: 'none' })).toBe(0);
|
||||
expect(computeMarkupMinor(10000, { type: 'percent', percent: null })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMarkup precedence (no contract / no DB)', () => {
|
||||
it('override > source clause', async () => {
|
||||
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 999 }, { markupType: 'percent', markupPercent: 5 }, null, null))
|
||||
.resolves.toEqual({ type: 'percent', percent: 5, flatMinor: null });
|
||||
});
|
||||
it("source clause when no override", async () => {
|
||||
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 200 }, {}, null, null))
|
||||
.resolves.toEqual({ type: 'flat', percent: null, flatMinor: 200 });
|
||||
});
|
||||
it('none when nothing set', async () => {
|
||||
await expect(resolveMarkup({ markupType: 'none' }, {}, null, null))
|
||||
.resolves.toEqual({ type: 'none', percent: null, flatMinor: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeExpenseAmount', () => {
|
||||
it('mileage / per-diem = quantity x rate, rounded', () => {
|
||||
expect(computeExpenseAmount('mileage', 42, 70, null)).toBe(2940); // 42 km x CHF 0.70
|
||||
expect(computeExpenseAmount('per_diem', 3, 8000, null)).toBe(24000); // 3 days x CHF 80
|
||||
expect(computeExpenseAmount('mileage', 10.5, 71, null)).toBe(746); // 745.5 -> 746
|
||||
});
|
||||
it('amount = the entered minor amount', () => {
|
||||
expect(computeExpenseAmount('amount', null, null, 5000)).toBe(5000);
|
||||
});
|
||||
it('null when quantity or rate missing', () => {
|
||||
expect(computeExpenseAmount('mileage', null, 70, null)).toBeNull();
|
||||
expect(computeExpenseAmount('mileage', 42, null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExpenseInsert (internal expense)', () => {
|
||||
it('defaults: kind=amount, disposition=eigener_aufwand, tax=domestic, status=open', () => {
|
||||
const row = buildExpenseInsert({ chfAmountMinor: 5000 }, 7);
|
||||
expect(row.kind).toBe('amount');
|
||||
expect(row.disposition).toBe('eigener_aufwand');
|
||||
expect(row.tax_treatment).toBe('domestic');
|
||||
expect(row.status).toBe('open');
|
||||
expect(row.chf_amount_minor).toBe(5000);
|
||||
expect(row.created_by_admin_id).toBe(7);
|
||||
expect(row.inbound_document_id).toBeNull();
|
||||
});
|
||||
|
||||
it('mileage uses the override rate, else the settings km rate', () => {
|
||||
const withDefault = buildExpenseInsert({ kind: 'mileage', quantity: 42 }, 1, { kmRateMinor: 70 });
|
||||
expect(withDefault.rate_minor).toBe(70);
|
||||
expect(withDefault.chf_amount_minor).toBe(2940);
|
||||
|
||||
const withOverride = buildExpenseInsert({ kind: 'mileage', quantity: 42, rateMinor: 100 }, 1, { kmRateMinor: 70 });
|
||||
expect(withOverride.rate_minor).toBe(100);
|
||||
expect(withOverride.chf_amount_minor).toBe(4200);
|
||||
});
|
||||
|
||||
it('per_diem uses days x per-diem rate', () => {
|
||||
const row = buildExpenseInsert({ kind: 'per_diem', quantity: 2 }, 1, { perDiemRateMinor: 8000 });
|
||||
expect(row.rate_minor).toBe(8000);
|
||||
expect(row.chf_amount_minor).toBe(16000);
|
||||
});
|
||||
|
||||
it('event_id null = booked to company; proof path carried', () => {
|
||||
const company = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100 }, 1, { receiptPath: '/p/x.pdf' });
|
||||
expect(company.event_id).toBeNull();
|
||||
expect(company.receipt_path).toBe('/p/x.pdf');
|
||||
const evt = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100, eventId: 9 }, 1);
|
||||
expect(evt.event_id).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInboundLineItem (re-bill line)', () => {
|
||||
it('rebill: base + percent markup, Weiterverrechnung suffix', () => {
|
||||
const li = buildInboundLineItem({ totalAmountMinor: 10000, supplierName: 'ACME' }, 'rebill', { type: 'percent', percent: 10 });
|
||||
expect(li.unit_price_minor).toBe(11000);
|
||||
expect(li.line_total_minor).toBe(11000);
|
||||
expect(li.quantity).toBe(1);
|
||||
expect(li.description).toBe('ACME (Weiterverrechnung)');
|
||||
});
|
||||
|
||||
it('passthrough: distinct suffix, no markup passes through at cost', () => {
|
||||
const li = buildInboundLineItem({ totalAmountMinor: 5000, supplierName: 'SBB' }, 'durchlaufend', { type: 'none' });
|
||||
expect(li.unit_price_minor).toBe(5000);
|
||||
expect(li.description).toBe('SBB (Durchlaufende Position)');
|
||||
});
|
||||
|
||||
it('falls back to net amount + generic label when total/supplier missing', () => {
|
||||
const li = buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: 7000 }, 'rebill', { type: 'flat', flatMinor: 300 });
|
||||
expect(li.unit_price_minor).toBe(7300);
|
||||
expect(li.description).toBe('Weiterverrechnete Auslage (Weiterverrechnung)');
|
||||
});
|
||||
|
||||
it('throws when there is no amount to re-bill', () => {
|
||||
expect(() => buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: null }, 'rebill', { type: 'none' }))
|
||||
.toThrow(/no amount/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTaxTreatment (supplier-country auto-default)', () => {
|
||||
const reclaim = ['CH', 'LI'];
|
||||
it('explicit valid treatment always wins', () => {
|
||||
expect(resolveTaxTreatment('reverse_charge_service', 'DE', reclaim)).toBe('reverse_charge_service');
|
||||
expect(resolveTaxTreatment('import_goods', 'CH', reclaim)).toBe('import_goods');
|
||||
});
|
||||
it('country in the reclaim list → domestic', () => {
|
||||
expect(resolveTaxTreatment(undefined, 'CH', reclaim)).toBe('domestic');
|
||||
expect(resolveTaxTreatment(null, 'li', reclaim)).toBe('domestic'); // case-insensitive
|
||||
});
|
||||
it('country outside the reclaim list → foreign non-reclaimable', () => {
|
||||
expect(resolveTaxTreatment(undefined, 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
|
||||
expect(resolveTaxTreatment(undefined, 'US', reclaim)).toBe('foreign_vat_non_reclaimable');
|
||||
});
|
||||
it('unknown / empty country falls back to domestic', () => {
|
||||
expect(resolveTaxTreatment(undefined, '', reclaim)).toBe('domestic');
|
||||
expect(resolveTaxTreatment(undefined, null, reclaim)).toBe('domestic');
|
||||
});
|
||||
it('an UNCONFIGURED (empty) reclaim list never auto-classifies as foreign (PR #636 #1)', () => {
|
||||
expect(resolveTaxTreatment(undefined, 'CH', [])).toBe('domestic');
|
||||
expect(resolveTaxTreatment(undefined, 'DE', [])).toBe('domestic');
|
||||
expect(resolveTaxTreatment(undefined, 'US', undefined)).toBe('domestic');
|
||||
});
|
||||
it('invalid explicit treatment is ignored (falls through to country logic)', () => {
|
||||
expect(resolveTaxTreatment('bogus', 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInvoiceMutable (re-categorise unwind guard)', () => {
|
||||
const future = new Date(Date.now() + 86400000).toISOString();
|
||||
const past = new Date(Date.now() - 86400000).toISOString();
|
||||
it('monthly draft and not-yet-armed scheduled are mutable', () => {
|
||||
expect(isInvoiceMutable(null)).toBe(true); // referenced invoice gone
|
||||
expect(isInvoiceMutable({ is_monthly_draft: true })).toBe(true);
|
||||
expect(isInvoiceMutable({ is_monthly_draft: 1 })).toBe(true);
|
||||
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: null })).toBe(true);
|
||||
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: future })).toBe(true);
|
||||
});
|
||||
it('armed / issued invoices are locked', () => {
|
||||
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: past })).toBe(false);
|
||||
expect(isInvoiceMutable({ status: 'sent' })).toBe(false);
|
||||
expect(isInvoiceMutable({ status: 'paid' })).toBe(false);
|
||||
expect(isInvoiceMutable({ status: 'cancelled' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Silence the logger so test output stays clean. Capture calls so the
|
||||
// "warning logged" assertions can still verify behaviour.
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
}));
|
||||
|
||||
const logger = require('../../src/utils/logger');
|
||||
// Required ONCE at module top so the jest.mock factory above applies to
|
||||
// the logger reference that fontsService captures. A previous version
|
||||
// re-required it inside beforeEach() with jest.resetModules() — that
|
||||
// silently bypassed the mock (logger calls went to the real logger),
|
||||
// so the "warning logged" assertions would resolve as 0 calls and
|
||||
// silently pass-as-noop. Module-level state in fontsService is just
|
||||
// the cache, which clearFontsCache() resets between tests.
|
||||
const fontsService = require('../../src/services/fontsService');
|
||||
|
||||
// Probe at load time: is the host filesystem case-sensitive?
|
||||
// macOS APFS and Windows NTFS treat "Inter" and "INTER" as the same
|
||||
// directory entry, which means the "two folders, same lowercase key"
|
||||
// dedup test below can't be set up via real folders on those platforms —
|
||||
// the second mkdir is a no-op. Skip that one test conditionally.
|
||||
const FS_IS_CASE_SENSITIVE = (() => {
|
||||
const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fs-probe-'));
|
||||
fs.writeFileSync(path.join(probeDir, 'casetest'), '');
|
||||
let sensitive = true;
|
||||
try {
|
||||
fs.accessSync(path.join(probeDir, 'CASETEST'));
|
||||
sensitive = false;
|
||||
} catch { /* file not found → case-sensitive FS */ }
|
||||
fs.rmSync(probeDir, { recursive: true, force: true });
|
||||
return sensitive;
|
||||
})();
|
||||
const testCaseSensitiveFS = FS_IS_CASE_SENSITIVE ? test : test.skip;
|
||||
|
||||
let bundledRoot;
|
||||
let userRoot;
|
||||
|
||||
/**
|
||||
* Create a font family folder with the given weights (and optional meta.json).
|
||||
* @param {string} root absolute path to the bundled or user root
|
||||
* @param {string} folderName e.g. "Inter" or "Playfair-Display"
|
||||
* @param {Array<number>|Array<string>} weights numeric weights (creates `<w>.woff2`)
|
||||
* or filenames to create directly
|
||||
* @param {Object|null} meta optional meta.json contents (object) or null
|
||||
*/
|
||||
async function makeFamily(root, folderName, weights, meta = null) {
|
||||
const dir = path.join(root, folderName);
|
||||
await fsPromises.mkdir(dir, { recursive: true });
|
||||
for (const w of weights) {
|
||||
const fname = typeof w === 'number' ? `${w}.woff2` : w;
|
||||
await fsPromises.writeFile(path.join(dir, fname), Buffer.from([]));
|
||||
}
|
||||
if (meta !== null) {
|
||||
await fsPromises.writeFile(
|
||||
path.join(dir, 'meta.json'),
|
||||
typeof meta === 'string' ? meta : JSON.stringify(meta)
|
||||
);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
bundledRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-bundled-'));
|
||||
userRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-user-'));
|
||||
|
||||
process.env.PICPEAK_BUNDLED_FONTS_ROOT = bundledRoot;
|
||||
// The user root resolves under STORAGE_PATH/fonts, so STORAGE_PATH must
|
||||
// point at the parent of userRoot — we name the leaf "fonts" ourselves.
|
||||
const storageParent = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-storage-'));
|
||||
await fsPromises.rename(userRoot, path.join(storageParent, 'fonts'));
|
||||
userRoot = path.join(storageParent, 'fonts');
|
||||
process.env.STORAGE_PATH = storageParent;
|
||||
|
||||
// Reset the module-level cache so each test sees a fresh scan.
|
||||
// (Both getBundledFontsRoot and getUserFontsRoot read process.env at
|
||||
// call-time, so the env vars set above are picked up without needing
|
||||
// to re-require the module — see fontsService.js getBundledFontsRoot /
|
||||
// getUserFontsRoot.)
|
||||
fontsService.clearFontsCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
fontsService.clearFontsCache();
|
||||
await fsPromises.rm(bundledRoot, { recursive: true, force: true }).catch(() => {});
|
||||
// userRoot's parent is the actual mkdtemp; remove it.
|
||||
await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true }).catch(() => {});
|
||||
delete process.env.PICPEAK_BUNDLED_FONTS_ROOT;
|
||||
delete process.env.STORAGE_PATH;
|
||||
});
|
||||
|
||||
describe('fontsService.listFonts', () => {
|
||||
describe('roots', () => {
|
||||
test('empty bundled root + missing user root → []', async () => {
|
||||
// delete user root so it triggers ENOENT
|
||||
await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true });
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts).toEqual([]);
|
||||
});
|
||||
|
||||
test('missing bundled root (ENOENT) → [], does not throw', async () => {
|
||||
await fsPromises.rm(bundledRoot, { recursive: true, force: true });
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts).toEqual([]);
|
||||
});
|
||||
|
||||
test('non-directory entries at the root are skipped', async () => {
|
||||
await fsPromises.writeFile(path.join(bundledRoot, 'README.md'), 'hi');
|
||||
await makeFamily(bundledRoot, 'Inter', [400, 700]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
|
||||
});
|
||||
|
||||
test('hidden folders are skipped', async () => {
|
||||
await makeFamily(bundledRoot, '.git', [400]);
|
||||
await makeFamily(bundledRoot, '.DS_Store', [400]);
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weight parsing', () => {
|
||||
test('three weight files → sorted ascending', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [700, 400, 600]);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 600, 700]);
|
||||
});
|
||||
|
||||
test('non-numeric filenames are ignored', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', ['bold.woff2', 'regular.woff2', '400.woff2', '700.woff2']);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 700]);
|
||||
});
|
||||
|
||||
test('non-.woff2 files are ignored', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', ['400.ttf', '400.woff', '400.woff2', '700.otf']);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400]);
|
||||
});
|
||||
|
||||
test('weight values out of range (sub-1 / over-1000) are ignored', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [0, 400, 1001, 700]);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 700]);
|
||||
});
|
||||
|
||||
test('family folder with no usable .woff2 files is silently skipped', async () => {
|
||||
await makeFamily(bundledRoot, 'NoWeights', ['readme.txt', 'bold.ttf']);
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Skipping NoWeights')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder name → display family', () => {
|
||||
test('hyphens become spaces', async () => {
|
||||
await makeFamily(bundledRoot, 'Playfair-Display', [400]);
|
||||
const [pd] = await fontsService.listFonts();
|
||||
expect(pd.family).toBe('Playfair Display');
|
||||
});
|
||||
|
||||
test('case is preserved', async () => {
|
||||
await makeFamily(bundledRoot, 'IBM-Plex-Sans', [400]);
|
||||
const [ibm] = await fontsService.listFonts();
|
||||
expect(ibm.family).toBe('IBM Plex Sans');
|
||||
});
|
||||
});
|
||||
|
||||
describe('user-overrides-bundled', () => {
|
||||
test('user folder of the same family wins; weights come from user', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400, 600, 700]);
|
||||
await makeFamily(userRoot, 'Inter', [400, 900]); // different weights
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 900]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('overrides bundled default')
|
||||
);
|
||||
});
|
||||
|
||||
test('user-only family is included', async () => {
|
||||
await makeFamily(userRoot, 'Lobster', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Lobster']);
|
||||
});
|
||||
|
||||
testCaseSensitiveFS('case-insensitive duplicate within the same root → second skipped, warning', async () => {
|
||||
// Two folder names whose lowercase keys collide. On a case-sensitive
|
||||
// FS (Linux ext4) we can create both `Inter/` and `INTER/`; on a
|
||||
// case-insensitive FS (macOS APFS, Windows NTFS) the second mkdir
|
||||
// resolves to the same directory as the first and the dedup branch
|
||||
// is unreachable from this test setup — see testCaseSensitiveFS above.
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
await makeFamily(bundledRoot, 'INTER', [700]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts).toHaveLength(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Duplicate family')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('meta.json — generic fallback', () => {
|
||||
test('valid generic="serif"', async () => {
|
||||
await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' });
|
||||
const [pd] = await fontsService.listFonts();
|
||||
expect(pd.generic).toBe('serif');
|
||||
});
|
||||
|
||||
test('valid generic="cursive"', async () => {
|
||||
await makeFamily(bundledRoot, 'Comic-Neue', [400], { generic: 'cursive' });
|
||||
const [cn] = await fontsService.listFonts();
|
||||
expect(cn.generic).toBe('cursive');
|
||||
});
|
||||
|
||||
test('valid generic="monospace"', async () => {
|
||||
await makeFamily(bundledRoot, 'Fira-Mono', [400], { generic: 'monospace' });
|
||||
const [fm] = await fontsService.listFonts();
|
||||
expect(fm.generic).toBe('monospace');
|
||||
});
|
||||
|
||||
test('missing meta.json → defaults to sans-serif (no warning)', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.generic).toBe('sans-serif');
|
||||
// No warning for the missing-file case (it's the normal path).
|
||||
const noisy = (logger.warn.mock.calls || []).filter((c) =>
|
||||
String(c[0]).includes('meta.json')
|
||||
);
|
||||
expect(noisy).toEqual([]);
|
||||
});
|
||||
|
||||
test('invalid generic value → defaults to sans-serif, warning logged', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400], { generic: 'bogus' });
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.generic).toBe('sans-serif');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('invalid generic "bogus"')
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed JSON → defaults to sans-serif, warning logged', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400], '{ this is not json');
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.generic).toBe('sans-serif');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('not valid JSON')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('result shape', () => {
|
||||
test('every family is { family, weights, generic }', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400, 700]);
|
||||
await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' });
|
||||
const fonts = await fontsService.listFonts();
|
||||
for (const f of fonts) {
|
||||
expect(f).toEqual({
|
||||
family: expect.any(String),
|
||||
weights: expect.any(Array),
|
||||
generic: expect.stringMatching(/^(sans-serif|serif|cursive|monospace)$/)
|
||||
});
|
||||
expect(f.weights.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('output sorted alphabetically by family', async () => {
|
||||
await makeFamily(bundledRoot, 'Zilla-Slab', [400]);
|
||||
await makeFamily(bundledRoot, 'Alpha-Sans', [400]);
|
||||
await makeFamily(bundledRoot, 'Mid-Pack', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual([
|
||||
'Alpha Sans',
|
||||
'Mid Pack',
|
||||
'Zilla Slab'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache', () => {
|
||||
test('cache hit: second call within TTL does not re-readdir', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const spy = jest.spyOn(fsPromises, 'readdir');
|
||||
await fontsService.listFonts();
|
||||
const callsAfterFirst = spy.mock.calls.length;
|
||||
await fontsService.listFonts();
|
||||
expect(spy.mock.calls.length).toBe(callsAfterFirst);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('clearFontsCache forces a fresh scan on the next call', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
await fontsService.listFonts();
|
||||
|
||||
// Add a new family AFTER the cache was populated.
|
||||
await makeFamily(bundledRoot, 'Roboto', [400]);
|
||||
|
||||
// Without clearing, listFonts returns the stale cache.
|
||||
const stale = await fontsService.listFonts();
|
||||
expect(stale.map((f) => f.family)).toEqual(['Inter']);
|
||||
|
||||
// After clear, the new family appears.
|
||||
fontsService.clearFontsCache();
|
||||
const fresh = await fontsService.listFonts();
|
||||
expect(fresh.map((f) => f.family)).toEqual(['Inter', 'Roboto']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Tests for the migration-119 hierarchy support in invoiceService —
|
||||
* the shared helpers come from quoteService._internal (validated in
|
||||
* quoteService.hierarchy.test.js), so we focus here on the
|
||||
* invoice-specific seams:
|
||||
*
|
||||
* - quote → invoice cloner preserves parent_position + details_text
|
||||
* across the conversion
|
||||
* - the cloner's installment "adjustment" line only reconciles
|
||||
* against TOP-LEVEL cloned items (sub-items don't contribute to
|
||||
* net so they can't appear in the sum)
|
||||
*
|
||||
* Pure helper, no DB.
|
||||
*/
|
||||
const quoteService = require('../../src/services/quoteService');
|
||||
|
||||
const { validateLineItemHierarchy, insertLineItemsHierarchical } = quoteService._internal;
|
||||
|
||||
describe('quote → invoice cloner shape', () => {
|
||||
// Models the in-memory transformation step from `scheduleInvoicesForEvent`:
|
||||
// take source quote line items (with parent_position) and produce the
|
||||
// `cloned` array that's passed into insertLineItemsHierarchical.
|
||||
function modelCloner(sourceLines) {
|
||||
return sourceLines.map((li) => ({
|
||||
position: parseInt(li.position, 10),
|
||||
quantity: Number(li.quantity || 1),
|
||||
description: li.description,
|
||||
unit_price_minor: parseInt(li.unit_price_minor, 10) || 0,
|
||||
discount_percent: Number(li.discount_percent || 0),
|
||||
line_total_minor: parseInt(li.line_total_minor, 10) || 0,
|
||||
parent_position: li.parent_position == null ? null : parseInt(li.parent_position, 10),
|
||||
details_text: li.details_text || null,
|
||||
}));
|
||||
}
|
||||
|
||||
it('preserves parent_position so the hierarchy carries across conversion', () => {
|
||||
const source = [
|
||||
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, line_total_minor: 50000, parent_position: null },
|
||||
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
|
||||
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
|
||||
];
|
||||
const cloned = modelCloner(source);
|
||||
expect(cloned[0].parent_position).toBeNull();
|
||||
expect(cloned[1].parent_position).toBe(1);
|
||||
expect(cloned[2].parent_position).toBe(1);
|
||||
// The cloned shape passes hierarchy validation — same positions
|
||||
// means the same parent links work without any remap.
|
||||
expect(() => validateLineItemHierarchy(cloned)).not.toThrow();
|
||||
});
|
||||
|
||||
it('preserves details_text verbatim', () => {
|
||||
const source = [
|
||||
{ position: 1, description: 'P', unit_price_minor: 0, line_total_minor: 0, parent_position: null,
|
||||
details_text: 'Includes online gallery + 100 high-res downloads.' },
|
||||
];
|
||||
const cloned = modelCloner(source);
|
||||
expect(cloned[0].details_text).toBe('Includes online gallery + 100 high-res downloads.');
|
||||
});
|
||||
|
||||
it('installment adjustment reconciles against TOP-LEVEL cloned items only', () => {
|
||||
// Recreate the inner math from scheduleInvoicesForEvent: sum
|
||||
// only line_total_minor where parent_position is null. Sub-items
|
||||
// would otherwise double-count and skew the adjustment.
|
||||
//
|
||||
// Note: the cloner stores raw line_total_minor on each row from
|
||||
// the source quote. By the time this sum runs, the parent's
|
||||
// line_total_minor has already been resolved upstream (via
|
||||
// computeTotals on the quote at save time) — so iterating
|
||||
// top-level only sums the resolved parent totals + standalone
|
||||
// top-level items. Sub-items never contribute here regardless of
|
||||
// whether their parent's total was auto-resolved or not.
|
||||
const cloned = modelCloner([
|
||||
// Parent — resolved line_total assumed to be €450 (sum of priced sub-items below)
|
||||
{ position: 1, unit_price_minor: 0, line_total_minor: 45000, parent_position: null },
|
||||
// Sub-items €150 + €200 + €100 — shown for transparency, must
|
||||
// NOT enter the reconciliation sum.
|
||||
{ position: 2, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
|
||||
{ position: 3, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
|
||||
{ position: 4, unit_price_minor: 10000, line_total_minor: 10000, parent_position: 1 },
|
||||
// Another top-level €100
|
||||
{ position: 5, unit_price_minor: 10000, line_total_minor: 10000, parent_position: null },
|
||||
]);
|
||||
const clonedSum = cloned
|
||||
.filter((x) => x.parent_position == null)
|
||||
.reduce((s, x) => s + x.line_total_minor, 0);
|
||||
// Top-level only: 45000 (resolved parent) + 10000 = 55000. NOT 100000.
|
||||
expect(clonedSum).toBe(55000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertLineItemsHierarchical for invoices', () => {
|
||||
function makeTrxMock() {
|
||||
let nextId = 200;
|
||||
const inserts = [];
|
||||
const trx = (tableName) => ({
|
||||
insert(row) {
|
||||
const id = nextId++;
|
||||
inserts.push({ table: tableName, row: { ...row, id } });
|
||||
return {
|
||||
returning() { return Promise.resolve([{ id }]); },
|
||||
then(resolve) { return Promise.resolve(undefined).then(resolve); },
|
||||
};
|
||||
},
|
||||
});
|
||||
return { trx, inserts };
|
||||
}
|
||||
|
||||
it('handles invoice_line_items with the same two-phase + remap logic', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 7, [
|
||||
{ position: 1, description: 'Parent', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
|
||||
{ position: 2, description: 'Sub A', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
|
||||
]);
|
||||
expect(inserts).toHaveLength(2);
|
||||
expect(inserts.every((i) => i.table === 'invoice_line_items')).toBe(true);
|
||||
expect(inserts.every((i) => i.row.invoice_id === 7)).toBe(true);
|
||||
// Parent inserted first, sub-item second with parent_line_item_id
|
||||
// matching the parent's synthesised id.
|
||||
expect(inserts[0].row.parent_line_item_id).toBeNull();
|
||||
expect(inserts[1].row.parent_line_item_id).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Tests for invoiceService.updateInstallmentPlan + validateInstallmentPlanInput.
|
||||
*
|
||||
* Validation tests run against the pure validator directly. Orchestration
|
||||
* tests use the same deep-mocked db pattern as invoiceService.locks.test.js
|
||||
* — chains are queued per table and assertions probe insert/update/delete
|
||||
* call shapes rather than SQL.
|
||||
*/
|
||||
|
||||
const chains = [];
|
||||
function makeChain() {
|
||||
const c = {
|
||||
_firstValue: undefined,
|
||||
_updateResult: 1,
|
||||
_insertResult: [{ id: 999 }],
|
||||
_selectResult: [],
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||||
},
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNull: jest.fn(function () { return this; }),
|
||||
whereNotNull: jest.fn(function () { return this; }),
|
||||
andWhere: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
limit: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return this; }),
|
||||
sum: jest.fn(function () { return this; }),
|
||||
count: jest.fn(function () { return this; }),
|
||||
clone: jest.fn(function () { return this; }),
|
||||
clearSelect: jest.fn(function () { return this; }),
|
||||
clearOrder: jest.fn(function () { return this; }),
|
||||
offset: jest.fn(function () { return this; }),
|
||||
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
|
||||
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
|
||||
insert: jest.fn(function () { return this; }),
|
||||
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
|
||||
del: jest.fn(function () { return Promise.resolve(1); }),
|
||||
onConflict: jest.fn(function () { return this; }),
|
||||
ignore: jest.fn(function () { return Promise.resolve(1); }),
|
||||
merge: jest.fn(function () { return Promise.resolve(1); }),
|
||||
increment: jest.fn(function () { return this; }),
|
||||
forUpdate: jest.fn(function () { return this; }),
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
};
|
||||
chains.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
const tableChains = {};
|
||||
function pickChainFor(name) {
|
||||
if (!tableChains[name]) tableChains[name] = makeChain();
|
||||
return tableChains[name];
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((name) => pickChainFor(name));
|
||||
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
|
||||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/documentSequences', () => {
|
||||
const claimNextSequence = jest.fn(async () => 42);
|
||||
// Delegates to the claimNextSequence mock so call-count assertions
|
||||
// below keep observing sequence claims.
|
||||
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
|
||||
const seq = await claimNextSequence(kind, 2026, trx);
|
||||
return `R-2026-${String(seq).padStart(4, '0')}`;
|
||||
});
|
||||
return { claimNextSequence, nextDocumentNumber };
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
}
|
||||
|
||||
describe('validateInstallmentPlanInput', () => {
|
||||
const { validateInstallmentPlanInput } = invoiceService;
|
||||
|
||||
it('throws on empty array', () => {
|
||||
expect(() => validateInstallmentPlanInput([]))
|
||||
.toThrow(/non-empty array/);
|
||||
});
|
||||
|
||||
it('throws on non-array', () => {
|
||||
expect(() => validateInstallmentPlanInput(null))
|
||||
.toThrow(/non-empty array/);
|
||||
});
|
||||
|
||||
it('throws on out-of-range percent', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 150, trigger: 'quote_accepted', offset_days: 0 },
|
||||
])).toThrow(/percent must be between 0 and 100/);
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: -5, trigger: 'quote_accepted', offset_days: 0 },
|
||||
])).toThrow(/percent must be between 0 and 100/);
|
||||
});
|
||||
|
||||
it('throws on unknown trigger', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 100, trigger: 'on_friday', offset_days: 0 },
|
||||
])).toThrow(/invalid trigger/);
|
||||
});
|
||||
|
||||
it('throws when percents do not sum to 100', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
|
||||
{ percent: 50, trigger: 'before_event', offset_days: -7 },
|
||||
])).toThrow(/must sum to 100/);
|
||||
});
|
||||
|
||||
it('accepts a valid three-row plan with mixed triggers', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'Anzahlung' },
|
||||
{ percent: 40, trigger: 'before_event', offset_days: -14, label: 'Zwischenrechnung' },
|
||||
{ percent: 30, trigger: 'after_delivery', offset_days: 0, label: 'Schlussrechnung' },
|
||||
])).not.toThrow();
|
||||
});
|
||||
|
||||
it('tolerates 0.001 rounding drift in the sum', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 33.333, trigger: 'quote_accepted', offset_days: 0 },
|
||||
{ percent: 33.333, trigger: 'before_event', offset_days: -7 },
|
||||
{ percent: 33.334, trigger: 'after_event', offset_days: 0 },
|
||||
])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInstallmentPlan — guards', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
const goodPlan = [
|
||||
{ percent: 50, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 50, trigger: 'before_event', offset_days: -14, label: 'B' },
|
||||
];
|
||||
|
||||
it('rejects when dealUuid is missing', async () => {
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: '', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('404s when the deal has no invoices', async () => {
|
||||
pickChainFor('invoices')._selectResult = [];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('400s + NOT_INSTALLMENT_PLAN on a single-invoice deal', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, deal_uuid: 'deal-1', installment_total: 1, status: 'scheduled', kind: 'invoice' },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 400, code: 'NOT_INSTALLMENT_PLAN' });
|
||||
});
|
||||
|
||||
it('409s + INVOICE_LOCKED when any sibling has already shipped', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
|
||||
status: 'sent', kind: 'invoice', invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
||||
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
|
||||
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 409, code: 'INVOICE_LOCKED' });
|
||||
});
|
||||
|
||||
it('409s + PLAN_HAS_STORNO when the deal contains a Storno', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
|
||||
status: 'scheduled', kind: 'storno', invoice_number: 'S-2026-0001',
|
||||
net_amount_minor: -5000, vat_amount_minor: -385, total_amount_minor: -5385, shipping_amount_minor: 0 },
|
||||
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
|
||||
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 409, code: 'PLAN_HAS_STORNO' });
|
||||
});
|
||||
|
||||
it('rejects an invalid plan (percents not summing to 100) before opening the txn', async () => {
|
||||
const badPlan = [
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
|
||||
{ percent: 30, trigger: 'before_event', offset_days: -7 },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: badPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 400, code: 'PERCENT_SUM_INVALID' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInstallmentPlan — reshape (smoke)', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
const sibling = (overrides) => ({
|
||||
id: 0, deal_uuid: 'deal-1', installment_total: 3, installment_index: 0,
|
||||
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
shipping_amount_minor: 0, vat_rate: 7.7,
|
||||
customer_account_id: 5, source_quote_id: null, event_id: null,
|
||||
event_name: 'Wedding', event_date: '2026-08-15',
|
||||
language: 'de', currency: 'CHF',
|
||||
issue_date: '2026-05-25', due_date: '2026-06-24',
|
||||
cc_pdf_email: null,
|
||||
payment_net_days_template_id: null, payment_timing_template_id: null,
|
||||
payment_term_snapshot: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('keeps invoice_numbers and does not claim new sequence on 3→3 reshape', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
|
||||
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
|
||||
];
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
|
||||
const result = await invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
||||
installments: [
|
||||
{ percent: 20, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
|
||||
{ percent: 50, trigger: 'after_event', offset_days: 7, label: 'C' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.kept).toEqual([1, 2, 3]);
|
||||
expect(result.created).toEqual([]);
|
||||
expect(result.deleted).toEqual([]);
|
||||
// Sequence helper never touched on a same-count reshape.
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grows 2→3 by claiming one new invoice_number and keeping the first two', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
|
||||
installment_total: 2 }),
|
||||
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
|
||||
installment_total: 2 }),
|
||||
];
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
pickChainFor('invoices')._insertResult = [{ id: 99 }];
|
||||
|
||||
const result = await invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
||||
installments: [
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
|
||||
{ percent: 40, trigger: 'after_event', offset_days: 7, label: 'C' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.kept).toEqual([1, 2]);
|
||||
expect(result.created.length).toBe(1);
|
||||
expect(result.deleted).toEqual([]);
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shrinks 3→2 by deleting the third row + its line items', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
|
||||
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
|
||||
];
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
|
||||
const result = await invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
||||
installments: [
|
||||
{ percent: 40, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 60, trigger: 'after_event', offset_days: 7, label: 'B' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.kept).toEqual([1, 2]);
|
||||
expect(result.created).toEqual([]);
|
||||
expect(result.deleted).toEqual([3]);
|
||||
// Line items + invoice rows deleted on the trimmed sibling.
|
||||
expect(pickChainFor('invoice_line_items').del).toHaveBeenCalled();
|
||||
expect(pickChainFor('invoices').del).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Tests for invoiceService lock + state-transition guards.
|
||||
*
|
||||
* Focuses on the rules that protect tax/audit integrity:
|
||||
* - reissueInvoice refuses to act on `scheduled` (use Edit)
|
||||
* - reissueInvoice cancels + clones any other status
|
||||
* - releaseForDelivery refuses to act on non-pending_delivery
|
||||
* - recordPaymentCheckAction refuses already-used / expired tokens
|
||||
*
|
||||
* db is deep-mocked so the tests are deterministic and fast.
|
||||
*/
|
||||
|
||||
// Mock db chain: each table call returns a builder whose methods
|
||||
// chain (return `this`) until a terminal method (.first / .update /
|
||||
// .insert / .returning) resolves with the queued value.
|
||||
|
||||
const chains = [];
|
||||
function makeChain() {
|
||||
const c = {
|
||||
_firstValue: undefined,
|
||||
_updateResult: 1,
|
||||
_insertResult: [{ id: 999 }],
|
||||
_selectResult: [],
|
||||
_allRows: [],
|
||||
// knex chains are thenable — awaiting them runs the query and
|
||||
// resolves with the row set. We mirror that so callers can
|
||||
// `await trx('t').where(...).orderBy(...)` and get an array.
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||||
},
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereNotIn: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNull: jest.fn(function () { return this; }),
|
||||
whereNotNull: jest.fn(function () { return this; }),
|
||||
andWhere: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
limit: jest.fn(function () { return this; }),
|
||||
// select is both chainable (`.select('col').first()`) and awaitable
|
||||
// via the chain's `then` (`await q.select(...)` returns `_selectResult`).
|
||||
select: jest.fn(function () { return this; }),
|
||||
sum: jest.fn(function () { return this; }),
|
||||
count: jest.fn(function () { return this; }),
|
||||
clone: jest.fn(function () { return this; }),
|
||||
clearSelect: jest.fn(function () { return this; }),
|
||||
clearOrder: jest.fn(function () { return this; }),
|
||||
offset: jest.fn(function () { return this; }),
|
||||
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
|
||||
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
|
||||
insert: jest.fn(function () { return this; }),
|
||||
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
|
||||
del: jest.fn(function () { return Promise.resolve(1); }),
|
||||
onConflict: jest.fn(function () { return this; }),
|
||||
ignore: jest.fn(function () { return Promise.resolve(1); }),
|
||||
merge: jest.fn(function () { return Promise.resolve(1); }),
|
||||
increment: jest.fn(function () { return this; }),
|
||||
forUpdate: jest.fn(function () { return this; }),
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
};
|
||||
chains.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
const tableChains = {};
|
||||
function pickChainFor(name) {
|
||||
if (!tableChains[name]) tableChains[name] = makeChain();
|
||||
return tableChains[name];
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((name) => pickChainFor(name));
|
||||
// db.transaction(cb) runs the callback with a "trx" — for our
|
||||
// purposes the same chain factory works as trx.
|
||||
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
|
||||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
}
|
||||
|
||||
describe('invoiceService.reissueInvoice', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('throws USE_EDIT_INSTEAD when the source is still scheduled', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled' };
|
||||
await expect(invoiceService.reissueInvoice(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
|
||||
});
|
||||
|
||||
it('throws when the source invoice does not exist', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.reissueInvoice(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('cancels the original and creates a new row when status is sent', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'sent', customer_account_id: 5,
|
||||
currency: 'CHF', language: 'de', vat_rate: 7.7,
|
||||
shipping_amount_minor: 0, cc_pdf_email: null,
|
||||
business_bank_account_id: null, qr_format: null,
|
||||
payment_term_template_id: null, event_id: null,
|
||||
source_quote_id: null,
|
||||
};
|
||||
pickChainFor('customer_accounts')._firstValue = {
|
||||
id: 5, is_active: 1, feature_bills: 1,
|
||||
};
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
pickChainFor('app_settings')._firstValue = null;
|
||||
// document_sequences row used by claimNextSequence.
|
||||
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
|
||||
|
||||
const result = await invoiceService.reissueInvoice(1, 42);
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.replaces).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.createStorno', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('rejects when the source invoice does not exist (404)', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.createStorno(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('rejects when the source is still scheduled (drafts edit in place)', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice' };
|
||||
await expect(invoiceService.createStorno(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
|
||||
});
|
||||
|
||||
it('rejects when the source is already cancelled (no double-Storno)', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
|
||||
await expect(invoiceService.createStorno(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||||
});
|
||||
|
||||
it('rejects when asked to Storno a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
|
||||
await expect(invoiceService.createStorno(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||||
});
|
||||
|
||||
it('inserts a Storno row and flips the original on a sent invoice', async () => {
|
||||
// Original is `sent`, no line items, no event.
|
||||
const invoicesChain = pickChainFor('invoices');
|
||||
invoicesChain._firstValue = {
|
||||
id: 1, status: 'sent', kind: 'invoice', customer_account_id: 5,
|
||||
currency: 'CHF', language: 'de', vat_rate: 7.7,
|
||||
net_amount_minor: 30000, vat_amount_minor: 2310,
|
||||
total_amount_minor: 32310, shipping_amount_minor: 0,
|
||||
cc_pdf_email: null, event_id: null,
|
||||
};
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
pickChainFor('app_settings')._firstValue = null;
|
||||
// document_sequences row used by claimNextSequence.
|
||||
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
|
||||
|
||||
const stornoId = await invoiceService.createStorno(1, 42);
|
||||
expect(stornoId).toBeDefined();
|
||||
|
||||
// The mock chain's .update() is called twice on `invoices`:
|
||||
// 1) `.insert(...).returning('id')` for the Storno row
|
||||
// 2) `.update({status:'cancelled', cancellation_storno_id})` on the original
|
||||
// We just verify the helpers were exercised on the right table.
|
||||
expect(invoicesChain.insert).toHaveBeenCalled();
|
||||
expect(invoicesChain.update).toHaveBeenCalled();
|
||||
// The Storno insert payload should carry kind='storno' and
|
||||
// negated row-level totals. Inspect the first insert call's
|
||||
// payload to confirm.
|
||||
const insertedRow = invoicesChain.insert.mock.calls[0][0];
|
||||
expect(insertedRow.kind).toBe('storno');
|
||||
expect(insertedRow.net_amount_minor).toBe(-30000);
|
||||
expect(insertedRow.vat_amount_minor).toBe(-2310);
|
||||
expect(insertedRow.total_amount_minor).toBe(-32310);
|
||||
expect(insertedRow.cancels_invoice_id).toBe(1);
|
||||
expect(insertedRow.status).toBe('scheduled');
|
||||
// No payment instrument on a Storno.
|
||||
expect(insertedRow.business_bank_account_id).toBeNull();
|
||||
expect(insertedRow.qr_format).toBeNull();
|
||||
expect(insertedRow.payment_term_template_id).toBeNull();
|
||||
// Storni have no real payment due, but the schema's NOT NULL
|
||||
// constraint on due_date forces a value — we mirror issue_date.
|
||||
expect(insertedRow.due_date).toBe(insertedRow.issue_date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.cancelInvoice', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('rejects when the invoice does not exist (404)', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.cancelInvoice(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('rejects with ALREADY_CANCELLED when status is cancelled', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
|
||||
await expect(invoiceService.cancelInvoice(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||||
});
|
||||
|
||||
it('rejects with IS_STORNO when asked to cancel a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
|
||||
await expect(invoiceService.cancelInvoice(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||||
});
|
||||
|
||||
it('soft-cancels a scheduled (draft) invoice without generating a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice', event_id: null };
|
||||
const result = await invoiceService.cancelInvoice(1, 42);
|
||||
expect(result).toEqual({ cancelled: true, stornoId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.releaseForDelivery', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('refuses when status is not pending_delivery', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent' };
|
||||
await expect(invoiceService.releaseForDelivery(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
|
||||
});
|
||||
|
||||
it('404s when the invoice does not exist', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.releaseForDelivery(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.recordPaymentCheckAction', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('rejects invalid actions', async () => {
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'abc', action: 'foo',
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('404s when the token is not on file', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = null;
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'unpaid',
|
||||
})).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('410s + TOKEN_ALREADY_USED when the row has used_at set', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: new Date(),
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'unpaid',
|
||||
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_ALREADY_USED' });
|
||||
});
|
||||
|
||||
it('410s + TOKEN_EXPIRED when the row is past expires_at', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() - 86400000),
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'unpaid',
|
||||
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_EXPIRED' });
|
||||
});
|
||||
|
||||
it('rejects partial with amount <= 0', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'partial', amountMinor: 0,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects partial with amount > outstanding', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, total_amount_minor: 5000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'partial', amountMinor: 9999,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.queuePaymentCheckEmail', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('skips when invoice does not exist', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res).toEqual({ sent: false, reason: 'not_found' });
|
||||
});
|
||||
|
||||
it('skips when status is not sent/overdue', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'paid',
|
||||
};
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res.sent).toBe(false);
|
||||
expect(res.reason).toMatch(/wrong_status_paid/);
|
||||
});
|
||||
|
||||
it('respects the 24h throttle', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'overdue',
|
||||
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
|
||||
};
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res).toEqual({ sent: false, reason: 'throttled_24h' });
|
||||
});
|
||||
|
||||
it('bypasses the throttle when skipThrottle=true', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'overdue',
|
||||
customer_account_id: 5,
|
||||
created_by_admin_id: 42,
|
||||
total_amount_minor: 10000,
|
||||
currency: 'CHF',
|
||||
language: 'de',
|
||||
reminder_level: 0,
|
||||
due_date: '2026-05-01',
|
||||
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
|
||||
event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, email: 'c@example.com', display_name: 'Test' };
|
||||
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1, { skipThrottle: true });
|
||||
expect(res.sent).toBe(true);
|
||||
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Smoke tests for invoiceService's primary flows ahead of the god-file
|
||||
* decomposition — createInvoice happy path (incl. the line-item
|
||||
* totals/VAT math), list/get reads, and the status-transition guards
|
||||
* on cancelInvoice / releaseForDelivery.
|
||||
*
|
||||
* Uses the same deep-mocked db pattern as
|
||||
* invoiceService.installmentPlan.test.js — chains are queued per table
|
||||
* and assertions probe insert/update call shapes rather than SQL.
|
||||
*/
|
||||
|
||||
const chains = [];
|
||||
function makeChain() {
|
||||
const c = {
|
||||
_firstValue: undefined,
|
||||
_updateResult: 1,
|
||||
_insertResult: [{ id: 999 }],
|
||||
_selectResult: [],
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||||
},
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNull: jest.fn(function () { return this; }),
|
||||
whereNotNull: jest.fn(function () { return this; }),
|
||||
andWhere: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
limit: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return this; }),
|
||||
sum: jest.fn(function () { return this; }),
|
||||
count: jest.fn(function () { return this; }),
|
||||
clone: jest.fn(function () { return this; }),
|
||||
clearSelect: jest.fn(function () { return this; }),
|
||||
clearOrder: jest.fn(function () { return this; }),
|
||||
offset: jest.fn(function () { return this; }),
|
||||
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
|
||||
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
|
||||
insert: jest.fn(function () { return this; }),
|
||||
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
|
||||
del: jest.fn(function () { return Promise.resolve(1); }),
|
||||
onConflict: jest.fn(function () { return this; }),
|
||||
ignore: jest.fn(function () { return Promise.resolve(1); }),
|
||||
merge: jest.fn(function () { return Promise.resolve(1); }),
|
||||
increment: jest.fn(function () { return this; }),
|
||||
forUpdate: jest.fn(function () { return this; }),
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
};
|
||||
chains.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
const tableChains = {};
|
||||
function pickChainFor(name) {
|
||||
if (!tableChains[name]) tableChains[name] = makeChain();
|
||||
return tableChains[name];
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((name) => pickChainFor(name));
|
||||
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
||||
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
|
||||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/documentSequences', () => {
|
||||
const claimNextSequence = jest.fn(async () => 42);
|
||||
// Delegates to the claimNextSequence mock so call-count assertions
|
||||
// below keep observing sequence claims.
|
||||
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
|
||||
const seq = await claimNextSequence(kind, 2026, trx);
|
||||
return `R-2026-${String(seq).padStart(4, '0')}`;
|
||||
});
|
||||
return { claimNextSequence, nextDocumentNumber };
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
jest.clearAllMocks();
|
||||
}
|
||||
|
||||
const activeCustomer = {
|
||||
id: 5, is_active: 1, feature_bills: 1,
|
||||
billing_cadence: 'per_event', preferred_language: 'de',
|
||||
};
|
||||
|
||||
describe('createInvoice — happy path + totals', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('creates a single invoice with a claimed sequence number and computed totals/VAT', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||||
pickChainFor('invoices')._insertResult = [{ id: 777 }];
|
||||
|
||||
const result = await invoiceService.createInvoice({
|
||||
customerAccountId: 5,
|
||||
vatRate: 8.1,
|
||||
lineItems: [
|
||||
// 2 × 100.00 = 200.00
|
||||
{ position: 1, description: 'Shoot', quantity: 2, unit_price_minor: 10000 },
|
||||
// 50.00 with 10% discount = 45.00
|
||||
{ position: 2, description: 'Discounted extra', quantity: 1, unit_price_minor: 5000, discount_percent: 10 },
|
||||
// Parent header — total auto-resolves from priced sub-items (350.00)
|
||||
{ position: 3, description: 'Package', quantity: 1, unit_price_minor: 0 },
|
||||
{ position: 4, description: 'Camera', quantity: 1, unit_price_minor: 15000, parent_position: 3 },
|
||||
{ position: 5, description: 'Lens', quantity: 1, unit_price_minor: 20000, parent_position: 3 },
|
||||
],
|
||||
}, 1);
|
||||
|
||||
expect(result.invoiceIds).toEqual([777]);
|
||||
|
||||
// Net = 20000 + 4500 + 35000 (resolved parent) — sub-items must NOT
|
||||
// double-count. VAT = round(59500 × 8.1%) = 4820.
|
||||
expect(pickChainFor('invoices').insert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
invoice_number: 'R-2026-0042',
|
||||
customer_account_id: 5,
|
||||
currency: 'CHF',
|
||||
status: 'scheduled',
|
||||
net_amount_minor: 59500,
|
||||
vat_rate: 8.1,
|
||||
vat_amount_minor: 4820,
|
||||
shipping_amount_minor: 0,
|
||||
total_amount_minor: 64320,
|
||||
installment_total: 1,
|
||||
}));
|
||||
// Exactly one sequence number claimed for a single-row create.
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).toHaveBeenCalledTimes(1);
|
||||
// Line items landed in invoice_line_items.
|
||||
expect(pickChainFor('invoice_line_items').insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('409s on a deactivated customer before touching the sequence', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer, is_active: 0 };
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: 5, vatRate: 0, lineItems: [],
|
||||
}, 1)).rejects.toMatchObject({ statusCode: 409 });
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400s + INVOICE_TOTAL_NEGATIVE when discounts push the total below zero', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: 5,
|
||||
vatRate: 7.7,
|
||||
lineItems: [
|
||||
{ position: 1, description: 'Shoot', quantity: 1, unit_price_minor: 5000 },
|
||||
{ position: 2, description: 'Rabatt', quantity: 1, unit_price_minor: -8000 },
|
||||
],
|
||||
}, 1)).rejects.toMatchObject({ statusCode: 400, code: 'INVOICE_TOTAL_NEGATIVE' });
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listInvoices / getInvoiceById — read paths (smoke)', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('lists invoices with total + pagination echo', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, invoice_number: 'R-2026-0001' },
|
||||
{ id: 2, invoice_number: 'R-2026-0002' },
|
||||
];
|
||||
pickChainFor('invoices')._firstValue = { total: 7 };
|
||||
|
||||
const result = await invoiceService.listInvoices({ page: 2, pageSize: 10 });
|
||||
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.total).toBe(7);
|
||||
expect(result.page).toBe(2);
|
||||
expect(result.pageSize).toBe(10);
|
||||
expect(pickChainFor('invoices').offset).toHaveBeenCalledWith(10);
|
||||
expect(pickChainFor('invoices').limit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('getInvoiceById returns { invoice, lineItems, payments } when found', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 3, invoice_number: 'R-2026-0003' };
|
||||
pickChainFor('invoice_line_items as li')._selectResult = [
|
||||
{ id: 30, position: 1, description: 'Shoot' },
|
||||
];
|
||||
pickChainFor('invoice_payment_log')._selectResult = [];
|
||||
|
||||
const result = await invoiceService.getInvoiceById(3);
|
||||
expect(result.invoice).toMatchObject({ id: 3, invoice_number: 'R-2026-0003' });
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.payments).toEqual([]);
|
||||
});
|
||||
|
||||
it('getInvoiceById returns null for an unknown id', async () => {
|
||||
pickChainFor('invoices')._firstValue = undefined;
|
||||
await expect(invoiceService.getInvoiceById(404)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status transitions — cancelInvoice / releaseForDelivery guards', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('soft-cancels a scheduled (never-issued) invoice without a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 9, status: 'scheduled', kind: 'invoice', event_id: null,
|
||||
};
|
||||
const result = await invoiceService.cancelInvoice(9, 1);
|
||||
expect(result).toEqual({ cancelled: true, stornoId: null });
|
||||
expect(pickChainFor('invoices').update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'cancelled' })
|
||||
);
|
||||
});
|
||||
|
||||
it('409s + ALREADY_CANCELLED on a second cancel', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 9, status: 'cancelled', kind: 'invoice',
|
||||
};
|
||||
await expect(invoiceService.cancelInvoice(9, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||||
});
|
||||
|
||||
it('409s + IS_STORNO when trying to cancel a Storno document', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 10, status: 'sent', kind: 'storno',
|
||||
};
|
||||
await expect(invoiceService.cancelInvoice(10, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||||
});
|
||||
|
||||
it('releaseForDelivery 409s + NOT_PENDING_DELIVERY on a non-pending invoice', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 11, status: 'sent', kind: 'invoice',
|
||||
};
|
||||
await expect(invoiceService.releaseForDelivery(11, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Tests for ledgerService (Accounting Layer A).
|
||||
*
|
||||
* Two layers:
|
||||
* 1. Pure helpers (rateKey, csvEscape, minorToDecimal).
|
||||
* 2. buildPostings + exportPostings — db chain + appSettings mocked so we can
|
||||
* feed canned invoices/inbound/expenses and assert the Buchungssätze +
|
||||
* the per-tool CSV shapes.
|
||||
*/
|
||||
|
||||
// ----- canned data per table ------------------------------------------
|
||||
let accountsRows = [];
|
||||
let vatRows = [];
|
||||
let invoiceRows = [];
|
||||
let inboundRows = [];
|
||||
let expenseRows = [];
|
||||
|
||||
function makeChain(rows) {
|
||||
const c = {
|
||||
_rows: rows,
|
||||
then(onR, onJ) { return Promise.resolve(this._rows).then(onR, onJ); },
|
||||
leftJoin() { return this; },
|
||||
where() { return this; },
|
||||
whereNot() { return this; },
|
||||
whereIn() { return this; },
|
||||
whereNotIn() { return this; },
|
||||
whereBetween() { return this; },
|
||||
whereRaw() { return this; },
|
||||
orderBy() { return this; },
|
||||
orderByRaw() { return this; },
|
||||
modify(cb) { if (typeof cb === 'function') cb(this); return this; },
|
||||
select() { return Promise.resolve(this._rows); },
|
||||
first() { return Promise.resolve(this._rows[0]); },
|
||||
};
|
||||
return c;
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((table) => {
|
||||
switch (table) {
|
||||
case 'ledger_accounts': return makeChain(accountsRows);
|
||||
case 'vat_codes': return makeChain(vatRows);
|
||||
case 'invoices': return makeChain(invoiceRows);
|
||||
case 'inbound_documents': return makeChain(inboundRows);
|
||||
case 'expenses': return makeChain(expenseRows);
|
||||
default: return makeChain([]);
|
||||
}
|
||||
});
|
||||
mockDbFn.raw = (s) => s;
|
||||
mockDbFn.schema = {
|
||||
hasTable: jest.fn(async () => true),
|
||||
hasColumn: jest.fn(async () => true),
|
||||
};
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: mockDbFn, withRetry: async (fn) => fn() }));
|
||||
|
||||
const SETTINGS = {
|
||||
ledger_account_debitoren: '1100',
|
||||
ledger_account_kreditoren: '2000',
|
||||
ledger_account_default_revenue: '3400',
|
||||
ledger_account_default_expense: '6700',
|
||||
ledger_account_mileage: '6200',
|
||||
ledger_account_per_diem: '6640',
|
||||
ledger_account_rebilled_revenue: '3940',
|
||||
ledger_vat_map: { domestic: 'VST81', reverse_charge_service: 'BZ', foreign_vat_non_reclaimable: 'VST00', import_goods: 'VST81' },
|
||||
ledger_output_vat_map: { '8.1': 'UN81', '2.6': 'UN26', '3.8': 'UN38', '0': 'UN00' },
|
||||
};
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async (key, def) => (key in SETTINGS ? SETTINGS[key] : def)),
|
||||
}));
|
||||
|
||||
const ledgerService = require('../../src/services/ledgerService');
|
||||
const { rateKey, csvEscape, minorToDecimal } = ledgerService._internal;
|
||||
|
||||
beforeEach(() => {
|
||||
accountsRows = [
|
||||
{ id: 1, number: '1100', name: 'Debitoren', type: 'asset' },
|
||||
{ id: 2, number: '3400', name: 'Dienstleistungsertrag', type: 'revenue' },
|
||||
{ id: 3, number: '2000', name: 'Kreditoren', type: 'liability' },
|
||||
{ id: 4, number: '6570', name: 'Informatikaufwand', type: 'expense' },
|
||||
{ id: 5, number: '6200', name: 'Fahrzeugaufwand', type: 'expense' },
|
||||
{ id: 6, number: '6700', name: 'Sonstiger Betriebsaufwand', type: 'expense' },
|
||||
];
|
||||
vatRows = [{ id: 9, code: 'UN81', rate: 8.1, direction: 'output', account_id: null }];
|
||||
invoiceRows = [];
|
||||
inboundRows = [];
|
||||
expenseRows = [];
|
||||
});
|
||||
|
||||
// ----- pure helpers ----------------------------------------------------
|
||||
describe('rateKey', () => {
|
||||
it('normalises rate to the output-map key', () => {
|
||||
expect(rateKey(8.1)).toBe('8.1');
|
||||
expect(rateKey(8.10)).toBe('8.1');
|
||||
expect(rateKey('2.60')).toBe('2.6');
|
||||
expect(rateKey(0)).toBe('0');
|
||||
expect(rateKey(null)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvEscape / minorToDecimal', () => {
|
||||
it('quotes + doubles inner quotes', () => {
|
||||
expect(csvEscape('a,b')).toBe('"a,b"');
|
||||
expect(csvEscape('he said "hi"')).toBe('"he said ""hi"""');
|
||||
expect(csvEscape(null)).toBe('""');
|
||||
});
|
||||
it('renders minor units as 2dp', () => {
|
||||
expect(minorToDecimal(10810)).toBe('108.10');
|
||||
expect(minorToDecimal(0)).toBe('0.00');
|
||||
expect(minorToDecimal(null)).toBe('0.00');
|
||||
});
|
||||
});
|
||||
|
||||
// ----- buildPostings ---------------------------------------------------
|
||||
describe('buildPostings', () => {
|
||||
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
|
||||
|
||||
it('books a revenue invoice as Dr Debitoren / Cr Ertrag with the output VAT code', async () => {
|
||||
invoiceRows = [{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1,
|
||||
net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
|
||||
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
|
||||
}];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings).toHaveLength(1);
|
||||
expect(postings[0]).toMatchObject({
|
||||
debitAccount: '1100', debitName: 'Debitoren',
|
||||
creditAccount: '3400', creditName: 'Dienstleistungsertrag',
|
||||
grossMinor: 10810, netMinor: 10000, vatMinor: 810,
|
||||
vatCode: 'UN81', source: 'revenue', eventName: 'Wedding A',
|
||||
});
|
||||
});
|
||||
|
||||
it('books an incoming invoice as Dr Aufwand(category) / Cr Kreditoren with the input VAT code', async () => {
|
||||
inboundRows = [{
|
||||
id: 5, invoice_number: 'L-77', invoice_date: '2026-01-12', created_at: '2026-01-13 09:00:00',
|
||||
supplier_name: 'Lab AG', tax_treatment: 'domestic',
|
||||
net_amount_minor: 2000, vat_amount_minor: 162, total_amount_minor: 2162,
|
||||
event_id: 7, cat_account_id: 4, event_name: 'Wedding A',
|
||||
}];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings).toHaveLength(1);
|
||||
expect(postings[0]).toMatchObject({
|
||||
debitAccount: '6570', creditAccount: '2000',
|
||||
grossMinor: 2162, netMinor: 2000, vatMinor: 162,
|
||||
vatCode: 'VST81', source: 'incoming', eventName: 'Wedding A',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the kind default account for a category-less mileage expense', async () => {
|
||||
expenseRows = [{
|
||||
id: 9, created_at: '2026-02-01 12:00:00', kind: 'mileage', supplier_name: null, description: 'Drive',
|
||||
tax_treatment: 'foreign_vat_non_reclaimable', event_id: null,
|
||||
original_amount_minor: null, chf_amount_minor: 5000,
|
||||
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null,
|
||||
}];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings).toHaveLength(1);
|
||||
expect(postings[0]).toMatchObject({
|
||||
debitAccount: '6200', creditAccount: '2000',
|
||||
grossMinor: 5000, vatMinor: 0,
|
||||
vatCode: 'VST00', source: 'expense', eventName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts the combined journal chronologically across all sources', async () => {
|
||||
invoiceRows = [{ id: 1, invoice_number: 'R1', issue_date: '2026-02-20', vat_rate: 8.1, net_amount_minor: 100, vat_amount_minor: 8, total_amount_minor: 108, customer_company_name: 'A' }];
|
||||
inboundRows = [{ id: 5, invoice_number: 'L1', invoice_date: '2026-01-05', created_at: '2026-01-05', supplier_name: 'Lab', tax_treatment: 'domestic', net_amount_minor: 50, vat_amount_minor: 4, total_amount_minor: 54, event_id: null, cat_account_id: null }];
|
||||
expenseRows = [{ id: 9, created_at: '2026-01-30', kind: 'amount', description: 'x', tax_treatment: 'domestic', event_id: null, chf_amount_minor: 200, net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null }];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings.map((p) => p.source)).toEqual(['incoming', 'expense', 'revenue']);
|
||||
});
|
||||
|
||||
it('requires from/to/currency', async () => {
|
||||
await expect(ledgerService.buildPostings({})).rejects.toThrow(/from.+to/);
|
||||
await expect(ledgerService.buildPostings({ from: '2026-01-01', to: '2026-03-31' })).rejects.toThrow(/currency/);
|
||||
});
|
||||
});
|
||||
|
||||
// ----- exportPostings --------------------------------------------------
|
||||
describe('exportPostings', () => {
|
||||
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
|
||||
beforeEach(() => {
|
||||
invoiceRows = [{ id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810, customer_company_name: 'ACME' }];
|
||||
});
|
||||
|
||||
it('generic format carries all human-friendly columns', async () => {
|
||||
const { content, filename, count } = await ledgerService.exportPostings({ ...period, format: 'generic' });
|
||||
const [header, row] = content.trim().split('\r\n');
|
||||
expect(count).toBe(1);
|
||||
expect(header).toContain('DebitAccountName');
|
||||
expect(header).toContain('NetAmount');
|
||||
expect(header).toContain('VatCode');
|
||||
expect(row).toContain('1100');
|
||||
expect(row).toContain('108.10'); // gross 2dp
|
||||
expect(filename).toMatch(/_generic\.csv$/);
|
||||
});
|
||||
|
||||
it('banana format is a TAB-separated .txt with Banana column names', async () => {
|
||||
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana' });
|
||||
const header = content.split('\r\n')[0];
|
||||
// Banana's "Text file with column headers" import wants TAB-separated,
|
||||
// unquoted values in a .txt — not a comma CSV.
|
||||
expect(header).toBe('Date\tDoc\tDescription\tAccountDebit\tAccountCredit\tAmount\tVatCode');
|
||||
expect(content.split('\r\n')[1]).toContain('\t');
|
||||
expect(content).not.toContain('"');
|
||||
expect(filename).toMatch(/_banana\.txt$/);
|
||||
expect(contentType).toMatch(/text\/plain/);
|
||||
});
|
||||
|
||||
it('banana_ie format is Income & Expense columns, tab-separated .txt', async () => {
|
||||
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana_ie' });
|
||||
const [header, row] = content.trim().split('\r\n');
|
||||
expect(header).toBe('Date\tDoc\tDescription\tIncome\tExpenses\tCategory\tVatCode');
|
||||
// The mock period holds one revenue posting (gross 108.10) → Income filled,
|
||||
// Expenses empty, Category = the revenue account.
|
||||
const cells = row.split('\t');
|
||||
expect(cells[3]).toBe('108.10'); // Income
|
||||
expect(cells[4]).toBe(''); // Expenses
|
||||
expect(cells[5]).not.toBe(''); // Category (revenue account)
|
||||
expect(filename).toMatch(/_banana_ie\.txt$/);
|
||||
expect(contentType).toMatch(/text\/plain/);
|
||||
});
|
||||
|
||||
it('formats a Postgres Date object as yyyy-mm-dd (not "Thu Jan ...")', async () => {
|
||||
// PG returns DATE columns as JS Date objects (SQLite returns strings); the
|
||||
// export must still emit an ISO date, or Banana rejects it and the Date
|
||||
// column imports empty.
|
||||
invoiceRows = [{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: new Date(2026, 0, 10),
|
||||
vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
|
||||
customer_company_name: 'ACME',
|
||||
}];
|
||||
const { content } = await ledgerService.exportPostings({ ...period, format: 'banana' });
|
||||
const dateCell = content.split('\r\n')[1].split('\t')[0];
|
||||
expect(dateCell).toBe('2026-01-10');
|
||||
});
|
||||
|
||||
it('bexio format includes tax_code + currency', async () => {
|
||||
const { content } = await ledgerService.exportPostings({ ...period, format: 'bexio' });
|
||||
const header = content.split('\r\n')[0];
|
||||
expect(header).toContain('tax_code');
|
||||
expect(header).toContain('currency');
|
||||
});
|
||||
|
||||
it('unknown format falls back to generic', async () => {
|
||||
const { filename } = await ledgerService.exportPostings({ ...period, format: 'nope' });
|
||||
expect(filename).toMatch(/_generic\.csv$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Unit tests for mfaService — admin TOTP MFA (#738).
|
||||
*
|
||||
* Pure unit: no DB, no Express. Exercises the crypto/verification surface
|
||||
* directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt
|
||||
* derivation has key material (the service derives the AES key from
|
||||
* MFA_ENCRYPTION_KEY, falling back to JWT_SECRET).
|
||||
*/
|
||||
|
||||
// Must be set BEFORE the service is required — the key is derived lazily per
|
||||
// call, but keep it explicit and stable so encrypt/decrypt round-trips.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret';
|
||||
delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET
|
||||
|
||||
const { authenticator } = require('otplib');
|
||||
const mfaService = require('../../src/services/mfaService');
|
||||
|
||||
describe('mfaService — secret encryption (AES-256-GCM)', () => {
|
||||
it('round-trips encrypt → decrypt to the original secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const blob = mfaService.encryptSecret(secret);
|
||||
expect(blob).toEqual(expect.any(String));
|
||||
expect(blob).not.toContain(secret); // stored form is not plaintext
|
||||
expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext
|
||||
expect(mfaService.decryptSecret(blob)).toBe(secret);
|
||||
});
|
||||
|
||||
it('produces a different ciphertext each time (random IV) but decrypts identically', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const a = mfaService.encryptSecret(secret);
|
||||
const b = mfaService.encryptSecret(secret);
|
||||
expect(a).not.toBe(b);
|
||||
expect(mfaService.decryptSecret(a)).toBe(secret);
|
||||
expect(mfaService.decryptSecret(b)).toBe(secret);
|
||||
});
|
||||
|
||||
it('throws when decrypting a malformed blob (wrong segment count)', () => {
|
||||
expect(() => mfaService.decryptSecret('garbage')).toThrow();
|
||||
expect(() => mfaService.decryptSecret('only.two')).toThrow();
|
||||
});
|
||||
|
||||
it('throws when the auth tag / ciphertext is tampered with', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.');
|
||||
// Flip a character in the ciphertext → GCM auth check must fail.
|
||||
const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA');
|
||||
expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — TOTP verification', () => {
|
||||
it('accepts a freshly generated code for the plaintext secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates whitespace in the submitted code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotp(wrong, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty inputs rather than throwing', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
expect(mfaService.verifyTotp('', secret)).toBe(false);
|
||||
expect(mfaService.verifyTotp('123456', '')).toBe(false);
|
||||
expect(mfaService.verifyTotp(null, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifies through the encrypted blob (verifyTotpEncrypted)', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const stored = mfaService.encryptSecret(secret);
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true);
|
||||
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — otpauth URI / QR', () => {
|
||||
it('builds an otpauth:// URI containing issuer, account and secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
expect(uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(uri).toContain(encodeURIComponent(mfaService.ISSUER));
|
||||
expect(uri).toContain(`secret=${secret}`);
|
||||
});
|
||||
|
||||
it('builds a PNG data-URL QR for the URI', async () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
const qr = await mfaService.buildQrDataUrl(uri);
|
||||
expect(qr).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — recovery codes', () => {
|
||||
it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(new Set(plain).size).toBe(10);
|
||||
expect(new Set(hashed).size).toBe(10);
|
||||
// Hashes are bcrypt, not the plaintext.
|
||||
hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/));
|
||||
plain.forEach((p) => expect(hashed).not.toContain(p));
|
||||
});
|
||||
|
||||
it('formats a raw code into 4-char groups', () => {
|
||||
expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij');
|
||||
});
|
||||
|
||||
it('consumes a valid recovery code once and removes it (single-use)', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const target = plain[3];
|
||||
|
||||
const first = await mfaService.consumeRecoveryCode(target, hashed);
|
||||
expect(first.matched).toBe(true);
|
||||
expect(first.remainingHashes).toHaveLength(9);
|
||||
|
||||
// Reusing the same code against the reduced set must now fail.
|
||||
const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes);
|
||||
expect(reuse.matched).toBe(false);
|
||||
expect(reuse.remainingHashes).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('matches case-insensitively and trims whitespace', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed);
|
||||
expect(res.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code and leaves the hash set unchanged', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('handles empty / missing input safely', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toBe(hashed);
|
||||
const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null);
|
||||
expect(noHashes.matched).toBe(false);
|
||||
expect(noHashes.remainingHashes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — parseRecoveryCodes', () => {
|
||||
it('parses a JSON string array', () => {
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
|
||||
});
|
||||
it('passes an already-array through', () => {
|
||||
expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
it('returns [] for null / garbage / non-array JSON', () => {
|
||||
expect(mfaService.parseRecoveryCodes(null)).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — isEnrolled coercion', () => {
|
||||
it('treats true / 1 / "1" as enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true);
|
||||
});
|
||||
it('treats false / 0 / null / missing as not enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false);
|
||||
expect(mfaService.isEnrolled({})).toBe(false);
|
||||
expect(mfaService.isEnrolled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Tests for the createBaseDocument + getPageMetrics helpers — the
|
||||
* shared PDF factory used by quote/invoice rendering AND by the
|
||||
* upcoming tax-report renderer. These verify orientation handling
|
||||
* and font defaults without touching DB or filesystem.
|
||||
*/
|
||||
const pdfService = require('../../src/services/pdfService');
|
||||
|
||||
describe('getPageMetrics', () => {
|
||||
it('returns portrait A4 metrics by default', () => {
|
||||
const p = pdfService.getPageMetrics();
|
||||
expect(p.width).toBeCloseTo(595.28, 1);
|
||||
expect(p.height).toBeCloseTo(841.89, 1);
|
||||
expect(p.contentWidth).toBeCloseTo(515.28, 1);
|
||||
});
|
||||
|
||||
it('returns portrait when orientation is "portrait"', () => {
|
||||
const p = pdfService.getPageMetrics('portrait');
|
||||
expect(p.width).toBeLessThan(p.height);
|
||||
});
|
||||
|
||||
it('returns landscape A4 metrics (width > height) when orientation is "landscape"', () => {
|
||||
const p = pdfService.getPageMetrics('landscape');
|
||||
expect(p.width).toBeCloseTo(841.89, 1);
|
||||
expect(p.height).toBeCloseTo(595.28, 1);
|
||||
expect(p.contentWidth).toBeCloseTo(761.89, 1);
|
||||
expect(p.width).toBeGreaterThan(p.height);
|
||||
});
|
||||
|
||||
it('ignores unknown orientation values (falls back to portrait)', () => {
|
||||
const p = pdfService.getPageMetrics('upside-down');
|
||||
expect(p.width).toBeLessThan(p.height);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBaseDocument', () => {
|
||||
it('returns a PDFKit doc, page metrics, and logical font names by default', () => {
|
||||
const { doc, page, fonts } = pdfService.createBaseDocument();
|
||||
expect(doc).toBeDefined();
|
||||
expect(typeof doc.on).toBe('function');
|
||||
expect(typeof doc.font).toBe('function');
|
||||
expect(page.width).toBeCloseTo(595.28, 1); // portrait by default
|
||||
expect(fonts).toEqual({ body: 'Helvetica', bold: 'Helvetica-Bold' });
|
||||
});
|
||||
|
||||
it('produces a landscape document when orientation is "landscape"', () => {
|
||||
const { doc, page } = pdfService.createBaseDocument({ orientation: 'landscape' });
|
||||
expect(page.width).toBeGreaterThan(page.height);
|
||||
// PDFKit stores the active page dims on doc.page.
|
||||
expect(doc.page.width).toBeCloseTo(841.89, 1);
|
||||
expect(doc.page.height).toBeCloseTo(595.28, 1);
|
||||
});
|
||||
|
||||
it('produces a buffered PDF of non-zero size with the PDF magic header', async () => {
|
||||
const { doc } = pdfService.createBaseDocument({ orientation: 'landscape' });
|
||||
const chunks = [];
|
||||
doc.on('data', (c) => chunks.push(c));
|
||||
const ended = new Promise((resolve) => doc.on('end', resolve));
|
||||
doc.text('hello', 40, 40);
|
||||
doc.end();
|
||||
await ended;
|
||||
const buf = Buffer.concat(chunks);
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('keeps Helvetica fonts when the issuer has no custom TTF path', () => {
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontTtfPath: null },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('falls back to Helvetica when the custom TTF path does not exist', () => {
|
||||
// No exception, no logger.error blow-up — just silent fallback.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontTtfPath: '/nonexistent/path/font.ttf' },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('registers a bundled font family when pdfFontFamily is set', () => {
|
||||
// Migration-121 dropdown path. Inter ships 400 + 600 + 700 under
|
||||
// backend/assets/fonts/Inter/, so the resolver should pick 400
|
||||
// for body and 700 for bold.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontFamily: 'Inter' },
|
||||
});
|
||||
expect(fonts.body).toBe('crm-body');
|
||||
expect(fonts.bold).toBe('crm-bold');
|
||||
});
|
||||
|
||||
it('falls back to Helvetica when pdfFontFamily names a non-existent directory', () => {
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontFamily: 'NotARealFamily' },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('strips path-traversal characters from pdfFontFamily', () => {
|
||||
// Defence in depth: the sanitiser keeps only [A-Za-z0-9_-].
|
||||
// "../../etc/passwd" becomes "etcpasswd" → no such font dir → fallback.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontFamily: '../../etc/passwd' },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('prefers pdfFontTtfPath over pdfFontFamily when both are set', () => {
|
||||
// The explicit upload is the priority-1 override. When the upload
|
||||
// path is unusable (file missing) the family is consulted next.
|
||||
// Here we set BOTH to invalid values and confirm Helvetica fallback
|
||||
// — what matters is that the family DIDN'T get registered while a
|
||||
// (failed) explicit path was being evaluated.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: {
|
||||
pdfFontTtfPath: '/nonexistent/path/font.ttf',
|
||||
pdfFontFamily: 'Inter',
|
||||
},
|
||||
});
|
||||
// pdfFontTtfPath misses → falls through to pdfFontFamily → Inter
|
||||
// registers successfully. crm-body / crm-bold confirm a custom
|
||||
// font won.
|
||||
expect(fonts.body).toBe('crm-body');
|
||||
expect(fonts.bold).toBe('crm-bold');
|
||||
});
|
||||
|
||||
it('forwards PDF info metadata (Title, Author) to the document', () => {
|
||||
const { doc } = pdfService.createBaseDocument({
|
||||
info: { Title: 'Tax Report 2026', Author: 'picpeak' },
|
||||
});
|
||||
// PDFKit copies these onto doc.info during construction.
|
||||
expect(doc.info.Title).toBe('Tax Report 2026');
|
||||
expect(doc.info.Author).toBe('picpeak');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exported letterhead helper', () => {
|
||||
it('exposes drawIssuerBlock for reuse by non-quote/invoice renderers', () => {
|
||||
expect(typeof pdfService.drawIssuerBlock).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// Storno rendering — smoke-tests that exercise the kind='storno'
|
||||
// branch in renderInvoiceToBuffer. We can't search the PDF buffer
|
||||
// directly for German strings because PDFKit Flate-compresses
|
||||
// content streams, but we CAN verify the renderer:
|
||||
// - completes without throwing on a Storno-shaped context,
|
||||
// - produces a valid %PDF magic header,
|
||||
// - produces a SMALLER document than its invoice counterpart
|
||||
// (no payment block, no QR slip → fewer bytes), proving the
|
||||
// suppression branches actually fire.
|
||||
//
|
||||
// Visual correctness (title swap, reference line, signed totals) is
|
||||
// validated by manual review of a real Storno PDF; the renderer's
|
||||
// branch logic is unit-tested in service tests where the inputs
|
||||
// can be asserted directly.
|
||||
describe('renderInvoiceToBuffer — Storno branch', () => {
|
||||
function buildContext(overrides = {}) {
|
||||
return {
|
||||
locale: 'de',
|
||||
currency: 'CHF',
|
||||
issuer: { companyName: 'AcmeCo' },
|
||||
recipient: {
|
||||
companyName: 'KundenCo', addressLine1: 'Strasse 1',
|
||||
city: 'Bern', postalCode: '3000',
|
||||
},
|
||||
lineItems: [{
|
||||
quantity: 1, description: 'Photo session',
|
||||
unitPriceMinor: 30000, lineTotalMinor: 30000,
|
||||
parentLineItemId: null, parentPosition: null,
|
||||
}],
|
||||
totals: {
|
||||
netAmountMinor: 30000, vatRate: 7.7, vatAmountMinor: 2310,
|
||||
shippingAmountMinor: 0, totalAmountMinor: 32310,
|
||||
},
|
||||
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||
// Bank + payment term are part of the baseline invoice so the
|
||||
// payment block renders a real IBAN + Zahlungsbedingungen
|
||||
// section. The Storno branch suppresses this entirely, which
|
||||
// produces a visible byte-size delta.
|
||||
bank: {
|
||||
accountHolder: 'AcmeCo',
|
||||
iban: 'CH9300762011623852957',
|
||||
bic: 'POFICHBE',
|
||||
currency: 'CHF',
|
||||
},
|
||||
qrFormat: 'none',
|
||||
paymentTerm: { netDays: 30, skontoPercent: 2, skontoWithinDays: 10 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders a valid Storno PDF (kind="storno", negated totals)', async () => {
|
||||
const buf = await pdfService.renderInvoiceToBuffer(buildContext({
|
||||
totals: {
|
||||
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
|
||||
shippingAmountMinor: 0, totalAmountMinor: -32310,
|
||||
},
|
||||
doc: {
|
||||
kind: 'storno',
|
||||
invoiceNumber: 'R-2026-0080',
|
||||
issueDate: '2026-05-15',
|
||||
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||
},
|
||||
}));
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('produces a smaller PDF than the equivalent invoice (no payment block, no QR slip)', async () => {
|
||||
// Baseline: normal invoice with a payment block.
|
||||
const invoiceBuf = await pdfService.renderInvoiceToBuffer(buildContext());
|
||||
// Storno: same context but kind='storno' → payment block + QR
|
||||
// both suppressed. Payment block alone is ~80pt tall in the
|
||||
// PDF; its absence is reliably detectable as a byte-size delta.
|
||||
const stornoBuf = await pdfService.renderInvoiceToBuffer(buildContext({
|
||||
totals: {
|
||||
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
|
||||
shippingAmountMinor: 0, totalAmountMinor: -32310,
|
||||
},
|
||||
doc: {
|
||||
kind: 'storno',
|
||||
invoiceNumber: 'R-2026-0080',
|
||||
issueDate: '2026-05-15',
|
||||
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||
},
|
||||
}));
|
||||
expect(stornoBuf.length).toBeGreaterThan(0);
|
||||
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Pure-function tests for the PDF rendering helpers. These are the
|
||||
* functions that DON'T touch PDFKit / DB — formatting, salutation
|
||||
* routing, EPC payload construction.
|
||||
*
|
||||
* The helpers aren't directly exported from pdfService.js (it
|
||||
* exports renderQuoteToBuffer / renderInvoiceToBuffer); we reach
|
||||
* them via `_internal` which the module already exposes for tests.
|
||||
*/
|
||||
const pdfService = require('../../src/services/pdfService');
|
||||
const { formatMinor, formatDate, t } = pdfService._internal;
|
||||
|
||||
describe('formatMinor', () => {
|
||||
it('formats CHF cents with 2 decimals (123456 minor = 1234.56 major)', () => {
|
||||
// de-CH uses ’ (U+2019) as the thousands separator.
|
||||
expect(formatMinor(123456, 'CHF', 'de-CH')).toMatch(/1[’',\u2019]?234\.56/);
|
||||
});
|
||||
|
||||
it('formats large amounts with thousands separators', () => {
|
||||
// 12345600 minor units = 123,456.00 major; the separator
|
||||
// varies by locale (de-CH = U+2019, en-GB = ',').
|
||||
expect(formatMinor(12345600, 'CHF', 'de-CH')).toMatch(/123[’',\u2019]456\.00/);
|
||||
});
|
||||
|
||||
it('returns 0,00 for zero or null', () => {
|
||||
expect(formatMinor(0, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
|
||||
expect(formatMinor(null, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
|
||||
});
|
||||
|
||||
it('returns 2-decimal output regardless of locale', () => {
|
||||
expect(formatMinor(99, 'EUR', 'en-GB')).toMatch(/0[.,]99/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
// formatDate now respects ctx.dateFormat (object with `format`
|
||||
// key) — when omitted defaults to DD.MM.YYYY.
|
||||
it('defaults to DD.MM.YYYY when no format passed', () => {
|
||||
expect(formatDate('2026-04-19')).toBe('19.04.2026');
|
||||
});
|
||||
|
||||
it('honors the configured DD/MM/YYYY format', () => {
|
||||
expect(formatDate('2026-04-19', { format: 'DD/MM/YYYY' })).toBe('19/04/2026');
|
||||
});
|
||||
|
||||
it('honors the configured MM/DD/YYYY format', () => {
|
||||
expect(formatDate('2026-04-19', { format: 'MM/DD/YYYY' })).toBe('04/19/2026');
|
||||
});
|
||||
|
||||
it('honors ISO YYYY-MM-DD', () => {
|
||||
expect(formatDate('2026-04-19', { format: 'YYYY-MM-DD' })).toBe('2026-04-19');
|
||||
});
|
||||
|
||||
it('returns empty string on empty input', () => {
|
||||
expect(formatDate('')).toBe('');
|
||||
expect(formatDate(null)).toBe('');
|
||||
expect(formatDate(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string on invalid input rather than throwing', () => {
|
||||
expect(formatDate('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('accepts Date objects', () => {
|
||||
expect(formatDate(new Date('2026-04-19T12:00:00Z'))).toMatch(/^(19|20)\.0[34]\.2026$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('t (i18n lookup)', () => {
|
||||
it('returns the EN value for an EN-only locale', () => {
|
||||
expect(t('en', 'invoice_title')).toBe('Invoice');
|
||||
expect(t('en', 'quote_title')).toBe('Quote');
|
||||
});
|
||||
|
||||
it('returns the DE value for de locale', () => {
|
||||
expect(t('de', 'invoice_title')).toBe('Rechnung');
|
||||
expect(t('de', 'quote_title')).toBe('Angebot');
|
||||
});
|
||||
|
||||
it('falls back to EN for unknown locales', () => {
|
||||
expect(t('xx', 'invoice_title')).toBe('Invoice');
|
||||
});
|
||||
|
||||
it('substitutes named tokens like {percent}', () => {
|
||||
const out = t('en', 'skonto_phrase', { percent: 3, days: 5 });
|
||||
expect(out).toMatch(/3% discount if paid within 5 working days\./);
|
||||
});
|
||||
|
||||
it('falls back to EN when the key is missing on the requested locale', () => {
|
||||
// page_of is seeded on all locales — pick something that
|
||||
// exists on EN with a substitution.
|
||||
const out = t('zz', 'page_of', { current: 1, total: 3 });
|
||||
expect(out).toBe('Page 1 of 3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* exportAsTxt — issue #623 regression test.
|
||||
*
|
||||
* The admin UI labels the TXT export "for Lightroom search". Lightroom's
|
||||
* filename search wants ONE comma-separated line WITHOUT file extensions
|
||||
* (the gallery JPEGs may map to RAW files in the catalog). The frontend
|
||||
* now passes separator='comma' + include_extension=false for the TXT
|
||||
* format; this test pins the resulting shape so a future refactor can't
|
||||
* silently regress it back to the newline-separated form the bug reported.
|
||||
*
|
||||
* Also pins backward compatibility: a direct API caller passing no options
|
||||
* still gets the original newline-with-extension behaviour, so existing
|
||||
* integrations don't break.
|
||||
*/
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
jest.mock('../../src/services/xmpGenerator', () => ({ XmpGenerator: class {} }));
|
||||
|
||||
const { PhotoExportService } = require('../../src/services/photoExportService');
|
||||
const service = new PhotoExportService();
|
||||
|
||||
const PHOTOS = [
|
||||
{ original_filename: 'IMG_0001.jpg', filename: 'abc123.jpg' },
|
||||
{ original_filename: 'IMG_0002.JPEG', filename: 'def456.jpeg' },
|
||||
{ original_filename: 'shoot.final.tif', filename: 'ghi789.tif' },
|
||||
{ original_filename: null, filename: 'fallback.png' }, // null original → falls back to filename
|
||||
];
|
||||
|
||||
describe('exportAsTxt (issue #623)', () => {
|
||||
it('Lightroom mode: comma-joined, no extension, no space', () => {
|
||||
const result = service.exportAsTxt(PHOTOS, {
|
||||
separator: 'comma',
|
||||
include_extension: false,
|
||||
});
|
||||
expect(result.content).toBe('IMG_0001,IMG_0002,shoot.final,fallback');
|
||||
expect(result.contentType).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('backward compatible: no options → newline-joined with extensions', () => {
|
||||
const result = service.exportAsTxt(PHOTOS);
|
||||
expect(result.content).toBe(
|
||||
'IMG_0001.jpg\nIMG_0002.JPEG\nshoot.final.tif\nfallback.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('semicolon separator joins without a trailing space', () => {
|
||||
const result = service.exportAsTxt(PHOTOS, {
|
||||
separator: 'semicolon',
|
||||
include_extension: false,
|
||||
});
|
||||
expect(result.content).toBe('IMG_0001;IMG_0002;shoot.final;fallback');
|
||||
});
|
||||
|
||||
it('filename_format=picpeak uses photo.filename (hashed) instead of original', () => {
|
||||
const result = service.exportAsTxt(PHOTOS, {
|
||||
filename_format: 'picpeak',
|
||||
separator: 'comma',
|
||||
include_extension: false,
|
||||
});
|
||||
expect(result.content).toBe('abc123,def456,ghi789,fallback');
|
||||
});
|
||||
|
||||
it('extension stripping uses only the last segment ("a.b.c" → "a.b")', () => {
|
||||
// path.parse('shoot.final.tif').name === 'shoot.final' — Lightroom
|
||||
// catalogs that store basenames like "shoot.final" still match.
|
||||
const result = service.exportAsTxt(
|
||||
[{ original_filename: 'shoot.final.tif', filename: 'x.tif' }],
|
||||
{ separator: 'comma', include_extension: false },
|
||||
);
|
||||
expect(result.content).toBe('shoot.final');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user