diff --git a/.env.example b/.env.example index 197fbc56..33e4d6f5 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,11 @@ # 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) @@ -38,19 +41,28 @@ JWT_SECRET=your_very_long_random_jwt_secret_here # Database Configuration (PostgreSQL) DATABASE_CLIENT=pg DB_USER=picpeak +# 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_PASSWORD=your_secure_postgres_password_here DB_NAME=picpeak_prod # Redis Configuration +# 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 +#REDIS_PASSWORD=your_secure_redis_password_here -# Admin Account (initial setup) -ADMIN_USERNAME=admin -ADMIN_EMAIL=admin@yourdomain.com -ADMIN_PASSWORD=your_secure_admin_password_here +# 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 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..80eedc0b --- /dev/null +++ b/.github/FUNDING.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index ed0a0f18..568d1a50 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/security_vulnerability.md b/.github/ISSUE_TEMPLATE/security_vulnerability.md index 8ca805e6..56ee3800 100644 --- a/.github/ISSUE_TEMPLATE/security_vulnerability.md +++ b/.github/ISSUE_TEMPLATE/security_vulnerability.md @@ -9,7 +9,7 @@ assignees: '' ⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.** -Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** 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: diff --git a/.github/workflows/README-DOCKER.md b/.github/workflows/README-DOCKER.md index d06791c1..c04f9a8c 100644 --- a/.github/workflows/README-DOCKER.md +++ b/.github/workflows/README-DOCKER.md @@ -42,16 +42,16 @@ Once published, images can be pulled using: ```bash # Pull backend image -docker pull ghcr.io/the-luap/picpeak/backend:latest +docker pull ghcr.io/picpeak/picpeak/backend:latest # Pull frontend image -docker pull ghcr.io/the-luap/picpeak/frontend:latest +docker pull ghcr.io/picpeak/picpeak/frontend:latest # Pull specific version -docker pull ghcr.io/the-luap/picpeak/backend:v1.0.0 +docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0 # Pull for specific architecture -docker pull --platform linux/arm64 ghcr.io/the-luap/picpeak/backend:latest +docker pull --platform linux/arm64 ghcr.io/picpeak/picpeak/backend:latest ``` ### Using in Docker Compose @@ -61,14 +61,14 @@ version: '3.8' services: backend: - image: ghcr.io/the-luap/picpeak/backend:latest + image: ghcr.io/picpeak/picpeak/backend:latest environment: - NODE_ENV=production ports: - "3001:3000" frontend: - image: ghcr.io/the-luap/picpeak/frontend:latest + image: ghcr.io/picpeak/picpeak/frontend:latest ports: - "80:80" ``` @@ -86,7 +86,7 @@ spec: spec: containers: - name: backend - image: ghcr.io/the-luap/picpeak/backend:latest + image: ghcr.io/picpeak/picpeak/backend:latest imagePullPolicy: Always ``` @@ -149,8 +149,8 @@ If images aren't visible after successful push: ### View Packages Your Docker images are available at: -- Backend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Fbackend` -- Frontend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Ffrontend` +- 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 diff --git a/.github/workflows/bypass-size-gate.yml b/.github/workflows/bypass-size-gate.yml new file mode 100644 index 00000000..a4e742c2 --- /dev/null +++ b/.github/workflows/bypass-size-gate.yml @@ -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 } + }); diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index bf98c108..21bbe0aa 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1,7 +1,8 @@ name: Build and Push Docker Images # This workflow is triggered by: -# - Push to main/beta branches (builds 'latest'/'stable' or 'beta' tagged images) +# - 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) @@ -20,10 +21,10 @@ name: Build and Push Docker Images on: push: - branches: [ main, beta ] + branches: [ main, stable ] tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta) pull_request: - branches: [ main, beta ] + branches: [ main, stable ] release: types: [ published ] # Triggered when Release Please creates a release workflow_dispatch: @@ -37,6 +38,16 @@ on: - '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 @@ -134,7 +145,10 @@ jobs: platforms: ${{ matrix.platform }} labels: ${{ steps.meta-backend.outputs.labels }} cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }} - cache-to: type=gha,mode=max,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 }} @@ -242,7 +256,9 @@ jobs: - name: Determine build context id: context run: | - if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then + 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 @@ -267,9 +283,14 @@ jobs: 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 - type=raw,value=latest,enable={{is_default_branch}} - type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }} - type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }} + # `: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 @@ -360,7 +381,10 @@ jobs: platforms: ${{ matrix.platform }} labels: ${{ steps.meta-frontend.outputs.labels }} cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }} - cache-to: type=gha,mode=max,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 }} @@ -449,7 +473,9 @@ jobs: - name: Determine build context id: context run: | - if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then + 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 @@ -474,9 +500,14 @@ jobs: 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 - type=raw,value=latest,enable={{is_default_branch}} - type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }} - type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }} + # `: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 diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index f93ef6bf..78a3c282 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -16,24 +16,17 @@ name: Fresh-install smoke # 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, beta] - paths: - - 'backend/Dockerfile' - - 'backend/wait-for-db.sh' - - 'backend/migrations/**' - - 'backend/package*.json' - - 'docker-compose.production.yml' - - '.github/workflows/install-smoke.yml' + branches: [main, stable] pull_request: - branches: [main, beta] - paths: - - 'backend/Dockerfile' - - 'backend/wait-for-db.sh' - - 'backend/migrations/**' - - 'backend/package*.json' - - 'docker-compose.production.yml' - - '.github/workflows/install-smoke.yml' + branches: [main, stable] workflow_dispatch: permissions: @@ -61,7 +54,8 @@ jobs: load: true tags: picpeak-backend:smoke cache-from: type=gha,scope=install-smoke - cache-to: type=gha,mode=max,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 diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml new file mode 100644 index 00000000..cd57a897 --- /dev/null +++ b/.github/workflows/pr-title-lint.yml @@ -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 diff --git a/.github/workflows/release-please-beta.yml b/.github/workflows/release-please-beta.yml index a6356d91..9bfbb6aa 100644 --- a/.github/workflows/release-please-beta.yml +++ b/.github/workflows/release-please-beta.yml @@ -2,7 +2,7 @@ name: Release Please (Beta) on: push: - branches: [beta] + branches: [main] permissions: contents: write @@ -20,10 +20,48 @@ jobs: uses: googleapis/release-please-action@v4 id: release with: - token: ${{ secrets.GITHUB_TOKEN }} + # 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: beta + 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 }} @@ -35,3 +73,16 @@ jobs: 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 }} + diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f7a148f3..91b977a4 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -2,7 +2,7 @@ name: Release Please on: push: - branches: [main] + branches: [stable] permissions: contents: write @@ -20,10 +20,39 @@ jobs: uses: googleapis/release-please-action@v4 id: release with: - token: ${{ secrets.GITHUB_TOKEN }} + # 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: | @@ -34,3 +63,16 @@ jobs: 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 }} + diff --git a/.github/workflows/schema-drift.yml b/.github/workflows/schema-drift.yml index 8811d77b..f9d94716 100644 --- a/.github/workflows/schema-drift.yml +++ b/.github/workflows/schema-drift.yml @@ -30,20 +30,17 @@ name: Schema drift (#530) # 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, beta] - paths: - - 'backend/migrations/**' - - 'backend/src/database/db.js' - - 'backend/knexfile.js' - - '.github/workflows/schema-drift.yml' + branches: [main, stable] pull_request: - branches: [main, beta] - paths: - - 'backend/migrations/**' - - 'backend/src/database/db.js' - - 'backend/knexfile.js' - - '.github/workflows/schema-drift.yml' + branches: [main, stable] workflow_dispatch: permissions: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..b4342ae4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/.github/workflows/whatsnew-highlights.yml b/.github/workflows/whatsnew-highlights.yml new file mode 100644 index 00000000..fc902d30 --- /dev/null +++ b/.github/workflows/whatsnew-highlights.yml @@ -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 +# `` 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<> "$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/.*?\n*//is') + gh release edit "$TAG" --notes "$(printf '\n%s\n\n\n%s' "$BULLETS" "$BODY")" diff --git a/.gitignore b/.gitignore index 52f31cf6..956c3b23 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,6 @@ docker-compose.dev.yml # New layout development files new-layouts/ + +# Generated CRM/accounting documents (runtime) — never commit +backend/storage/business-docs/ diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 9d8ba6b2..2482b2f5 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "3.55.0-beta.0" + ".": "3.83.0-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f0f2776..ffbf0365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,764 @@ All notable changes to PicPeak will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [3.44.0](https://github.com/the-luap/picpeak/compare/v3.43.1...v3.44.0) (2026-05-27) +## [3.83.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.6-beta.0...v3.83.0-beta.0) (2026-07-08) + + +### Features + +* **messages:** create/select quote, contract, invoice, gallery from a message ([0dbf863](https://github.com/PicPeak/picpeak/commit/0dbf863f60b919560b766f78b107ebac9612bd9d)) +* **messages:** search bar + Archive/Delete with Archived & Deleted folders ([99d5996](https://github.com/PicPeak/picpeak/commit/99d5996561a2dcff2d431692d5bab5c7286d1f6f)) +* **messages:** unified Messages email client (flag-gated, default off) ([a71b9b5](https://github.com/PicPeak/picpeak/commit/a71b9b5ed721df17b61062ae3a2361d448c95cf7)) + + +### Bug Fixes + +* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) nits — server-side search, bare-email recipient, DE i18n ([1e08a4f](https://github.com/PicPeak/picpeak/commit/1e08a4fb156d34ee8ddff69b0a7612001aa6d67e)) +* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) review — escape reply sender (XSS), gate backend routes, exact customer match ([bb235e7](https://github.com/PicPeak/picpeak/commit/bb235e72e58359f55f8aeccf2f22c671584fdbd7)) +* **messages:** show the resolved customer's name in the doc-action modal ([2c5c1d5](https://github.com/PicPeak/picpeak/commit/2c5c1d561bbe567b9d7615e7c2d071d08bb6d63c)) + +## [3.82.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.5-beta.0...v3.82.6-beta.0) (2026-07-07) + + +### Bug Fixes + +* **workflows:** backfill existing invoices + anchor dunning grace to due date when enabled ([#750](https://github.com/PicPeak/picpeak/issues/750)) ([9596342](https://github.com/PicPeak/picpeak/commit/9596342d6a9ef107193cfc123487a8061f4a91ca)) +* **workflows:** scope dunning backfill to its own flow via targetWorkflowId ([da3a77d](https://github.com/PicPeak/picpeak/commit/da3a77dac40a892158167aec939a1458d488a951)) + +## [3.82.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.4-beta.0...v3.82.5-beta.0) (2026-07-07) + + +### Bug Fixes + +* **admin:** stop the event-date field crashing the page on backspace ([760a201](https://github.com/PicPeak/picpeak/commit/760a201b6070a4edfe8192bcddce948c5f0c3fec)) + +## [3.82.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.3-beta.0...v3.82.4-beta.0) (2026-07-07) + + +### Bug Fixes + +* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([0c2d319](https://github.com/PicPeak/picpeak/commit/0c2d319fc1ed67843cc60afdcaea5807ea49226f)) +* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([fcc3e91](https://github.com/PicPeak/picpeak/commit/fcc3e9195d6f63b2dffddfa72a867a3e32325e81)) +* **email:** sibling billing emails follow customer language too ([c0008be](https://github.com/PicPeak/picpeak/commit/c0008be39bc8a9d354e48ce8d6bd89662bc53ebb)) + +## [3.82.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.2-beta.0...v3.82.3-beta.0) (2026-07-06) + + +### Bug Fixes + +* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([a88da99](https://github.com/PicPeak/picpeak/commit/a88da99c8d35c0c7cb7f96a235e984edad74ac7c)) +* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([96fe478](https://github.com/PicPeak/picpeak/commit/96fe478bf87a3350185206b3d6f15133138b995d)) +* **branding:** unify hero logo SIZE the same way as visibility ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([60b03b1](https://github.com/PicPeak/picpeak/commit/60b03b17287539b3ad5e5d32f4eda8622f0575e4)) + +## [3.82.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.1-beta.0...v3.82.2-beta.0) (2026-07-05) + + +### Bug Fixes + +* **og:** broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.) ([a0a28a4](https://github.com/PicPeak/picpeak/commit/a0a28a47777db9ca9e60a5134c8d86503c060e79)) +* **og:** route branded short URLs + slideshow links to OG, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([0dffe0c](https://github.com/PicPeak/picpeak/commit/0dffe0ce92339e0608b3ef660e84c31a62f4a98c)) +* **og:** route branded short URLs + slideshow to OG handler, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a87ad77](https://github.com/PicPeak/picpeak/commit/a87ad77d8d5215c88f5d95cc7aebaa1769938ec0)) + +## [3.82.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.0-beta.0...v3.82.1-beta.0) (2026-07-05) + + +### Bug Fixes + +* **invoices:** correct payment-check email template key so dunning email sends ([9a76333](https://github.com/PicPeak/picpeak/commit/9a763337b658299aae0d7c985071c4a775000f99)) +* **invoices:** correct payment-check email template key so dunning email sends ([3682de1](https://github.com/PicPeak/picpeak/commit/3682de195b46eae692db3ff4a1476b00d3a6e216)) + +## [3.82.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.81.0-beta.0...v3.82.0-beta.0) (2026-07-03) + + +### Features + +* **setup:** final community step ([#732](https://github.com/PicPeak/picpeak/issues/732)) + fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([a5f49e3](https://github.com/PicPeak/picpeak/commit/a5f49e32350564ee4d3894f33e9611e9244cc994)) +* **setup:** final community/thank-you step ([#732](https://github.com/PicPeak/picpeak/issues/732)); fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([dadaaee](https://github.com/PicPeak/picpeak/commit/dadaaeea7781cb62811256b512003e5c4d6ad95e)) + +## [3.81.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.80.0-beta.0...v3.81.0-beta.0) (2026-07-03) + + +### Features + +* admin two-factor authentication (TOTP) with recovery codes + CLI reset ([cf07361](https://github.com/PicPeak/picpeak/commit/cf073615effa8a91e19374ad3e9924e6e7322950)) +* **admin-ui:** TOTP MFA enrollment + two-step login; remove stub 2FA toggle ([96e3c68](https://github.com/PicPeak/picpeak/commit/96e3c68b9d6b35a82abcad664a6da7b19150b4fd)) +* **auth:** admin TOTP MFA — enrollment, login challenge, recovery, CLI reset ([72e2ef6](https://github.com/PicPeak/picpeak/commit/72e2ef6721b0572ed34455de901aa357eacd8c76)) + + +### Bug Fixes + +* event creation 500s on PostgreSQL (NaN slideshow seed) + stray "0" boolean renders ([b187f58](https://github.com/PicPeak/picpeak/commit/b187f588b4d12af7a7849f8558c0085573d4af76)) +* **security:** close cross-event thumbnail leak, bulk-op ownership bypass, + hardening ([081f3ed](https://github.com/PicPeak/picpeak/commit/081f3edcdffc65a77000cc638e364ea9dc03767f)) +* **security:** cross-event thumbnail leak, bulk-op ownership bypass + auth hardening ([b732974](https://github.com/PicPeak/picpeak/commit/b732974779803b67097c81ae6bce2de0f2910794)) + +## [3.80.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.1-beta.0...v3.80.0-beta.0) (2026-07-03) + + +### Features + +* **backup:** fold .picpeak restore into the Restore wizard's Upload source ([86324e7](https://github.com/PicPeak/picpeak/commit/86324e7da75069e61686b1b77495f02c33b12e1a)) +* first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip ([e513e83](https://github.com/PicPeak/picpeak/commit/e513e8345b73e37ebedc9c9ec09665ffc5773e23)) +* **setup:** add restore-from-backup branch to the first-run wizard ([a95ee47](https://github.com/PicPeak/picpeak/commit/a95ee473ae7fb2bf3c94c610724476701145d113)) +* **setup:** per-feature config step after feature selection ([07b450a](https://github.com/PicPeak/picpeak/commit/07b450a954a53781d23a71749552e4101c637777)) + + +### Bug Fixes + +* **backup:** address .picpeak review — table filter, superuser guard, tests ([fa7665c](https://github.com/PicPeak/picpeak/commit/fa7665c5b1ad18a4db4f0b59eb4c197a3c9a36e2)) +* **setup:** keep the first-run wizard light regardless of dark mode ([d4b143f](https://github.com/PicPeak/picpeak/commit/d4b143f313d00f2a30abcbc1a880c140d7455e7b)) + +## [3.79.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.0-beta.0...v3.79.1-beta.0) (2026-07-02) + + +### Bug Fixes + +* **settings:** remove duplicate Mail import that broke the dev server ([5b535f8](https://github.com/PicPeak/picpeak/commit/5b535f86580275eda768fa2d85a8c94bd701f832)) +* **settings:** remove duplicate Mail import that crashes the dev server ([4aa6583](https://github.com/PicPeak/picpeak/commit/4aa6583baef55e2c12e9cde7d391156436de518f)) + +## [3.79.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.78.0-beta.0...v3.79.0-beta.0) (2026-07-02) + + +### Features + +* setup wizard + argument-driven unattended install ([681619f](https://github.com/PicPeak/picpeak/commit/681619f0a14070309342a9f908a5bbc8a57d47d8)) +* **setup:** step-by-step wizard + argument-driven unattended install ([d35c413](https://github.com/PicPeak/picpeak/commit/d35c413651bc10f177a683a8057ad92c03b1cf00)) + +## [3.78.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.3-beta.0...v3.78.0-beta.0) (2026-07-02) + + +### Features + +* zero-config first run — in-browser admin bootstrap + auto-generated secrets ([bafc96f](https://github.com/PicPeak/picpeak/commit/bafc96f468e3b5cca2ec3291e7b568886755099d)) + + +### Bug Fixes + +* **ci:** enable release-PR auto-merge with the PAT, not GITHUB_TOKEN ([e08a33d](https://github.com/PicPeak/picpeak/commit/e08a33d9ea273dc18877743f71f59d64bfc3dfb5)) +* enable release-PR auto-merge with the PAT so releases actually publish ([97b9853](https://github.com/PicPeak/picpeak/commit/97b9853709fb59a900d70bb2a6bf365d98ae4f86)) + +## [3.77.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.2-beta.0...v3.77.3-beta.0) (2026-07-02) + + +### Bug Fixes + +* set GH_REPO in release-please auto-merge step ([d00d52a](https://github.com/PicPeak/picpeak/commit/d00d52a2215dfcae34086cf3e10fe4da0aef09c9)) + +## [3.77.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.1-beta.0...v3.77.2-beta.0) (2026-07-02) + + +### Bug Fixes + +* auto-publish release-please PRs without manual approval ([fb64ec0](https://github.com/PicPeak/picpeak/commit/fb64ec0910f8c3ecffb40d85e4f3a08f73503671)) +* **ci:** auto-publish release-please PRs without manual approval ([#719](https://github.com/PicPeak/picpeak/issues/719)) ([a3e7232](https://github.com/PicPeak/picpeak/commit/a3e7232b8ed012b8449a76d3e4ea3c5daddd5514)) + +## [3.77.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.0-beta.0...v3.77.1-beta.0) (2026-07-02) + + +### Documentation + +* require screenshots for UI changes in PRs ([f5b4aa7](https://github.com/PicPeak/picpeak/commit/f5b4aa7a5bc321ffbd33f1c1b92003435a7ee842)) +* require screenshots for UI changes in PRs ([8ca7477](https://github.com/PicPeak/picpeak/commit/8ca74776f4d3f7be930a713afe4ac4de594adedd)) + +## [3.77.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.76.2-beta.0...v3.77.0-beta.0) (2026-07-01) + + +### Features + +* admin photos list/grid toggle + upload failure report ([#707](https://github.com/PicPeak/picpeak/issues/707), [#708](https://github.com/PicPeak/picpeak/issues/708)) ([e873f7c](https://github.com/PicPeak/picpeak/commit/e873f7c98ce108b090d70a3b7df2d2929699e997)) +* admin photos list/grid toggle + upload failure report ([#707](https://github.com/PicPeak/picpeak/issues/707), [#708](https://github.com/PicPeak/picpeak/issues/708)) ([6f95796](https://github.com/PicPeak/picpeak/commit/6f95796b7c19829197eaff0d4934ad9b84d0e2f3)) + +## [3.76.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.76.1-beta.0...v3.76.2-beta.0) (2026-06-30) + + +### Bug Fixes + +* **ci:** whatsnew highlights — set GH_REPO so gh runs without a checkout ([3feed0f](https://github.com/PicPeak/picpeak/commit/3feed0fae6a5792a7192a529942e08d9872b7e6e)) +* **ci:** whatsnew highlights — set GH_REPO so gh runs without a checkout ([2a5f0a8](https://github.com/PicPeak/picpeak/commit/2a5f0a8601ba5cb28243b39278ecdc0892388a96)) + +## [3.76.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.76.0-beta.0...v3.76.1-beta.0) (2026-06-30) + + +### Bug Fixes + +* **whatsnew:** decode HTML entities and trim em-dash detail in fallback bullets ([5582644](https://github.com/PicPeak/picpeak/commit/5582644dc49330549be2a3a4cdd5b1ba0f21a294)) + +## [3.76.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.75.1-beta.0...v3.76.0-beta.0) (2026-06-30) + + +### Features + +* **gallery:** branded URL shortener — /s/<slug> with OG injection ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a0f7033](https://github.com/PicPeak/picpeak/commit/a0f7033ffc812f92d56e2eac7bd2f498b95ef83b)) + +## [3.75.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.75.0-beta.0...v3.75.1-beta.0) (2026-06-30) + + +### Bug Fixes + +* **og:** rich social previews for share-token + slideshow URLs ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([25bf7bb](https://github.com/PicPeak/picpeak/commit/25bf7bb5239420da078749bac270196df6968581)) +* **og:** rich social previews for share-token + slideshow URLs ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([1b8747d](https://github.com/PicPeak/picpeak/commit/1b8747dc82763ba6b4da3a55045cab8740da2a13)) + +## [3.75.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.74.0-beta.0...v3.75.0-beta.0) (2026-06-30) + + +### Features + +* **updates:** "What's New" highlights after update + pre-update teaser ([a1a73bf](https://github.com/PicPeak/picpeak/commit/a1a73bf75ff3fcd0833fdf7922a35ad09f19439b)) +* **updates:** "What's New" highlights after update + pre-update teaser ([500cf85](https://github.com/PicPeak/picpeak/commit/500cf8522e556575bd74d4c71d38a83fb2596b5e)) + + +### Documentation + +* **readme:** credit [@the-luap](https://github.com/the-luap) as creator/lead maintainer ([3528f6b](https://github.com/PicPeak/picpeak/commit/3528f6b8b7e2b537b111f7787d48459a976ef744)) +* **readme:** credit [@the-luap](https://github.com/the-luap) as creator/lead maintainer ([748238e](https://github.com/PicPeak/picpeak/commit/748238e8caf198e3899954804e61a2e179058957)) + +## [3.74.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.73.0-beta.0...v3.74.0-beta.0) (2026-06-29) + + +### Features + +* **admin:** in-app migration banner for the org rename ([0213347](https://github.com/PicPeak/picpeak/commit/02133478bd2684c562d11cc122cf5059832ff76a)) +* **admin:** in-app migration banner for the org rename ([#669](https://github.com/PicPeak/picpeak/issues/669)) ([2a4bf3b](https://github.com/PicPeak/picpeak/commit/2a4bf3b868c6733d0b865c8c0e977ba84d6e6453)) + + +### Documentation + +* branch model + migration-to-org guide + PR template ([166ef47](https://github.com/PicPeak/picpeak/commit/166ef47611a248c4d517e26d390d87d21f077ca1)) +* branch model + migration-to-org guide + PR-template target hint ([d606fcd](https://github.com/PicPeak/picpeak/commit/d606fcd5a425fed3c968ec06b071a386bf558c28)) +* prominent migration banner at the top of README ([14bd3e1](https://github.com/PicPeak/picpeak/commit/14bd3e1a6c6cf74378d6f316024584d8941cbcd5)) +* prominent migration banner at the top of README ([#669](https://github.com/PicPeak/picpeak/issues/669)) ([5839bba](https://github.com/PicPeak/picpeak/commit/5839bba72a56cc29077f63f7daa038995fb09dfb)) + +## [3.73.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.72.0-beta.0...v3.73.0-beta.0) (2026-06-29) + + +### Features + +* **dashboard:** revenue "year" tile toggles 365 days ↔ calendar YTD ([d1c9e02](https://github.com/the-luap/picpeak/commit/d1c9e02bcf50b6c08eebc85acdbfba29bfee84ac)) +* **invoices:** surface monthly/manual accumulator drafts in the Bills list ([e457656](https://github.com/the-luap/picpeak/commit/e457656b9d06bb420c9d0985fe15c30d6c88aed9)) + + +### Bug Fixes + +* **invoices:** add bank transfer to the mark-paid method list ([e96ef4c](https://github.com/the-luap/picpeak/commit/e96ef4c5a35bc9e575bc3419fb318a3ee9df1bd6)) +* **invoices:** badge held (unsent, no send date) invoices as "Draft" ([e4367e0](https://github.com/the-luap/picpeak/commit/e4367e028a5228ef50c4bbd522d0777bc7340b52)) +* **invoices:** show "Draft" on the invoice detail page for accumulator drafts ([ca09442](https://github.com/the-luap/picpeak/commit/ca0944293f66b6465a577340e63d592598915092)) +* **reminders:** wrap is_active/is_archived wheres in formatBoolean ([b9d9138](https://github.com/the-luap/picpeak/commit/b9d91385b43de7ede508884f7cf78b5cf785f853)) + +## [3.72.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.3-beta.0...v3.72.0-beta.0) (2026-06-28) + + +### Features + +* **workflows:** booking cutover — wire booking actions + hold documents behind approval gates ([ec33ec7](https://github.com/the-luap/picpeak/commit/ec33ec7670a4feb1108d1bcbfe34727f63cc8cf9)) + + +### Bug Fixes + +* **workflows:** defer quote.accepted/declined emit until the 15-min response window locks ([539a837](https://github.com/the-luap/picpeak/commit/539a83711d1996dc9c262365f2c511e7bc445add)) +* **workflows:** make the dashboard pending-approvals card items clickable too ([6e20d58](https://github.com/the-luap/picpeak/commit/6e20d58487c5e20b08e1d1b4ddd4e76f9e922a79)) + +## [3.71.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.2-beta.0...v3.71.3-beta.0) (2026-06-27) + + +### Bug Fixes + +* **events:** wire customer notifications into both public API entry points ([#647](https://github.com/the-luap/picpeak/issues/647)) ([f017542](https://github.com/the-luap/picpeak/commit/f01754247cdb94c5935ad5abbda116841f6c7fba)) + +## [3.71.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.1-beta.0...v3.71.2-beta.0) (2026-06-27) + + +### Bug Fixes + +* event-reminder, email-language & gallery-publish bugs surfaced during workflow testing ([c8714ca](https://github.com/the-luap/picpeak/commit/c8714ca42f4d82d50fe611b2a630260ebecbe740)) + +## [3.71.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.0-beta.0...v3.71.1-beta.0) (2026-06-26) + + +### Bug Fixes + +* **admin:** stack publish-gallery dialog CTAs so the German label fits ([#670](https://github.com/the-luap/picpeak/issues/670)) ([748af98](https://github.com/the-luap/picpeak/commit/748af98f3d3f8c00695b82e94d741a0e10a39a81)) + +## [3.71.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.70.0-beta.0...v3.71.0-beta.0) (2026-06-25) + + +### Features + +* admin-configurable workflow engine + dunning/Mahngebühr rework (RFC — feedback welcome) ([15be3b8](https://github.com/the-luap/picpeak/commit/15be3b8d32965eedc08d46ccc525c65a3bf34de6)) +* **workflows:** per-quote booking-workflow picker + quote→invoice (no gallery) built-in ([d14f1d8](https://github.com/the-luap/picpeak/commit/d14f1d850cc995b2cb1119ba0424f123feba50ec)) +* **workflows:** pre-event reminder picks the template GROUP on the block, type stays automatic ([10d091b](https://github.com/the-luap/picpeak/commit/10d091b55e0c44738b4001a71def6416a8f0aeb0)) +* **workflows:** route webhook node through the delivery pipeline (full Option 1) ([675e41a](https://github.com/the-luap/picpeak/commit/675e41a2f72c8c23fa5c36b13bc6b95abcb9d570)) +* **workflows:** warn when disabling a built-in (reverts to legacy, not off) ([c5f131c](https://github.com/the-luap/picpeak/commit/c5f131cec32826331722ef3705c5f5422e31726d)) + + +### Bug Fixes + +* **crm:** pre-event reminder resolves recipient from the event row, not a non-existent column ([5fbe514](https://github.com/the-luap/picpeak/commit/5fbe514db6e386eee2eeade548bccbb5bbc5b422)) +* **event-types:** renaming a type's slug cascades to events, quotes + reminder template ([415c93a](https://github.com/the-luap/picpeak/commit/415c93a512f74898d0225ce2e9298f24cc12f60d)) +* **workflows:** close review blockers — prefetch-safe approvals + loud gate-edge failure ([98ab717](https://github.com/the-luap/picpeak/commit/98ab717043e3fdefac0934bf8f4621d523b15e9a)) +* **workflows:** harden graph validation + refuse enabling unimplemented flows ([d927464](https://github.com/the-luap/picpeak/commit/d927464778272bd862aa01903179672f4d47368a)) +* **workflows:** matchFilter strict equality + accurate comment ([dee8d40](https://github.com/the-luap/picpeak/commit/dee8d40bb3235a908bba514a97a62d3a91a6e131)) +* **workflows:** ship built-ins disabled for first beta + enabled-based mutex + admin sentinel ([5893ecb](https://github.com/the-luap/picpeak/commit/5893ecb27a0365a79ec04336c5a122b31d31db0e)) +* **workflows:** wire a real, SSRF-guarded webhook action (was a silent no-op) ([af7eea8](https://github.com/the-luap/picpeak/commit/af7eea8b43e37905a79138bcde4b1026dea13050)) + +## [3.70.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.69.1-beta.0...v3.70.0-beta.0) (2026-06-23) + + +### Features + +* **analytics:** pluggable trackers — Umami + Rybbit + Custom ([#663](https://github.com/the-luap/picpeak/issues/663) Phase 1) ([83461fe](https://github.com/the-luap/picpeak/commit/83461fe5d4d44006482167464d92e70546cf7377)) + +## [3.69.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.69.0-beta.0...v3.69.1-beta.0) (2026-06-23) + + +### Bug Fixes + +* **analytics:** admin dashboard reads correct fields + Umami device API ([#661](https://github.com/the-luap/picpeak/issues/661)) ([349f566](https://github.com/the-luap/picpeak/commit/349f566e87b33c59f61eb28b8abc5f889e6285d6)) +* **analytics:** admin dashboard reads correct fields + Umami device API ([#661](https://github.com/the-luap/picpeak/issues/661)) ([7534447](https://github.com/the-luap/picpeak/commit/7534447b6c0df4290fd8dac12270673097096f1b)) + +## [3.69.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.68.1-beta.0...v3.69.0-beta.0) (2026-06-22) + + +### Features + +* **feedback:** per-guest favorite + like caps with mobile-friendly limit modal ([#655](https://github.com/the-luap/picpeak/issues/655)) ([3ac7017](https://github.com/the-luap/picpeak/commit/3ac70177efc237b8169278208983b0de3629bc72)) +* **feedback:** per-guest favorite + like caps with mobile-friendly limit modal ([#655](https://github.com/the-luap/picpeak/issues/655)) ([f2814e4](https://github.com/the-luap/picpeak/commit/f2814e4a4ce3aa9affc232243d615a15a1aae0c0)) + + +### Bug Fixes + +* **i18n:** replace ASCII quote with U+201D in DE perGuestLimitsDesc ([98e97e3](https://github.com/the-luap/picpeak/commit/98e97e3cf214c96cdefd99bfedd6724f0b85c41c)) + +## [3.68.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.68.0-beta.0...v3.68.1-beta.0) (2026-06-22) + + +### Bug Fixes + +* **gallery:** unbreak password entry in Instagram in-app browser ([#654](https://github.com/the-luap/picpeak/issues/654)) ([6193ab7](https://github.com/the-luap/picpeak/commit/6193ab7f6aafd94b6e2e432ddf170361fd306d4e)) +* **gallery:** unbreak password entry in Instagram in-app browser ([#654](https://github.com/the-luap/picpeak/issues/654)) ([b1bfd48](https://github.com/the-luap/picpeak/commit/b1bfd4838e7104e4f85695e180b20222206073ac)) +* **test:** raise bootCrmDb beforeAll timeout on slideshow suites ([f4b6b89](https://github.com/the-luap/picpeak/commit/f4b6b8941a30a20615cc87627a0663ff6d03c932)) + +## [3.68.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.67.1-beta.0...v3.68.0-beta.0) (2026-06-21) + + +### Features + +* **whatsapp:** admin-selectable template parameters + reorder ([#647](https://github.com/the-luap/picpeak/issues/647) follow-up) ([80e8ec5](https://github.com/the-luap/picpeak/commit/80e8ec5bc71f0653d56f1087521f5207aee0ba8f)) + +## [3.67.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.67.0-beta.0...v3.67.1-beta.0) (2026-06-21) + + +### Bug Fixes + +* **branding+whatsapp:** preserve customCss through preset switches ([#645](https://github.com/the-luap/picpeak/issues/645)) + admin-pinned WhatsApp template language ([#647](https://github.com/the-luap/picpeak/issues/647)) ([cde028e](https://github.com/the-luap/picpeak/commit/cde028e9199a9ddb09957a87590732f4bd4d7a7b)) + +## [3.67.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.66.1-beta.0...v3.67.0-beta.0) (2026-06-21) + + +### Features + +* Live Slideshow ("Diashow") — fullscreen, auto-updating projector view for live events ([4356393](https://github.com/the-luap/picpeak/commit/4356393b4433dd6b4147388688766b9464294c89)) +* **slideshow:** add image fit setting (fill vs black bars) ([b5c73e0](https://github.com/the-luap/picpeak/commit/b5c73e05bd41b262f864e8c700b1d38582b3817f)) +* **slideshow:** admin ui for live slideshow ([385b05a](https://github.com/the-luap/picpeak/commit/385b05adcf6a4acb7939e372328a55df7dae5e08)) +* **slideshow:** backend api for live slideshow ([dea5e0f](https://github.com/the-luap/picpeak/commit/dea5e0f8a6421c056868c2d9bea11e5bf1ee106a)) +* **slideshow:** db columns for live slideshow ([1029dd0](https://github.com/the-luap/picpeak/commit/1029dd05bdb9ca0a97ad86100145221850648651)) +* **slideshow:** en/de strings for live slideshow ([cb761ee](https://github.com/the-luap/picpeak/commit/cb761ee621aa553cf210c4224b6cbbf7bf2ef0cb)) +* **slideshow:** gate behind a feature flag + move globals to a Settings tab ([69367b4](https://github.com/the-luap/picpeak/commit/69367b45be1c13d87e73e72da34a1f41a5849dfe)) +* **slideshow:** public fullscreen slideshow viewer ([fd02254](https://github.com/the-luap/picpeak/commit/fd02254f78bd1860780355ebaa68293d58ce18b3)) + + +### Bug Fixes + +* **slideshow:** deny display-only token on download/upload/feedback (PR [#646](https://github.com/the-luap/picpeak/issues/646) review) ([e36b330](https://github.com/the-luap/picpeak/commit/e36b3309ca66404d189d5b218cc1f0eba925e4c7)) +* **slideshow:** dip-to-white/black no longer flickers the image ([db8388c](https://github.com/the-luap/picpeak/commit/db8388c79e44f5d254d984810bd62bfb11effd0f)) +* **slideshow:** drop updated_at from event writes ([1e40f82](https://github.com/the-luap/picpeak/commit/1e40f8296ca59ff0395f6cc09ee452ab62653cdc)) +* **slideshow:** feature flag is a master kill-switch, not just admin UI ([759784a](https://github.com/the-luap/picpeak/commit/759784a4d1cfe7e67c825293760169ad6904f090)) +* **slideshow:** fill the viewport instead of black bars ([6ec46de](https://github.com/the-luap/picpeak/commit/6ec46de0e7bb821ea4e4a7fc2318792b810c3f36)) +* **slideshow:** read globals from app_settings, not the missing settings table ([0f4388d](https://github.com/the-luap/picpeak/commit/0f4388d68ab85049c46e7af566d35f4fbf6e4d02)) +* **slideshow:** surface backend error in the live slideshow card ([056f938](https://github.com/the-luap/picpeak/commit/056f9381de5dbe90243bea409b587b4910050cbf)) + + +### Performance Improvements + +* **slideshow:** cache global settings to cut /state DB reads (PR [#646](https://github.com/the-luap/picpeak/issues/646) review) ([a995131](https://github.com/the-luap/picpeak/commit/a995131f4266e112c96c6e8cedd5158995ebe899)) + + +### Documentation + +* **slideshow:** add Live Slideshow guide + README entries ([16013d1](https://github.com/the-luap/picpeak/commit/16013d1cf9ad82ee052f905f9702feffde7b67eb)) + +## [3.66.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.66.0-beta.0...v3.66.1-beta.0) (2026-06-19) + + +### Bug Fixes + +* **deps:** bump qs/brace-expansion overrides + add uuid override for node-cron ([d705059](https://github.com/the-luap/picpeak/commit/d705059d3c2904184f037bbe0208fe128fdb9b63)) +* **security:** close BOLA on photo-export + NAT64 SSRF in URL guard ([b8211e9](https://github.com/the-luap/picpeak/commit/b8211e9944da9e7b1c43a25e2f24c8a2425000cf)) +* **security:** close NAT64 SSRF + photo-export BOLA + sweep Trivy alerts (GHSA-wmjx-pc37-272r, GHSA-9v4w-jrhx-g5wr) ([6f40db8](https://github.com/the-luap/picpeak/commit/6f40db859751efc2c931bc981a48148808fd3701)) + +## [3.66.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.65.1-beta.0...v3.66.0-beta.0) (2026-06-19) + + +### Features + +* **categories:** per-category download permissions ([#640](https://github.com/the-luap/picpeak/issues/640) part B) ([820f483](https://github.com/the-luap/picpeak/commit/820f4835f1f5a41cbef6816c387ef9ec3dafd526)) +* **common:** generic Promise-based ConfirmDialog primitive ([#640](https://github.com/the-luap/picpeak/issues/640) part C) ([a3fcb5b](https://github.com/the-luap/picpeak/commit/a3fcb5bc9e82849ebe1f55620e8aa7e60ccd973f)) +* **feedback:** export shape toggle — per-action vs per-guest pivot ([#640](https://github.com/the-luap/picpeak/issues/640) part E) ([fabd67a](https://github.com/the-luap/picpeak/commit/fabd67aecd6caf308956e5b4cb9df7dd44452142)) +* **whatsapp:** WhatsApp Business API notification channel ([#640](https://github.com/the-luap/picpeak/issues/640) part D) ([78c8e9d](https://github.com/the-luap/picpeak/commit/78c8e9d9f91d56e07e04df4ed90fb05ccdfb69d2)) + + +### Bug Fixes + +* **archives:** stream-extract restore for >2 GiB + preserve original_filename via manifest ([#640](https://github.com/the-luap/picpeak/issues/640)) ([e4e79a0](https://github.com/the-luap/picpeak/commit/e4e79a0b3a6d3ddbbc2f3cebdcadc89307147248)) +* **i18n:** wrap WhatsApp token show/hide aria-label through t() ([a8bb7b4](https://github.com/the-luap/picpeak/commit/a8bb7b439f6f57af9653ce283c951070bd52f3c2)) +* **settings:** hoist tab-visibility useEffect above isLoading early return ([49bfb45](https://github.com/the-luap/picpeak/commit/49bfb45332993b919ad4f949a0cd912a85888620)) + +## [3.65.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.65.0-beta.0...v3.65.1-beta.0) (2026-06-18) + + +### Bug Fixes + +* **i18n:** sweep activity-type translations + Events / API Tokens / Webhooks settings tabs ([f17c654](https://github.com/the-luap/picpeak/commit/f17c654e146683683f347ae2cd46de9cf3e47989)) + +## [3.65.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.64.0-beta.0...v3.65.0-beta.0) (2026-06-18) + + +### Features + +* **accounting:** consolidate VAT/financial config into Settings → Accounting ([dc7b87b](https://github.com/the-luap/picpeak/commit/dc7b87bb874e22ac6902fd2d48531ecdb6108c88)) +* **accounting:** explain dispositions inline, drop markup from pass-through ([9a023c0](https://github.com/the-luap/picpeak/commit/9a023c019750ebcd8d21e005aaf9a77a32cb34a3)) +* **accounting:** incoming-invoice workflow v2 + VAT/financial settings consolidation ([b527915](https://github.com/the-luap/picpeak/commit/b5279155ea4c545e13bab8bde46a39cfccf107fe)) +* **accounting:** invoices force-enable the Accounting master ([51837c3](https://github.com/the-luap/picpeak/commit/51837c3a88f711b164fafe2c7677e1a91c7542f9)) +* **accounting:** re-categorize incoming invoices, note field, pending re-bill pool ([36a8e42](https://github.com/the-luap/picpeak/commit/36a8e42f90f15a1ba96d9c4f004fa542d33e4937)) +* **accounting:** supplier-country tax default + configurable default output VAT code ([267b121](https://github.com/the-luap/picpeak/commit/267b121d66994bc57b10cd0694ab0e4320b163d9)) + + +### Bug Fixes + +* **accounting:** address the-luap PR [#636](https://github.com/the-luap/picpeak/issues/636) review ([707c5d0](https://github.com/the-luap/picpeak/commit/707c5d027798bdafb9fe09d7efcbd9ea65330076)) +* **accounting:** tax-report storno totals + hours-line date on Postgres ([db9e41d](https://github.com/the-luap/picpeak/commit/db9e41d19846b31b29c5c1be2ee06a7958bb43b0)) +* **crm:** editor totals box computed VAT 100× too small ([e9b297c](https://github.com/the-luap/picpeak/commit/e9b297c162a19da31d53de377b90bfd5cda1b0a7)) +* **hours:** move logActivity out of the entry transactions (SQLite deadlock) ([348955b](https://github.com/the-luap/picpeak/commit/348955b261713fc9f0b48391a1d4117f6f8c873f)) + +## [3.64.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.63.0-beta.0...v3.64.0-beta.0) (2026-06-18) + + +### Features + +* **admin/exports:** inline preview modal with copy-to-clipboard ([#631](https://github.com/the-luap/picpeak/issues/631)) ([fc5c1ae](https://github.com/the-luap/picpeak/commit/fc5c1ae93f87678fcc16bc84a14a60a59b1a3c7b)) +* **admin/exports:** inline preview modal with copy-to-clipboard ([#631](https://github.com/the-luap/picpeak/issues/631)) ([27b5f7e](https://github.com/the-luap/picpeak/commit/27b5f7e4b68e43345cd99dd5cc77308dcd7ec98b)) + +## [3.63.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.62.0-beta.0...v3.63.0-beta.0) (2026-06-17) + + +### Features + +* **events:** duplicate-gallery action ([#626](https://github.com/the-luap/picpeak/issues/626)) ([e985d25](https://github.com/the-luap/picpeak/commit/e985d25207671cbfefcdda9775a96eadf2fe0698)) + + +### Bug Fixes + +* **events:** publish-from-draft email carries the real password ([#627](https://github.com/the-luap/picpeak/issues/627)) ([83b568e](https://github.com/the-luap/picpeak/commit/83b568ee2ddc007b7d981fd4b46b69810f0165c3)) +* **gallery:** admin edits to welcome_message land for returning guests ([#625](https://github.com/the-luap/picpeak/issues/625)) ([ea6245c](https://github.com/the-luap/picpeak/commit/ea6245cfdea67bd4668e2100f295433a3d29f7f1)) +* **upload:** auto-throttle on low-memory hosts + correct documented RAM minimum ([#628](https://github.com/the-luap/picpeak/issues/628)) ([714a9f6](https://github.com/the-luap/picpeak/commit/714a9f6fb1f48ba1316cc240054d5128749581d8)) + +## [3.62.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.61.0-beta.0...v3.62.0-beta.0) (2026-06-17) + + +### Features + +* **accounting:** add a Banana "Income & Expense" (cash-book) export format ([445d6d7](https://github.com/the-luap/picpeak/commit/445d6d7b6d6b0692d5b7c0a3dd0ca8b71dfad6ef)) +* **accounting:** bill editor VAT dropdown + GET returns vat_code snapshot ([2479d87](https://github.com/the-luap/picpeak/commit/2479d87afc5b094a5323bca0b67e6404ce05a4e2)) +* **accounting:** clearer tax-export window + gate journal export on accounting flag ([3edd832](https://github.com/the-luap/picpeak/commit/3edd8321035c48d6b3e8b157d4b94075657fc7a0)) +* **accounting:** data-driven revenue-rate VAT map (multi-country) ([873be91](https://github.com/the-luap/picpeak/commit/873be910a5e88a6d942f116c2bcfecfef9161024)) +* **accounting:** move Chart of accounts into Settings → Accounting ([97795f6](https://github.com/the-luap/picpeak/commit/97795f6d1ed25d23396a76b63c225b110ddc315e)) +* **accounting:** move Treuhänder export onto the Tax page ([b1f73c1](https://github.com/the-luap/picpeak/commit/b1f73c1df9408ddae821760eb8ed57c726d2e056)) +* **accounting:** relocate VAT codes + rate maps into Settings → Accounting ([4ff5b84](https://github.com/the-luap/picpeak/commit/4ff5b84cb66e40be960c2be7a67334cf0cc98be2)) +* **accounting:** scope the tax-report export to income-only or cost-only ([9f3b286](https://github.com/the-luap/picpeak/commit/9f3b28684ff36b131420cd975df634a29d660323)) +* **accounting:** snapshot the chosen VAT code on quote/invoice create + storno ([5b52969](https://github.com/the-luap/picpeak/commit/5b52969e36a41bbc08a98e0bca1ce0e937d77f56)) +* **accounting:** snapshot vat_code on quotes/invoices + export prefers it (foundation) ([0a7dc1c](https://github.com/the-luap/picpeak/commit/0a7dc1cf5da17a5bef204f6680844c8ab2b44269)) +* **accounting:** tax report VAT-payable honours registration + reclaim ([d7107aa](https://github.com/the-luap/picpeak/commit/d7107aaf0adf5e03f08085c7691b6530b15ed702)) +* **accounting:** unify tax report into one signed, typed, sortable ledger ([fd1dd81](https://github.com/the-luap/picpeak/commit/fd1dd81e8dfc790d960a7e6d888beba895a23461)) +* **accounting:** VAT registration + reclaim-country settings in the Accounting tab ([4d87684](https://github.com/the-luap/picpeak/commit/4d876848823bce2c79e629308c92206c64d9893d)) +* **accounting:** VAT registration/reclaim settings + un-gated VAT-codes read ([fbbbb8a](https://github.com/the-luap/picpeak/commit/fbbbb8ab7335f07ecf48e217632c7011bd7c88cd)) +* **accounting:** VAT-code dropdown in the quote editor (+ reusable VatRateSelect) ([6e1924b](https://github.com/the-luap/picpeak/commit/6e1924bae8b50dbb8460e151c1d9a79553fddb19)) +* **branding:** force color mode = standard look; hide overridden theme controls ([4749e22](https://github.com/the-luap/picpeak/commit/4749e222dc41695a2494a3b740952745bb854d4e)) + + +### Bug Fixes + +* **accounting:** Banana export is now a tab-separated .txt (actually importable) ([a195067](https://github.com/the-luap/picpeak/commit/a19506749a449ec0a628da776af5a5bea8a2e46e)) +* **accounting:** Banana I&E export uses the 'Category' column (not 'ContraAccount') ([53a16f9](https://github.com/the-luap/picpeak/commit/53a16f9f6f9b11c224b3ff5f337f8a7fe4dbfc38)) +* **accounting:** emit ISO dates in exports (Postgres returns Date objects) ([0c0fb29](https://github.com/the-luap/picpeak/commit/0c0fb29770d7559b8b35a1b2a0aae1315485d875)) +* **accounting:** label the outgoing-invoice totals block in the tax summary ([f3e77e7](https://github.com/the-luap/picpeak/commit/f3e77e78079c6869a3a5de062a90f1a04fecde3c)) +* **accounting:** PR [#622](https://github.com/the-luap/picpeak/issues/622) blockers — CSV formula injection + IMAP double-ingest race ([cd6d578](https://github.com/the-luap/picpeak/commit/cd6d57839b4753b2848620c5960332cc945580ce)) +* **accounting:** PR [#622](https://github.com/the-luap/picpeak/issues/622) concerns — flag-cache, customer master gate, VAT-unconfigured, helpers, page cap ([a93b6dc](https://github.com/the-luap/picpeak/commit/a93b6dc232375e1362e091c8868a409b1335dcae)) +* **accounting:** tax report cost side queried a non-existent column ([ab65a47](https://github.com/the-luap/picpeak/commit/ab65a470a009d33558a7167dd2c3c649f785e686)) +* **accounting:** tidy the tax-export scope selector styling ([8deb7e0](https://github.com/the-luap/picpeak/commit/8deb7e0741a5bf559cb9f9b78350821dc84bdb53)) +* **accounting:** UTF-8 BOM on the ledger export so Banana reads it correctly ([74144da](https://github.com/the-luap/picpeak/commit/74144da45fc0a7a2c2e88d17a23b584ba77e9262)) +* **branding:** force lock = light/dark only; Branding stays the full preset, galleries hide color+mode ([a7c1913](https://github.com/the-luap/picpeak/commit/a7c19135bb9645a7f95d5fe76581098db305394f)) +* **branding:** when a force lock is active, collapse the theme customizer to just the Force control ([1ac653a](https://github.com/the-luap/picpeak/commit/1ac653ad1b9f47af8cb24cb53ffa60a1e95192fc)) +* **crm:** admin surfaces follow the admin light/dark toggle, not the gallery theme ([#620](https://github.com/the-luap/picpeak/issues/620)) ([d3266a0](https://github.com/the-luap/picpeak/commit/d3266a0d1c458e8ae9c57ccd2f650e699544d2d6)) +* **flags:** close CRM/accounting feature-gating gaps from the audit ([03fa3d8](https://github.com/the-luap/picpeak/commit/03fa3d82962d6d6f3cd9630e01258013d567865e)) +* **settings:** don't insert non-existent created_at into app_settings ([8621338](https://github.com/the-luap/picpeak/commit/8621338c489cbd5194da6ac22d1fe1bd9d730cb0)) + + +### Documentation + +* **readme:** add CRM + accounting to features, tax disclaimer, update contributor ([116743b](https://github.com/the-luap/picpeak/commit/116743ba438505a52b021f80f678c5a0094d20d4)) + +## [3.61.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.6-beta.0...v3.61.0-beta.0) (2026-06-13) + + +### Features + +* **projects:** Project Overview cockpit — link (multiple) quotes/contracts/hours into projects ([58f93ae](https://github.com/the-luap/picpeak/commit/58f93ae71350cc4a100f15a1a11f478750dace91)) + + +### Bug Fixes + +* **projects:** "one customer matches" rule for deal-lineage attach ([f74d8d4](https://github.com/the-luap/picpeak/commit/f74d8d4e8cd9fa040e067ffd751b183a9673161b)) +* **projects:** address review — cross-customer guards + email/queue hardening ([9d13880](https://github.com/the-luap/picpeak/commit/9d13880f2b177a1a090c4685798e199a1f47b5ec)) +* **projects:** enforce single-customer projects (guard event attach + re-label) ([4b1e85c](https://github.com/the-luap/picpeak/commit/4b1e85c8555b03cbed4abbd80e9cb45b831df6bf)) + +## [3.60.6-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.5-beta.0...v3.60.6-beta.0) (2026-06-10) + + +### Bug Fixes + +* **gallery:** guest upload honours general_max_files_per_upload + i18n placeholder interpolates ([#613](https://github.com/the-luap/picpeak/issues/613)) ([40a4aa2](https://github.com/the-luap/picpeak/commit/40a4aa2d85d93c9dc1faa69f0e6f8524ff917e29)) +* **gallery:** guest upload honours general_max_files_per_upload + i18n placeholder interpolates ([#613](https://github.com/the-luap/picpeak/issues/613)) ([69b5186](https://github.com/the-luap/picpeak/commit/69b5186582d56c42cec520abbe5454171f9f666b)) + +## [3.60.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.4-beta.0...v3.60.5-beta.0) (2026-06-09) + + +### Bug Fixes + +* **admin/events:** delete cascade orphaned photo folders because it read a non-existent column ([#608](https://github.com/the-luap/picpeak/issues/608)) ([284680e](https://github.com/the-luap/picpeak/commit/284680e0357db20177e45ab4ab01de0fbcac2a98)) +* **admin/events:** delete cascade orphaned photo folders because it read a non-existent column ([#608](https://github.com/the-luap/picpeak/issues/608)) ([457c956](https://github.com/the-luap/picpeak/commit/457c9563869156bc4773d873661a75d5115b25db)) + +## [3.60.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.3-beta.0...v3.60.4-beta.0) (2026-06-08) + + +### Bug Fixes + +* **admin:** graceful logo-img fallback + show sidebar widgets during perm hydration ([#523](https://github.com/the-luap/picpeak/issues/523) follow-up 2) ([f51b9cf](https://github.com/the-luap/picpeak/commit/f51b9cf8df2dfba07590b35cc63def689df98c4a)) +* **admin:** logo-img fallback + sidebar perm hydration + filename NFD transliteration ([#523](https://github.com/the-luap/picpeak/issues/523) follow-up 2, [#607](https://github.com/the-luap/picpeak/issues/607)) ([fcd3ca3](https://github.com/the-luap/picpeak/commit/fcd3ca36c659eafa47c036f622da8114433b1c74)) +* **downloads:** transliterate accented characters in filename via NFD instead of dropping them ([#607](https://github.com/the-luap/picpeak/issues/607)) ([620163f](https://github.com/the-luap/picpeak/commit/620163f2db77cda40b81edcac79a32cbb4fd278f)) + +## [3.60.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.2-beta.0...v3.60.3-beta.0) (2026-06-04) + + +### Bug Fixes + +* **security:** re-apply SVG CSP on the direct favicon route (PR [#603](https://github.com/the-luap/picpeak/issues/603) blocker) ([1214b6b](https://github.com/the-luap/picpeak/commit/1214b6b762ce6c763b9a28389c17905d8e47d87f)) + +## [3.60.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.1-beta.0...v3.60.2-beta.0) (2026-06-04) + + +### Bug Fixes + +* **admin-header:** skeleton brand block + move LanguageSelector into profile menu on <sm ([#523](https://github.com/the-luap/picpeak/issues/523) follow-up) ([b48b5b0](https://github.com/the-luap/picpeak/commit/b48b5b0000fd95bc149335614eb062dd373fc50a)) +* **admin-header:** skeleton brand block + move LanguageSelector into profile menu on <sm ([#523](https://github.com/the-luap/picpeak/issues/523) follow-up) ([fe10191](https://github.com/the-luap/picpeak/commit/fe10191b82546473f035435731bf6d6ecca2efd6)) + +## [3.60.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.0-beta.0...v3.60.1-beta.0) (2026-06-02) + + +### Bug Fixes + +* **notifications:** restore /clear-all route the frontend already calls ([#597](https://github.com/the-luap/picpeak/issues/597)) ([940fc60](https://github.com/the-luap/picpeak/commit/940fc607400afa528f7f464c1d41541f46a4070d)) + +## [3.60.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.59.1-beta.0...v3.60.0-beta.0) (2026-06-02) + + +### Features + +* **restore:** docker-logs visibility + ADMIN_CREDENTIALS.txt restore notice ([3322a1d](https://github.com/the-luap/picpeak/commit/3322a1d998bedf39274dbb473b44e7b1e7cbff51)) + + +### Bug Fixes + +* **backup-ui:** respect general_date_format + general_time_format ([09f6a1a](https://github.com/the-luap/picpeak/commit/09f6a1af6acfce5ef0da173fb3c17a16bb679047)) +* **restore:** coerce pg bigint counts to Number before comparing (PR [#596](https://github.com/the-luap/picpeak/issues/596) round 2) ([354fbed](https://github.com/the-luap/picpeak/commit/354fbed18220b47167b9bf6b11b3881fc120b7ae)) +* **restore:** hoist preservedMeta above SQLite/PG split (PR [#596](https://github.com/the-luap/picpeak/issues/596) blocker) ([a23fa3b](https://github.com/the-luap/picpeak/commit/a23fa3bb12cd05a922c0fb860ae97ffd3fe2baff)) +* **restore:** move operator-meta replay after post-restore verification (PR [#596](https://github.com/the-luap/picpeak/issues/596) round 3) ([20e3092](https://github.com/the-luap/picpeak/commit/20e3092c146dd9151b6e7a370f885154f0adeecc)) +* **restore:** set was_successful=true on the completed update ([7988c18](https://github.com/the-luap/picpeak/commit/7988c189723fa4639413b3a5339c148521c92c11)) + + +### Documentation + +* consolidate disaster-recovery into Backup & Restore guide ([43cb0ea](https://github.com/the-luap/picpeak/commit/43cb0ea4bfc4f241a55d2681f14c064df6b87920)) + +## [3.59.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.59.0-beta.0...v3.59.1-beta.0) (2026-05-31) + + +### Bug Fixes + +* **admin-header:** hide wordmark on <sm when logo also shows ([#523](https://github.com/the-luap/picpeak/issues/523)) ([c246fd3](https://github.com/the-luap/picpeak/commit/c246fd3cc89962d626218029ab3da1346a726246)) +* **admin-header:** truncate long company names on narrow widths ([#523](https://github.com/the-luap/picpeak/issues/523) regression) ([e7cf834](https://github.com/the-luap/picpeak/commit/e7cf834325e8686613fbbee78d52213cb3ba98b1)) +* **api/v1/events:** also honour require_password + branding defaults ([#592](https://github.com/the-luap/picpeak/issues/592) follow-up) ([2d44b1a](https://github.com/the-luap/picpeak/commit/2d44b1ab2d251a4fc752cfeb645cb2126c0da3b7)) +* **api/v1/events:** honour global devtools-detection default on create ([#592](https://github.com/the-luap/picpeak/issues/592)) ([2304b25](https://github.com/the-luap/picpeak/commit/2304b2562465f8c86c82f2155fbdce264e68fdb3)) +* **bug-batch:** [#523](https://github.com/the-luap/picpeak/issues/523) [#564](https://github.com/the-luap/picpeak/issues/564) [#590](https://github.com/the-luap/picpeak/issues/590) [#591](https://github.com/the-luap/picpeak/issues/591) [#592](https://github.com/the-luap/picpeak/issues/592) ([c68a03c](https://github.com/the-luap/picpeak/commit/c68a03c20e70b8049ed851d41e20143813935a3a)) +* **csp:** external bootstrap script to survive strict reverse-proxy CSP ([#564](https://github.com/the-luap/picpeak/issues/564)) ([dcc629c](https://github.com/the-luap/picpeak/commit/dcc629cad23ce0ca89aabb6f8b1eacfde599774e)) +* **gallery:** preserve per-viewer is_liked across hard refresh ([#590](https://github.com/the-luap/picpeak/issues/590) follow-up) ([791e997](https://github.com/the-luap/picpeak/commit/791e9974eb4c81cc4b095f9b604eccd708fb3a66)) +* **gallery:** toggle (not add) the local liked set on click ([#590](https://github.com/the-luap/picpeak/issues/590)) ([d292b9f](https://github.com/the-luap/picpeak/commit/d292b9fa10bffe5d751629b900c606763fa8e73a)) +* **nginx:** defensive large_client_header_buffers bump ([#591](https://github.com/the-luap/picpeak/issues/591)) ([c83e883](https://github.com/the-luap/picpeak/commit/c83e88348fbd473fb1de041cebabd3ace65d4d98)) + +## [3.59.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.58.0-beta.0...v3.59.0-beta.0) (2026-05-29) + + +### Features + +* **admin/users:** reactivate + delete actions for deactivated admin users ([c4a9b36](https://github.com/the-luap/picpeak/commit/c4a9b3636fc7a9209ae724acb7c8cffe141d93ea)) +* **admin/users:** reactivate + delete actions for deactivated admin users ([dfcebcc](https://github.com/the-luap/picpeak/commit/dfcebccee98a551980ad2be78a8356fd39894f8a)) + +## [3.58.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.57.2-beta.0...v3.58.0-beta.0) (2026-05-29) + + +### Features + +* **i18n:** add Slovenian (sl) language support ([433af15](https://github.com/the-luap/picpeak/commit/433af1514687b5c103e72db800954f96fd42b9b8)) + +## [3.57.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.57.1-beta.0...v3.57.2-beta.0) (2026-05-29) + + +### Documentation + +* list CRM under Beta Features + note dev-compose rebuild gotcha ([1ed4804](https://github.com/the-luap/picpeak/commit/1ed48046cbba1df3710cee197943521aedd1fe3d)) + +## [3.57.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.57.0-beta.0...v3.57.1-beta.0) (2026-05-29) + + +### Bug Fixes + +* **email:** preserve dots + subaddresses across all normalization sites ([de9a924](https://github.com/the-luap/picpeak/commit/de9a924c77faebc2d0c1ff230a52d8da313259e8)) + +## [3.57.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.56.0-beta.0...v3.57.0-beta.0) (2026-05-29) + + +### Features + +* **admin:** clickable version links + update-available modal with changelog & upgrade command ([48cf112](https://github.com/the-luap/picpeak/commit/48cf1121e546e112cd37d94f7924a808c020dd8f)) + + +### Documentation + +* **release:** establish stable-channel cadence + promotion process ([e537923](https://github.com/the-luap/picpeak/commit/e537923857b4b6000d715bba23a78f8685b44ee6)) + +## [3.56.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.55.0-beta.0...v3.56.0-beta.0) (2026-05-29) + + +### Features + +* CRM module — quotes, contracts, invoices, hours, calendar, tax ([5f0fcc2](https://github.com/the-luap/picpeak/commit/5f0fcc225c29ce0f4410c5aba5ecd5a3a6d07259)) + + +### Bug Fixes + +* **crm:** thread trx through sequence-claim sites to unblock SQLite ([d1aecaa](https://github.com/the-luap/picpeak/commit/d1aecaa1804c0039744eb79a4032d1ed923e0b85)) +* **quote-response:** compute minutes-remaining for the DE changeWithin string ([5ce0b6e](https://github.com/the-luap/picpeak/commit/5ce0b6edc3fef41f2f22dd585a668f471ac47a2e)) + +## [3.55.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.54.7-beta.0...v3.55.0-beta.0) (2026-05-27) + + +### Features + +* **lightbox:** multi-photo Web Share save-to-Photos on iOS ([#557](https://github.com/the-luap/picpeak/issues/557)) ([d5823c7](https://github.com/the-luap/picpeak/commit/d5823c79d9a187461c0126adcff7f4374cd0e8aa)) + + +### Bug Fixes + +* **events:** preserve branding inheritance when saving events with null color_theme ([d5a37df](https://github.com/the-luap/picpeak/commit/d5a37df2c41425511dc8a1f974088bebb768f0d5)) +* **lightbox+events:** Android download lag, multi-photo Web Share re-land, theme branding inheritance ([e016f51](https://github.com/the-luap/picpeak/commit/e016f510b6cc57a9ed1b59e2ee24fedd5d7097c3)) +* **lightbox:** eliminate download lag on Android by skipping the blob round-trip ([0479521](https://github.com/the-luap/picpeak/commit/04795219a0b66fdd1ef73748d803adfdfc0d676f)) + +## [3.54.7-beta.0](https://github.com/the-luap/picpeak/compare/v3.54.6-beta.0...v3.54.7-beta.0) (2026-05-26) + + +### Bug Fixes + +* **lightbox:** restrict Web Share save-to-Photos path to iOS ([#554](https://github.com/the-luap/picpeak/issues/554)) ([578397b](https://github.com/the-luap/picpeak/commit/578397bc6b27b56ccf3bf1f2f244e0e0053c493a)) +* **lightbox:** restrict Web Share save-to-Photos path to iOS ([#554](https://github.com/the-luap/picpeak/issues/554)) ([2a309c7](https://github.com/the-luap/picpeak/commit/2a309c75a74be3af3eb67758f8d65f801ef3019a)) + +## [3.54.6-beta.0](https://github.com/the-luap/picpeak/compare/v3.54.5-beta.0...v3.54.6-beta.0) (2026-05-25) + + +### Bug Fixes + +* **api/v1:** accept color_theme + create feedback row on event create ([#550](https://github.com/the-luap/picpeak/issues/550)) ([7ef0e40](https://github.com/the-luap/picpeak/commit/7ef0e40e7cee2ab6eeea4fe75c558e930e31241d)) +* **api/v1:** accept color_theme + create feedback row on event create ([#550](https://github.com/the-luap/picpeak/issues/550)) ([1b521e7](https://github.com/the-luap/picpeak/commit/1b521e761c3e2cc6c885d03ef746aa7e77e6f067)) + +## [3.54.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.54.4-beta.0...v3.54.5-beta.0) (2026-05-22) + + +### Bug Fixes + +* **nginx:** honour outer X-Forwarded-Proto when behind a reverse proxy ([#547](https://github.com/the-luap/picpeak/issues/547)) ([b351d17](https://github.com/the-luap/picpeak/commit/b351d17ee99528dd4251e74dfc47cd1fe289d9c3)) +* **nginx:** honour outer X-Forwarded-Proto when behind a reverse proxy ([#547](https://github.com/the-luap/picpeak/issues/547)) ([5488de3](https://github.com/the-luap/picpeak/commit/5488de3383d33d8a037587dd9112d36ea035c465)) + +## [3.54.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.54.3-beta.0...v3.54.4-beta.0) (2026-05-21) + + +### Bug Fixes + +* recover three orphaned commits from [#527](https://github.com/the-luap/picpeak/issues/527) (BRAND_TITLE runtime, Web Share, pan zoom) ([9607b46](https://github.com/the-luap/picpeak/commit/9607b4666c0abf22e54b43be3e87e3243db2cdbc)) + +## [3.54.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.54.2-beta.0...v3.54.3-beta.0) (2026-05-21) + + +### Bug Fixes + +* **lightbox:** fill the heart icon when liked ([#538](https://github.com/the-luap/picpeak/issues/538) follow-up) ([3e39112](https://github.com/the-luap/picpeak/commit/3e39112a1276c194259d936dad813f3b0fc2dc3f)) +* **lightbox:** fill the heart icon when liked ([#538](https://github.com/the-luap/picpeak/issues/538) follow-up) ([600c29d](https://github.com/the-luap/picpeak/commit/600c29db8a75fa44e72da55bc5288908de614d9d)) + +## [3.54.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.54.1-beta.0...v3.54.2-beta.0) (2026-05-20) + + +### Bug Fixes + +* **feedback:** three guest-mode bugs from [#538](https://github.com/the-luap/picpeak/issues/538) (filter, like state, count leak) ([c900be9](https://github.com/the-luap/picpeak/commit/c900be92dd490b21aabb10fd56b6fbc3da444ee0)) +* **feedback:** three guest-mode bugs reported in [#538](https://github.com/the-luap/picpeak/issues/538) ([5311588](https://github.com/the-luap/picpeak/commit/5311588baf3c6acfc971cb014a142d6b4b153aa1)) + +## [3.54.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.54.0-beta.0...v3.54.1-beta.0) (2026-05-20) + + +### Bug Fixes + +* **public-site:** honor dark theme surface colors ([8b72721](https://github.com/the-luap/picpeak/commit/8b727218127db2a738ad5a4381358076c1575c8a)) + +## [3.54.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.53.0-beta.0...v3.54.0-beta.0) (2026-05-20) + + +### Features + +* **install:** skip legacy chain when modern bootstrap fingerprint detected ([#530](https://github.com/the-luap/picpeak/issues/530)) ([8f0108c](https://github.com/the-luap/picpeak/commit/8f0108ce233f457d6a0f4f3dbc3e1b0a7217e74e)) + + +### Bug Fixes + +* **install:** skip legacy chain on recovery-state DBs + schema-drift CI ([#530](https://github.com/the-luap/picpeak/issues/530)) ([a0ebc97](https://github.com/the-luap/picpeak/commit/a0ebc97cdd871041ff3cfdcc7276c413ac89d24f)) + +## [3.53.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.52.1-beta.0...v3.53.0-beta.0) (2026-05-19) + + +### Features + +* **events:** default Guest Feedback ON via admin setting ([#520](https://github.com/the-luap/picpeak/issues/520)) ([3465b55](https://github.com/the-luap/picpeak/commit/3465b55abc98e52cf58ba46b811f4ec115d53012)) + + +### Bug Fixes + +* **bug-batch-518:** lightbox comments toggle + further fixes ([633a2ae](https://github.com/the-luap/picpeak/commit/633a2ae72405ae1fc885cb710ed896476ffee467)) +* **header:** hide language name on mobile to free the title ([#523](https://github.com/the-luap/picpeak/issues/523)) ([4b4ecfd](https://github.com/the-luap/picpeak/commit/4b4ecfdf7143c8f353355ecd6d5ee14bbf50c9bb)) +* **lightbox:** hide comments toggle when allow_comments=false ([#518](https://github.com/the-luap/picpeak/issues/518)) ([d44e1ad](https://github.com/the-luap/picpeak/commit/d44e1adba7a444b03511e9402cd39d25fe5acafe)) +* **og:** brandable static title + wider crawler UA coverage ([#521](https://github.com/the-luap/picpeak/issues/521)) ([b960639](https://github.com/the-luap/picpeak/commit/b96063903513fcc4cbe0e72f59ccbc37d7b1c0ab)) + +## [3.52.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.52.0-beta.0...v3.52.1-beta.0) (2026-05-18) + + +### Bug Fixes + +* **install:** self-chowning entrypoint kills fresh-install restart loop ([#484](https://github.com/the-luap/picpeak/issues/484)) ([42c5cda](https://github.com/the-luap/picpeak/commit/42c5cda38c0deeb4e61554e9e4a913bd5cd0b980)) + +## [3.52.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.51.5-beta.0...v3.52.0-beta.0) (2026-05-18) ### Features diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 14432042..c25875dc 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66aedcec..e6e37d17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `beta` +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! 🎉 \ No newline at end of file diff --git a/README.md b/README.md index 2d0ffa85..c3f8957c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # PicPeak +> [!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. +
PicPeak Logo @@ -35,13 +43,30 @@ Admin panel: [demo.picpeak.app/admin](https://demo.picpeak.app/admin) — login **Themes & Branding** — 11 built-in theme presets, custom CSS templates, configurable colors/fonts/layouts. White-label your admin panel and login page with your own logo and company name. -**Email Notifications** — Automated gallery creation, expiration warning, and archive emails. Multilingual templates (EN, DE, NL, PT, RU) editable from the admin UI. +### 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 **Photo Protection** — Watermarking, right-click prevention, canvas rendering, DevTools detection. Configurable per gallery. **External Media** — Reference photos from a mounted folder instead of uploading. PicPeak reads originals in place and generates thumbnails on demand. -**Multi-Language** — Full UI translations for English, German, Dutch, Portuguese, and Russian. Email templates support all languages independently. +### 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 **Analytics** — Built-in view/download tracking plus optional Umami integration for privacy-focused analytics. @@ -52,20 +77,42 @@ Admin panel: [demo.picpeak.app/admin](https://demo.picpeak.app/admin) — login ## Quick Start ```bash -git clone https://github.com/the-luap/picpeak.git +# Clone the repository +git clone https://github.com/PicPeak/picpeak.git cd picpeak + +# 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 .env — set at least JWT_SECRET and passwords + +# Start with Docker Compose docker compose up -d ``` -Open `http://localhost:3000` and log in with the credentials from your `.env`. +### 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). > **Permissions:** Set `PUID` and `PGID` in `.env` to match your host user (`id -u` / `id -g`) so Docker volumes are writable. See the [Deployment Guide](DEPLOYMENT_GUIDE.md) for reverse proxy setup, SSL, external media, and production configuration. -## Screenshots +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.
Admin Dashboard @@ -131,7 +178,13 @@ We welcome contributions — bug fixes, features, translations, documentation. S - [Admin API Quickstart](docs/admin-api-quickstart.md) — Authentication and testing guide - [Security Policy](SECURITY.md) -## Contributors +- 🚀 [**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 Thanks to the people whose code, reports, and feedback have shaped PicPeak: @@ -143,10 +196,372 @@ If you've contributed and aren't listed here, please open a PR. ## License -MIT — use it for personal or commercial projects. +## 🎯 Use Cases + +Perfect for: +- 💒 **Wedding Photographers** - Share ceremony photos securely +- 🎂 **Event Photography** - Birthday parties, corporate events +- 📸 **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**: 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**: **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 +- **Database**: SQLite (included) or PostgreSQL 12+ + +### Docker Requirements (Recommended) +- **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. + +See our [Contributing Guide](CONTRIBUTING.md) for details. + +## 📊 Comparison with Alternatives + +| 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 | ❌ | ❌ | ❌ | + +*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 + +PicPeak takes security seriously: +- 🔐 Password hashing with bcrypt +- 🎫 JWT-based authentication +- 🚦 Rate limiting on all endpoints +- 🛡️ CORS protection +- 📝 Activity logging +- 🔒 Secure file access + +Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub + +## 📸 Screenshots + +### 🎛️ **Admin Dashboard** +Get a complete overview of your photo galleries, analytics, and system status. + +PicPeak Admin Dashboard + +### 📊 **Analytics & Insights** +Track gallery performance, view statistics, and monitor user engagement. + +PicPeak Analytics Dashboard + +### 📁 **Event Management** +Organize and manage your photo galleries with intuitive event management tools. + +PicPeak Events Management + +### ✨ **Key Interface Highlights** + +
+👆 Click to see more interface details + +#### What makes PicPeak's interface special: + +- **🎨 Clean Design**: Modern, photographer-friendly interface +- **📱 Responsive**: Perfect on desktop, tablet, and mobile +- **⚡ Fast Loading**: Optimized for quick photo browsing +- **🔒 Secure Access**: Password-protected galleries with expiration +- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management +- **🎯 Client-Focused**: Intuitive gallery experience for your clients + +
+ +## 🗺️ Roadmap + +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 | +| **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 | +| **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. + +

+ + Buy Me A Coffee + +

+ +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: +- ✅ **Fully tested end-to-end** by human developers +- 🔒 **Security audited** with comprehensive security checks +- 👨‍💻 **Human-reviewed** for code quality and best practices +- 🧪 **Production-tested** in real-world scenarios + +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. + +## 🚀 Ready to Get Started? + +1. ⭐ **Star this repository** to show your support +2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app) +3. 🐛 Report issues or request features +4. 🤝 Join our community and contribute! ---

- Homepage · Live Demo · Docs · Issues + Made with ❤️ by photographers, for photographers +
+ Homepage • + Live Demo • + GitHub • + Documentation • + Support

diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..1fe8b755 --- /dev/null +++ b/RELEASING.md @@ -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 :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. diff --git a/SECURITY.md b/SECURITY.md index d663cb7f..487527fa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,7 +16,7 @@ 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 privately by: -- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) +- **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 @@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed: ## Contact -- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) -- 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! \ No newline at end of file diff --git a/SIMPLE_SETUP.md b/SIMPLE_SETUP.md index 498535ee..9b6d3da8 100644 --- a/SIMPLE_SETUP.md +++ b/SIMPLE_SETUP.md @@ -8,7 +8,7 @@ This guide provides easy installation instructions for PicPeak on Linux servers ```bash # Download and run the unified setup script -curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \ +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 ``` @@ -163,6 +163,19 @@ sudo ./picpeak-setup.sh --native --unattended \ - `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) @@ -472,7 +485,7 @@ sudo -u picpeak node scripts/reset-admin-password.js - [Deployment Guide](https://docs.picpeak.app/deployment) 3. **Support:** - - [GitHub Issues](https://github.com/the-luap/picpeak/issues) + - [GitHub Issues](https://github.com/PicPeak/picpeak/issues) - Include: Error messages, system info (`uname -a`), installation method ## 🔒 Security Best Practices @@ -550,4 +563,4 @@ sudo ./picpeak-setup.sh --native \ --- -**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues) +**PicPeak Setup v1.0** | [Documentation](https://github.com/PicPeak/picpeak) | [Support](https://github.com/PicPeak/picpeak/issues) diff --git a/backend/.env.example b/backend/.env.example index 15ab3ddd..300858e0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,6 +9,16 @@ 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 — diff --git a/backend/Dockerfile b/backend/Dockerfile index e8e66a74..e89058db 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,7 @@ ARG VCS_REF ARG VERSION # Add labels for GitHub Container Registry -LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak" +LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak" LABEL org.opencontainers.image.description="PicPeak Backend Service" LABEL org.opencontainers.image.licenses="MIT" @@ -30,9 +30,14 @@ WORKDIR /app # Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs) RUN apk upgrade --no-cache -# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps -# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues) -RUN npm install -g npm@10 +# 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 @@ -43,7 +48,21 @@ RUN npm install -g npm@10 # `@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()). -RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec +# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that +# contain live 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 @@ -55,6 +74,14 @@ COPY --chown=nodejs:nodejs . . # 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//*.ttf — the +# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg +# rasterises an SVG logo its 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 '\n\n\n /app/assets/fonts\n\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 diff --git a/backend/__tests__/integration/adminBackupCoverage.test.js b/backend/__tests__/integration/adminBackupCoverage.test.js new file mode 100644 index 00000000..18fe862a --- /dev/null +++ b/backend/__tests__/integration/adminBackupCoverage.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/integration/adminBackupIntegrity.test.js b/backend/__tests__/integration/adminBackupIntegrity.test.js new file mode 100644 index 00000000..9a046cc8 --- /dev/null +++ b/backend/__tests__/integration/adminBackupIntegrity.test.js @@ -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', + ])); + }); +}); diff --git a/backend/__tests__/integration/backupService.businessDocs.test.js b/backend/__tests__/integration/backupService.businessDocs.test.js new file mode 100644 index 00000000..2b74b161 --- /dev/null +++ b/backend/__tests__/integration/backupService.businessDocs.test.js @@ -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'); + }); +}); diff --git a/backend/__tests__/integration/backupService.configurableWalker.test.js b/backend/__tests__/integration/backupService.configurableWalker.test.js new file mode 100644 index 00000000..583ab504 --- /dev/null +++ b/backend/__tests__/integration/backupService.configurableWalker.test.js @@ -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'); + }); +}); diff --git a/backend/__tests__/integration/backupService.inlineDbDump.test.js b/backend/__tests__/integration/backupService.inlineDbDump.test.js new file mode 100644 index 00000000..431d6dc4 --- /dev/null +++ b/backend/__tests__/integration/backupService.inlineDbDump.test.js @@ -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/); + }); +}); diff --git a/backend/__tests__/integration/backupService.perPathStats.test.js b/backend/__tests__/integration/backupService.perPathStats.test.js new file mode 100644 index 00000000..3e65ca12 --- /dev/null +++ b/backend/__tests__/integration/backupService.perPathStats.test.js @@ -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. diff --git a/backend/__tests__/integration/backupService.smoke.test.js b/backend/__tests__/integration/backupService.smoke.test.js new file mode 100644 index 00000000..e1f90fb6 --- /dev/null +++ b/backend/__tests__/integration/backupService.smoke.test.js @@ -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/); + }); + }); +}); diff --git a/backend/__tests__/integration/bookingCutover.test.js b/backend/__tests__/integration/bookingCutover.test.js new file mode 100644 index 00000000..563d4fd6 --- /dev/null +++ b/backend/__tests__/integration/bookingCutover.test.js @@ -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(); + }); +}); diff --git a/backend/__tests__/integration/crmSchema.test.js b/backend/__tests__/integration/crmSchema.test.js new file mode 100644 index 00000000..fde94cad --- /dev/null +++ b/backend/__tests__/integration/crmSchema.test.js @@ -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'); + }); + }); +}); diff --git a/backend/__tests__/integration/discountLineItems.test.js b/backend/__tests__/integration/discountLineItems.test.js new file mode 100644 index 00000000..6b222d36 --- /dev/null +++ b/backend/__tests__/integration/discountLineItems.test.js @@ -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, + }); + }); + }); +}); diff --git a/backend/__tests__/integration/emailTemplateBoot.test.js b/backend/__tests__/integration/emailTemplateBoot.test.js new file mode 100644 index 00000000..27265b6a --- /dev/null +++ b/backend/__tests__/integration/emailTemplateBoot.test.js @@ -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'); + }); +}); diff --git a/backend/__tests__/integration/eventTypeRename.test.js b/backend/__tests__/integration/eventTypeRename.test.js new file mode 100644 index 00000000..caf41597 --- /dev/null +++ b/backend/__tests__/integration/eventTypeRename.test.js @@ -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'); + }); +}); diff --git a/backend/__tests__/integration/galleryShortUrlRoute.test.js b/backend/__tests__/integration/galleryShortUrlRoute.test.js new file mode 100644 index 00000000..fd157c84 --- /dev/null +++ b/backend/__tests__/integration/galleryShortUrlRoute.test.js @@ -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 , canonical = /s/ + * - 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(', 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/ 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); + }); +}); diff --git a/backend/__tests__/integration/galleryShortUrls.test.js b/backend/__tests__/integration/galleryShortUrls.test.js new file mode 100644 index 00000000..cc3b40ec --- /dev/null +++ b/backend/__tests__/integration/galleryShortUrls.test.js @@ -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/ 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/ 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(); + }); +}); diff --git a/backend/__tests__/integration/helpers/crmDb.js b/backend/__tests__/integration/helpers/crmDb.js new file mode 100644 index 00000000..b695ace7 --- /dev/null +++ b/backend/__tests__/integration/helpers/crmDb.js @@ -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 `. + */ +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, +}; diff --git a/backend/__tests__/integration/incomingInvoiceRebill.test.js b/backend/__tests__/integration/incomingInvoiceRebill.test.js new file mode 100644 index 00000000..9d9312c7 --- /dev/null +++ b/backend/__tests__/integration/incomingInvoiceRebill.test.js @@ -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(); + }); +}); diff --git a/backend/__tests__/integration/installFromBackupBoot.test.js b/backend/__tests__/integration/installFromBackupBoot.test.js new file mode 100644 index 00000000..976def16 --- /dev/null +++ b/backend/__tests__/integration/installFromBackupBoot.test.js @@ -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(); + }); +}); diff --git a/backend/__tests__/integration/invoiceDunning.test.js b/backend/__tests__/integration/invoiceDunning.test.js new file mode 100644 index 00000000..8b4194a3 --- /dev/null +++ b/backend/__tests__/integration/invoiceDunning.test.js @@ -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(); + }); +}); diff --git a/backend/__tests__/integration/picpeakExport.test.js b/backend/__tests__/integration/picpeakExport.test.js new file mode 100644 index 00000000..bb443d6c --- /dev/null +++ b/backend/__tests__/integration/picpeakExport.test.js @@ -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 }); + } + }); +}); diff --git a/backend/__tests__/integration/picpeakRoundtrip.test.js b/backend/__tests__/integration/picpeakRoundtrip.test.js new file mode 100644 index 00000000..8e26de07 --- /dev/null +++ b/backend/__tests__/integration/picpeakRoundtrip.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/integration/resetAdminMfaCli.test.js b/backend/__tests__/integration/resetAdminMfaCli.test.js new file mode 100644 index 00000000..3aa3f2bb --- /dev/null +++ b/backend/__tests__/integration/resetAdminMfaCli.test.js @@ -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 --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'); +}); diff --git a/backend/__tests__/integration/restoreService.pgBranch.test.js b/backend/__tests__/integration/restoreService.pgBranch.test.js new file mode 100644 index 00000000..6b6eab8b --- /dev/null +++ b/backend/__tests__/integration/restoreService.pgBranch.test.js @@ -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: `.count ` + // where 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); + }); +}); diff --git a/backend/__tests__/integration/setupService.test.js b/backend/__tests__/integration/setupService.test.js new file mode 100644 index 00000000..0f48f48d --- /dev/null +++ b/backend/__tests__/integration/setupService.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js new file mode 100644 index 00000000..5881d90e --- /dev/null +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -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 (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(7); + + 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(7); + 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 + }); +}); diff --git a/backend/__tests__/integration/workflowRoutes.test.js b/backend/__tests__/integration/workflowRoutes.test.js new file mode 100644 index 00000000..c5126c84 --- /dev/null +++ b/backend/__tests__/integration/workflowRoutes.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/middleware/ownership.filterOwnedEventIds.test.js b/backend/__tests__/middleware/ownership.filterOwnedEventIds.test.js new file mode 100644 index 00000000..066080c0 --- /dev/null +++ b/backend/__tests__/middleware/ownership.filterOwnedEventIds.test.js @@ -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]); + }); +}); diff --git a/backend/__tests__/middleware/photoAuth.thumbnailScope.test.js b/backend/__tests__/middleware/photoAuth.thumbnailScope.test.js new file mode 100644 index 00000000..df4050f7 --- /dev/null +++ b/backend/__tests__/middleware/photoAuth.thumbnailScope.test.js @@ -0,0 +1,103 @@ +/** + * Regression test for the cross-event thumbnail enumeration leak. + * + * Thumbnails are served flat from /thumbnails/thumb_ 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(); + }); +}); diff --git a/backend/__tests__/routes/adminCrmAuth.test.js b/backend/__tests__/routes/adminCrmAuth.test.js new file mode 100644 index 00000000..3365994c --- /dev/null +++ b/backend/__tests__/routes/adminCrmAuth.test.js @@ -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); + }); + }); +}); diff --git a/backend/__tests__/routes/adminEvents.smoke.test.js b/backend/__tests__/routes/adminEvents.smoke.test.js new file mode 100644 index 00000000..fdee970b --- /dev/null +++ b/backend/__tests__/routes/adminEvents.smoke.test.js @@ -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/. + 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); + }); + }); +}); diff --git a/backend/__tests__/routes/adminMfa.test.js b/backend/__tests__/routes/adminMfa.test.js new file mode 100644 index 00000000..352f2a6e --- /dev/null +++ b/backend/__tests__/routes/adminMfa.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/routes/publicContracts.test.js b/backend/__tests__/routes/publicContracts.test.js new file mode 100644 index 00000000..a1ed781c --- /dev/null +++ b/backend/__tests__/routes/publicContracts.test.js @@ -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'); + }); + }); +}); diff --git a/backend/__tests__/routes/publicPaymentCheck.test.js b/backend/__tests__/routes/publicPaymentCheck.test.js new file mode 100644 index 00000000..ea5c0ee8 --- /dev/null +++ b/backend/__tests__/routes/publicPaymentCheck.test.js @@ -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); + }); + }); +}); diff --git a/backend/__tests__/routes/publicQuotes.test.js b/backend/__tests__/routes/publicQuotes.test.js new file mode 100644 index 00000000..f5234f71 --- /dev/null +++ b/backend/__tests__/routes/publicQuotes.test.js @@ -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); + }); + }); +}); diff --git a/backend/__tests__/routes/slideshowAdmin.test.js b/backend/__tests__/routes/slideshowAdmin.test.js new file mode 100644 index 00000000..ca277004 --- /dev/null +++ b/backend/__tests__/routes/slideshowAdmin.test.js @@ -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'); + }); + }); +}); diff --git a/backend/__tests__/routes/slideshowPublic.test.js b/backend/__tests__/routes/slideshowPublic.test.js new file mode 100644 index 00000000..5bb6633a --- /dev/null +++ b/backend/__tests__/routes/slideshowPublic.test.js @@ -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); + }); + }); +}); diff --git a/backend/__tests__/services/backupIntegrityService.test.js b/backend/__tests__/services/backupIntegrityService.test.js new file mode 100644 index 00000000..52e2c6bf --- /dev/null +++ b/backend/__tests__/services/backupIntegrityService.test.js @@ -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(); + }); +}); diff --git a/backend/__tests__/services/billingRecipients.test.js b/backend/__tests__/services/billingRecipients.test.js new file mode 100644 index 00000000..700d28e9 --- /dev/null +++ b/backend/__tests__/services/billingRecipients.test.js @@ -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 }); + }); +}); diff --git a/backend/__tests__/services/contractService.test.js b/backend/__tests__/services/contractService.test.js new file mode 100644 index 00000000..55268afe --- /dev/null +++ b/backend/__tests__/services/contractService.test.js @@ -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(), + ); + }); +}); diff --git a/backend/__tests__/services/customScriptSanitiser.test.js b/backend/__tests__/services/customScriptSanitiser.test.js new file mode 100644 index 00000000..e56ebb62 --- /dev/null +++ b/backend/__tests__/services/customScriptSanitiser.test.js @@ -0,0 +1,100 @@ +/** + * Tests for the custom-tracker HTML sanitiser (#663 Phase 1). + * + * The field accepts admin-pasted ``-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 = ''; + 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 = ''; + 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 = ''; + const out = sanitizeTrackerSnippet(input); + expect(out).toContain('window.GA = "x"'); + expect(out).toContain('console.log("init")'); + }); + + test('allows