Compare commits
239 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b5c08e99b | |||
| 11ecad136b | |||
| 3a4dccd9f0 | |||
| 0a5e55ca96 | |||
| a72741c0d9 | |||
| e7ed7006fd | |||
| 3d3013d9d6 | |||
| bccaa649dc | |||
| 7aca927937 | |||
| e229c60b22 | |||
| 4966bc6a58 | |||
| 617f292516 | |||
| 8e95004022 | |||
| 93df328853 | |||
| 657e74a2e3 | |||
| ea77f7917e | |||
| f6e5a454ae | |||
| cc7ad4b2bc | |||
| 827a599102 | |||
| b9841b762c | |||
| e636cf5d56 | |||
| a896fa66c0 | |||
| 027c1090a4 | |||
| 4e214588a7 | |||
| 9d0607f4f0 | |||
| 8cbe97d2f4 | |||
| 0d31c9037c | |||
| ffcfd9766d | |||
| 3501a52f0e | |||
| 5ca598b80a | |||
| 4e2075c638 | |||
| 0a691d4251 | |||
| 34846ae71a | |||
| ea261dd03b | |||
| eb93223d79 | |||
| b20f9cc108 | |||
| 515814e1d5 | |||
| fba9838e21 | |||
| a9c2761986 | |||
| db7e5913eb | |||
| aa27d1ea79 | |||
| cf8df2780e | |||
| 419a283c62 | |||
| 1b075a4beb | |||
| 68ff93cf17 | |||
| b22a29e877 | |||
| e601311ca3 | |||
| bb00c3993b | |||
| c9c0de46bf | |||
| 5374299cd5 | |||
| 536e2b2874 | |||
| 141acd5736 | |||
| 5f4337a18d | |||
| fec7b687f7 | |||
| cfa29ad5cb | |||
| 76ae35217c | |||
| fe651fa38e | |||
| f7b8c0c0fe | |||
| 4af3cc2486 | |||
| 3632b936e9 | |||
| 66a6d4003a | |||
| bdf73c1f06 | |||
| f032743690 | |||
| a9902b95b4 | |||
| 9d1c0b672a | |||
| 727fd8bae8 | |||
| 954103510a | |||
| 801e1f81d9 | |||
| f9861480aa | |||
| a26dfd3d6f | |||
| 1db908771f | |||
| 59651b8c24 | |||
| 7ccd48297f | |||
| d05ff6380e | |||
| 605f773a7e | |||
| c844f634c8 | |||
| 1d94398e2d | |||
| a2551dc0ad | |||
| 32821934e6 | |||
| b9c28e52cd | |||
| c94b6268cf | |||
| 439c743fd1 | |||
| 74144f1fc6 | |||
| 99a0376657 | |||
| 21b1e79672 | |||
| cfaee103b6 | |||
| c0e346992d | |||
| 04f45a16c9 | |||
| efad1da74d | |||
| 0a2b010332 | |||
| 6906c8bcf7 | |||
| ac48bfdd0d | |||
| ec99243b6f | |||
| 9932621e14 | |||
| 0fb17c78fa | |||
| 4bcca58a11 | |||
| 9fa5ba1cf7 | |||
| 88919fa0d3 | |||
| 5e43fc9cd9 | |||
| f053f42b6d | |||
| dc17e7d59d | |||
| f05ad87602 | |||
| 2efc74a687 | |||
| 85e7fbe73f | |||
| 0a21856a8d | |||
| 349e7c7eb1 | |||
| be07438915 | |||
| b31ae72153 | |||
| 1db08b1e9b | |||
| 237a3332cc | |||
| e2d0a83d51 | |||
| c546657285 | |||
| e8d5ee1a7b | |||
| 1761ebd531 | |||
| 5e5e98601f | |||
| 1cda80792b | |||
| 8740d5e618 | |||
| a619d52d17 | |||
| dd8cc14d30 | |||
| 3b7d723c2a | |||
| dc6252ff56 | |||
| 1d4e79a4f9 | |||
| 0b0e3e22d2 | |||
| f22e3c133f | |||
| 64c0a58f78 | |||
| 4182089c17 | |||
| cecf773fb7 | |||
| 973af17b85 | |||
| 6c3e88a588 | |||
| 97bbb3c8e1 | |||
| ac1cd96ecd | |||
| 689861f671 | |||
| 41fb575e80 | |||
| 6ebc4f3fc4 | |||
| de973f5613 | |||
| 279c70b3d6 | |||
| 5a73f6963f | |||
| 5101a05bca | |||
| c82caf6539 | |||
| ae1b508726 | |||
| 26c05912fc | |||
| d1033cb83a | |||
| b7458b5a37 | |||
| face8f1496 | |||
| ba6ee55bf7 | |||
| e343106af5 | |||
| 2f848eb602 | |||
| 1c7fa781ad | |||
| 66940c2f5b | |||
| a3638fe954 | |||
| 0934695a69 | |||
| 77ece5c5f1 | |||
| 1c2c1f177a | |||
| f38014099e | |||
| 10649691de | |||
| f439d0b318 | |||
| 4e977f7624 | |||
| 66841e8af7 | |||
| 051e21cbaf | |||
| e35ac6a41c | |||
| 0d33f21ee6 | |||
| 1cfd6a44d6 | |||
| 2b5b875dfe | |||
| f39427d9d9 | |||
| 74d85eadbb | |||
| 0b550cdaf6 | |||
| 288b0c25e6 | |||
| d065132bb7 | |||
| 52ef3e33f4 | |||
| d89a605579 | |||
| 9006b754a8 | |||
| 5328b4f73a | |||
| 6438374258 | |||
| d8fb4c9565 | |||
| f5cf757142 | |||
| 0a203d16cf | |||
| 8231f2b60d | |||
| 9c1e79b5a5 | |||
| 472445a2e5 | |||
| ec3d5a0f80 | |||
| cf32b01356 | |||
| c8cfce3e36 | |||
| 12ba91952e | |||
| 69b56ed582 | |||
| 1bc9b547c7 | |||
| d594d00227 | |||
| 2012b0bab9 | |||
| cfa0b0da69 | |||
| eac573c4a5 | |||
| ff370f6dbd | |||
| 7e3009cedc | |||
| 23ec674e05 | |||
| 971397c338 | |||
| f0768cd31b | |||
| 91601c77a4 | |||
| 53704ec92e | |||
| fece843505 | |||
| 35681d5346 | |||
| 193cadef27 | |||
| 2e7cba9e8a | |||
| 2e10374e2c | |||
| 9dd643338b | |||
| f75ee680a5 | |||
| 8dad933ff1 | |||
| 225d017718 | |||
| f38a8ef598 | |||
| 024c8eac2d | |||
| 932e5e137c | |||
| 3470120a0d | |||
| 28632e8970 | |||
| 6c82958c79 | |||
| 032bbae50d | |||
| d66ff29b3e | |||
| 0ea3ee837a | |||
| 7de326c296 | |||
| fa71cad843 | |||
| ae71834aa6 | |||
| 128452f580 | |||
| f306a2539d | |||
| 160f26f104 | |||
| 01c37098d4 | |||
| f78142cda4 | |||
| 6c84f701ca | |||
| 1802ddaebd | |||
| d1d48fb3da | |||
| 8ba72e8aa1 | |||
| feea04b1ce | |||
| c2e1d30153 | |||
| 993a132d20 | |||
| 1c374a2f82 | |||
| 576e7c5d35 | |||
| 07b93636eb | |||
| a2fca63d3a | |||
| ebf1dabbaa | |||
| 3647855163 | |||
| bdc7e73523 | |||
| f3b83829ca | |||
| 59b1b87cba | |||
| 206539f51e |
@@ -0,0 +1,286 @@
|
||||
# Security Scan Report - Wedding Photo Sharing Application
|
||||
**Date**: July 13, 2025
|
||||
**Scanner**: Claude Security Audit with --security --validate flags
|
||||
**Overall Risk Level**: MEDIUM-HIGH
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The wedding photo sharing application demonstrates strong security fundamentals with comprehensive input validation, proper authentication mechanisms, and good file security practices. However, several critical issues require immediate attention, particularly around hardcoded secrets, token storage, and Content Security Policy configuration.
|
||||
|
||||
### Security Score: 6.5/10
|
||||
|
||||
**Strengths**: Excellent input validation, parameterized queries, file security, rate limiting
|
||||
**Critical Issues**: Hardcoded JWT secrets, localStorage token storage, weak CSP, console logging in production
|
||||
|
||||
---
|
||||
|
||||
## 🔴 CRITICAL FINDINGS (Immediate Action Required)
|
||||
|
||||
### 1. Hardcoded JWT Secret in Development
|
||||
- **Location**: Backend `.env` file
|
||||
- **Risk**: Token forgery, authentication bypass
|
||||
- **Impact**: Complete authentication compromise
|
||||
- **Remediation**:
|
||||
```bash
|
||||
# Generate secure secret
|
||||
openssl rand -base64 32
|
||||
# Never commit to repository
|
||||
echo ".env" >> .gitignore
|
||||
```
|
||||
|
||||
### 2. Gallery Tokens in localStorage
|
||||
- **Location**: Frontend `api.ts` and auth contexts
|
||||
- **Risk**: XSS token theft
|
||||
- **Impact**: Gallery access compromise
|
||||
- **Remediation**: Move to httpOnly cookies:
|
||||
```typescript
|
||||
Cookies.set(`gallery_token_${slug}`, token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict'
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Weak Content Security Policy
|
||||
- **Location**: Frontend `nginx.conf`
|
||||
- **Risk**: XSS, code injection
|
||||
- **Current**: `unsafe-inline` and `unsafe-eval` allowed
|
||||
- **Remediation**: Implement strict CSP (see detailed recommendations below)
|
||||
|
||||
---
|
||||
|
||||
## 🟠 HIGH SEVERITY FINDINGS
|
||||
|
||||
### 1. Console Logging in Production
|
||||
- **Locations**: 61 instances across frontend
|
||||
- **Risk**: Information disclosure
|
||||
- **Impact**: Leaking sensitive data, debugging info
|
||||
- **Remediation**: Implement environment-aware logging
|
||||
|
||||
### 2. Token Revocation Vulnerability
|
||||
- **Location**: Backend `tokenRevocation.js`
|
||||
- **Risk**: Token manipulation
|
||||
- **Impact**: Bypass revocation checks
|
||||
- **Remediation**: Verify token signature before decoding
|
||||
|
||||
### 3. Source Maps in Production
|
||||
- **Location**: Frontend build configuration
|
||||
- **Risk**: Source code exposure
|
||||
- **Impact**: Reveals application structure
|
||||
- **Remediation**: Disable in production builds
|
||||
|
||||
### 4. Missing Security Headers
|
||||
- **Location**: nginx configuration
|
||||
- **Missing**: HSTS, Permissions-Policy
|
||||
- **Impact**: Various client-side attacks
|
||||
- **Remediation**: Add comprehensive security headers
|
||||
|
||||
---
|
||||
|
||||
## 🟡 MEDIUM SEVERITY FINDINGS
|
||||
|
||||
### 1. Rate Limiting Bypass Potential
|
||||
- **Location**: Backend rate limiter
|
||||
- **Risk**: DoS attacks
|
||||
- **Current**: JWT validation in rate limiter
|
||||
- **Remediation**: Use IP-based limiting only
|
||||
|
||||
### 2. Incomplete SQL Injection Protection
|
||||
- **Location**: Complex dashboard queries
|
||||
- **Risk**: Potential injection in edge cases
|
||||
- **Current**: Mostly parameterized
|
||||
- **Remediation**: Use query builder exclusively
|
||||
|
||||
### 3. Session Management
|
||||
- **Issue**: No gallery token invalidation on password change
|
||||
- **Risk**: Persistent access after compromise
|
||||
- **Remediation**: Implement token revocation
|
||||
|
||||
### 4. Path Traversal in Gallery Slugs
|
||||
- **Location**: Frontend gallery routes
|
||||
- **Risk**: Directory traversal attempts
|
||||
- **Remediation**: Validate and sanitize slugs
|
||||
|
||||
---
|
||||
|
||||
## 🟢 LOW SEVERITY FINDINGS
|
||||
|
||||
### 1. Verbose Error Messages
|
||||
- **Location**: Multiple API endpoints
|
||||
- **Risk**: Information disclosure
|
||||
- **Remediation**: Generic client errors, detailed server logs
|
||||
|
||||
### 2. Weak Gallery Passwords
|
||||
- **Current**: zxcvbn score 2/4 allowed
|
||||
- **Risk**: Brute force attacks
|
||||
- **Remediation**: Increase to score 3/4
|
||||
|
||||
### 3. Missing File Size Validation
|
||||
- **Location**: Frontend upload components
|
||||
- **Risk**: DoS via large uploads
|
||||
- **Remediation**: Add client-side size checks
|
||||
|
||||
---
|
||||
|
||||
## ✅ SECURITY STRENGTHS
|
||||
|
||||
### Authentication & Authorization
|
||||
- JWT with proper expiration (24h/7d)
|
||||
- Token type validation
|
||||
- IP tracking and validation
|
||||
- Password change detection
|
||||
- Token revocation system
|
||||
- Bcrypt with 12 rounds
|
||||
- zxcvbn password strength checking
|
||||
|
||||
### Input Validation & SQL Security
|
||||
- express-validator on all endpoints
|
||||
- Parameterized queries via Knex
|
||||
- SQL injection protection utilities
|
||||
- Path traversal prevention
|
||||
- Comprehensive input sanitization
|
||||
|
||||
### File Security
|
||||
- Magic number verification
|
||||
- MIME type validation
|
||||
- Safe filename generation
|
||||
- Directory traversal protection
|
||||
- File extension whitelist
|
||||
|
||||
### Rate Limiting & DoS Protection
|
||||
- General: 100 req/15min
|
||||
- Auth endpoints: 5 req/15min
|
||||
- Account lockout after failed attempts
|
||||
- Suspicious activity detection
|
||||
|
||||
### Frontend Security
|
||||
- React's built-in XSS protection
|
||||
- DOMPurify for HTML content
|
||||
- No eval() or innerHTML usage
|
||||
- Proper error boundaries
|
||||
- ReCAPTCHA integration
|
||||
|
||||
---
|
||||
|
||||
## 📊 DEPENDENCY ANALYSIS
|
||||
|
||||
### Current Status
|
||||
- **Backend**: 0 vulnerabilities (691 packages)
|
||||
- **Frontend**: 0 vulnerabilities (434 packages)
|
||||
|
||||
### Recommended Updates
|
||||
1. **bcrypt** 5.1.1 → 6.0.0 (performance, compatibility)
|
||||
2. **helmet** 7.2.0 → 8.1.0 (new security features)
|
||||
3. **@tiptap** 2.x → 3.x (security improvements)
|
||||
|
||||
### Supply Chain Assessment
|
||||
- All major dependencies from trusted sources
|
||||
- No typosquatting detected
|
||||
- Regular maintenance observed
|
||||
- MIT/ISC/Apache licenses only
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ REMEDIATION PLAN
|
||||
|
||||
### Phase 1: Critical (Within 24 hours)
|
||||
1. Replace hardcoded JWT secret with secure random value
|
||||
2. Move gallery tokens from localStorage to httpOnly cookies
|
||||
3. Implement strict CSP without unsafe-eval
|
||||
4. Remove or wrap console.log statements
|
||||
|
||||
### Phase 2: High Priority (Within 1 week)
|
||||
1. Disable source maps in production
|
||||
2. Add missing security headers (HSTS, Permissions-Policy)
|
||||
3. Fix token revocation vulnerability
|
||||
4. Update critical dependencies (bcrypt, helmet)
|
||||
|
||||
### Phase 3: Medium Priority (Within 1 month)
|
||||
1. Implement comprehensive logging strategy
|
||||
2. Add gallery slug validation
|
||||
3. Enhance rate limiting logic
|
||||
4. Implement session invalidation on password change
|
||||
|
||||
### Phase 4: Ongoing
|
||||
1. Weekly dependency scanning
|
||||
2. Implement security testing in CI/CD
|
||||
3. Regular penetration testing
|
||||
4. Security awareness training
|
||||
|
||||
---
|
||||
|
||||
## 🔒 RECOMMENDED CSP CONFIGURATION
|
||||
|
||||
```nginx
|
||||
add_header Content-Security-Policy "
|
||||
default-src 'self';
|
||||
script-src 'self' 'nonce-{RANDOM}' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob: https:;
|
||||
font-src 'self';
|
||||
connect-src 'self' https://analytics.domain.com;
|
||||
frame-src https://www.google.com/recaptcha/;
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
upgrade-insecure-requests;
|
||||
" always;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 SECURITY IMPROVEMENTS ROADMAP
|
||||
|
||||
### Immediate Implementation
|
||||
```bash
|
||||
# 1. Generate secure secrets
|
||||
openssl rand -base64 32 > jwt-secret.txt
|
||||
|
||||
# 2. Update dependencies
|
||||
cd backend && npm install bcrypt@^6.0.0 helmet@^8.1.0
|
||||
cd ../frontend && npm update
|
||||
|
||||
# 3. Add security scanning
|
||||
npm install -D npm-audit-resolver
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
```yaml
|
||||
# Add to CI pipeline
|
||||
- name: Security Scan
|
||||
run: |
|
||||
npm audit --audit-level=moderate
|
||||
npm run test:security
|
||||
```
|
||||
|
||||
### Monitoring & Alerting
|
||||
1. Implement fail2ban for repeated auth failures
|
||||
2. Set up log analysis for suspicious patterns
|
||||
3. Configure alerts for security events
|
||||
4. Regular vulnerability scanning
|
||||
|
||||
---
|
||||
|
||||
## 📋 COMPLIANCE CHECKLIST
|
||||
|
||||
- [ ] OWASP Top 10 addressed
|
||||
- [ ] GDPR compliance (data minimization, right to erasure)
|
||||
- [ ] Security headers implemented
|
||||
- [ ] Dependency scanning automated
|
||||
- [ ] Incident response plan documented
|
||||
- [ ] Security documentation maintained
|
||||
- [ ] Regular security reviews scheduled
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CONCLUSION
|
||||
|
||||
The wedding photo sharing application has a solid security foundation with excellent input validation and authentication mechanisms. However, operational security practices need immediate attention. The critical issues around secret management and token storage must be addressed before production deployment.
|
||||
|
||||
Implementing the recommended fixes will raise the security score from 6.5/10 to approximately 8.5/10, providing a robust and secure platform for wedding photo sharing.
|
||||
|
||||
---
|
||||
|
||||
*Generated by Claude Security Scanner v1.0*
|
||||
*Next scan recommended: After Phase 1 remediation completion*
|
||||
+1
-32
@@ -5,10 +5,7 @@
|
||||
.env.*
|
||||
docker-compose*.yml
|
||||
.DS_Store
|
||||
# **/ so backend/node_modules and frontend/node_modules are excluded too —
|
||||
# the root-context Dockerfile.aio COPYs those directories and must get its
|
||||
# deps from its builder stages, never from the host checkout.
|
||||
**/node_modules
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
.nyc_output
|
||||
@@ -21,31 +18,3 @@ storage/events/archived/*
|
||||
storage/thumbnails/*
|
||||
data/*.db
|
||||
logs/*
|
||||
|
||||
# Dockerfile.aio builds from the REPOSITORY ROOT and Docker reads only this
|
||||
# file — backend/.dockerignore is never consulted — so the unprefixed rules
|
||||
# above miss backend/data, backend/logs and backend/storage. A checkout that has
|
||||
# been used to run PicPeak would otherwise bake its database, photos, logs and
|
||||
# SETUP_TOKEN into a published image layer.
|
||||
# backend/data wholesale, not a suffix list. It holds only runtime state and is
|
||||
# gitignored in full (.gitignore: `data/`), while suffix rules kept letting real
|
||||
# secrets through: a used checkout here carries ADMIN_CREDENTIALS.txt alongside
|
||||
# the database, plus -journal files and any DATABASE_PATH that does not end in
|
||||
# .db. Any of those in a published layer is a credential leak.
|
||||
backend/data
|
||||
backend/logs
|
||||
backend/storage
|
||||
|
||||
# Same root-context trap, one level deeper: the `.env`, `.env.*` and `data/*.db`
|
||||
# rules above are unanchored only in appearance — Docker matches them against the
|
||||
# path from the build context, so they catch `./.env` and never `backend/.env`.
|
||||
# A checkout that has been used to run PicPeak locally keeps its JWT_SECRET,
|
||||
# DB_PASSWORD and SMTP credentials there, and `COPY backend/ .` puts the file at
|
||||
# /app/.env in the published layer. Match at any depth instead, the way
|
||||
# **/node_modules above already does.
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/*.db
|
||||
**/*.db-journal
|
||||
**/*.sqlite*
|
||||
frontend/dist
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
# Build Backend Docker Image
|
||||
- name: build-backend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_BRANCH}-latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG:-dev}
|
||||
|
||||
# Build Frontend Docker Image
|
||||
- name: build-frontend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_BRANCH}-latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG:-dev}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
- develop
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: release
|
||||
|
||||
steps:
|
||||
# Build Backend Release
|
||||
- name: build-backend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
|
||||
# Build Frontend Release
|
||||
- name: build-frontend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
+28
-360
@@ -1,366 +1,34 @@
|
||||
# PicPeak Environment Configuration
|
||||
# Copy this file to .env and update with your values
|
||||
# Environment Configuration Template
|
||||
# Copy this file to .env and adjust values for your environment
|
||||
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
# Development: Use docker-compose.dev.yml
|
||||
# Production: Use docker-compose.prod.yml with .env.production.example
|
||||
|
||||
# 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
|
||||
# JWT Secret (CRITICAL for production)
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=dev-secret-change-in-production
|
||||
|
||||
# How long a gallery guest stays recognised (#1210). Default 30d. It was 24h,
|
||||
# which meant a client reviewing a gallery across two weekends registered again
|
||||
# in between — and each re-registration is a separate guest whose likes and
|
||||
# favourites no longer join up with the first visit's. Takes any jsonwebtoken
|
||||
# duration ('7d', '12h'); shorten it if your galleries hold sensitive work.
|
||||
#GUEST_TOKEN_TTL=30d
|
||||
# Application URLs
|
||||
ADMIN_URL=http://localhost:3005
|
||||
FRONTEND_URL=http://localhost:3005
|
||||
|
||||
# OIDC SSO for admins (#798) — configured in the admin UI; only these two
|
||||
# values live in the environment:
|
||||
# Key encrypting the OIDC client secret at rest (defaults to JWT_SECRET).
|
||||
#OIDC_ENCRYPTION_KEY=
|
||||
# Break-glass: 'true' re-enables local password login even while the SSO
|
||||
# settings disable it (recovery when the IdP is down or misconfigured).
|
||||
#OIDC_BREAK_GLASS=false
|
||||
# Database Configuration
|
||||
# SQLite is used for development by default
|
||||
# For production PostgreSQL config, see .env.production.example
|
||||
DATABASE_CLIENT=sqlite3
|
||||
DATABASE_PATH=./data/photo_sharing.db
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: follows NODE_ENV (production=true, dev=false)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP
|
||||
#
|
||||
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
|
||||
# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
|
||||
# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads
|
||||
# req.secure from Express, which respects the X-Forwarded-Proto header
|
||||
# when the proxy is in the trust list.
|
||||
#
|
||||
# Requirements for auto mode:
|
||||
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
|
||||
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
|
||||
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
|
||||
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
|
||||
# 192.168.x, link-local). Proxies outside those ranges need custom
|
||||
# trust proxy configuration.
|
||||
# COOKIE_SECURE=auto
|
||||
# Email Configuration
|
||||
# Development: Uses Mailhog (included in docker-compose.dev.yml)
|
||||
# Production: Configure real SMTP server
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
EMAIL_FROM=noreply@localhost
|
||||
|
||||
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
|
||||
# COOKIE_SAMESITE=Lax
|
||||
|
||||
# Cookie Domain — set this if serving auth cookies across subdomains.
|
||||
# Leave unset for same-origin setups.
|
||||
# COOKIE_DOMAIN=.example.com
|
||||
|
||||
# Database Configuration (PostgreSQL)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_USER=picpeak
|
||||
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
|
||||
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
|
||||
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
|
||||
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
|
||||
#DB_PASSWORD=your_secure_postgres_password_here
|
||||
DB_NAME=picpeak_prod
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
|
||||
# IMPORTANT: Same warning applies - avoid $ or escape as $$
|
||||
#REDIS_PASSWORD=your_secure_redis_password_here
|
||||
|
||||
# Admin Account (initial setup) — OPTIONAL
|
||||
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
|
||||
# open /admin and PicPeak shows a setup screen. The one-time setup token is
|
||||
# written to data/SETUP_TOKEN with mode 0600 — read it with
|
||||
# `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged
|
||||
# unless that write fails, so it never sits in `docker logs`.
|
||||
# The all-in-one image keeps it at /data/db/SETUP_TOKEN — inside the volume,
|
||||
# in the db/ subdirectory (#1218). On a NAS with no shell, set ADMIN_PASSWORD
|
||||
# below instead: it needs no file at all.
|
||||
# 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 — OPTIONAL, and normally left alone.
|
||||
# SMTP is configured in the setup wizard / Settings -> Email and stored in the
|
||||
# database (email_configs); that is what the mail queue actually sends with.
|
||||
# These variables are a legacy path kept for config-as-code deployments: when
|
||||
# SMTP_HOST is set, the initial migration seeds the database row from it.
|
||||
# Developers running the `dev` compose profile want SMTP_HOST=mailhog here so
|
||||
# that seed points at the mailhog container.
|
||||
# For Gmail: use app-specific password
|
||||
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
|
||||
#SMTP_HOST=smtp.gmail.com
|
||||
#SMTP_PORT=587
|
||||
#SMTP_SECURE=false
|
||||
#SMTP_USER=your-email@gmail.com
|
||||
#SMTP_PASS=your-app-specific-password
|
||||
#EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Webhook email transport (#1225) — OPTIONAL, an alternative to SMTP entirely.
|
||||
# When EMAIL_WEBHOOK_URL is set, PicPeak stops sending mail itself and POSTs
|
||||
# each composed message as JSON to that URL instead; something downstream
|
||||
# (n8n, Make, a self-hosted relay) delivers it. Useful when SMTP is the part
|
||||
# you cannot get working — app passwords, blocked ports, a NAS with no
|
||||
# outbound 25.
|
||||
#
|
||||
# Deliberately environment-only, not an admin setting: it redirects every
|
||||
# outbound message including password resets, so it should not be changeable
|
||||
# from a compromised admin session.
|
||||
#
|
||||
# EMAIL_WEBHOOK_SECRET is REQUIRED. The body is signed with it and sent as
|
||||
# X-PicPeak-Signature (HMAC-SHA256, hex) — the same scheme as gallery
|
||||
# webhooks, so a receiver verifies both the same way. Set the URL without a
|
||||
# secret and the transport stays OFF and says so in the log, rather than
|
||||
# posting unauthenticated mail to the internet.
|
||||
#
|
||||
# Payload: { from, to[], cc[], subject, html, text, attachments[] }, where each
|
||||
# attachment is { filename, content_type, content_base64 }. Attachments are
|
||||
# included rather than dropped; a message whose attachments exceed 10 MB fails
|
||||
# and stays in the queue instead of arriving without its invoice.
|
||||
#
|
||||
# The receiver must be a public https:// address unless you opt in — a container or LAN
|
||||
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
|
||||
# normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that.
|
||||
#
|
||||
# Webhook and email-webhook deliveries connect to the DNS answer they just
|
||||
# validated and ignore HTTP_PROXY / HTTPS_PROXY. Behind a mandatory egress
|
||||
# proxy set the *_ALLOW_PRIVATE_URLS flag, which sends through the proxy
|
||||
# without pinning.
|
||||
#
|
||||
# A mail account with its own SMTP host (Settings -> Mail accounts) keeps
|
||||
# sending through it; this replaces the global transport only.
|
||||
#
|
||||
# Set EMAIL_FROM above as well. A webhook-only install never gets an
|
||||
# email_configs row (that is seeded only when SMTP_HOST is set), so EMAIL_FROM
|
||||
# is where the sender address comes from.
|
||||
#EMAIL_WEBHOOK_URL=https://n8n.example.com/webhook/picpeak-mail
|
||||
#EMAIL_WEBHOOK_SECRET=generate-a-long-random-string
|
||||
#EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=false
|
||||
|
||||
# Application URLs — OPTIONAL. Leave unset for the normal install.
|
||||
# The public origin is captured by the setup wizard (it proposes the address
|
||||
# you opened the browser at) and stored as the `general_site_url` setting, so
|
||||
# you can change it later in Settings -> General without touching this file.
|
||||
# Setting FRONTEND_URL here OVERRIDES that setting and makes the field
|
||||
# read-only in the admin UI - use it only for config-as-code deployments.
|
||||
# Use full origin with scheme, no trailing slash.
|
||||
# Admin UI is served by the frontend at /admin.
|
||||
#FRONTEND_URL=https://yourdomain.com
|
||||
#ADMIN_URL=https://yourdomain.com
|
||||
|
||||
# Static HTML title + description used for social link previews when the
|
||||
# fetcher doesn't trigger the per-event OG endpoint — most notably the
|
||||
# WhatsApp Business API and various 3rd-party preview-service caches
|
||||
# (#521). Set these to your brand so link previews aren't generic.
|
||||
# Substituted into index.html at frontend-container start, so changes
|
||||
# take effect on the next `docker compose up -d frontend` — no rebuild
|
||||
# required.
|
||||
BRAND_TITLE=PicPeak
|
||||
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
|
||||
|
||||
# API URL for email assets (logos, images in notification emails)
|
||||
# OPTIONAL: when unset this is derived from the resolved public origin + /api,
|
||||
# so the wizard's answer covers it. Set it only for split-origin deployments
|
||||
# where the API lives on a different host than the gallery.
|
||||
#API_URL=https://yourdomain.com/api
|
||||
|
||||
# Frontend API base
|
||||
# For pre-built images and production behind a reverse proxy, keep '/api'.
|
||||
# If you rebuild the frontend yourself, you may set a full URL at build time.
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Port Configuration (optional)
|
||||
# BACKEND_PORT=3001
|
||||
# FRONTEND_PORT=3000
|
||||
# DB_PORT=5432
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# File watcher (watch-folder auto-import, local storage only)
|
||||
# Max photos processed in parallel — raise on hosts with memory headroom,
|
||||
# lower to 1 on very small hosts. Default: 2
|
||||
# FILE_WATCHER_CONCURRENCY=2
|
||||
|
||||
# External-media folder watcher (issue 1187). Reference-mode events can opt in
|
||||
# per event (Event → Source Mode → "Watch folder for new files"); new images in
|
||||
# the folder are then imported without pressing Import. Deleted files are
|
||||
# never removed from the gallery.
|
||||
# EXTERNAL_MEDIA_WATCH=true # global kill switch
|
||||
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
|
||||
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
|
||||
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
|
||||
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
|
||||
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
|
||||
|
||||
# Release Channel
|
||||
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
|
||||
# 'stable' uses the :stable tag (same as :latest on main)
|
||||
# 'beta' uses the :beta tag for pre-release versions
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# Update Check Configuration
|
||||
# Set to 'false' to disable update notifications in admin UI
|
||||
UPDATE_CHECK_ENABLED=true
|
||||
|
||||
# Timezone
|
||||
TZ=UTC
|
||||
|
||||
# Analytics (Optional - Umami)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
VITE_UMAMI_SHARE_URL=
|
||||
|
||||
# Storage variables (host paths)
|
||||
# These control where data is stored on the host. Defaults are local folders.
|
||||
APP_STORAGE=./storage
|
||||
APP_DATA=./data
|
||||
LOGS=./logs
|
||||
|
||||
# ─── Storage Backend ────────────────────────────────────────────────────────
|
||||
# PicPeak can store photos, thumbnails and archive zips on the local filesystem
|
||||
# (default) or on any S3-compatible object store (AWS S3, MinIO, Cloudflare R2,
|
||||
# Backblaze B2, Wasabi, DigitalOcean Spaces, …).
|
||||
#
|
||||
# STORAGE_BACKEND=local (default)
|
||||
# Uses STORAGE_PATH on the local filesystem. Backwards compatible — every
|
||||
# existing deployment keeps working unchanged.
|
||||
#
|
||||
# STORAGE_BACKEND=s3
|
||||
# Reads STORAGE_S3_* below. Auto-import via the filesystem watcher is
|
||||
# disabled in this mode (S3 has no inotify) — every photo must enter via the
|
||||
# admin upload UI/API. Run `node backend/scripts/migrate-storage.js` to copy
|
||||
# existing local content to S3 before flipping the env.
|
||||
#
|
||||
# STORAGE_BACKEND=local
|
||||
#
|
||||
# STORAGE_S3_BUCKET=picpeak
|
||||
# STORAGE_S3_REGION=us-east-1
|
||||
# STORAGE_S3_ACCESS_KEY=AKIAxxxxxxxxxxxxxxxx
|
||||
# STORAGE_S3_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# Custom endpoint — set this for MinIO / R2 / B2 / Spaces. Leave unset for AWS.
|
||||
# STORAGE_S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com
|
||||
# Optional namespace prefix inside the bucket — useful for multi-deployment buckets.
|
||||
# STORAGE_S3_PREFIX=picpeak
|
||||
# STORAGE_S3_FORCE_PATH_STYLE=false # MinIO needs true; auto-on when endpoint is set
|
||||
# STORAGE_S3_SSL=true
|
||||
#
|
||||
# Minimum IAM policy (AWS S3) for the bucket above:
|
||||
# {
|
||||
# "Version": "2012-10-17",
|
||||
# "Statement": [{
|
||||
# "Effect": "Allow",
|
||||
# "Action": [
|
||||
# "s3:GetObject", "s3:PutObject", "s3:DeleteObject",
|
||||
# "s3:ListBucket", "s3:GetBucketLocation"
|
||||
# ],
|
||||
# "Resource": [
|
||||
# "arn:aws:s3:::picpeak",
|
||||
# "arn:aws:s3:::picpeak/*"
|
||||
# ]
|
||||
# }]
|
||||
# }
|
||||
#
|
||||
# EXTERNAL_MEDIA_ROOT (above) always lives on the local filesystem regardless
|
||||
# of STORAGE_BACKEND — reference-mode galleries are not migrated to S3 in v1.
|
||||
|
||||
# ─── Outbound Webhooks (#327) ────────────────────────────────────────────────
|
||||
# PicPeak POSTs event/photo lifecycle notifications to URLs you configure
|
||||
# under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a
|
||||
# per-webhook secret in the X-PicPeak-Signature header.
|
||||
#
|
||||
# WEBHOOK_ALLOW_PRIVATE_URLS (default: false)
|
||||
# Block URLs resolving to private IPs / loopback / .local etc. as an
|
||||
# SSRF mitigation. Set to "true" ONLY in dev when your receiver is on
|
||||
# the same docker network or localhost. Production deployments must
|
||||
# leave this OFF.
|
||||
# WEBHOOK_ALLOW_PRIVATE_URLS=false
|
||||
#
|
||||
# WEBHOOK_DELIVERY_INTERVAL_MS (default: 5000)
|
||||
# How often the worker polls webhook_deliveries for pending rows.
|
||||
# WEBHOOK_DELIVERY_INTERVAL_MS=5000
|
||||
#
|
||||
# WEBHOOK_DELIVERY_CONCURRENCY (default: 5)
|
||||
# Maximum in-flight deliveries per worker tick. One slow consumer can
|
||||
# monopolize all 5 slots — bump this if your receivers are slow OR ship
|
||||
# a separate webhook-only deployment.
|
||||
# WEBHOOK_DELIVERY_CONCURRENCY=5
|
||||
#
|
||||
# WEBHOOK_HTTP_TIMEOUT_MS (default: 10000)
|
||||
# Per-request timeout. Beyond this, the delivery is recorded as a
|
||||
# network error and retried.
|
||||
# WEBHOOK_HTTP_TIMEOUT_MS=10000
|
||||
#
|
||||
# WEBHOOK_MAX_ATTEMPTS (default: 5)
|
||||
# Total attempts before a delivery is marked failed. Backoff between
|
||||
# attempts is exponential: 1m, 5m, 30m, 2h, 12h.
|
||||
# WEBHOOK_MAX_ATTEMPTS=5
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Face recognition — "People in this gallery" (#1074, optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Requires the optional picpeak-ml sidecar container:
|
||||
# docker compose --profile faces up -d
|
||||
#
|
||||
# NONE of these variables do anything until the `faces` feature flag is
|
||||
# enabled in Admin → Settings, AND the per-event "Detect people in this
|
||||
# gallery" toggle is switched on. Both default to OFF. With the flag off the
|
||||
# backend never contacts the sidecar, so leaving these at their defaults on an
|
||||
# install without the container is completely inert.
|
||||
#
|
||||
# Face embeddings are biometric data (GDPR Art. 9 special category in the EU).
|
||||
# The photographer is the controller and needs a lawful basis for the people
|
||||
# in their photos — read https://docs.picpeak.app/features/face-recognition
|
||||
# before enabling.
|
||||
#
|
||||
# NOT AVAILABLE ON THE ALL-IN-ONE IMAGE. The single-container build sets
|
||||
# PICPEAK_SINGLE_CONTAINER=true and the backend refuses to enable face
|
||||
# recognition there regardless of these variables or the feature flag: that
|
||||
# image runs the backend, frontend, database and every worker in one
|
||||
# container, with no ML sidecar to talk to, and face detection would compete
|
||||
# with image processing for the same CPU and memory. Use the standard
|
||||
# multi-container deployment if you want this feature.
|
||||
#
|
||||
# FACE_ML_TOKEN (no default — REQUIRED to run the sidecar)
|
||||
# Shared secret between the backend and the sidecar. The sidecar refuses to
|
||||
# start without it rather than serving anonymously, so an accidentally
|
||||
# published port is never a free face-detection API. Generate with:
|
||||
# openssl rand -hex 32
|
||||
# FACE_ML_TOKEN=
|
||||
#
|
||||
# FACE_ML_URL (default: http://picpeak-ml:8000)
|
||||
# Defaults to the sidecar's compose service name, so the standard
|
||||
# deployment needs no configuration here. Only change it if you run the
|
||||
# sidecar outside the default compose network.
|
||||
# FACE_ML_URL=http://picpeak-ml:8000
|
||||
#
|
||||
# FACE_PROCESSOR_CONCURRENCY (default: 1)
|
||||
# Face-detection workers in the backend. Defaults to 1 deliberately: face
|
||||
# scanning shares a host with Sharp image processing, which is the real
|
||||
# memory pressure (see UPLOAD_PROCESSOR_CONCURRENCY). Raise only on hosts
|
||||
# with headroom to spare.
|
||||
# FACE_PROCESSOR_CONCURRENCY=1
|
||||
#
|
||||
# FACE_ORT_THREADS (default: 1)
|
||||
# ONNX Runtime threads inside the sidecar. More threads mean faster
|
||||
# per-photo inference and higher RSS.
|
||||
# FACE_ORT_THREADS=1
|
||||
|
||||
# Note on FRONTEND_API_URL (documentation only):
|
||||
# When using pre-built frontend images, runtime env vars cannot override the built JS.
|
||||
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
||||
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
|
||||
# should you change VITE_API_URL at build time.
|
||||
|
||||
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
|
||||
# Keep this value stable until participation has been deleted.
|
||||
# USAGE_ENCRYPTION_KEY=
|
||||
|
||||
# Graceful shutdown budget in milliseconds. On SIGTERM the server stops
|
||||
# accepting requests, drains workers and closes the pool; whatever is still
|
||||
# running after this long is abandoned so the process exits before Docker's
|
||||
# 10 s stop grace period (raise stop_grace_period together with this value).
|
||||
#SHUTDOWN_TIMEOUT_MS=8000
|
||||
# Optional: Umami Analytics
|
||||
UMAMI_URL=
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_HASH_SALT=
|
||||
@@ -0,0 +1,44 @@
|
||||
# PicPeak Production Configuration
|
||||
# Copy this file to .env and update with your values
|
||||
|
||||
# Required: Security
|
||||
JWT_SECRET=CHANGE_THIS_TO_RANDOM_32_CHAR_STRING
|
||||
|
||||
# Required: URLs (update with your domain)
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
BACKEND_URL=https://your-domain.com
|
||||
ADMIN_URL=https://your-domain.com
|
||||
|
||||
# Required: Email Settings
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=your-email@gmail.com
|
||||
|
||||
# Required: Initial Admin Account
|
||||
ADMIN_EMAIL=admin@your-domain.com
|
||||
ADMIN_PASSWORD=change-this-password
|
||||
|
||||
# Database (PostgreSQL recommended for production)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=picpeak
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=secure-database-password
|
||||
|
||||
# Optional: Customization
|
||||
SITE_NAME=PicPeak
|
||||
DEFAULT_EXPIRATION_DAYS=30
|
||||
SESSION_TIMEOUT_MINUTES=60
|
||||
|
||||
# Optional: Analytics (Umami)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
|
||||
# Advanced: Performance Tuning
|
||||
NODE_ENV=production
|
||||
BCRYPT_ROUNDS=12
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
@@ -0,0 +1,12 @@
|
||||
# Files to exclude from GitHub mirror
|
||||
.env* export-ignore
|
||||
docker-compose.prod.yml export-ignore
|
||||
.claudedocs/ export-ignore
|
||||
backend/data/ export-ignore
|
||||
backend/storage/ export-ignore
|
||||
backend/.env* export-ignore
|
||||
frontend/.env* export-ignore
|
||||
secrets/ export-ignore
|
||||
*.key export-ignore
|
||||
*.pem export-ignore
|
||||
.gitea/ export-ignore
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for mirroring
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config --global user.name "the-luap"
|
||||
git config --global user.email "paul-nothaft@hotmail.de"
|
||||
|
||||
- name: Debug - Show current branch and status
|
||||
run: |
|
||||
echo "Current branch:"
|
||||
git branch -a
|
||||
echo "Git status:"
|
||||
git status
|
||||
echo "Remote info:"
|
||||
git remote -v
|
||||
|
||||
- name: Create filtered branch
|
||||
run: |
|
||||
# Clean up any existing github-mirror branch
|
||||
git branch -D github-mirror || true
|
||||
|
||||
# Create a new branch for GitHub
|
||||
git checkout --orphan github-mirror
|
||||
|
||||
# Remove sensitive files/directories
|
||||
# Example: Remove .env files, private configs, etc.
|
||||
git rm -r --cached .env* || true
|
||||
git rm -r --cached backend/.env* || true
|
||||
git rm -r --cached frontend/.env* || true
|
||||
git rm -r --cached docker-compose.prod.yml || true
|
||||
git rm -r --cached .claudedocs/ || true
|
||||
git rm -r --cached backend/data/ || true
|
||||
git rm -r --cached backend/storage/ || true
|
||||
git rm -r --cached .gitea/ || true
|
||||
git rm -r --cached scripts/install-gitea-runner.sh || true
|
||||
git rm -r --cached .drone* || true
|
||||
git rm -r --cached .github-mirror-exclude || true
|
||||
git rm -r --cached .gitattributes-github || true
|
||||
git rm -r --cached photo-sharing-prd.md || true
|
||||
git rm -r --cached CLAUDE.md || true
|
||||
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true
|
||||
git rm -r --cached logs/ || true
|
||||
git rm -r --cached frontend/.claudedocs/ || true
|
||||
git rm -r --cached test-maintenance.sh || true
|
||||
git rm -r --cached storage/ || true
|
||||
|
||||
|
||||
# Commit the changes
|
||||
git commit -m "Remove sensitive files for GitHub mirror" || true
|
||||
|
||||
- name: Check GitHub token
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
if [ -z "$GITHUBTOKEN" ]; then
|
||||
echo "ERROR: GITHUBTOKEN secret is not set!"
|
||||
exit 1
|
||||
else
|
||||
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||
fi
|
||||
|
||||
- name: Push to GitHub
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
# Remove existing github remote if it exists
|
||||
git remote remove github || true
|
||||
|
||||
# Add GitHub remote
|
||||
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
||||
|
||||
# Verify remote was added
|
||||
echo "GitHub remote added:"
|
||||
git remote -v
|
||||
|
||||
# Force push the filtered branch to GitHub main
|
||||
echo "Pushing to GitHub..."
|
||||
git push github github-mirror:main --force
|
||||
echo "Push completed successfully!"
|
||||
|
||||
- name: Workflow completed
|
||||
run: |
|
||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||
echo "Check https://github.com/the-luap/picpeak to verify the mirror."
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Test and Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend linting
|
||||
working-directory: ./backend
|
||||
run: npm run lint || true # Continue on lint errors for now
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: ./backend
|
||||
run: npm test || true # Continue on test failures for now
|
||||
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Run frontend linting
|
||||
working-directory: ./frontend
|
||||
run: npm run lint || true # Continue on lint errors for now
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ./frontend
|
||||
run: npm run build
|
||||
@@ -0,0 +1,209 @@
|
||||
name: Version and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitea/**'
|
||||
- '.drone.yml'
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.version.outputs.new_version }}
|
||||
version_changed: ${{ steps.version.outputs.version_changed }}
|
||||
component_changed: ${{ steps.version.outputs.component_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name 'Gitea Actions Bot'
|
||||
git config --global user.email 'actions@gitea.local'
|
||||
|
||||
- name: Detect changes and bump version
|
||||
id: version
|
||||
run: |
|
||||
set -e # Exit on error
|
||||
|
||||
echo "=== Debug Info ==="
|
||||
echo "GitHub event before: ${{ github.event.before }}"
|
||||
echo "GitHub SHA: ${{ github.sha }}"
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Git log (last 5): $(git log --oneline -5)"
|
||||
|
||||
# Get the commit range for changed files
|
||||
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
|
||||
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
# First commit or no previous commit, check against HEAD~1 if it exists
|
||||
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||
COMMIT_RANGE="HEAD~1..HEAD"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
echo "First commit detected, checking all files"
|
||||
CHANGED_FILES=$(git ls-files)
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
|
||||
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
|
||||
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
|
||||
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
|
||||
|
||||
echo "Backend files changed: $BACKEND_CHANGED"
|
||||
echo "Frontend files changed: $FRONTEND_CHANGED"
|
||||
echo "Root files changed: $ROOT_CHANGED"
|
||||
|
||||
# Get current versions
|
||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
|
||||
echo "Current backend version: $BACKEND_VERSION"
|
||||
echo "Current frontend version: $FRONTEND_VERSION"
|
||||
|
||||
# Determine what to update based on changes
|
||||
BACKEND_UPDATE=false
|
||||
FRONTEND_UPDATE=false
|
||||
COMPONENT_CHANGED="none"
|
||||
|
||||
if [ "$ROOT_CHANGED" -gt 0 ]; then
|
||||
# Root changes affect both components
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Root changes detected - updating both components"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Both components changed
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
# Use the higher version as source
|
||||
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
else
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
fi
|
||||
echo "Both backend and frontend changed - updating both"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
|
||||
# Only backend changed
|
||||
BACKEND_UPDATE=true
|
||||
COMPONENT_CHANGED="backend"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Only backend changed - updating backend"
|
||||
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Only frontend changed
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="frontend"
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
echo "Only frontend changed - updating frontend"
|
||||
else
|
||||
echo "No relevant changes detected"
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=none" >> $GITHUB_OUTPUT
|
||||
echo "new_version=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Component changed: $COMPONENT_CHANGED"
|
||||
echo "Source version: $SOURCE_VERSION"
|
||||
echo "Backend update: $BACKEND_UPDATE"
|
||||
echo "Frontend update: $FRONTEND_UPDATE"
|
||||
|
||||
# Calculate new version
|
||||
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
|
||||
MAJOR="${version_parts[0]}"
|
||||
MINOR="${version_parts[1]}"
|
||||
PATCH="${version_parts[2]}"
|
||||
|
||||
# Increment patch version
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||
|
||||
# Update versions in package.json files
|
||||
if [ "$BACKEND_UPDATE" = true ]; then
|
||||
echo "Updating backend version to $NEW_VERSION"
|
||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
if [ "$FRONTEND_UPDATE" = true ]; then
|
||||
echo "Updating frontend version to $NEW_VERSION"
|
||||
cd frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Check if there are changes to commit
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit version bump
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
|
||||
fi
|
||||
|
||||
git push
|
||||
|
||||
- name: Create Git tag
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
|
||||
fi
|
||||
|
||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
|
||||
git push origin "v${{ steps.version.outputs.new_version }}"
|
||||
|
||||
trigger-drone:
|
||||
needs: version-bump
|
||||
if: needs.version-bump.outputs.version_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Drone Build
|
||||
run: |
|
||||
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
||||
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
||||
echo "Drone will automatically trigger on the new tag"
|
||||
# Drone CI will automatically trigger on the tag push event
|
||||
@@ -0,0 +1,24 @@
|
||||
# Exclude patterns for GitHub mirror
|
||||
.env
|
||||
.env.*
|
||||
.env*
|
||||
docker-compose.prod.yml
|
||||
docker-compose.traefik.yml
|
||||
.claudedocs/
|
||||
backend/data/
|
||||
backend/storage/
|
||||
backend/.env*
|
||||
frontend/.env*
|
||||
secrets/
|
||||
*.key
|
||||
*.pem
|
||||
.gitea/
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
.DS_Store
|
||||
deploy/
|
||||
certbot/
|
||||
nginx/
|
||||
photo-sharing-prd.md
|
||||
@@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
buy_me_a_coffee: theluap
|
||||
@@ -24,20 +24,17 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- OS and version:
|
||||
- Browser and version:
|
||||
- PicPeak version and Docker image tag (if applicable):
|
||||
- Deployment method: [Docker Compose, all-in-one container, manual]
|
||||
- Database and version: [PostgreSQL, SQLite]
|
||||
- OS: [e.g. Ubuntu 22.04]
|
||||
- Browser: [e.g. Chrome 120, Safari 17]
|
||||
- PicPeak Version: [e.g. 1.0.22]
|
||||
- Deployment Method: [e.g. Docker Compose, Manual]
|
||||
- Database: [e.g. PostgreSQL 15, SQLite]
|
||||
|
||||
**Logs**
|
||||
Please include relevant logs:
|
||||
```
|
||||
# Backend logs (Docker Compose)
|
||||
docker compose logs --tail=50 backend
|
||||
|
||||
# Or all-in-one container logs (replace picpeak if your container has another name)
|
||||
docker logs --tail=50 picpeak
|
||||
# Backend logs
|
||||
docker-compose logs backend | tail -50
|
||||
|
||||
# Frontend console errors
|
||||
[paste any browser console errors]
|
||||
@@ -47,4 +44,4 @@ docker logs --tail=50 picpeak
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Possible Solution**
|
||||
If you have an idea how to fix the issue, please describe it here.
|
||||
If you have an idea how to fix the issue, please describe it here.
|
||||
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📚 Documentation
|
||||
url: https://docs.picpeak.app
|
||||
about: Installation, configuration and feature guides
|
||||
url: https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md
|
||||
about: Please read the documentation before opening an issue
|
||||
- name: 💬 Discussions
|
||||
url: https://github.com/PicPeak/picpeak/discussions
|
||||
url: https://github.com/the-luap/picpeak/discussions
|
||||
about: Ask questions and discuss with the community
|
||||
- name: 🔒 Security Issues
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
@@ -9,7 +9,6 @@ assignees: ''
|
||||
|
||||
**What documentation needs improvement?**
|
||||
Please specify which document or section needs attention:
|
||||
- [ ] Documentation website (https://docs.picpeak.app)
|
||||
- [ ] README.md
|
||||
- [ ] DEPLOYMENT.md
|
||||
- [ ] CONTRIBUTING.md
|
||||
@@ -17,8 +16,6 @@ Please specify which document or section needs attention:
|
||||
- [ ] Code Comments
|
||||
- [ ] Other: ___________
|
||||
|
||||
Link to the affected page or file:
|
||||
|
||||
**Describe the issue**
|
||||
What's wrong or missing in the documentation?
|
||||
|
||||
@@ -33,4 +30,4 @@ Who is this documentation for?
|
||||
- [ ] End users (photographers/clients)
|
||||
|
||||
**Additional context**
|
||||
Add any other context, examples, or references here.
|
||||
Add any other context, examples, or references here.
|
||||
@@ -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/PicPeak/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
|
||||
Instead, please email security@example.com with the details.
|
||||
|
||||
For minor security improvements or questions, you can use this template:
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# picpeak — All-in-one
|
||||
|
||||
**picpeak** is an open-source, self-hosted **photo-sharing platform for photographers**, with an optional CRM / accounting suite. This image is the **all-in-one** build: the backend, the built web UI and SQLite in **one container, one process** — no compose file, no separate database, no reverse proxy to wire up.
|
||||
|
||||
- 📦 **Source, docs & issues:** https://github.com/PicPeak/picpeak
|
||||
- 🧩 **Multi-container images:** [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) + [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend)
|
||||
|
||||
## Supported tags
|
||||
- `latest` / `stable` — latest stable release
|
||||
- `x.y.z` — a pinned release (**recommended for production**)
|
||||
- `beta` / `main` — latest build from `main` (may be unstable)
|
||||
- **Architectures:** `linux/amd64`, `linux/arm64` (x86 and ARM NAS)
|
||||
|
||||
## Quick start
|
||||
|
||||
docker run -d --name picpeak -p 3000:3000 \
|
||||
-v picpeak:/data \
|
||||
-e JWT_SECRET="$(openssl rand -base64 48)" \
|
||||
picpeak/aio:stable
|
||||
|
||||
Then open **http://localhost:3000/admin** and complete the setup wizard. Read the one-time setup token with:
|
||||
|
||||
docker exec picpeak cat /data/db/SETUP_TOKEN
|
||||
|
||||
> 🔗 Share links need to know your address. The image defaults `FRONTEND_URL` to `http://localhost:3000`; pass `-e FRONTEND_URL=https://photos.example.com` (or set the site URL in Settings) before you send a gallery to a client.
|
||||
|
||||
## Ports & volumes
|
||||
- Container port **3000** (HTTP; put your own TLS terminator in front for public use).
|
||||
- **One volume: `/data`** — back it up and you have backed up the install.
|
||||
- `/data/db` — `picpeak.db` and `SETUP_TOKEN`
|
||||
- `/data/storage` — originals, thumbnails, archives
|
||||
- `/data/logs`, `/data/backup`
|
||||
|
||||
## External Postgres
|
||||
SQLite is this image's default, not its only option. Point it at an existing database exactly like the backend image:
|
||||
|
||||
-e DATABASE_CLIENT=pg -e DB_HOST=… -e DB_USER=… -e DB_PASSWORD=…
|
||||
|
||||
## How it differs from the compose stack
|
||||
- **SQLite takes one writer at a time** — right for a home server, a NAS or a single studio; the compose stack with PostgreSQL is what scales.
|
||||
- **No Redis** — background jobs run in-process.
|
||||
- **Face recognition is unavailable** here. It needs the separate [`picpeak/ml`](https://hub.docker.com/r/picpeak/ml) sidecar, and a second image-processing pipeline competing with thumbnailing for one container's CPU would just make the install slow. Run the multi-container deployment for that feature.
|
||||
|
||||
You can move to the full stack later without reinstalling: take a `.picpeak` backup and restore it there.
|
||||
|
||||
## Docs
|
||||
Volume layout, the external-Postgres variant, TLS, updates and the limits: **https://docs.picpeak.app/deployment/single-container**
|
||||
@@ -1,56 +0,0 @@
|
||||
# picpeak — ML sidecar (face detection)
|
||||
|
||||
**picpeak** is an open-source, self-hosted **photo-sharing platform for photographers**. This image is the **optional face-detection sidecar**: it detects faces in one image and returns a bounding box, five landmarks, quality signals and a 512-d embedding per face.
|
||||
|
||||
**Nothing else.** No database, no volumes, no state, no egress, no model download at runtime. Clustering, person identity, thresholds and every privacy decision live in the picpeak backend, where the data already is — this service forgets each image the moment it answers.
|
||||
|
||||
If you don't run this container, the feature does not exist.
|
||||
|
||||
- 📦 **Source, docs & issues:** https://github.com/PicPeak/picpeak
|
||||
- 🧩 **Runs with:** [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) + [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend)
|
||||
|
||||
## Supported tags
|
||||
- `latest` / `stable` — latest stable release
|
||||
- `x.y.z` — a pinned release (**recommended for production** — keep it on the **same** tag as the backend)
|
||||
- `beta` / `main` — latest build from `main` (may be unstable)
|
||||
- **Architectures:** `linux/amd64`, `linux/arm64`
|
||||
|
||||
> The sidecar's API contract is versioned with the backend that calls it, so `PICPEAK_CHANNEL` resolves the same string across all picpeak images.
|
||||
|
||||
## Turning it on
|
||||
The maintained compose file already contains this service behind a profile — you do not write it by hand:
|
||||
|
||||
docker compose --profile faces up -d
|
||||
|
||||
Then two deliberate actions in the app, neither of which is installing this container:
|
||||
|
||||
1. Enable the **`faces`** feature flag in admin settings.
|
||||
2. Enable **"Detect people in this gallery"** per event.
|
||||
|
||||
**Nothing in the backend touches this service while the flag is off**, so an install without this container never attempts a connection.
|
||||
|
||||
## Configuration
|
||||
| | |
|
||||
|---|---|
|
||||
| `FACE_ML_TOKEN` | **Required.** The container **refuses to start** without it, so an accidentally published port is never a free face-detection API. Must match the backend's `FACE_ML_TOKEN`. |
|
||||
| `FACE_ORT_THREADS` | ONNX Runtime threads (default `1`). |
|
||||
|
||||
Port **8000**, no volumes, no published ports needed — the backend reaches it on the compose network. `FACE_ML_URL` defaults to `http://picpeak-ml:8000` (the compose service name), so the standard deployment needs no URL configuration.
|
||||
|
||||
## API
|
||||
All endpoints except `/health` require the `X-Face-ML-Token` header.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /health` | `{"status": "ok"}` — unauthenticated, used by the healthcheck |
|
||||
| `GET /info` | `{detector, embedder, model_version, dim}` |
|
||||
| `POST /faces` | multipart `image` → `{model_version, faces: [...]}` |
|
||||
|
||||
## Models
|
||||
YuNet (detection) + FaceNet-512 (embedding), **both MIT**, baked into the image and verified by SHA-256 at build time — never downloaded at runtime, so airgapped installs work and a model cannot change under a running deployment. See [`ml/LICENSES.md`](https://github.com/PicPeak/picpeak/blob/main/ml/LICENSES.md) for why these and not InsightFace's non-commercial weights.
|
||||
|
||||
## Not available on the all-in-one image
|
||||
[`picpeak/aio`](https://hub.docker.com/r/picpeak/aio) sets `PICPEAK_SINGLE_CONTAINER=true` and the backend refuses to enable face recognition there — a second image-processing pipeline competing with thumbnailing for one small container's CPU would not fail loudly, it would just make the install slow. Run the multi-container deployment for this feature.
|
||||
|
||||
## Docs
|
||||
**https://docs.picpeak.app** · sidecar internals, model conversion and the alignment/threshold contract: [`ml/README.md`](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)
|
||||
@@ -1,221 +0,0 @@
|
||||
# Docker Build and Push Workflow
|
||||
|
||||
This GitHub Actions workflow automatically builds and pushes Docker images for the backend, the frontend, the all-in-one image and the optional ML sidecar to GitHub Container Registry (ghcr.io). On the canonical org repo every one of them is mirrored to Docker Hub as `docker.io/picpeak/{backend,frontend,aio,ml}`; forks build the same images GHCR-only.
|
||||
|
||||
The **all-in-one image** (`<repo>/aio`, built from `Dockerfile.aio` at the repo root, #1042) bundles the backend and the built frontend into a single container with SQLite as the default engine — one `docker run`, no compose. It follows the same per-arch build → digest-merge → per-version tag scheme as the other two images, is mirrored to Docker Hub (`docker.io/picpeak/aio`) alongside GHCR on the canonical org repo, and every PR additionally runs a `smoke-aio` job that boots the image and asserts the SPA shell, brand-title rendering, immutable asset caching, and the SQLite engine resolution.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔧 **Automatic builds** on push to main/develop branches, PRs, and releases
|
||||
- 🏗️ **Multi-architecture support** (linux/amd64 and linux/arm64)
|
||||
- 🏷️ **Smart tagging** based on branches, versions, and commits
|
||||
- 🔒 **Security scanning** with Trivy vulnerability scanner
|
||||
- 💾 **Build caching** for faster subsequent builds
|
||||
- 📊 **Build summaries** in GitHub Actions UI
|
||||
- 📝 **Docker Hub pages** for `aio` and `ml` synced from `.github/dockerhub/*.md` on every `main` merge (`dockerhub-descriptions` job). `backend` and `frontend` pages are still hand-maintained in the Hub UI — add `.github/dockerhub/{backend,frontend}.md` with their current text before putting them under the same job.
|
||||
|
||||
## Authentication
|
||||
|
||||
The workflow uses the built-in `GITHUB_TOKEN` for authentication with GitHub Container Registry. No additional setup or personal access tokens are required.
|
||||
|
||||
### Required Permissions
|
||||
|
||||
The workflow automatically sets the necessary permissions:
|
||||
- `contents: read` - To checkout the repository
|
||||
- `packages: write` - To push images to ghcr.io
|
||||
- `security-events: write` - To upload security scan results
|
||||
|
||||
## Image Tags
|
||||
|
||||
Images are automatically tagged based on the trigger event:
|
||||
|
||||
| Event | Tags Generated |
|
||||
|-------|---------------|
|
||||
| Push to main | `latest`, `main`, `main-<short-sha>` |
|
||||
| Push to develop | `develop`, `develop-<short-sha>` |
|
||||
| Pull Request | `pr-<number>` |
|
||||
| Release (v1.2.3) | `1.2.3`, `1.2`, `1`, `latest` |
|
||||
| Manual trigger | Based on branch + optional push |
|
||||
|
||||
## Usage
|
||||
|
||||
### Pull Images
|
||||
|
||||
Once published, images can be pulled using:
|
||||
|
||||
```bash
|
||||
# Pull backend image
|
||||
docker pull ghcr.io/picpeak/picpeak/backend:latest
|
||||
|
||||
# Pull frontend image
|
||||
docker pull ghcr.io/picpeak/picpeak/frontend:latest
|
||||
|
||||
# Pull specific version
|
||||
docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0
|
||||
|
||||
# Pull for specific architecture
|
||||
docker pull --platform linux/arm64 ghcr.io/picpeak/picpeak/backend:latest
|
||||
|
||||
# The same images on Docker Hub (identical tags, identical digests)
|
||||
docker pull picpeak/backend:latest
|
||||
docker pull picpeak/aio:stable
|
||||
docker pull picpeak/ml:stable
|
||||
```
|
||||
|
||||
### Using in Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: ghcr.io/picpeak/picpeak/backend:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
ports:
|
||||
- "3001:3000"
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/picpeak/picpeak/frontend:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
```
|
||||
|
||||
### Using in Kubernetes
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: picpeak-backend
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: ghcr.io/picpeak/picpeak/backend:latest
|
||||
imagePullPolicy: Always
|
||||
```
|
||||
|
||||
## Manual Workflow Trigger
|
||||
|
||||
You can manually trigger the workflow from the Actions tab:
|
||||
|
||||
1. Go to Actions → "Build and Push Docker Images"
|
||||
2. Click "Run workflow"
|
||||
3. Select branch and whether to push images
|
||||
4. Click "Run workflow"
|
||||
|
||||
## Security Scanning
|
||||
|
||||
The workflow includes Trivy vulnerability scanning that:
|
||||
- Scans for CRITICAL and HIGH severity vulnerabilities
|
||||
- Uploads results to GitHub Security tab
|
||||
- Available under Security → Code scanning alerts
|
||||
|
||||
## Build Optimization
|
||||
|
||||
The workflow uses several optimization techniques:
|
||||
|
||||
1. **GitHub Actions Cache**: Speeds up builds by caching layers
|
||||
2. **Multi-stage builds**: Reduces final image size
|
||||
3. **Parallel builds**: Backend and frontend build simultaneously
|
||||
4. **Smart rebuilds**: Only rebuilds changed components
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permission Denied Errors
|
||||
|
||||
If you encounter permission errors when pushing images:
|
||||
|
||||
1. **First-time setup**: The first push creates a private package. You may need to:
|
||||
- Go to your package settings at `https://github.com/users/YOUR_USERNAME/packages`
|
||||
- Link the package to your repository
|
||||
- Set package visibility (public/private)
|
||||
|
||||
2. **Organization repositories**: Ensure the organization allows GitHub Actions to create packages
|
||||
|
||||
### Build Failures
|
||||
|
||||
Check the workflow logs in the Actions tab for detailed error messages. Common issues:
|
||||
- Missing dependencies in package.json
|
||||
- Dockerfile syntax errors
|
||||
- Network issues during package installation
|
||||
|
||||
### Image Not Found
|
||||
|
||||
If images aren't visible after successful push:
|
||||
- Check package visibility settings
|
||||
- Ensure you're authenticated to pull private images:
|
||||
```bash
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
|
||||
```
|
||||
|
||||
## Package Management
|
||||
|
||||
### View Packages
|
||||
|
||||
Your Docker images are available at:
|
||||
- Backend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Fbackend`
|
||||
- Frontend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Ffrontend`
|
||||
|
||||
### Delete Old Versions
|
||||
|
||||
To save storage, you can delete old versions:
|
||||
1. Go to package settings
|
||||
2. Click on "Manage versions"
|
||||
3. Select versions to delete
|
||||
4. Click "Delete selected versions"
|
||||
|
||||
### Set Retention Policy
|
||||
|
||||
Configure automatic cleanup in package settings:
|
||||
1. Go to package settings
|
||||
2. Click on "Manage Actions access"
|
||||
3. Set retention days for untagged versions
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use semantic versioning** for releases (e.g., v1.2.3)
|
||||
2. **Test images locally** before pushing to production
|
||||
3. **Monitor security alerts** from Trivy scans
|
||||
4. **Clean up old images** regularly to save storage
|
||||
5. **Use specific tags** in production (avoid `latest`)
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Registry
|
||||
|
||||
To use a different registry, update the workflow:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
REGISTRY: docker.io # or your custom registry
|
||||
BACKEND_IMAGE_NAME: yourusername/picpeak-backend
|
||||
```
|
||||
|
||||
### Additional Platforms
|
||||
|
||||
To build for more platforms:
|
||||
|
||||
```yaml
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
```
|
||||
|
||||
### Custom Build Arguments
|
||||
|
||||
Add build arguments in the workflow:
|
||||
|
||||
```yaml
|
||||
build-args: |
|
||||
NODE_VERSION=20
|
||||
API_URL=${{ secrets.API_URL }}
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [GitHub Container Registry Docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)
|
||||
- [Docker Build Action](https://github.com/docker/build-push-action)
|
||||
- [Trivy Security Scanner](https://github.com/aquasecurity/trivy)
|
||||
- [Multi-platform Builds](https://docs.docker.com/build/building/multi-platform/)
|
||||
@@ -1,70 +0,0 @@
|
||||
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 }
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,221 +0,0 @@
|
||||
name: Fresh-install smoke
|
||||
|
||||
# Verifies that a clean Postgres install boots cleanly under the same
|
||||
# conditions a new user hits on their first `docker compose up -d`. The
|
||||
# specific scenarios this guards against — see #484 for the original
|
||||
# reproduction:
|
||||
#
|
||||
# 1. Bind-mounted host directories owned by a UID other than 1001
|
||||
# (the container's nodejs user). The entrypoint must self-chown
|
||||
# and drop privileges via su-exec.
|
||||
# 2. Cold-start Postgres with no prior schema (the FK-order bug fixed
|
||||
# in #494, the index/created_at error fixed in #511, and any
|
||||
# future migration-order issue that only surfaces on an empty DB).
|
||||
#
|
||||
# Triggers only on changes that touch the install path so unrelated PRs
|
||||
# don't pay the build cost.
|
||||
|
||||
on:
|
||||
# No `paths:` filter — branch protection on `main` + `stable` lists
|
||||
# `fresh-install` as a REQUIRED check, and a path-filtered trigger
|
||||
# that skipped on unrelated PRs (e.g. frontend-only) would leave the
|
||||
# required check "missing" forever and block the merge. Better to
|
||||
# pay the boot cost on every PR than maintain a per-path allowlist
|
||||
# that drifts as the install surface evolves. (Branches also updated
|
||||
# post-#669 rename: beta → main, old main → stable.)
|
||||
push:
|
||||
branches: [main, stable]
|
||||
pull_request:
|
||||
branches: [main, stable]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
fresh-install:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Build for the runner's arch only — we just need a runnable image.
|
||||
# The full multi-arch build is the docker-build workflow's job.
|
||||
- name: Build backend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
load: true
|
||||
tags: picpeak-backend:smoke
|
||||
cache-from: type=gha,scope=install-smoke
|
||||
# ignore-error: a flaky GHA cache write must not fail the build.
|
||||
cache-to: type=gha,mode=max,scope=install-smoke,ignore-error=true
|
||||
|
||||
- name: Create Docker network
|
||||
run: docker network create picpeak-smoke
|
||||
|
||||
# Mount as UID 1000 (the typical GitHub Actions runner user, and a
|
||||
# common mismatch case on Linux hosts). The entrypoint must chown
|
||||
# this to 1001 itself — that's the regression we're guarding.
|
||||
- name: Prepare host bind-mount dirs owned by UID 1000
|
||||
run: |
|
||||
mkdir -p smoke-mounts/storage smoke-mounts/data smoke-mounts/logs
|
||||
chmod 755 smoke-mounts smoke-mounts/*
|
||||
ls -ld smoke-mounts/*
|
||||
|
||||
- name: Start Postgres
|
||||
run: |
|
||||
docker run -d --name picpeak-smoke-pg --network picpeak-smoke \
|
||||
-e POSTGRES_USER=picpeak \
|
||||
-e POSTGRES_PASSWORD=smokepass \
|
||||
-e POSTGRES_DB=picpeak_prod \
|
||||
--health-cmd="pg_isready -U picpeak -d picpeak_prod" \
|
||||
--health-interval=2s --health-timeout=2s --health-retries=30 \
|
||||
postgres:15-alpine
|
||||
|
||||
- name: Wait for Postgres healthy
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-pg 2>/dev/null || echo starting)
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo "postgres healthy after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "postgres did not become healthy in 60s"
|
||||
docker logs picpeak-smoke-pg
|
||||
exit 1
|
||||
|
||||
- name: Start backend with mismatched-UID bind mounts (fresh install)
|
||||
run: |
|
||||
docker run -d --name picpeak-smoke-bk --network picpeak-smoke \
|
||||
-e NODE_ENV=production \
|
||||
-e JWT_SECRET=smoketestsecretvalueof32characters \
|
||||
-e DB_HOST=picpeak-smoke-pg \
|
||||
-e DB_USER=picpeak \
|
||||
-e DB_PASSWORD=smokepass \
|
||||
-e DB_NAME=picpeak_prod \
|
||||
-e ADMIN_EMAIL=admin@smoke.local \
|
||||
-e ADMIN_PASSWORD=smokeAdminPass12345 \
|
||||
-e STORAGE_PATH=/app/storage \
|
||||
-v "$PWD/smoke-mounts/storage:/app/storage" \
|
||||
-v "$PWD/smoke-mounts/data:/app/data" \
|
||||
-v "$PWD/smoke-mounts/logs:/app/logs" \
|
||||
picpeak-backend:smoke
|
||||
|
||||
- name: Wait for backend healthy
|
||||
run: |
|
||||
for i in $(seq 1 120); do
|
||||
status=$(docker inspect -f '{{.State.Status}}' picpeak-smoke-bk 2>/dev/null || echo missing)
|
||||
health=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-bk 2>/dev/null || echo none)
|
||||
if [ "$status" = "exited" ]; then
|
||||
echo "FAIL: backend exited during cold-start (restart loop scenario)"
|
||||
docker logs picpeak-smoke-bk
|
||||
echo "--- error.log ---"
|
||||
cat smoke-mounts/logs/error.log 2>/dev/null || echo "(no error.log)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$health" = "healthy" ]; then
|
||||
echo "backend healthy after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "FAIL: backend did not become healthy in 120s"
|
||||
docker ps -a
|
||||
docker logs picpeak-smoke-bk
|
||||
exit 1
|
||||
|
||||
- name: Verify chown happened (container view)
|
||||
run: |
|
||||
# All three dirs should now be owned by nodejs (UID 1001).
|
||||
# If the entrypoint's self-chown branch didn't fire, they'd
|
||||
# still be owned by the runner UID and node would have hit
|
||||
# EACCES creating storage subdirs.
|
||||
for d in /app/storage /app/data /app/logs; do
|
||||
owner_uid=$(docker exec picpeak-smoke-bk stat -c '%u' "$d")
|
||||
if [ "$owner_uid" != "1001" ]; then
|
||||
echo "FAIL: $d is owned by UID $owner_uid (expected 1001)"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: $d owned by UID $owner_uid"
|
||||
done
|
||||
|
||||
- name: Verify app is actually serving
|
||||
run: |
|
||||
# /health is what docker's HEALTHCHECK polls, but hit it
|
||||
# directly to confirm the response shape matches what the
|
||||
# frontend + reverse proxy expect.
|
||||
body=$(docker exec picpeak-smoke-bk wget -qO- http://localhost:3000/health)
|
||||
echo "/health => $body"
|
||||
echo "$body" | grep -q '"status":"ok"' || {
|
||||
echo "FAIL: /health did not return status:ok"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Verify node runs as nodejs (not root)
|
||||
run: |
|
||||
# dumb-init runs as root (PID 1), node must be running as
|
||||
# nodejs (UID 1001) — if su-exec drop didn't happen the app
|
||||
# would be running as root which is the security regression
|
||||
# we're guarding against. Alpine ships BusyBox ps, which
|
||||
# doesn't support `-p PID` or pgrep, so list + awk instead.
|
||||
user=$(docker exec picpeak-smoke-bk ps -o user,comm | awk '$2=="node" {print $1; exit}')
|
||||
if [ "$user" != "nodejs" ]; then
|
||||
echo "FAIL: node running as '$user' (expected nodejs)"
|
||||
docker exec picpeak-smoke-bk ps -o pid,user,comm
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: node running as $user"
|
||||
|
||||
- name: Verify no restart loop
|
||||
run: |
|
||||
restart_count=$(docker inspect -f '{{.RestartCount}}' picpeak-smoke-bk)
|
||||
if [ "$restart_count" -gt 0 ]; then
|
||||
echo "FAIL: container restarted $restart_count time(s) — install loop bug returning"
|
||||
docker logs picpeak-smoke-bk
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: 0 restarts"
|
||||
|
||||
# Restart with `--user 5005:5005` (no root, can't chown) against
|
||||
# bind mounts owned by 1000 — entrypoint must fail loud with the
|
||||
# actionable preflight error, not silently restart-loop.
|
||||
- name: Verify preflight fails loud on unwritable mounts
|
||||
run: |
|
||||
docker rm -f picpeak-smoke-bk2 2>/dev/null || true
|
||||
set +e
|
||||
out=$(docker run --rm --user 5005:5005 --network picpeak-smoke \
|
||||
-e NODE_ENV=production -e JWT_SECRET=x \
|
||||
-e DB_HOST=picpeak-smoke-pg -e DB_USER=picpeak \
|
||||
-e DB_PASSWORD=smokepass -e DB_NAME=picpeak_prod \
|
||||
-e STORAGE_PATH=/app/storage \
|
||||
-v "$PWD/smoke-mounts/storage:/app/storage" \
|
||||
-v "$PWD/smoke-mounts/data:/app/data" \
|
||||
-v "$PWD/smoke-mounts/logs:/app/logs" \
|
||||
picpeak-backend:smoke 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
echo "$out"
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "FAIL: preflight should have exited non-zero"
|
||||
exit 1
|
||||
fi
|
||||
echo "$out" | grep -q "is not writable by UID 5005" || {
|
||||
echo "FAIL: preflight error message missing or wrong"
|
||||
exit 1
|
||||
}
|
||||
echo "ok: preflight failed loud with actionable error"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f picpeak-smoke-bk picpeak-smoke-bk2 picpeak-smoke-pg 2>/dev/null || true
|
||||
docker network rm picpeak-smoke 2>/dev/null || true
|
||||
@@ -1,36 +0,0 @@
|
||||
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
|
||||
@@ -1,88 +0,0 @@
|
||||
name: Release Please (Beta)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
steps:
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
# A dedicated token (fine-grained PAT) makes the release PR run CI
|
||||
# automatically (no "workflows awaiting approval") and lets it be
|
||||
# merged without a manual review. Falls back to GITHUB_TOKEN so the
|
||||
# workflow still works before the secret is added (#719).
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config-beta.json
|
||||
manifest-file: .release-please-manifest-beta.json
|
||||
target-branch: main
|
||||
|
||||
# Auto-approve + enable auto-merge on the open release PR so betas publish
|
||||
# with no manual clicks. Approval uses GITHUB_TOKEN (github-actions[bot]) —
|
||||
# a different identity than the PR author (RELEASE_PLEASE_TOKEN) — so it is
|
||||
# a valid review (requires the org's "Allow GitHub Actions to approve pull
|
||||
# requests" + the repo's "Allow auto-merge"). Only meaningful when a PAT is
|
||||
# set: without it the PR is bot-authored and can't be self-approved, so we
|
||||
# skip and leave today's manual flow. Best-effort — never blocks the run.
|
||||
- name: Auto-approve and enable auto-merge on the release PR
|
||||
if: ${{ steps.release.outputs.release_created != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# This job has no checkout, so gh can't infer the repo from a git
|
||||
# remote — set it explicitly (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
|
||||
exit 0
|
||||
fi
|
||||
pr=$(gh pr list --head release-please--branches--main --state open --json number --jq '.[0].number // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
# Approve as github-actions[bot] (GITHUB_TOKEN) — a different identity
|
||||
# than the PR author (the PAT) — so it counts as a valid review.
|
||||
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
|
||||
# Enable auto-merge as the PAT so the eventual merge commit is
|
||||
# attributed to a real identity. If enabled via GITHUB_TOKEN the merge
|
||||
# push is suppressed by recursion prevention and the follow-up run that
|
||||
# cuts the tag/release never fires (#719).
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
|
||||
else
|
||||
echo "No open release PR to auto-merge."
|
||||
fi
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
echo "## Beta Release Created!" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Best-effort "What's New" highlights on the freshly-created release. Runs in
|
||||
# this same workflow run (not a `release:` trigger) because release-please
|
||||
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
|
||||
whatsnew:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
permissions:
|
||||
contents: write # edit the release body
|
||||
models: read # GitHub Models (free tier)
|
||||
uses: ./.github/workflows/whatsnew-highlights.yml
|
||||
with:
|
||||
tag: ${{ needs.release-please.outputs.tag_name }}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [stable]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}
|
||||
steps:
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
# Dedicated token so the release PR runs CI + can auto-merge without a
|
||||
# manual review. Falls back to GITHUB_TOKEN before the secret is set (#719).
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
target-branch: stable
|
||||
|
||||
# NOTE: stable release PRs are intentionally NOT auto-merged here
|
||||
# anymore. Fixes accumulate in the rolling release PR and are cut as
|
||||
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
|
||||
# or on demand via workflow_dispatch / a manual merge of the release
|
||||
# PR). Beta keeps instant releases — see release-please-beta.yml —
|
||||
# because same-day reporter verification depends on it.
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
echo "## Release Created! " >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Best-effort "What's New" highlights on the freshly-created release. Runs in
|
||||
# this same workflow run (not a `release:` trigger) because release-please
|
||||
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
|
||||
whatsnew:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
permissions:
|
||||
contents: write # edit the release body
|
||||
models: read # GitHub Models (free tier)
|
||||
uses: ./.github/workflows/whatsnew-highlights.yml
|
||||
with:
|
||||
tag: ${{ needs.release-please.outputs.tag_name }}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
name: Cut Stable Release (daily batch)
|
||||
|
||||
# Stable fixes accumulate in release-please's rolling release PR instead of
|
||||
# each cutting its own patch version (the old per-merge auto-merge produced
|
||||
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
|
||||
# stable release PR once a day, so a day of N bugfixes ships as ONE version
|
||||
# with all N changelog entries — and one Docker build instead of N.
|
||||
#
|
||||
# - schedule only fires from the default branch (main); the stable copy of
|
||||
# this file is inert and exists to keep the branches in sync.
|
||||
# - Need a release NOW? Run this via workflow_dispatch, or merge the
|
||||
# release PR by hand — the schedule is a default, not a gate.
|
||||
# - Approval/merge mechanics mirror the old inline step (#719): approve as
|
||||
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
|
||||
# auto-merge as the PAT so the merge attributes to a real identity and
|
||||
# triggers the tag-cutting run. --auto waits for green checks.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
merge-stable-release-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Approve and enable auto-merge on the open stable release PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# No checkout — set the repo explicitly so gh works without a
|
||||
# git remote (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
|
||||
exit 0
|
||||
fi
|
||||
# Strict selection (review P1): this job runs daily even without a
|
||||
# stable push, and `gh pr list --head` matches the branch NAME only
|
||||
# — a fork PR can spoof `release-please--branches--stable`. Pin the
|
||||
# base to stable AND require a same-repo head (isCrossRepository
|
||||
# == false); a fork PR is cross-repository, so it can never be
|
||||
# picked and auto-merged with the privileged PAT.
|
||||
pr=$(gh pr list \
|
||||
--base stable \
|
||||
--head release-please--branches--stable \
|
||||
--state open \
|
||||
--json number,isCrossRepository \
|
||||
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
|
||||
if [ -z "$pr" ]; then
|
||||
echo "No open same-repo stable release PR — nothing to cut today."
|
||||
exit 0
|
||||
fi
|
||||
# Approve is tolerant — a pre-existing approval already satisfies
|
||||
# branch protection and re-approving can return non-zero.
|
||||
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
|
||||
# But the auto-merge enable is the load-bearing step: this scheduled
|
||||
# job is the ONLY automatic stable cut, so DON'T swallow its failure
|
||||
# (review P2) — an expired/under-scoped PAT would otherwise stop
|
||||
# releases while the workflow stays green.
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
|
||||
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
|
||||
# are already green — the normal case at 18:00, since the fixes
|
||||
# merged hours earlier and CI passed. So success is EITHER the PR is
|
||||
# already merged OR an auto-merge request is now pending; only a PR
|
||||
# that is still open with no auto-merge request is a real failure
|
||||
# (expired/under-scoped PAT) worth failing the job on (review round 2).
|
||||
# One snapshot of both fields (review round 3): querying state and
|
||||
# autoMergeRequest separately races — auto-merge can complete
|
||||
# between the two calls, so the first sees OPEN and the second sees
|
||||
# the request already cleared on the now-merged PR → false failure.
|
||||
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
|
||||
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
|
||||
if [ "$state" = "MERGED" ]; then
|
||||
echo "Stable release PR #$pr merged immediately (checks were already green)."
|
||||
elif [ "$automerge" = "true" ]; then
|
||||
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
|
||||
else
|
||||
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,186 +0,0 @@
|
||||
name: Schema drift (#530)
|
||||
|
||||
# Verifies that `migrate:safe` can recover a DB that's been seeded only
|
||||
# by `initializeDatabase()` — the recovery scenario where the migrations
|
||||
# tracking table is empty but the schema already has the modern bootstrap.
|
||||
#
|
||||
# This is NOT how production reaches its state on normal installs or
|
||||
# upgrades. The scenario only fires when:
|
||||
# - A backup was restored that captured tables but not the migrations
|
||||
# table (manifest divergence),
|
||||
# - Someone manually invoked initializeDatabase() outside the migration
|
||||
# runner (recovery / debugging),
|
||||
# - The DB was moved between systems and the migrations table was not
|
||||
# copied along.
|
||||
#
|
||||
# When `detectExistingSchema()` sees the modern-bootstrap fingerprint
|
||||
# (photo_categories + cms_pages tables) but an empty migrations table,
|
||||
# it treats it as an "existing deployment" — which runs the legacy
|
||||
# chain first. Legacy/008 renames email_templates.subject → subject_en,
|
||||
# but core/029 (which runs later in this chain) inserts email templates
|
||||
# referencing the pre-rename column name. The chain dies with a
|
||||
# "column subject does not exist" error.
|
||||
#
|
||||
# Fix (in the same PR as this workflow): when the modern-bootstrap
|
||||
# fingerprint is detected, mark all legacy migrations as applied so the
|
||||
# chain matches what a fresh install runs — only core/*, in order.
|
||||
#
|
||||
# This workflow boots the failing scenario from scratch on every PR
|
||||
# that touches the migrations or db.js, so any future migration with
|
||||
# the same shape is caught before merge.
|
||||
|
||||
on:
|
||||
# No `paths:` filter — branch protection on `main` + `stable` lists
|
||||
# `upgrade-from-bootstrap` as a REQUIRED check. A path-filtered
|
||||
# trigger that skipped on unrelated PRs would leave the required
|
||||
# check "missing" forever, blocking every PR that doesn't touch
|
||||
# migrations. The ~75-second cost on every PR buys an unconditional
|
||||
# safety net. (Branches also updated post-#669 rename: beta → main,
|
||||
# old main → stable.)
|
||||
push:
|
||||
branches: [main, stable]
|
||||
pull_request:
|
||||
branches: [main, stable]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upgrade-from-bootstrap:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_USER: picpeak
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: picpeak_drift
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U picpeak -d picpeak_drift"
|
||||
--health-interval 2s
|
||||
--health-timeout 2s
|
||||
--health-retries 30
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install backend deps
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
# Step 1: simulate the recovery state — DB has the modern bootstrap
|
||||
# (post-initializeDatabase) but no migrations recorded. Calling
|
||||
# initializeDatabase() directly outside the migration runner is the
|
||||
# one-line repro for backup-restore-lost-migrations and manual-
|
||||
# invocation paths.
|
||||
- name: Seed DB with initializeDatabase() only
|
||||
working-directory: ./backend
|
||||
env:
|
||||
NODE_ENV: production
|
||||
DATABASE_CLIENT: pg
|
||||
DB_HOST: localhost
|
||||
DB_PORT: 5432
|
||||
DB_USER: picpeak
|
||||
DB_PASSWORD: testpass
|
||||
DB_NAME: picpeak_drift
|
||||
run: |
|
||||
node -e "require('./src/database/db').initializeDatabase().then(() => { console.log('bootstrap ok'); process.exit(0); }).catch(e => { console.error('bootstrap FAILED:', e.message); process.exit(1); })"
|
||||
|
||||
# Sanity-check the recovery shape before migrate:safe runs. If
|
||||
# initializeDatabase() ever stops producing photo_categories +
|
||||
# cms_pages, the fingerprint check would silently no-op and this
|
||||
# workflow would lose its teeth — assert the precondition.
|
||||
- name: Assert recovery-state fingerprint
|
||||
env:
|
||||
PGPASSWORD: testpass
|
||||
run: |
|
||||
installed=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND tablename IN ('photo_categories', 'cms_pages')")
|
||||
if [ "$installed" != "2" ]; then
|
||||
echo "FAIL: expected photo_categories + cms_pages from initializeDatabase(); got $installed."
|
||||
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
|
||||
exit 1
|
||||
fi
|
||||
# initializeDatabase() doesn't create the `migrations` tracking
|
||||
# table — that's the migrate:safe runner's job. So in the recovery
|
||||
# scenario, the table either (a) doesn't exist yet or (b) exists
|
||||
# but is empty (e.g. someone created it but didn't populate it).
|
||||
# Both are valid recovery states; check via to_regclass first so
|
||||
# we don't parse a SELECT against a nonexistent table.
|
||||
has_migrations_table=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT to_regclass('public.migrations')::text")
|
||||
if [ -z "$has_migrations_table" ]; then
|
||||
migrations_count=0
|
||||
else
|
||||
migrations_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations")
|
||||
fi
|
||||
if [ "$migrations_count" != "0" ]; then
|
||||
echo "FAIL: migrations table should be empty for the recovery scenario; has $migrations_count rows."
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: recovery state confirmed (bootstrap tables present, migrations table empty or absent)."
|
||||
|
||||
# Step 2: run migrate:safe — the test. Before #530's fix in
|
||||
# detectExistingSchema, this died at core/029 with a "column
|
||||
# subject does not exist" error. After the fix, it should complete
|
||||
# cleanly with every migration either applied or marked.
|
||||
- name: Run migrate:safe against the recovery state
|
||||
working-directory: ./backend
|
||||
env:
|
||||
NODE_ENV: production
|
||||
DATABASE_CLIENT: pg
|
||||
DB_HOST: localhost
|
||||
DB_PORT: 5432
|
||||
DB_USER: picpeak
|
||||
DB_PASSWORD: testpass
|
||||
DB_NAME: picpeak_drift
|
||||
run: npm run migrate:safe
|
||||
|
||||
# Step 3: schema-shape assertion. A fresh install through migrate:
|
||||
# safe produces 48 tables; the recovery scenario should converge
|
||||
# to the same number. Off-by-one is fine but a 10+ table delta
|
||||
# means a migration silently bailed in the recovery path.
|
||||
- name: Assert final schema matches fresh-install shape
|
||||
env:
|
||||
PGPASSWORD: testpass
|
||||
run: |
|
||||
tables=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public'")
|
||||
echo "Final table count: $tables"
|
||||
# Allow a small drift window — exact count creeps over time as
|
||||
# new migrations land; tight pin would force a workflow edit
|
||||
# on every schema PR. 40+ is a healthy floor that catches the
|
||||
# original bug (which left 17 tables) while staying robust to
|
||||
# forward changes.
|
||||
if [ "$tables" -lt 40 ]; then
|
||||
echo "FAIL: too few tables ($tables) — migrate:safe likely bailed mid-chain."
|
||||
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: schema converged to a fresh-install-equivalent shape."
|
||||
|
||||
# Step 4: verify the legacy migrations were all marked applied
|
||||
# (rather than silently bailing inside the chain). The fix in
|
||||
# detectExistingSchema marks legacy/* when the modern bootstrap
|
||||
# is detected — confirm the markings actually landed.
|
||||
- name: Assert legacy migrations marked applied
|
||||
env:
|
||||
PGPASSWORD: testpass
|
||||
run: |
|
||||
legacy_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations WHERE filename LIKE '008_%' OR filename LIKE '009_%' OR filename LIKE '013_%' OR filename LIKE '019_%' OR filename LIKE '020_%' OR filename LIKE '026_%' OR filename LIKE '028_%'")
|
||||
if [ "$legacy_count" -lt 7 ]; then
|
||||
echo "FAIL: legacy migrations not marked applied ($legacy_count of 7 expected)."
|
||||
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT filename FROM migrations WHERE filename LIKE '0%' ORDER BY filename"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: legacy migrations marked applied by detectExistingSchema."
|
||||
@@ -1,160 +0,0 @@
|
||||
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.
|
||||
#
|
||||
# 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, stable]
|
||||
pull_request:
|
||||
branches: [main, beta, stable]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
# 20, not 10. This job normally finishes in ~3 minutes, but it is the only
|
||||
# one that boots Postgres and runs the full integration suite, so it is the
|
||||
# only one exposed to runner contention — observed spread has reached 9.2
|
||||
# minutes, and a release PR (#1088) was cancelled at 10.3 with every test
|
||||
# passing and jest still running. A cancelled job reads as a red X on a
|
||||
# green branch, which costs a re-run and a diagnosis every time it happens.
|
||||
#
|
||||
# The cap is a runaway guard, not a performance budget; 20 leaves real
|
||||
# headroom over the worst observed run while still killing a hung suite
|
||||
# well inside the hour GitHub would otherwise allow. frontend and ml keep
|
||||
# 10 — they take seconds and have never come close.
|
||||
timeout-minutes: 20
|
||||
|
||||
# The .picpeak restore suites gate their real-Postgres cases behind
|
||||
# PICPEAK_PG_TEST_URL and `describe.skip` themselves out when it is
|
||||
# unset — so until now they never ran here. That hid the half that
|
||||
# matters: sequence resync, operator/role preservation across a
|
||||
# cross-instance restore, and (with #1041) whether a SQLite-shaped
|
||||
# row actually lands in Postgres with the right STORED VALUES rather
|
||||
# than merely not throwing. Everything else in the suite still runs
|
||||
# on SQLite; this service only un-gates those cases.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_USER: picpeak
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: picpeak_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U picpeak -d picpeak_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 2s
|
||||
--health-retries 30
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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'
|
||||
# Un-gates the real-Postgres cases in the .picpeak restore suites
|
||||
# (see the `services:` note above). Absent it they silently skip.
|
||||
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
|
||||
run: npx jest --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: Lint frontend (including Rules of Hooks)
|
||||
working-directory: ./frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Run Vitest suite
|
||||
working-directory: ./frontend
|
||||
run: npm test -- --run
|
||||
|
||||
nginx:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
strategy:
|
||||
matrix:
|
||||
# Match the two shipped frontend Dockerfiles.
|
||||
image: ['nginx:1.28-alpine', 'nginx:1.30-alpine']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify token-safe nginx logging
|
||||
env:
|
||||
NGINX_TEST_IMAGE: ${{ matrix.image }}
|
||||
run: python3 tests/nginx/test_request_logging.py
|
||||
|
||||
# Optional face-detection sidecar (#1074). Runs on every PR regardless of
|
||||
# whether the feature is enabled anywhere — these tests need no model
|
||||
# weights (they stub the pipeline out) and cover the auth boundary, the
|
||||
# request guards and the alignment geometry, which is where a mistake is a
|
||||
# security problem or a silent accuracy problem rather than a visible bug.
|
||||
ml:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
# Matches ml/Dockerfile's base image, so a wheel that resolves here
|
||||
# resolves in the image too.
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: ml/requirements.txt
|
||||
|
||||
- name: Install ml deps
|
||||
working-directory: ./ml
|
||||
run: pip install -r requirements.txt pytest httpx
|
||||
|
||||
- name: Run pytest suite
|
||||
working-directory: ./ml
|
||||
run: python -m pytest tests/ -q
|
||||
@@ -1,98 +0,0 @@
|
||||
# What's New highlights — GitHub Models release step (reusable)
|
||||
#
|
||||
# Called by the release-please workflows AFTER a release is created
|
||||
# (release-please.yml for `stable`, release-please-beta.yml for `main`). It runs
|
||||
# as a job in the SAME workflow run rather than on its own `release: published`
|
||||
# trigger, because release-please creates the release with the default
|
||||
# GITHUB_TOKEN and GitHub does not start new workflow runs from token-generated
|
||||
# events — a standalone `release:` workflow would simply never fire.
|
||||
#
|
||||
# What it does: condenses the new release's "### Features" into <=8 short
|
||||
# bullets via GitHub Models (free tier, `models: read`) and injects a
|
||||
# `<!-- whatsnew -->` block at the top of the release notes. The app reads that
|
||||
# block (backend utils/whatsNew.parseWhatsNew) and falls back to the raw
|
||||
# Features list for releases without it — so this is purely a quality upgrade,
|
||||
# never a hard dependency. Failure is isolated by `continue-on-error` + the
|
||||
# deterministic fallback below, so it can never break a release.
|
||||
#
|
||||
# GitHub Models is OPTIONAL. If it is disabled/unavailable for the org the AI
|
||||
# step fails soft (continue-on-error) and the deterministic fallback produces
|
||||
# the bullets instead — the feature works either way, Models just polishes them.
|
||||
#
|
||||
# Validated end-to-end on a fork (extract -> openai/gpt-4o-mini -> inject into
|
||||
# real release notes; app parseWhatsNew() reads the block back).
|
||||
|
||||
name: What's New highlights
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to annotate (e.g. v2.3.0)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
highlights:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # to edit the release body
|
||||
models: read # GitHub Models (free tier)
|
||||
# GH_REPO at job scope so every `gh` call targets the right repo without
|
||||
# needing an actions/checkout step. Without this, `gh` falls back to
|
||||
# parsing `.git/config` in the runner's empty workspace and dies with
|
||||
# "fatal: not a git repository" — which hard-fails the whole job before
|
||||
# any continue-on-error can save it.
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Extract Features from the published release
|
||||
id: feat
|
||||
# Belt-and-braces: the job-level comment says "never let highlights
|
||||
# break a release", but the original wiring only marked the AI +
|
||||
# inject steps as continue-on-error. A hiccup here (rate limit,
|
||||
# transient API error) would still hard-fail the job. Match the
|
||||
# design intent and fail soft.
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
BODY=$(gh release view "$TAG" --json body -q .body)
|
||||
FEATURES=$(printf '%s\n' "$BODY" | awk '/^#{2,4} +Features/{f=1;next} /^#{1,4} +\S/{f=0} f')
|
||||
{ echo "features<<EOF"; printf '%s\n' "$FEATURES"; echo EOF; } >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Summarize with GitHub Models
|
||||
if: ${{ steps.feat.outputs.features != '' }}
|
||||
id: ai
|
||||
continue-on-error: true # Models may be disabled/unavailable for the org; fall back deterministically below
|
||||
uses: actions/ai-inference@v1
|
||||
with:
|
||||
model: openai/gpt-4o-mini # catalog id (verified present); openai/gpt-4.1-mini or openai/gpt-5-nano also work
|
||||
system-prompt: >
|
||||
You write release highlights for the admins of a self-hosted
|
||||
photo-gallery + CRM app. Given raw changelog "Features" lines, output
|
||||
AT MOST 8 markdown bullets, each 3-4 words, user-facing, no scopes,
|
||||
no jargon, no issue numbers. One bullet per distinct user-visible
|
||||
feature. Output ONLY "- " bullets, nothing else.
|
||||
prompt: ${{ steps.feat.outputs.features }}
|
||||
|
||||
- name: Inject the What's New block
|
||||
if: ${{ steps.feat.outputs.features != '' }}
|
||||
continue-on-error: true # never let highlights break a release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
AI: ${{ steps.ai.outputs.response }}
|
||||
FEATURES: ${{ steps.feat.outputs.features }}
|
||||
run: |
|
||||
BULLETS="$AI"
|
||||
# Deterministic fallback if the model returned nothing (e.g. Models not yet enabled).
|
||||
if [ -z "$BULLETS" ]; then
|
||||
BULLETS=$(printf '%s\n' "$FEATURES" | head -8 \
|
||||
| sed -E 's/^\* \*\*[^:]+:\*\* */- /; s/ \(\[[^]]*\]\([^)]*\)\)//g')
|
||||
fi
|
||||
BODY=$(gh release view "$TAG" --json body -q .body)
|
||||
# Idempotent: strip any prior block before re-injecting.
|
||||
BODY=$(printf '%s' "$BODY" | perl -0pe 's/<!--\s*whatsnew\s*-->.*?<!--\s*\/whatsnew\s*-->\n*//is')
|
||||
gh release edit "$TAG" --notes "$(printf '<!-- whatsnew -->\n%s\n<!-- /whatsnew -->\n\n%s' "$BULLETS" "$BODY")"
|
||||
-104
@@ -11,9 +11,6 @@ yarn-error.log*
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Docker override file
|
||||
docker-compose.override.yml
|
||||
|
||||
# Security - Never commit credentials
|
||||
ADMIN_CREDENTIALS.txt
|
||||
ADMIN_PASSWORD_RESET.txt
|
||||
@@ -51,110 +48,9 @@ coverage/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Backup and test directories
|
||||
backups/
|
||||
test-archiver/
|
||||
|
||||
# Keep directory structure
|
||||
!storage/events/active/.gitkeep
|
||||
!storage/events/archived/.gitkeep
|
||||
!storage/thumbnails/.gitkeep
|
||||
!data/.gitkeep
|
||||
!logs/.gitkeep
|
||||
|
||||
# development files
|
||||
backend/.swarm/
|
||||
.claudedocs/
|
||||
backend/data/
|
||||
backend/docs/
|
||||
backend/logs/
|
||||
logs/
|
||||
# Anchored to repo root: matches the top-level runtime storage dir,
|
||||
# NOT backend/src/services/storage/ (the storage backend abstraction code).
|
||||
/storage/
|
||||
data/
|
||||
certbot/
|
||||
|
||||
# Ignore local contributor guide copy
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Working/planning documents (not for release)
|
||||
BUGS_AND_FEATURES.md
|
||||
frontend/TEST_PLAN.md
|
||||
docs/REFACTORING_PLAN.md
|
||||
docs/MULTIPLE_ADMINISTRATORS_PLAN.md
|
||||
docs/*_PLAN.md
|
||||
docs/test-*.md
|
||||
docs/feature-*.md
|
||||
|
||||
# Scaffolding documentation (local development reference)
|
||||
docs/DATABASE_SCHEMA.md
|
||||
docs/BACKEND_SERVICES.md
|
||||
docs/API_ROUTES.md
|
||||
docs/FRONTEND_ARCHITECTURE.md
|
||||
docs/DEVELOPER_ONBOARDING.md
|
||||
docs/ENVIRONMENT_VARIABLES.md
|
||||
|
||||
# Build artifact: OpenAPI spec generated locally + synced into the
|
||||
# picpeak-docs repo. Never tracked here — the docs site at
|
||||
# docs.picpeak.app is the source of truth.
|
||||
docs/openapi.json
|
||||
docs/openapi.yaml
|
||||
|
||||
# Local backup directory (from testing)
|
||||
backup/
|
||||
|
||||
# Local artifacts from browser tooling
|
||||
.playwright-mcp/
|
||||
|
||||
# Local-only E2E suite (never pushed; runs as pre-push gate on this machine)
|
||||
tests/e2e/local/
|
||||
playwright-local-results/
|
||||
e2e-test.log
|
||||
scripts/e2e-local.sh
|
||||
|
||||
# Local SQLite files in backend
|
||||
backend/*.sqlite*
|
||||
backend/*.db
|
||||
|
||||
# Test files and artifacts
|
||||
test-images/
|
||||
test-logo*.jpg
|
||||
test-logo*.png
|
||||
test-results/
|
||||
|
||||
# Development docker compose
|
||||
docker-compose.dev.yml
|
||||
|
||||
# New layout development files
|
||||
new-layouts/
|
||||
|
||||
# Backend runtime storage (generated media, previews, thumbnails,
|
||||
# CRM/accounting documents) — never commit
|
||||
backend/storage/
|
||||
|
||||
# Python artifacts — the picpeak-ml sidecar (#1074) is the only Python in
|
||||
# this tree, but bytecode and virtualenvs must never be committed.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
ml/.venv/
|
||||
ml/venv/
|
||||
# Locally produced model weights. The image fetches these by pinned URL and
|
||||
# SHA-256 at build time; a 90MB blob must not end up in git history.
|
||||
ml/*.onnx
|
||||
ml/*.h5
|
||||
|
||||
# Issue / PR screenshots belong on a `screenshots/*` branch, never on main or
|
||||
# stable — that is what those branches exist for. Two landed at the repo root
|
||||
# in #1241 and shipped as part of the source tree.
|
||||
#
|
||||
# Anchored with a leading slash so docs/ keeps its own images.
|
||||
/issue-*.png
|
||||
/issue-*.jpg
|
||||
/screenshot-*.png
|
||||
/screenshot-*.jpg
|
||||
/*-screenshot.png
|
||||
/*-screenshot.jpg
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
".": "3.131.3-beta.0"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{".":"3.44.0"}
|
||||
-3980
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Product Overview
|
||||
|
||||
A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage
|
||||
- **Frontend**: React SPA with scrappbook.de-style design (requires implementation)
|
||||
- **Storage**: File-based with active/archived separation
|
||||
- **Services**: Background workers for email, archiving, file watching, and expiration monitoring
|
||||
- **Analytics**: Umami integration for engagement tracking
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
cd backend
|
||||
npm install # Install dependencies
|
||||
npm run migrate # Initialize database schema
|
||||
npm run dev # Start with hot-reload (port 3001)
|
||||
npm test # Run Jest tests
|
||||
npm run lint # ESLint checks
|
||||
```
|
||||
|
||||
### Running a Single Test
|
||||
```bash
|
||||
cd backend
|
||||
npm test -- path/to/test.test.js
|
||||
npm test -- --testNamePattern="test name"
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up -d # Production deployment
|
||||
pm2 start ecosystem.config.js # Alternative: PM2 deployment
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL PRODUCTION NOTICE:**
|
||||
- Production runs on a SEPARATE SERVER - never assume local changes affect production
|
||||
- ALWAYS request production server details before any troubleshooting
|
||||
- NO trial-and-error approaches in production - data loss is unacceptable
|
||||
- Every change must be thoroughly analyzed and tested locally first
|
||||
|
||||
## Key Product Requirements (from PRD)
|
||||
|
||||
### Core Features
|
||||
1. **File-Based System**: Drop photos in folders → automatic gallery creation
|
||||
2. **Automatic Expiration**: Default 30 days, with 7-day warning emails
|
||||
3. **Password Protection**: Secure access with customizable passwords
|
||||
4. **Automatic Archiving**: ZIP compression and storage after expiration
|
||||
5. **Email Notifications**: Creation, warning, and expiration notifications
|
||||
6. **Analytics**: Umami tracking for views, downloads, and engagement
|
||||
|
||||
### Folder Structure
|
||||
```
|
||||
/events/
|
||||
├── active/
|
||||
│ ├── wedding-smith-jones-2024-06-15/
|
||||
│ │ ├── collages/
|
||||
│ │ └── individual/
|
||||
│ └── birthday-emma-2024-07-20/
|
||||
└── archived/
|
||||
└── wedding-smith-jones-2024-06-15.zip
|
||||
```
|
||||
|
||||
## Frontend Implementation Requirements
|
||||
|
||||
### Design Style (scrappbook.de-inspired)
|
||||
- **Color Palette**: Primary green (#5C8762), neutral backgrounds
|
||||
- **Typography**: Clean, modern sans-serif (Noto Sans or similar)
|
||||
- **Layout**: Minimalist, modular sections with grid-based photo displays
|
||||
- **Aesthetic**: Professional yet approachable, photographer-focused
|
||||
|
||||
### Key Frontend Components to Build
|
||||
1. **Landing Page**: Password entry with event preview
|
||||
2. **Gallery View**:
|
||||
- Responsive photo grid with lazy loading
|
||||
- Toggle between collages/individual photos
|
||||
- Prominent expiration banner
|
||||
- Download urgency indicators
|
||||
3. **Photo Lightbox**: Full-screen viewing with zoom
|
||||
4. **Mobile-First**: Responsive design with touch gestures
|
||||
5. **Personalization**: Dynamic theming per event type
|
||||
|
||||
### User Experience Priorities
|
||||
- Clear expiration warnings (sticky banner)
|
||||
- One-click "Download All" for urgent galleries
|
||||
- Smooth image loading with skeleton screens
|
||||
- Intuitive navigation between photo categories
|
||||
- Professional presentation matching photographer branding
|
||||
|
||||
## Key Architecture Patterns
|
||||
|
||||
### Authentication Flow
|
||||
- JWT-based with separate tokens for admin and gallery access
|
||||
- Gallery tokens include event-specific claims
|
||||
- Auth middleware: `backend/src/middleware/auth.js`
|
||||
- `adminAuth` - Admin panel protection
|
||||
- `photoAuth` - Protected photo access
|
||||
- `verifyGalleryAccess` - Gallery-specific validation
|
||||
|
||||
### Database Schema (Knex/SQLite)
|
||||
Main tables:
|
||||
- `events` - Gallery metadata with expiration, custom messages, themes
|
||||
- `photos` - Photo records linked to events
|
||||
- `access_logs` - IP-based usage tracking
|
||||
- `email_queue` - Async email processing
|
||||
- `admin_users` - Admin authentication
|
||||
|
||||
### Service Architecture
|
||||
Background services run as separate processes:
|
||||
- **emailService**: Processes email queue with retry logic
|
||||
- **archiveService**: Creates ZIP archives of expired events
|
||||
- **expirationChecker**: Cron job for expiration warnings
|
||||
- **fileWatcher**: Monitors for new photo uploads
|
||||
|
||||
### API Structure
|
||||
- `/api/admin/*` - Admin panel endpoints (requires adminAuth)
|
||||
- `/api/gallery/*` - Public gallery endpoints
|
||||
- `/api/auth/*` - Authentication endpoints
|
||||
- Rate limiting: 100 req/15min (general), 5 req/15min (auth)
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
1. **Security**: All gallery access requires valid JWT with event-specific claims
|
||||
2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings
|
||||
3. **Email Queue**: Async processing with retry logic, check `email_queue` table
|
||||
4. **File Processing**: Sharp library for thumbnail generation (300x300)
|
||||
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
|
||||
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
|
||||
|
||||
## Troubleshooting Guidelines
|
||||
|
||||
### Before ANY Production Troubleshooting:
|
||||
1. **ALWAYS request specific details**:
|
||||
- Production server URL/IP
|
||||
- Current error messages/logs
|
||||
- Recent changes or deployments
|
||||
- Affected users/galleries
|
||||
- Time of issue occurrence
|
||||
|
||||
2. **Thorough Analysis Required**:
|
||||
- Use detailed thinking/analysis for EVERY troubleshooting task
|
||||
- Review all related code before suggesting changes
|
||||
- Consider all potential side effects
|
||||
- Never make assumptions about production environment
|
||||
|
||||
3. **Safe Troubleshooting Steps**:
|
||||
- First, reproduce issue in local/dev environment
|
||||
- Analyze logs without modifying production
|
||||
- Create detailed action plan before any changes
|
||||
- Always have rollback strategy ready
|
||||
- Document every step taken
|
||||
|
||||
### Common Issues & Safe Approaches:
|
||||
- **Email not sending**: Check email_queue table, SMTP settings, service status
|
||||
- **Photos not loading**: Verify file permissions, storage paths, nginx config
|
||||
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
|
||||
- **Performance problems**: Analyze with monitoring tools first, never experiment
|
||||
|
||||
### Data Safety Rules:
|
||||
- NEVER delete or modify production data without explicit backup confirmation
|
||||
- ALWAYS verify backups exist before any data operations
|
||||
- NO direct database modifications without transaction safety
|
||||
- Log all actions for audit trail
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
- `JWT_SECRET` - Token signing
|
||||
- `ADMIN_URL`, `FRONTEND_URL` - CORS origins
|
||||
- `SMTP_*` - Email configuration
|
||||
- `DB_*` - PostgreSQL credentials (production)
|
||||
- `UMAMI_URL` - Umami instance URL (for server-side tracking)
|
||||
- `UMAMI_WEBSITE_ID` - Website ID from Umami
|
||||
|
||||
### Frontend (.env)
|
||||
- `VITE_API_URL` - Backend API URL
|
||||
- `VITE_UMAMI_URL` - Umami analytics URL
|
||||
- `VITE_UMAMI_WEBSITE_ID` - Website ID from Umami
|
||||
- `VITE_UMAMI_SHARE_URL` - (Optional) Public share URL for embedded dashboard
|
||||
|
||||
## Testing Approach
|
||||
- Jest with Supertest for API testing
|
||||
- Test files in `__tests__` directories
|
||||
- Database migrations run before tests
|
||||
- Mock email sending in tests
|
||||
|
||||
## Umami Analytics Integration
|
||||
|
||||
The frontend includes comprehensive Umami analytics integration for tracking user behavior and gallery performance.
|
||||
|
||||
### Tracked Events:
|
||||
- **Gallery Events**:
|
||||
- `gallery_password_entry` - Password attempts (success/failure)
|
||||
- `gallery_photo_view` - Individual photo views
|
||||
- `gallery_photo_download` - Single photo downloads
|
||||
- `gallery_bulk_download` - Bulk/all photo downloads
|
||||
- `gallery_expired` - Expired gallery access attempts
|
||||
- **Admin Events**:
|
||||
- `admin_login` - Admin authentication
|
||||
- `admin_event_created` - New event creation
|
||||
- `admin_event_archived` - Event archiving
|
||||
- `admin_event_deleted` - Event deletion
|
||||
- `admin_settings_updated` - Settings changes
|
||||
- **User Behavior**:
|
||||
- Search queries (with debouncing)
|
||||
- Expiration warning views
|
||||
- Page views with automatic tracking
|
||||
|
||||
### Setup:
|
||||
1. Install Umami (self-hosted or cloud)
|
||||
2. Create a website in Umami dashboard
|
||||
3. Set environment variables:
|
||||
```
|
||||
VITE_UMAMI_URL=https://your-umami-instance.com
|
||||
VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
VITE_UMAMI_SHARE_URL=https://your-umami-instance.com/share/...
|
||||
```
|
||||
|
||||
### Analytics Dashboard:
|
||||
- Admin panel includes analytics page at `/admin/analytics`
|
||||
- Summary view with key metrics
|
||||
- Option to embed full Umami dashboard
|
||||
- Real-time event tracking
|
||||
|
||||
## Accessibility & Performance Features
|
||||
|
||||
### Accessibility (WCAG 2.1 AA Compliance)
|
||||
- **Error Boundaries**: Graceful error handling with recovery options
|
||||
- **Skip Links**: Skip to main content for keyboard navigation
|
||||
- **ARIA Labels**: Proper labeling for screen readers
|
||||
- **Focus Management**: Focus trap in modals, visible focus indicators
|
||||
- **Keyboard Navigation**: Full keyboard support in gallery lightbox (arrows, escape, +/-, d for download)
|
||||
- **Loading States**: Skeleton screens instead of spinners for better UX
|
||||
- **Offline Support**: Visual indicator when offline
|
||||
- **Form Validation**: Accessible error messages with aria-describedby
|
||||
|
||||
### Performance Optimizations
|
||||
- **Lazy Loading**: Images load on scroll with Intersection Observer
|
||||
- **Skeleton Screens**: Instant visual feedback during loading
|
||||
- **Error Recovery**: Component-level error boundaries prevent full page crashes
|
||||
- **Optimistic Updates**: Immediate UI updates with background sync
|
||||
- **Debounced Search**: Prevents excessive API calls
|
||||
- **Analytics**: Non-blocking Umami integration
|
||||
|
||||
### Component Library Enhancements
|
||||
- `<ErrorBoundary>` - Catches and displays errors gracefully
|
||||
- `<PageErrorBoundary>` - Full-page error recovery
|
||||
- `<Skeleton>` - Flexible skeleton loader with variants
|
||||
- `<OfflineIndicator>` - Network status monitoring
|
||||
- `<SkipLink>` - Accessibility navigation
|
||||
- `useFocusTrap` - Modal focus management hook
|
||||
- `useOnlineStatus` - Network status hook
|
||||
|
||||
## Theme System & Branding
|
||||
|
||||
### Theme Features
|
||||
- **Dynamic Theming**: CSS variables for runtime theme switching
|
||||
- **Preset Themes**: Default, Wedding, Birthday, Corporate, Minimal
|
||||
- **Customization Options**:
|
||||
- Primary/Accent/Background/Text colors
|
||||
- Font family selection
|
||||
- Border radius (none, sm, md, lg)
|
||||
- Custom logo upload
|
||||
- Custom CSS injection
|
||||
- **Event-Specific Themes**: Override global theme per gallery
|
||||
- **Live Preview**: Real-time theme changes in admin panel
|
||||
|
||||
### Theme Context API
|
||||
```typescript
|
||||
const { theme, setTheme, setThemeByName } = useTheme();
|
||||
```
|
||||
|
||||
### Branding Settings
|
||||
- Company name, tagline, and support email
|
||||
- Custom footer text
|
||||
- Optional watermarking on downloads
|
||||
- Logo upload for gallery header
|
||||
|
||||
### CSS Variables
|
||||
```css
|
||||
--color-primary: #5C8762;
|
||||
--color-primary-light: #7aa583;
|
||||
--color-primary-dark: #4a6f4f;
|
||||
--color-accent: #22c55e;
|
||||
--color-background: #fafafa;
|
||||
--color-text: #171717;
|
||||
--font-family: 'Inter', sans-serif;
|
||||
--border-radius: 0.5rem;
|
||||
```
|
||||
|
||||
## Success Metrics (from PRD)
|
||||
- Time to generate gallery: <2 minutes
|
||||
- Guest satisfaction: >90%
|
||||
- System uptime: 99.9%
|
||||
- Email delivery rate: >98%
|
||||
- Successful archiving: 100%
|
||||
|
||||
## Documentation & Development Practices
|
||||
|
||||
### Documentation Guidelines:
|
||||
- **NEVER create new documentation files for simple tasks**
|
||||
- **ALWAYS update existing documentation (like this CLAUDE.md)**
|
||||
- Only create new .md files when explicitly requested
|
||||
- Avoid creating temporary scripts for one-off tasks
|
||||
|
||||
### Development Best Practices:
|
||||
- Test all changes thoroughly in local environment first
|
||||
- Use version control for all changes
|
||||
- Keep commits atomic and well-described
|
||||
- Review impact on all integrated services
|
||||
- Consider backward compatibility
|
||||
- Update tests when changing functionality
|
||||
|
||||
### Production Deployment Checklist:
|
||||
- [ ] All tests passing locally
|
||||
- [ ] Linting and type checks pass
|
||||
- [ ] Database migrations tested with rollback plan
|
||||
- [ ] Environment variables documented
|
||||
- [ ] Backup strategy confirmed
|
||||
- [ ] Monitoring alerts configured
|
||||
- [ ] Rollback procedure documented
|
||||
- [ ] Stakeholders notified of maintenance window
|
||||
+1
-1
@@ -20,7 +20,7 @@ We are committed to providing a welcoming and inspiring community for all photog
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/PicPeak/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
|
||||
Instances of unacceptable behavior may be reported to the project team at conduct@example.com. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
|
||||
+18
-60
@@ -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/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
|
||||
* [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
|
||||
|
||||
### Pull Requests
|
||||
|
||||
1. **Fork the repo** and create your branch from `main` (active development)
|
||||
1. **Fork the repo** and create your branch from `main`
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
cd backend && npm install
|
||||
@@ -50,16 +50,13 @@ 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. **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.
|
||||
6. **Create a Pull Request**
|
||||
|
||||
## 💻 Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 22.12.0 or later (matches `backend/package.json`)
|
||||
- Node.js 18+
|
||||
- Docker & Docker Compose
|
||||
- Git
|
||||
|
||||
@@ -73,33 +70,15 @@ cd picpeak
|
||||
# Install dependencies
|
||||
cd backend && npm install
|
||||
cd ../frontend && npm install
|
||||
cd ..
|
||||
|
||||
# Start Postgres and Redis (the app itself runs on the host, see below)
|
||||
docker compose up -d postgres redis
|
||||
# Set up environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
|
||||
# Backend config — note this is backend/.env, not the root one
|
||||
cp backend/.env.example backend/.env
|
||||
# JWT_SECRET must be set: the host process validates it and exits without one.
|
||||
# (The containers generate it themselves; `npm run dev` does not.)
|
||||
|
||||
# Backend, with nodemon hot reload — http://localhost:3001
|
||||
cd backend && npm run dev
|
||||
|
||||
# Frontend, with Vite hot reload, in a second shell — http://localhost:5173
|
||||
cd frontend && npm run dev
|
||||
# Start development servers
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
Open **http://localhost:5173**. Vite proxies `/api` to the backend on `3001`, so
|
||||
you do not need the root `.env` for this loop at all — that one configures the
|
||||
compose stack.
|
||||
|
||||
Running the two Node processes on the host is the fastest loop: both reload on save, and you get a real debugger and stack traces without rebuilding an image.
|
||||
|
||||
**Prefer everything in containers?** `docker compose up -d` builds `backend`, `frontend` and `ml` from source using the production Dockerfiles. That works, but there is no hot reload — you rebuild on every change (`docker compose up -d --build backend`).
|
||||
|
||||
> `docker-compose.dev.yml` is listed in `.gitignore` and is not part of the repo. If you keep a local one for live-mounting `./backend/src` and `./frontend/src` against `backend/Dockerfile.dev` / `frontend/Dockerfile.dev`, remember it bakes `node_modules` into the image: after pulling a change to `backend/package.json`, rebuild that image or you will get a `MODULE_NOT_FOUND` restart loop.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
@@ -165,38 +144,17 @@ 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. | Security fixes and regular bugfix backports, kept small and free of unrelated features. |
|
||||
|
||||
### Which branch should my PR target?
|
||||
|
||||
- **New feature** → target `main`.
|
||||
- **Bugfix that ONLY affects active dev** → target `main`.
|
||||
- **Bugfix that current stable users need** → target `main`; regular bug fixes are generally backported automatically to `stable`. Maintainers handle conflicts or create a separate focused backport PR when needed.
|
||||
- **Security vulnerability** → report privately using [SECURITY.md](SECURITY.md). Security fixes are always released on both `stable` and `main`; coordinate any fix with the maintainers before opening a public PR.
|
||||
|
||||
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
|
||||
|
||||
If you're not sure which branch to target, default to `main` and a maintainer will retarget during review.
|
||||
|
||||
## 🔄 Release Process
|
||||
|
||||
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).
|
||||
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
|
||||
|
||||
## 📮 Contact
|
||||
|
||||
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
|
||||
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
|
||||
- Security vulnerabilities: Follow the [security policy](SECURITY.md) and use [private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- Create an issue for bugs or features
|
||||
- Join discussions for questions
|
||||
- Email: picpeak@example.com for security issues
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
Thank you for contributing! 🎉
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
# 🚀 PicPeak Deployment Guide
|
||||
|
||||
This guide will help you deploy PicPeak in production. The entire process takes about 10-15 minutes.
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
- A server with Docker and Docker Compose installed
|
||||
- A domain name (for SSL certificates)
|
||||
- SMTP credentials for sending emails
|
||||
- Basic command line knowledge
|
||||
|
||||
## 🏃 Quick Deploy (Recommended)
|
||||
|
||||
### 1. Clone and Configure
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy environment template
|
||||
cp .env.production.example .env
|
||||
|
||||
# Generate a secure JWT secret
|
||||
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
|
||||
|
||||
# Edit configuration
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 2. Required Environment Variables
|
||||
|
||||
Edit your `.env` file with these essential settings:
|
||||
|
||||
```env
|
||||
# Application URLs
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
BACKEND_URL=https://your-domain.com
|
||||
|
||||
# Email Configuration (Required for notifications)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=your-email@gmail.com
|
||||
|
||||
# Admin Configuration
|
||||
ADMIN_EMAIL=admin@your-domain.com
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
# Database (PostgreSQL for production)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=postgres
|
||||
DB_NAME=picpeak
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=secure-db-password
|
||||
```
|
||||
|
||||
### 3. Deploy with Docker Compose
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Access your site at https://your-domain.com
|
||||
```
|
||||
|
||||
## 🔧 Configuration Options
|
||||
|
||||
### Storage Settings
|
||||
|
||||
```env
|
||||
# Storage paths (default: ./storage)
|
||||
STORAGE_PATH=./storage
|
||||
ARCHIVE_PATH=./storage/archives
|
||||
|
||||
# Gallery expiration (days)
|
||||
DEFAULT_EXPIRATION_DAYS=30
|
||||
WARNING_DAYS_BEFORE_EXPIRY=7
|
||||
```
|
||||
|
||||
### Security Settings
|
||||
|
||||
```env
|
||||
# Session timeout (minutes)
|
||||
SESSION_TIMEOUT=60
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
```
|
||||
|
||||
### Analytics (Optional)
|
||||
|
||||
```env
|
||||
# Umami Analytics
|
||||
VITE_UMAMI_URL=https://analytics.your-domain.com
|
||||
VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
```
|
||||
|
||||
## 🔒 SSL/TLS Setup
|
||||
|
||||
The production Docker Compose includes automatic SSL via Let's Encrypt:
|
||||
|
||||
1. **Ensure your domain points to your server**
|
||||
2. **Update nginx configuration**:
|
||||
```bash
|
||||
nano nginx/nginx.conf
|
||||
# Replace your-domain.com with your actual domain
|
||||
```
|
||||
3. **Start services** - Certbot will automatically obtain certificates
|
||||
|
||||
## 📁 Directory Structure
|
||||
|
||||
After deployment, your directory structure will be:
|
||||
|
||||
```
|
||||
picpeak/
|
||||
├── backend/ # API server
|
||||
├── frontend/ # React app
|
||||
├── storage/ # Photo storage
|
||||
│ ├── events/ # Active galleries
|
||||
│ │ ├── active/ # Current photos
|
||||
│ │ └── archived/ # Expired galleries
|
||||
│ ├── thumbnails/ # Generated thumbnails
|
||||
│ └── uploads/ # User uploads
|
||||
├── data/ # Database files
|
||||
└── logs/ # Application logs
|
||||
```
|
||||
|
||||
## 🔄 Maintenance
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
# Backup database and photos
|
||||
./scripts/backup.sh
|
||||
|
||||
# Backups are stored in ./backups/
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# View all logs
|
||||
docker-compose logs
|
||||
|
||||
# View specific service
|
||||
docker-compose logs backend
|
||||
docker-compose logs frontend
|
||||
```
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Photos not appearing:**
|
||||
- Check storage permissions: `chmod -R 755 storage/`
|
||||
- Verify file watcher is running: `docker-compose logs backend | grep watcher`
|
||||
|
||||
**Email not sending:**
|
||||
- Test SMTP settings: Admin Panel → Settings → Email → Send Test
|
||||
- Check email queue: Admin Panel → System → Email Queue
|
||||
|
||||
**Can't access admin panel:**
|
||||
- Default login: Use email/password from `.env`
|
||||
- Reset password: `docker exec picpeak-backend npm run reset-admin`
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
docker-compose ps
|
||||
|
||||
# Test backend API
|
||||
curl https://your-domain.com/api/health
|
||||
|
||||
# Check disk space
|
||||
df -h storage/
|
||||
```
|
||||
|
||||
## 🐳 Alternative Deployment Methods
|
||||
|
||||
### Using Docker Swarm
|
||||
|
||||
For high availability deployments, see [Docker Swarm Setup](deploy/README.md).
|
||||
|
||||
### Manual Installation
|
||||
|
||||
If you prefer not to use Docker:
|
||||
|
||||
1. Install Node.js 18+
|
||||
2. Install PostgreSQL
|
||||
3. Clone repository
|
||||
4. Install dependencies: `npm install` in both `/backend` and `/frontend`
|
||||
5. Build frontend: `cd frontend && npm run build`
|
||||
6. Start services with PM2
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- 📘 [Documentation](https://github.com/the-luap/picpeak)
|
||||
- 🐛 [Report Issues](https://github.com/the-luap/picpeak/issues)
|
||||
- 💬 [Discussions](https://github.com/the-luap/picpeak/discussions)
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Open an issue on GitHub and we'll assist you!
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
# All-in-one image (#1042): one container, one Node process.
|
||||
#
|
||||
# The backend serves the built frontend itself via server.js's SERVE_FRONTEND
|
||||
# block (SPA fallback, OG crawler intercept, brand-title render, immutable
|
||||
# asset caching) — no nginx, no supervisor, no bundled Postgres/Redis. SQLite
|
||||
# is the explicit default engine; pointing DB_HOST/DB_USER/DB_PASSWORD (+
|
||||
# DATABASE_CLIENT=pg) at an external Postgres works exactly like the backend
|
||||
# image. Build context is the REPO ROOT (both backend/ and frontend/ are
|
||||
# needed): docker build -f Dockerfile.aio .
|
||||
#
|
||||
# KEEP IN SYNC: the runtime stage below mirrors backend/Dockerfile's
|
||||
# production stage (base image, apk set, npm removal, nodejs user, fontconfig
|
||||
# registration, directory layout, healthcheck, entrypoint). When
|
||||
# backend/Dockerfile changes, change this file too — the aio smoke job in
|
||||
# docker-build.yml catches boot-level drift, not package-level drift.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontend build — mirrors frontend/Dockerfile's builder stage
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
|
||||
ARG CACHEBUST=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend deps — mirrors backend/Dockerfile's builder stage
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS backend-builder
|
||||
|
||||
ARG CACHEBUST=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime — mirrors backend/Dockerfile's production stage + the frontend dist
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine
|
||||
|
||||
ARG CACHEBUST=1
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak all-in-one (backend + frontend, single container)"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Explicit engine selection (#1038/#1042): SQLite is this image's DEFAULT
|
||||
# engine — set explicitly, never inferred, and wait-for-db.sh skips its
|
||||
# Postgres readiness wait for it. Point the container at an external Postgres
|
||||
# by overriding DATABASE_CLIENT=pg and setting DB_HOST/DB_USER/DB_PASSWORD,
|
||||
# exactly like the backend image. The boot resolver still logs the engine and
|
||||
# refuses the populated-both conflict.
|
||||
# STORAGE_PATH: getStoragePath() falls back to path.join(__dirname,
|
||||
# '../../../storage') — which resolves to the container-root `/storage` here,
|
||||
# writable by root but EACCES for the nodejs user after the su-exec drop.
|
||||
# Compose masks this by setting STORAGE_PATH=/app/storage; this image must
|
||||
# pin the same path (it is the directory the Dockerfile creates and chowns).
|
||||
ENV NODE_ENV=production \
|
||||
DATABASE_CLIENT=sqlite3
|
||||
|
||||
# See backend/Dockerfile for the rationale of each of the following blocks.
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# sqlite — DatabaseBackupService.createSQLiteBackup() SPAWNS the `sqlite3`
|
||||
# CLI for `.backup` and PRAGMA integrity_check; the npm module does not
|
||||
# ship that binary. backend/Dockerfile omits it because compose always runs
|
||||
# Postgres — this image defaults to SQLite, so without it every database
|
||||
# backup fails with ENOENT.
|
||||
RUN apk add --no-cache dumb-init postgresql-client sqlite ffmpeg su-exec \
|
||||
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
|
||||
fc-cache -f
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
|
||||
COPY --from=backend-builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||
COPY --chown=nodejs:nodejs backend/ .
|
||||
|
||||
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
|
||||
|
||||
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
|
||||
fc-cache -f /app/assets/fonts
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# One volume, one layout (#1042 scope: "single data layout on one volume")
|
||||
# ---------------------------------------------------------------------------
|
||||
# /data/db picpeak.db (+ -wal/-shm) and SETUP_TOKEN
|
||||
# /data/storage originals, thumbnails, archives
|
||||
# /data/logs application logs
|
||||
# /data/backup built-in backup output; /backup symlinks here
|
||||
#
|
||||
# `-v picpeak:/data` and nothing else to remember — back up /data and you have
|
||||
# backed up the install. /backup is where migrations 029 + 030 seed the backup
|
||||
# destinations, so it is symlinked in rather than left dangling.
|
||||
ENV DATA_ROOT=/data \
|
||||
DATA_DIR=/data/db \
|
||||
DATABASE_PATH=/data/db/picpeak.db \
|
||||
STORAGE_PATH=/data/storage \
|
||||
LOG_DIR=/data/logs \
|
||||
BACKUP_DIR=/data/backup
|
||||
|
||||
# FRONTEND_URL is deliberately NOT set here (#705). It used to default to
|
||||
# http://localhost:3000 so share links would not come out relative, but a
|
||||
# baked-in value OVERRIDES the general_site_url setting the setup wizard
|
||||
# writes — so a single-container install could never configure its own public
|
||||
# address, and the Settings field would show as env-pinned for everyone.
|
||||
# getFrontendBaseUrl() now resolves the setting, then the origin the request
|
||||
# arrived on, and getAbsoluteFrontendUrl() still ends at http://localhost:3000,
|
||||
# so links stay absolute without pinning anything. Override with
|
||||
# -e FRONTEND_URL=https://photos.example.com for config-as-code deployments.
|
||||
|
||||
# /app/storage is a second entrance to the same volume. The business-document
|
||||
# writers (quoteService, invoice sending/reminders, contract signatures) build
|
||||
# their paths from `path.join(process.cwd(), 'storage', ...)` and never consult
|
||||
# STORAGE_PATH. Compose hides that because it sets STORAGE_PATH=/app/storage
|
||||
# with WORKDIR /app, so the two happen to be the same directory; here they are
|
||||
# not, and /app is root-owned, so a quote or invoice PDF would fail to write as
|
||||
# UID 1001 — and be lost with the container even if it succeeded. Teaching
|
||||
# those services STORAGE_PATH is the real fix and belongs in its own change;
|
||||
# the symlink restores the coincidence compose already relies on.
|
||||
RUN mkdir -p /data/db /data/storage/events/active /data/storage/events/archived \
|
||||
/data/storage/thumbnails /data/logs \
|
||||
/data/backup/picpeak /data/backup/database && \
|
||||
ln -s /data/backup /backup && \
|
||||
ln -s /data/storage /app/storage && \
|
||||
chown -R nodejs:nodejs /data
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
# The frontend bundle, served by server.js's SERVE_FRONTEND block. Explicit
|
||||
# opt-in rather than the dist-exists autodetect, so the behavior is pinned
|
||||
# even if the autodetect heuristic ever changes.
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/dist /app/frontend/dist
|
||||
ENV SERVE_FRONTEND=true \
|
||||
FRONTEND_DIR=/app/frontend/dist
|
||||
|
||||
# Marks this as the single-container build. The backend refuses to enable face
|
||||
# recognition (#1074) when it sees this, on performance grounds: that feature
|
||||
# needs a separate ML container this image does not contain, and it would add
|
||||
# a second image-processing pipeline competing with Sharp for the CPU and
|
||||
# memory of a container sized for one photographer plus guests browsing. The
|
||||
# failure would not be loud — just a slow install that looks broken.
|
||||
#
|
||||
# An explicit marker rather than inferring it from SERVE_FRONTEND or the
|
||||
# SQLite path: legitimate multi-container deployments do both of those, and
|
||||
# none of them should lose the feature by accident.
|
||||
ENV PICPEAK_SINGLE_CONTAINER=true
|
||||
|
||||
# No USER directive — same as backend/Dockerfile: the container starts as root
|
||||
# so wait-for-db.sh can chown bind-mounted volumes to UID 1001, then drops
|
||||
# privileges via su-exec (#484).
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Shell form so it resolves $PORT: a hard-coded 3000 marks an otherwise healthy
|
||||
# container unhealthy forever the moment anyone overrides the port.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider "http://localhost:${PORT:-3000}/health" || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
# --max-http-header-size matches nginx's `large_client_header_buffers 4 32k`.
|
||||
# Requests reach Node directly here, and its 16 KiB default would reject a guest
|
||||
# carrying several per-gallery JWT cookies before Express ever saw them.
|
||||
CMD ["./wait-for-db.sh", "node", "--max-http-header-size=32768", "server.js"]
|
||||
@@ -0,0 +1,98 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide explains how to deploy PicPeak in production behind a reverse proxy like Traefik.
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Frontend Configuration
|
||||
|
||||
For production deployment behind a reverse proxy (Traefik, Nginx, etc.), the frontend should use relative URLs to automatically inherit the protocol (HTTPS) and domain.
|
||||
|
||||
1. Copy the production environment template:
|
||||
```bash
|
||||
cp frontend/.env.production.example frontend/.env.production
|
||||
```
|
||||
|
||||
2. Set the API URL to use relative path:
|
||||
```env
|
||||
# frontend/.env.production
|
||||
VITE_API_URL=/api
|
||||
```
|
||||
|
||||
This ensures all API calls will use the same domain and protocol as the frontend.
|
||||
|
||||
### Backend Configuration
|
||||
|
||||
Ensure your backend `.env` file has the correct URLs:
|
||||
```env
|
||||
# backend/.env
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
```
|
||||
|
||||
## Docker Compose Production
|
||||
|
||||
When using Docker Compose in production:
|
||||
|
||||
1. Build with production environment:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml build --build-arg NODE_ENV=production
|
||||
```
|
||||
|
||||
2. The frontend nginx configuration already includes proper proxy settings for:
|
||||
- `/api` → Backend API
|
||||
- `/photos` → Protected photo access
|
||||
- `/thumbnails` → Thumbnail images
|
||||
- `/uploads` → Public uploads (logos, favicons)
|
||||
|
||||
## Traefik Configuration
|
||||
|
||||
Example Traefik labels for docker-compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
|
||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **No Hardcoded URLs**: The application uses environment variables with relative URL fallbacks, making it production-ready.
|
||||
|
||||
2. **HTTPS Only**: When `VITE_API_URL=/api`, all requests will use the same protocol as the page (HTTPS in production).
|
||||
|
||||
3. **CORS Configuration**: The backend CORS is configured to accept requests from the URLs specified in `FRONTEND_URL` and `ADMIN_URL`.
|
||||
|
||||
4. **Static Assets**: All static assets (photos, thumbnails, uploads) are served through the nginx proxy, inheriting authentication headers.
|
||||
|
||||
## Verification
|
||||
|
||||
After deployment, verify:
|
||||
|
||||
1. Check browser console for any localhost URLs (there should be none)
|
||||
2. Verify all API calls use HTTPS
|
||||
3. Check that images load correctly with authentication
|
||||
4. Test favicon and logo display
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you see console errors about localhost:
|
||||
|
||||
1. Ensure `VITE_API_URL=/api` in frontend environment
|
||||
2. Clear browser cache
|
||||
3. Rebuild frontend with production environment:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
If images don't load:
|
||||
|
||||
1. Check that nginx proxy locations are configured
|
||||
2. Verify authentication tokens are being sent
|
||||
3. Check backend logs for authentication errors
|
||||
@@ -0,0 +1,312 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide addresses all known production deployment issues and provides solutions.
|
||||
|
||||
## Pre-Deployment Checklist
|
||||
|
||||
### 1. Environment Variables
|
||||
Create a `.env` file with ALL required variables:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
JWT_SECRET=<generate-with-openssl-rand-base64-32>
|
||||
DB_PASSWORD=<strong-password>
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# Database
|
||||
DB_USER=picpeak
|
||||
DB_NAME=picpeak
|
||||
|
||||
# Email (Optional but recommended)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Umami Analytics (Optional)
|
||||
UMAMI_URL=https://analytics.yourdomain.com
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
UMAMI_HASH_SALT=<generate-random-string>
|
||||
```
|
||||
|
||||
### 2. Generate Secrets
|
||||
|
||||
```bash
|
||||
# Generate JWT Secret
|
||||
openssl rand -base64 32
|
||||
|
||||
# Generate Database Password
|
||||
openssl rand -base64 24
|
||||
|
||||
# Generate Umami Hash Salt
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Initial Setup
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/the-luap/wedding-photo-sharing.git
|
||||
cd wedding-photo-sharing
|
||||
|
||||
# Create required directories
|
||||
mkdir -p storage/events/active storage/events/archived storage/thumbnails storage/uploads
|
||||
mkdir -p data logs
|
||||
mkdir -p certbot/conf certbot/www
|
||||
|
||||
# Set permissions (important!)
|
||||
chmod -R 755 storage data logs
|
||||
```
|
||||
|
||||
### 2. Fix Docker Volume Permissions
|
||||
|
||||
Create `docker-compose.override.yml` for local volume configuration:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
volumes:
|
||||
- ./storage:/app/storage:delegated
|
||||
- ./data:/app/data:delegated
|
||||
- ./logs:/app/logs:delegated
|
||||
user: "1001:1001" # nodejs user
|
||||
|
||||
db:
|
||||
volumes:
|
||||
- ./postgres-data:/var/lib/postgresql/data
|
||||
```
|
||||
|
||||
### 3. Build and Deploy
|
||||
|
||||
```bash
|
||||
# Build images
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# Start services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
### 4. Create Admin User
|
||||
|
||||
After deployment, create the first admin user:
|
||||
|
||||
```bash
|
||||
# Enter backend container
|
||||
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||
|
||||
# Create admin
|
||||
node scripts/create-admin.js \
|
||||
--username admin \
|
||||
--email admin@yourdomain.com \
|
||||
--password <your-secure-password>
|
||||
|
||||
# Exit container
|
||||
exit
|
||||
```
|
||||
|
||||
### 5. Configure Email (if using database config)
|
||||
|
||||
1. Login to admin panel: https://yourdomain.com/admin
|
||||
2. Go to Settings > Email Configuration
|
||||
3. Enter SMTP details
|
||||
4. Test email sending
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue 1: Migration Failures
|
||||
|
||||
**Error**: "relation already exists"
|
||||
|
||||
**Solution**: The safe migration runner handles this automatically. If issues persist:
|
||||
|
||||
```bash
|
||||
# Reset migrations tracking
|
||||
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak
|
||||
|
||||
# In PostgreSQL:
|
||||
DROP TABLE IF EXISTS migrations;
|
||||
\q
|
||||
|
||||
# Re-run migrations
|
||||
docker-compose -f docker-compose.prod.yml exec backend npm run migrate:safe
|
||||
```
|
||||
|
||||
### Issue 2: Permission Denied Errors
|
||||
|
||||
**Error**: "EACCES: permission denied"
|
||||
|
||||
**Solution**: Fix container permissions:
|
||||
|
||||
```bash
|
||||
# Stop containers
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# Fix permissions on host
|
||||
sudo chown -R 1001:1001 storage data logs
|
||||
|
||||
# Restart
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Issue 3: Database Connection Failed
|
||||
|
||||
**Error**: "no pg_hba.conf entry"
|
||||
|
||||
**Solution**: Already fixed in docker-compose.prod.yml with:
|
||||
- SSL disabled for internal Docker network
|
||||
- Proper authentication method (scram-sha-256)
|
||||
|
||||
### Issue 4: Frontend Can't Connect to Backend
|
||||
|
||||
**Error**: CORS errors or connection refused
|
||||
|
||||
**Solution**: Ensure environment variables match:
|
||||
- Backend: `FRONTEND_URL` must match your frontend URL
|
||||
- Frontend: `VITE_API_URL` must be set during build
|
||||
|
||||
### Issue 5: Email Not Sending
|
||||
|
||||
**Solution**: Check email configuration:
|
||||
|
||||
```bash
|
||||
# Check backend logs
|
||||
docker-compose -f docker-compose.prod.yml logs backend | grep email
|
||||
|
||||
# Verify SMTP settings
|
||||
# Gmail users: Use app password, not regular password
|
||||
# Enable "Less secure app access" or use OAuth2
|
||||
```
|
||||
|
||||
## SSL/HTTPS Setup
|
||||
|
||||
1. Update `nginx/sites-enabled/default` with your domain
|
||||
2. Run certbot:
|
||||
|
||||
```bash
|
||||
# Initial certificate
|
||||
docker-compose -f docker-compose.prod.yml run --rm certbot certonly \
|
||||
--webroot --webroot-path=/var/www/certbot \
|
||||
-d yourdomain.com -d www.yourdomain.com
|
||||
|
||||
# Auto-renewal is handled by the certbot container
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Backend health
|
||||
curl http://localhost/api/health
|
||||
|
||||
# Database connection
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
psql -U picpeak -d picpeak -c "SELECT 1"
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup.sh
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="./backups/$DATE"
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Database
|
||||
docker-compose -f docker-compose.prod.yml exec -T db \
|
||||
pg_dump -U picpeak picpeak > $BACKUP_DIR/database.sql
|
||||
|
||||
# Files
|
||||
tar -czf $BACKUP_DIR/storage.tar.gz storage/
|
||||
|
||||
echo "Backup completed: $BACKUP_DIR"
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
```bash
|
||||
# Database
|
||||
docker-compose -f docker-compose.prod.yml exec -T db \
|
||||
psql -U picpeak picpeak < ./backups/20240713_120000/database.sql
|
||||
|
||||
# Files
|
||||
tar -xzf ./backups/20240713_120000/storage.tar.gz
|
||||
```
|
||||
|
||||
## Production Best Practices
|
||||
|
||||
1. **Always use named volumes** in production for better data persistence
|
||||
2. **Set up monitoring** with Prometheus/Grafana
|
||||
3. **Enable backups** with automated scripts
|
||||
4. **Use a reverse proxy** (Nginx) for SSL termination
|
||||
5. **Implement rate limiting** at the Nginx level
|
||||
6. **Regular updates** - Keep Docker images updated
|
||||
7. **Log rotation** - Configure log rotation for application logs
|
||||
|
||||
## Troubleshooting Commands
|
||||
|
||||
```bash
|
||||
# Check running containers
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Restart a service
|
||||
docker-compose -f docker-compose.prod.yml restart backend
|
||||
|
||||
# View real-time logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f --tail=100
|
||||
|
||||
# Execute commands in container
|
||||
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||
|
||||
# Database shell
|
||||
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak
|
||||
|
||||
# Clean restart
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Strong JWT_SECRET (min 32 chars)
|
||||
- [ ] Strong database password
|
||||
- [ ] SSL/HTTPS enabled
|
||||
- [ ] Firewall configured (only 80/443 open)
|
||||
- [ ] Regular security updates
|
||||
- [ ] Backup encryption
|
||||
- [ ] Access logs monitored
|
||||
- [ ] Rate limiting enabled
|
||||
- [ ] File upload restrictions configured
|
||||
|
||||
## Support
|
||||
|
||||
For issues not covered here:
|
||||
1. Check application logs
|
||||
2. Review error messages carefully
|
||||
3. Ensure all environment variables are set
|
||||
4. Verify file permissions
|
||||
5. Check Docker daemon logs
|
||||
@@ -1,47 +1,52 @@
|
||||
# 📸 PicPeak - Open Source Photo Sharing for Events
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||
|
||||
# 📸 PicPeak
|
||||
|
||||
**Open-source, self-hosted photo sharing for events.**
|
||||
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://buymeacoffee.com/theluap)
|
||||
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support ☕](https://buymeacoffee.com/theluap)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||
|
||||

|
||||
|
||||
> [!IMPORTANT]
|
||||
> **PicPeak has moved to its own GitHub organization.** Docker images are now at `ghcr.io/picpeak/picpeak/{backend,frontend,aio,ml}` (and on Docker Hub as `picpeak/{backend,frontend,aio,ml}`) and active development is on `main`. The old `ghcr.io/the-luap/...` path still responds but its tags are **frozen** at 2026-05-27 — if updates never arrive, check your image path first. See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit.
|
||||
## 🌟 Why Choose PicPeak?
|
||||
|
||||
## Contents
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
|
||||
- [Live Demo](#-live-demo)
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Why PicPeak?](#-why-picpeak)
|
||||
- [Features](#-features)
|
||||
- [Documentation](#-documentation)
|
||||
- [Comparison](#-comparison-with-alternatives)
|
||||
- [Tech Stack](#️-tech-stack)
|
||||
- [Contributing & Support](#-contributing)
|
||||
- [License](#-license)
|
||||
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
|
||||
- **🔒 Complete Data Control** - Your photos stay on your server
|
||||
- **🎨 White-Label Ready** - Full branding customization
|
||||
- **📱 Mobile-First Design** - Beautiful on all devices
|
||||
- **🚀 Lightning Fast** - Optimized performance and caching
|
||||
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
|
||||
|
||||
## 🎮 Live Demo
|
||||
## ✨ Key Features
|
||||
|
||||
Try PicPeak without installing anything — [demo.picpeak.app](https://demo.picpeak.app) · [admin panel](https://demo.picpeak.app/admin)
|
||||
### For Photographers
|
||||
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
|
||||
- ⏰ **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
|
||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||
|
||||
| Email | Password |
|
||||
|---|---|
|
||||
| `demo@picpeak.app` | `Demo2026!` |
|
||||
### For Clients
|
||||
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
||||
- 📱 **Mobile Optimized** - Swipe through photos on any device
|
||||
- ⬇️ **Bulk Downloads** - Download all photos with one click
|
||||
- 🔍 **Smart Search** - Find photos quickly
|
||||
- 📤 **Guest Uploads** - Optional client photo uploads
|
||||
|
||||
> The demo resets periodically. Uploaded content may be removed without notice.
|
||||
### Technical Excellence
|
||||
- 🐳 **Docker Ready** - Deploy in minutes
|
||||
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
||||
- 💾 **Smart Storage** - Automatic archiving of expired galleries
|
||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||
- 📈 **Scalable** - From small studios to large agencies
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
@@ -49,196 +54,132 @@ Get PicPeak running in under 5 minutes:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/PicPeak/picpeak.git
|
||||
git clone https://github.com/the-luap/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. Edit .env only to customise
|
||||
# (domain, SMTP, storage paths, …) — nothing is required.
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit configuration (required: JWT_SECRET)
|
||||
nano .env
|
||||
|
||||
# Start with Docker Compose
|
||||
docker compose up -d
|
||||
docker-compose up -d
|
||||
|
||||
# Access at http://localhost:3000
|
||||
# Access at http://localhost:3005
|
||||
```
|
||||
|
||||
On first start, open **http://localhost:3000/admin** and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in **[First-run setup](https://docs.picpeak.app/getting-started/first-login)**.
|
||||
|
||||
> **Updating / release channels:** set `PICPEAK_CHANNEL` (`stable` default, or `beta`) in `.env`, then `docker compose pull && docker compose up -d`. See [RELEASING.md](RELEASING.md) for the promotion cadence.
|
||||
|
||||
### Or: one container, no compose file
|
||||
|
||||
For a home server, a NAS, or a single small studio, the all-in-one image runs the whole app as one process with SQLite — no compose file, no separate database, no reverse proxy to wire up:
|
||||
|
||||
```bash
|
||||
docker run -d --name picpeak -p 3000:3000 \
|
||||
-v picpeak:/data \
|
||||
ghcr.io/picpeak/picpeak/aio:main
|
||||
```
|
||||
|
||||
No environment variables to set — the JWT secret is generated on first start and kept on the volume.
|
||||
|
||||
Then open **http://localhost:3000/admin** and read the setup token with `docker exec picpeak cat /data/db/SETUP_TOKEN`, or open `db/SETUP_TOKEN` on the volume with any file manager if the host has no shell.
|
||||
|
||||
`:main` is the active-development tag, and today it is the only one the all-in-one image has — `Dockerfile.aio` landed after the current stable release, so `:stable` and `:latest` first appear for this image once the aio build reaches the `stable` branch. Switch to `:stable` then, or pin a published version tag if you would rather not track `main`.
|
||||
|
||||
The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a `.picpeak` backup and restore it into the full stack. See **[Single-container install](https://docs.picpeak.app/deployment/single-container)** for the volume layout, the external-Postgres variant, TLS, and the limits.
|
||||
|
||||
### Docker images
|
||||
|
||||
| | GHCR | Docker Hub |
|
||||
|---|---|---|
|
||||
| Backend | `ghcr.io/picpeak/picpeak/backend` | [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) |
|
||||
| Frontend | `ghcr.io/picpeak/picpeak/frontend` | [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend) |
|
||||
| All-in-one | `ghcr.io/picpeak/picpeak/aio` | [`picpeak/aio`](https://hub.docker.com/r/picpeak/aio) |
|
||||
| ML sidecar (optional) | `ghcr.io/picpeak/picpeak/ml` | [`picpeak/ml`](https://hub.docker.com/r/picpeak/ml) |
|
||||
|
||||
Both registries get the same digests and the same tags — `stable`/`latest`, a pinned `x.y.z`, and `beta`/`main` for the active development channel — for `linux/amd64` and `linux/arm64`. Keep every image in one install on the **same** tag.
|
||||
|
||||
## 🌟 Why PicPeak?
|
||||
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
|
||||
- **💰 No Monthly Fees** — one-time setup, unlimited galleries
|
||||
- **🔒 Complete Data Control** — your photos stay on your server
|
||||
- **🎨 White-Label Ready** — full branding customization
|
||||
- **📱 Mobile-First Design** — beautiful on all devices
|
||||
- **🌍 Multi-Language** — built-in i18n (EN, DE)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
**For photographers** — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a [Live Slideshow](https://docs.picpeak.app/features/live-slideshow) projector view that auto-picks-up new uploads during live events.
|
||||
|
||||
**For clients** — clean mobile-optimized galleries, one-click bulk downloads, smart search, **[People in this gallery](https://docs.picpeak.app/features/face-recognition)** face grouping (opt-in per gallery, needs the optional [ML sidecar](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)), optional guest uploads, and download protection (watermarking + right-click prevention).
|
||||
|
||||
**Technical** — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible [storage backends](https://docs.picpeak.app/features/storage-backends), [webhooks](https://docs.picpeak.app/features/webhooks), and security-first defaults (JWT, rate limiting, CORS).
|
||||
|
||||
<details>
|
||||
<summary><strong>🧾 For studios — CRM & Accounting (Beta, off by default)</strong></summary>
|
||||
|
||||
- 📝 **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
|
||||
- 🌍 **VAT & Multi-currency** — single VAT-code registry snapshotted onto each document
|
||||
|
||||
</details>
|
||||
|
||||
> [!WARNING]
|
||||
> **CRM & Accounting — examples only, verify locally.** Feature-flagged off by default. Seeded contract blocks are written by the maintainer, **not a lawyer**; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are **jurisdiction-specific guidance only**. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read **[the CRM disclaimers](https://docs.picpeak.app/features/crm/disclaimers)** first.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings, API, branding, and more.
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT.md) - Detailed installation instructions
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||
|
||||
| Topic | Link |
|
||||
|---|---|
|
||||
| 🚀 Deployment (Docker, env, reverse proxy, SSL) | [docs.picpeak.app/deployment](https://docs.picpeak.app/deployment) |
|
||||
| 📦 Single-container install (one `docker run`, SQLite) | [docs.picpeak.app/deployment/single-container](https://docs.picpeak.app/deployment/single-container) |
|
||||
| ⚙️ Admin settings reference | [docs.picpeak.app/guides/admin-settings](https://docs.picpeak.app/guides/admin-settings) |
|
||||
| 🎯 Creating events | [docs.picpeak.app/guides/creating-events](https://docs.picpeak.app/guides/creating-events) |
|
||||
| 📽️ Live Slideshow | [docs.picpeak.app/features/live-slideshow](https://docs.picpeak.app/features/live-slideshow) |
|
||||
| 🙂 People in galleries (face grouping) | [docs.picpeak.app/features/face-recognition](https://docs.picpeak.app/features/face-recognition) |
|
||||
| 💾 Backup & Restore | [docs.picpeak.app/guides/backup-restore](https://docs.picpeak.app/guides/backup-restore) |
|
||||
| 🔌 API reference | [docs.picpeak.app/api](https://docs.picpeak.app/api) |
|
||||
| 🪝 Webhooks | [docs.picpeak.app/features/webhooks](https://docs.picpeak.app/features/webhooks) |
|
||||
| 💾 Storage backends (local / S3) | [docs.picpeak.app/features/storage-backends](https://docs.picpeak.app/features/storage-backends) |
|
||||
| 💻 System requirements & tuning | [docs.picpeak.app/deployment/system-requirements](https://docs.picpeak.app/deployment/system-requirements) |
|
||||
| 🧾 CRM & Accounting | [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm) · [disclaimers](https://docs.picpeak.app/features/crm/disclaimers) |
|
||||
| 🗺️ Roadmap | [GitHub Issues](https://github.com/PicPeak/picpeak/issues) |
|
||||
## 🎯 Use Cases
|
||||
|
||||
**Project meta:** [Support](SUPPORT.md) · [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
|
||||
## 📊 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 | ❌ | ❌ | ❌ |
|
||||
|
||||
<sub>*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.</sub>
|
||||
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
|
||||
|
||||
## 🏗️ 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](https://docs.picpeak.app/features/storage-backends)
|
||||
- **Storage**: File-based with automatic archiving
|
||||
- **Email**: SMTP with customizable templates
|
||||
- **Analytics**: Privacy-focused with Umami integration
|
||||
- **External media**: point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals read-only, index quickly, and generate thumbnails on demand
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<details>
|
||||
<summary>Click to see the admin dashboard, analytics, and event management</summary>
|
||||
|
||||
### 🎛️ Admin Dashboard
|
||||
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
|
||||
|
||||
### 📊 Analytics & Insights
|
||||
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
|
||||
|
||||
### 📁 Event Management
|
||||
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
|
||||
|
||||
</details>
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the [Contributing Guide](CONTRIBUTING.md) to get started.
|
||||
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
||||
|
||||
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security). See [SECURITY.md](SECURITY.md) for the policy.
|
||||
See our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
## ☕ Support the Project
|
||||
## 📊 Comparison with Alternatives
|
||||
|
||||
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider [buying me a coffee](https://buymeacoffee.com/theluap) — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also ⭐ star the repo, share it, file good bug reports, or open a PR.
|
||||
| Feature | PicPeak | PicDrop | Scrapbook.de |
|
||||
|---------|---------|---------|--------------|
|
||||
| Self-Hosted | ✅ | ❌ | ❌ |
|
||||
| Custom Branding | ✅ Full | Limited | Limited |
|
||||
| Monthly Cost | $0 | $29-199 | €19-99 |
|
||||
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
|
||||
| Client Uploads | ✅ | ✅ | ✅ |
|
||||
| API Access | ✅ | Paid | ❌ |
|
||||
| Open Source | ✅ | ❌ | ❌ |
|
||||
|
||||
*Limited only by your server storage
|
||||
|
||||
## 🛡️ 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 email security@example.com
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
### 🎛️ **Admin Dashboard**
|
||||
Get a complete overview of your photo galleries, analytics, and system status.
|
||||
|
||||
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
|
||||
|
||||
### 📊 **Analytics & Insights**
|
||||
Track gallery performance, view statistics, and monitor user engagement.
|
||||
|
||||
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
|
||||
|
||||
### 📁 **Event Management**
|
||||
Organize and manage your photo galleries with intuitive event management tools.
|
||||
|
||||
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
|
||||
|
||||
### ✨ **Key Interface Highlights**
|
||||
|
||||
<details>
|
||||
<summary>👆 Click to see more interface details</summary>
|
||||
|
||||
#### 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
|
||||
|
||||
</details>
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.
|
||||
|
||||
### 👥 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
|
||||
- Gallery foundation (events, uploads, sharing, download protection, templates)
|
||||
- Backup & restore, analytics, branding/theming
|
||||
- The architecture every later feature builds on
|
||||
|
||||
**[@Luca-Timo](https://github.com/Luca-Timo)**
|
||||
- Native Apple Silicon multi-arch images
|
||||
- CRM & accounting suite (quotes/contracts/invoices)
|
||||
- Hours logging & Treuhänder/Banana tax export
|
||||
- Gallery header/banner decoupling
|
||||
|
||||
**[@Rekoo-PS](https://github.com/Rekoo-PS)** — bug reports & product feedback
|
||||
- Login-loop fix, mobile-lightbox overhaul, bulk-delete workflow
|
||||
- Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter
|
||||
|
||||
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
|
||||
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.
|
||||
|
||||
## 📄 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 [Deployment Guide](DEPLOYMENT.md)
|
||||
3. 🐛 Report issues or request features
|
||||
4. 🤝 Join our community and contribute!
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ by photographers, for photographers
|
||||
<br>
|
||||
<a href="https://www.picpeak.app">Homepage</a> ·
|
||||
<a href="https://demo.picpeak.app">Live Demo</a> ·
|
||||
<a href="https://docs.picpeak.app">Documentation</a> ·
|
||||
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
|
||||
</p>
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="DEPLOYMENT.md">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
</p>
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
# Release Process
|
||||
|
||||
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs pre-release) see the [Release Channels section in README.md](README.md#-release-channels).
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **`main` branch** receives all merged work (active development). Every push triggers a `release-please` PR that proposes the next `vX.Y.Z-beta.N` pre-release. Merging that PR tags the pre-release and publishes Docker images under the `:main` rolling tag + the version-specific tag.
|
||||
- **`stable` branch** holds the curated stable channel. Stable releases are cut from a known-good `main` point via a `release/X.Y.Z-merge-from-main` branch and a manual PR to `stable`. Merging that PR triggers `release-please` to propose the stable release.
|
||||
- Target cadence: **a stable release every 4–6 weeks**, or sooner if `main` has been quiet and ready for promotion.
|
||||
|
||||
> **Branch model background** — `main` (active dev) was previously called `beta`, and `stable` (curated channel) was previously called `main`. The rename happened with #669 to match the convention every other open-source project uses. The mechanics below all reference the post-rename names.
|
||||
|
||||
## Cadence target
|
||||
|
||||
4–6 weeks between stable releases is the working target. Reasoning:
|
||||
|
||||
- Long enough that each stable carries meaningful changes worth the upgrade burden.
|
||||
- Short enough that pre-release users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
|
||||
- Aligns with how release-please surfaces pre-releases (multiple pre-release points usually accumulate inside a 4–6 week window, which gives natural promotion candidates).
|
||||
|
||||
This is a target, not a hard rule. Cut sooner if `main` has been quiet and stable longer than usual. Cut later if `main` is in flux for security or migration reasons.
|
||||
|
||||
## Promotion criteria
|
||||
|
||||
A `main` tip is eligible for promotion to `stable` when **all** of the following hold:
|
||||
|
||||
1. **CI green on the candidate `main` tip.** Specifically: `schema-drift` (`upgrade-from-bootstrap`), `fresh-install`, `Tests` (backend Jest + frontend Vitest), the four `Build and Push Docker Images` arch matrices, and `GitGuardian Security Checks`.
|
||||
2. **No open `bug`-labelled issues against the candidate for at least 7 days.** Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidate `main` tip before closing them out.
|
||||
3. **An upgrade walk has been done on real production-shaped data** — apply the candidate's migration chain to a snapshot of the previous stable's DB and verify no manual intervention is required. CI proves fresh-install works; the upgrade walk is what proves the upgrade path works.
|
||||
4. **Operator-time smoke** on the candidate: log in, create event, upload photos, share gallery, open as a customer, log out. Catches binary-incompatibility regressions and UI-level breaks that unit tests don't see.
|
||||
|
||||
If any of the four fail, the promotion waits. File any blockers as `bug`-labelled issues and let them bake on `main` before re-evaluating.
|
||||
|
||||
## How a stable release is cut
|
||||
|
||||
The actual mechanics, in order:
|
||||
|
||||
1. **Pick the `main` tip.** Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting.
|
||||
|
||||
2. **Create the release branch from the `main` tip.**
|
||||
```bash
|
||||
git push origin <main-tip-sha>:refs/heads/release/X.Y.Z-merge-from-main
|
||||
```
|
||||
Naming convention: `release/X.Y.Z-merge-from-main`, where `X.Y.Z` is the stable version you intend to land. release-please will write the actual `X.Y.Z` on merge — the branch name is just a human label.
|
||||
|
||||
3. **Open a PR to `stable`.** Title: `chore(release): promote main → stable as vX.Y.Z`. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example (predates the rename; the mechanics are unchanged).
|
||||
|
||||
4. **Resolve conflicts.** `stable` almost always has commits `main` doesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:
|
||||
- **`backend/package.json` / `package-lock.json` + `frontend/package.json` / `package-lock.json`** — usually take `main`'s version (superset), but verify any security-pinned deps (`axios`, `nodemailer`, `i18next-http-backend`, `multer`, `tar`) on `main` are `>=` the pinned versions on `stable`. If `stable` has a newer pinned version (e.g. an emergency CVE backport `main` hasn't picked up), take `stable`'s pin.
|
||||
- **`README.md`** — keep `stable`'s version if it has had a recent rewrite that `main` didn't pick up; otherwise take `main`'s.
|
||||
- **`CHANGELOG.md`** — keep `stable`'s; release-please regenerates entries on its next stable cut from the commits going forward.
|
||||
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
|
||||
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
|
||||
|
||||
5. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
|
||||
```bash
|
||||
git commit --allow-empty -m "chore: release X.Y.Z" -m "Release-As: X.Y.Z"
|
||||
```
|
||||
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
|
||||
|
||||
6. **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).
|
||||
|
||||
7. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
|
||||
|
||||
8. **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.
|
||||
|
||||
9. **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)
|
||||
|
||||
Regular bug fixes are generally backported automatically from `main` to `stable`. Keep backports focused on the fix, without unrelated features, and resolve conflicts manually when needed.
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** Do not wait for a full promotion to deliver a security update. A fix first applied to `stable` must also be forward-ported to `main`; a fix first applied to `main` must also reach `stable`. See [SECURITY.md](SECURITY.md) for the support policy.
|
||||
|
||||
When a backport needs manual handling:
|
||||
|
||||
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
|
||||
2. Cherry-pick or hand-write the minimal fix.
|
||||
3. Open a PR to `stable` with the smallest possible diff.
|
||||
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
|
||||
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
|
||||
6. For security fixes, verify that the fix has been published through **both** release channels; merging the code is only part of delivery.
|
||||
|
||||
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
|
||||
|
||||
## 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.
|
||||
|
||||
### Stable ↔ pre-release number alignment
|
||||
|
||||
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
|
||||
|
||||
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
|
||||
|
||||
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
|
||||
|
||||
## 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.
|
||||
+65
-61
@@ -1,81 +1,85 @@
|
||||
# Security Policy
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the PicPeak backend, frontend, all-in-one (AIO) image, optional
|
||||
ML component, and the Docker images published by the PicPeak project. Other
|
||||
PicPeak repositories define their own supported versions and release channels.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security support follows the current release channels:
|
||||
We release patches for security vulnerabilities. Currently supported versions:
|
||||
|
||||
| Version or channel | Security support |
|
||||
| --- | --- |
|
||||
| Latest stable release from `stable` | Supported; security fixes are published through this channel |
|
||||
| Latest beta release from `main` | Supported; security fixes are published through this channel |
|
||||
| Superseded stable or beta releases | Upgrade to the latest release in the same channel; older releases are not maintained separately |
|
||||
| 2.x and earlier | No longer supported |
|
||||
|
||||
See the [latest stable release](https://github.com/PicPeak/picpeak/releases/latest)
|
||||
and [all releases, including betas](https://github.com/PicPeak/picpeak/releases).
|
||||
Version numbers differ between channels; each channel receives its own updates.
|
||||
|
||||
### Security fixes and bug backports
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** A fix that
|
||||
lands on one branch must also reach the other branch and be published through
|
||||
both release channels. Security updates do not wait for the next full
|
||||
`main`-to-`stable` promotion.
|
||||
|
||||
Regular bug fixes are also generally backported automatically to `stable`.
|
||||
Backports remain focused on the fix, without pulling in unrelated features.
|
||||
Maintainers resolve conflicts or handle a backport manually when necessary.
|
||||
|
||||
The [release process](RELEASING.md) describes backports, forward-ports and
|
||||
publication. Operators must apply the published updates to their installations.
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Do not report vulnerabilities in public issues, discussions or pull requests.**
|
||||
We take the security of PicPeak seriously. If you have discovered a security vulnerability, please follow these steps:
|
||||
|
||||
Report privately through:
|
||||
### 1. **Do NOT create a public GitHub issue**
|
||||
|
||||
- [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) (preferred).
|
||||
- Email **info@picpeak.app** if you cannot use GitHub's private reporting form.
|
||||
### 2. Email us at security@example.com with:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
|
||||
Include the affected component, version or image tag, deployment method,
|
||||
reproduction steps, expected impact and any suggested fix. Share only the
|
||||
information needed to reproduce the problem; remove credentials and personal
|
||||
data from logs or examples.
|
||||
### 3. You can expect:
|
||||
- Acknowledgment within 48 hours
|
||||
- Regular updates on our progress
|
||||
- Credit in the fix announcement (unless you prefer to remain anonymous)
|
||||
|
||||
We aim to acknowledge reports within 48 hours. This is a response target, not a
|
||||
guaranteed service level or a promised resolution time. We will provide progress
|
||||
updates and coordinate disclosure with the reporter. Reporter credit is optional;
|
||||
tell us if you prefer to remain anonymous.
|
||||
## Security Measures
|
||||
|
||||
## Deployment Security
|
||||
PicPeak implements several security measures:
|
||||
|
||||
Security depends on both the software and its configuration. Operators should:
|
||||
### Authentication & Authorization
|
||||
- JWT-based authentication with secure token storage
|
||||
- bcrypt password hashing with configurable rounds
|
||||
- Role-based access control for admin functions
|
||||
- Session timeout management
|
||||
|
||||
- Use HTTPS and configure the reverse proxy and trusted proxy settings correctly.
|
||||
- Use strong credentials and keep deployment secrets private.
|
||||
- Apply updates for the chosen release channel and restrict unnecessary network access.
|
||||
- Keep backups and verify that they can be restored.
|
||||
### Input Validation
|
||||
- All user inputs are validated and sanitized
|
||||
- SQL injection prevention through parameterized queries
|
||||
- XSS protection via Content Security Policy
|
||||
- File upload restrictions and validation
|
||||
|
||||
See the deployment guides for [HTTPS](https://docs.picpeak.app/deployment/ssl-certificates),
|
||||
[reverse proxies](https://docs.picpeak.app/deployment/reverse-proxy),
|
||||
[security settings](https://docs.picpeak.app/guides/admin-settings/security)
|
||||
and [backup and restore](https://docs.picpeak.app/guides/backup-restore).
|
||||
### Rate Limiting
|
||||
- API rate limiting to prevent abuse
|
||||
- Brute force protection on authentication endpoints
|
||||
- Configurable limits per endpoint
|
||||
|
||||
### Data Protection
|
||||
- HTTPS enforcement in production
|
||||
- Secure cookie settings
|
||||
- CORS configuration
|
||||
- Sensitive data encryption
|
||||
|
||||
### Infrastructure
|
||||
- Regular dependency updates
|
||||
- Security headers (HSTS, X-Frame-Options, etc.)
|
||||
- Activity logging for audit trails
|
||||
- Automated backups
|
||||
|
||||
## Best Practices for Deployment
|
||||
|
||||
1. **Always use HTTPS** in production
|
||||
2. **Change default passwords** immediately
|
||||
3. **Keep dependencies updated** regularly
|
||||
4. **Configure firewall rules** appropriately
|
||||
5. **Monitor logs** for suspicious activity
|
||||
6. **Backup regularly** and test restoration
|
||||
|
||||
## Vulnerability Disclosure
|
||||
|
||||
We coordinate disclosure with the reporter while preparing fixes. Security fixes
|
||||
are published through both supported channels. Advisories and release notes
|
||||
identify affected versions, the fixed version in each channel, the impact and
|
||||
any required mitigation or upgrade steps. Reporter credit is included with
|
||||
permission.
|
||||
We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
|
||||
For ordinary bugs and support requests, use
|
||||
[GitHub Issues](https://github.com/PicPeak/picpeak/issues) or
|
||||
[GitHub Discussions](https://github.com/PicPeak/picpeak/discussions).
|
||||
1. We'll publish a security advisory
|
||||
2. Credit researchers (with permission)
|
||||
3. Detail the impact and mitigation steps
|
||||
4. Release patches for all supported versions
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: security@example.com
|
||||
- General support: https://github.com/the-luap/picpeak/issues
|
||||
|
||||
Thank you for helping keep PicPeak and its users safe!
|
||||
-568
@@ -1,568 +0,0 @@
|
||||
# 🚀 PicPeak Simple Setup Guide
|
||||
|
||||
This guide provides easy installation instructions for PicPeak on Linux servers with both Docker and non-Docker options.
|
||||
|
||||
## 📋 Quick Start
|
||||
|
||||
### One-Line Installation
|
||||
|
||||
```bash
|
||||
# Download and run the unified setup script
|
||||
curl -fsSL https://raw.githubusercontent.com/PicPeak/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will automatically detect your environment and recommend the best installation method.
|
||||
|
||||
## 🎯 Installation Methods
|
||||
|
||||
### Method 1: Docker Installation (Recommended)
|
||||
Best for: Most users, easy updates, isolated environment
|
||||
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --docker
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Easier installation and updates
|
||||
- ✅ Better isolation from system
|
||||
- ✅ Consistent environment across platforms
|
||||
- ✅ Built-in PostgreSQL and Redis
|
||||
|
||||
**Cons:**
|
||||
- ❌ Requires more resources (~4GB RAM recommended)
|
||||
- ❌ Additional Docker overhead
|
||||
|
||||
### Method 2: Native Installation
|
||||
Best for: Resource-constrained systems, Raspberry Pi, direct control
|
||||
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --native
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Lower resource usage (~1GB RAM minimum)
|
||||
- ✅ Direct system control
|
||||
- ✅ No Docker overhead
|
||||
- ✅ Better for ARM devices
|
||||
|
||||
**Cons:**
|
||||
- ❌ More complex setup
|
||||
- ❌ System dependencies required
|
||||
- ❌ Manual update process
|
||||
|
||||
## 📋 System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
- **OS**: Ubuntu 20.04+, Debian 11+, Fedora 38+, RHEL/CentOS 8+, Raspberry Pi OS
|
||||
- **RAM**:
|
||||
- Docker: 2GB minimum (4GB recommended)
|
||||
- Native: 1GB minimum (2GB recommended)
|
||||
- **Storage**: 2GB for application + space for photos
|
||||
- **Network**: Port 3001 (or 80/443 with proxy)
|
||||
|
||||
### Supported Platforms
|
||||
- ✅ Ubuntu 20.04, 22.04, 24.04
|
||||
- ✅ Debian 11, 12
|
||||
- ✅ Raspberry Pi OS (32-bit and 64-bit)
|
||||
- ✅ Fedora 38, 39, 40
|
||||
- ✅ RHEL/CentOS/Rocky/AlmaLinux 8, 9
|
||||
|
||||
## 🛠️ Installation Options
|
||||
|
||||
### Interactive Mode (Default)
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will prompt you to choose:
|
||||
1. Installation method (Docker or Native)
|
||||
2. Admin email and password
|
||||
3. Domain configuration (optional)
|
||||
4. Email server settings (optional)
|
||||
5. SSL/HTTPS setup (optional)
|
||||
|
||||
### Unattended Installation
|
||||
|
||||
#### Docker with full configuration:
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --docker --unattended \
|
||||
--domain photos.example.com \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123 \
|
||||
--smtp-host smtp.gmail.com \
|
||||
--smtp-port 587 \
|
||||
--smtp-user your-email@gmail.com \
|
||||
--smtp-pass your-app-password \
|
||||
--enable-ssl
|
||||
```
|
||||
|
||||
#### Native with minimal configuration:
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --native --unattended \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
|
||||
| Option | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `--docker` | Use Docker installation | `--docker` |
|
||||
| `--native` | Use native installation | `--native` |
|
||||
| `--unattended` | Run without prompts | `--unattended` |
|
||||
| `--domain` | Domain for HTTPS setup | `--domain photos.example.com` |
|
||||
| `--email` | Admin email address | `--email admin@example.com` |
|
||||
| `--admin-password` | Set admin password | `--admin-password MySecurePass` |
|
||||
| `--smtp-host` | SMTP server hostname | `--smtp-host smtp.gmail.com` |
|
||||
| `--smtp-port` | SMTP server port | `--smtp-port 587` |
|
||||
| `--smtp-user` | SMTP username | `--smtp-user user@gmail.com` |
|
||||
| `--smtp-pass` | SMTP password | `--smtp-pass app-password` |
|
||||
| `--enable-ssl` | Enable HTTPS with Let's Encrypt | `--enable-ssl` |
|
||||
| `--port` | Custom port (native only) | `--port 8080` |
|
||||
| `--update` | Update existing installation | `--update` |
|
||||
| `--uninstall` | Remove installation | `--uninstall` |
|
||||
| `--help` | Show help message | `--help` |
|
||||
|
||||
## 🏗️ What Gets Installed
|
||||
|
||||
### Docker Installation
|
||||
```
|
||||
~/picpeak/ # Or custom directory
|
||||
├── docker-compose.yml # Service definitions
|
||||
├── .env # Configuration
|
||||
├── storage/
|
||||
│ └── events/ # Photo storage
|
||||
│ ├── active/ # Current galleries
|
||||
│ └── archived/ # Expired galleries
|
||||
├── logs/ # Application logs
|
||||
└── backup/ # Backup directory
|
||||
```
|
||||
|
||||
**Services:**
|
||||
- PicPeak Backend (Node.js application)
|
||||
- PostgreSQL Database
|
||||
- Redis Cache
|
||||
- Nginx Reverse Proxy (optional)
|
||||
- Background Workers
|
||||
|
||||
### Native Installation
|
||||
```
|
||||
/opt/picpeak/ # Installation directory
|
||||
├── backend/ # Application code
|
||||
├── events/ # Photo storage
|
||||
│ ├── active/ # Current galleries
|
||||
│ └── archived/ # Expired galleries
|
||||
├── logs/ # Application logs
|
||||
└── config/ # Configuration files
|
||||
```
|
||||
|
||||
**Services (systemd):**
|
||||
- `picpeak-backend` - Main application
|
||||
- `picpeak-workers` - Background workers
|
||||
- `caddy` - Web server (optional)
|
||||
|
||||
## 🔑 First Login — Create Your Admin
|
||||
|
||||
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
|
||||
|
||||
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
|
||||
|
||||
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
|
||||
2. Read the **one-time setup token** from the 0600 file the backend writes it to
|
||||
(it is not logged — that would leave a live credential in `docker logs`):
|
||||
```bash
|
||||
docker compose exec backend cat /app/data/SETUP_TOKEN
|
||||
```
|
||||
Only if that write fails does the backend log the token instead.
|
||||
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
|
||||
|
||||
## 🌐 Access Methods
|
||||
|
||||
### Direct Access (Simplest)
|
||||
- Docker: `http://your-server:3000` (frontend and admin at `/admin`)
|
||||
- Backend/API: `http://your-server:3001` (API only; no UI routes)
|
||||
|
||||
For native installs, serve the built frontend (e.g., with nginx or Caddy) and access the admin at `/admin` on the frontend domain.
|
||||
|
||||
### With Domain & HTTPS
|
||||
If configured during setup:
|
||||
- `https://your-domain.com` - Gallery frontend
|
||||
- `https://your-domain.com/admin` - Admin panel
|
||||
|
||||
### Behind Existing Proxy
|
||||
Add to your Nginx/Apache configuration (split frontend vs backend):
|
||||
```nginx
|
||||
# Frontend (UI + /admin/*)
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend API and protected resources
|
||||
location /api {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
location ~ ^/(photos|thumbnails|uploads) {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
## 📁 Managing Galleries
|
||||
|
||||
### Creating a Gallery
|
||||
|
||||
#### Via Admin Panel
|
||||
1. Login to admin panel at `/admin`
|
||||
2. Click "Create New Event"
|
||||
3. Configure settings (name, date, password, customer email)
|
||||
4. Upload photos via drag & drop in the Photos tab
|
||||
5. Publish the gallery when ready
|
||||
|
||||
#### Adding Photos via File System
|
||||
|
||||
> **Important:** You must first create the event in the admin panel. The file watcher only detects new photos for events that already exist in the database. You cannot create a gallery by copying files alone.
|
||||
|
||||
Once an event exists, you can add photos by copying them into the event's folder. PicPeak's built-in file watcher will automatically detect the new files, create database records, and generate thumbnails.
|
||||
|
||||
```bash
|
||||
# Docker installation — copy photos into an existing event's folder
|
||||
cp /path/to/photos/*.jpg ~/picpeak/storage/events/active/<event-slug>/
|
||||
|
||||
# Native installation
|
||||
sudo cp /path/to/photos/*.jpg /opt/picpeak/events/active/<event-slug>/
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/<event-slug>
|
||||
```
|
||||
|
||||
The event slug is visible in the admin panel URL or share link (e.g. `wedding-smith-2024`). Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`. The file watcher has a 2-second stability delay before processing new files.
|
||||
|
||||
### Gallery Structure
|
||||
```
|
||||
<event-slug>/
|
||||
├── collages/ # Group photos (optional subfolder)
|
||||
├── individual/ # Individual photos (optional subfolder)
|
||||
└── photo.jpg # Photos at root level also work
|
||||
```
|
||||
|
||||
## 🔧 Service Management
|
||||
|
||||
### Docker Installation
|
||||
|
||||
```bash
|
||||
cd ~/picpeak
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker compose down
|
||||
|
||||
# Start services
|
||||
docker compose up -d
|
||||
|
||||
# Restart services
|
||||
docker compose restart
|
||||
|
||||
# Update PicPeak
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Native Installation
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
sudo systemctl status picpeak-backend
|
||||
sudo systemctl status picpeak-workers
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u picpeak-backend -f
|
||||
sudo journalctl -u picpeak-workers -f
|
||||
|
||||
# Start services
|
||||
sudo systemctl start picpeak-backend picpeak-workers
|
||||
|
||||
# Stop services
|
||||
sudo systemctl stop picpeak-backend picpeak-workers
|
||||
|
||||
# Restart services
|
||||
sudo systemctl restart picpeak-backend picpeak-workers
|
||||
|
||||
# Update PicPeak
|
||||
# (reruns migrations to pick up schema fixes for native installs)
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Docker Configuration
|
||||
Edit `~/picpeak/.env`:
|
||||
```bash
|
||||
nano ~/picpeak/.env
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### Native Configuration
|
||||
Edit `/opt/picpeak/app/backend/.env`:
|
||||
```bash
|
||||
sudo nano /opt/picpeak/app/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
### Key Settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| `JWT_SECRET` | Token signing secret | Auto-generated |
|
||||
| `ADMIN_EMAIL` | Admin email | admin@example.com |
|
||||
| `ADMIN_PASSWORD` | Admin password | Auto-generated |
|
||||
| `PHOTOS_DIR` | Photo storage path | Varies by method |
|
||||
| `SMTP_ENABLED` | Email notifications | false |
|
||||
| `DEFAULT_EXPIRY_DAYS` | Gallery expiration | 30 |
|
||||
|
||||
## 📧 Email Configuration
|
||||
|
||||
### Gmail Setup
|
||||
1. Enable 2-Factor Authentication
|
||||
2. Generate App Password
|
||||
3. Configure:
|
||||
```env
|
||||
SMTP_ENABLED=true
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
### SendGrid Setup
|
||||
1. Sign up at sendgrid.com (100 emails/day free)
|
||||
2. Create API key
|
||||
3. Configure:
|
||||
```env
|
||||
SMTP_ENABLED=true
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
SMTP_FROM=verified-sender@yourdomain.com
|
||||
```
|
||||
|
||||
## 🔄 Maintenance
|
||||
|
||||
### Backups
|
||||
|
||||
#### Docker:
|
||||
```bash
|
||||
# Backup script included
|
||||
cd ~/picpeak
|
||||
./backup.sh
|
||||
|
||||
# Manual backup
|
||||
docker exec picpeak-postgres pg_dump -U picpeak picpeak > backup.sql
|
||||
tar -czf photos-backup.tar.gz storage/events/
|
||||
```
|
||||
|
||||
#### Native:
|
||||
```bash
|
||||
# Database backup
|
||||
sudo cp /opt/picpeak/app/backend/data/photo_sharing.db /backup/database-$(date +%Y%m%d).sqlite
|
||||
|
||||
# Photos backup
|
||||
sudo tar -czf /backup/photos-$(date +%Y%m%d).tar.gz /opt/picpeak/events/
|
||||
```
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
cd ~/picpeak
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Native
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
# Will prompt for confirmation and data removal options
|
||||
sudo ./picpeak-setup.sh --uninstall
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Service Won't Start
|
||||
```bash
|
||||
# Docker
|
||||
docker compose logs backend
|
||||
docker compose down && docker compose up -d
|
||||
|
||||
# Native
|
||||
sudo journalctl -u picpeak-backend -n 50
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
#### Can't Access Admin Panel
|
||||
1. Check firewall:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 3001
|
||||
|
||||
# RHEL/CentOS
|
||||
sudo firewall-cmd --add-port=3001/tcp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
2. Verify service:
|
||||
```bash
|
||||
# Docker
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Native
|
||||
sudo systemctl is-active picpeak-backend
|
||||
```
|
||||
|
||||
#### Photos Not Showing
|
||||
```bash
|
||||
# Check permissions (Native)
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/
|
||||
sudo chmod -R 755 /opt/picpeak/events/
|
||||
|
||||
# Check permissions (Docker)
|
||||
ls -la ~/picpeak/storage/events/
|
||||
```
|
||||
|
||||
#### Reset Admin Password
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker exec picpeak-backend node scripts/reset-admin-password.js
|
||||
|
||||
# Native
|
||||
cd /opt/picpeak/app/backend
|
||||
sudo -u picpeak node scripts/reset-admin-password.js
|
||||
```
|
||||
|
||||
> **Note:** The new password will be displayed in the console output and saved to `ADMIN_PASSWORD_RESET.txt`. Save it immediately!
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. **Check logs:**
|
||||
- Docker: `docker compose logs -f`
|
||||
- Native: `sudo journalctl -u picpeak-backend -f`
|
||||
- Installation: `/tmp/picpeak-setup-*.log`
|
||||
|
||||
2. **Documentation:**
|
||||
- [Full Documentation](https://docs.picpeak.app)
|
||||
- [Deployment Guide](https://docs.picpeak.app/deployment)
|
||||
|
||||
3. **Support:**
|
||||
- [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
|
||||
- Include: Error messages, system info (`uname -a`), installation method
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
### Essential Security
|
||||
1. **Change default admin password immediately**
|
||||
2. **Use HTTPS for production** (Let's Encrypt included)
|
||||
3. **Configure firewall** (only open necessary ports)
|
||||
4. **Regular updates** (system and PicPeak)
|
||||
5. **Automated backups** (configure in admin panel)
|
||||
|
||||
### Advanced Security
|
||||
- Use VPN for admin panel access
|
||||
- Configure fail2ban for brute force protection
|
||||
- Enable audit logging
|
||||
- Regular security scans
|
||||
- Implement IP whitelisting
|
||||
|
||||
## 📊 Performance Optimization
|
||||
|
||||
### Docker Optimization
|
||||
```yaml
|
||||
# Adjust in docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
```
|
||||
|
||||
### Native Optimization
|
||||
```bash
|
||||
# Increase Node.js memory
|
||||
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/app/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
## 🎯 Quick Setup Examples
|
||||
|
||||
### Home/Office Network
|
||||
```bash
|
||||
# Simple local setup without domain
|
||||
sudo ./picpeak-setup.sh --native --email admin@local.com
|
||||
```
|
||||
|
||||
### Public Website with HTTPS
|
||||
```bash
|
||||
# Full production setup
|
||||
sudo ./picpeak-setup.sh --docker \
|
||||
--domain photos.company.com \
|
||||
--email admin@company.com \
|
||||
--enable-ssl
|
||||
```
|
||||
|
||||
### Raspberry Pi Setup
|
||||
```bash
|
||||
# Optimized for ARM devices
|
||||
sudo ./picpeak-setup.sh --native \
|
||||
--port 8080 \
|
||||
--email pi@local.com
|
||||
```
|
||||
|
||||
## ✅ Post-Installation Checklist
|
||||
|
||||
- [ ] Admin password changed
|
||||
- [ ] Email configuration tested
|
||||
- [ ] First test gallery created
|
||||
- [ ] Backup schedule configured
|
||||
- [ ] Firewall rules applied
|
||||
- [ ] SSL certificate working (if applicable)
|
||||
- [ ] Monitoring setup
|
||||
- [ ] Documentation bookmarked
|
||||
|
||||
---
|
||||
|
||||
**PicPeak Setup v1.0** | [Documentation](https://github.com/PicPeak/picpeak) | [Support](https://github.com/PicPeak/picpeak/issues)
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
# Getting help with PicPeak
|
||||
|
||||
## Documentation
|
||||
|
||||
Start with [docs.picpeak.app](https://docs.picpeak.app) for installation,
|
||||
configuration, gallery features and administration guides.
|
||||
|
||||
- [Getting started](https://docs.picpeak.app/getting-started)
|
||||
- [Deployment](https://docs.picpeak.app/deployment)
|
||||
- [Admin settings](https://docs.picpeak.app/guides/admin-settings)
|
||||
- [Release channels](https://docs.picpeak.app/deployment/release-channels)
|
||||
|
||||
## Questions and troubleshooting
|
||||
|
||||
Use [GitHub Discussions](https://github.com/PicPeak/picpeak/discussions) for
|
||||
setup questions, troubleshooting and advice from the community. Include your
|
||||
PicPeak version, deployment method and what you have already tried.
|
||||
|
||||
## Bugs and feature requests
|
||||
|
||||
Search [existing issues](https://github.com/PicPeak/picpeak/issues) first, then
|
||||
[choose an issue template](https://github.com/PicPeak/picpeak/issues/new/choose)
|
||||
to report a bug, suggest a feature or identify a documentation problem.
|
||||
|
||||
For bugs, include the exact version, reproduction steps and relevant logs.
|
||||
See [Contributing](CONTRIBUTING.md) for development and pull request guidance.
|
||||
|
||||
## Security vulnerabilities
|
||||
|
||||
Follow the [security policy](SECURITY.md) and use
|
||||
[private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
or email **info@picpeak.app**. Do not report vulnerabilities in public issues
|
||||
or discussions.
|
||||
@@ -1,7 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
storage
|
||||
storage/events/active/*
|
||||
storage/events/archived/*
|
||||
storage/thumbnails/*
|
||||
data/*.db
|
||||
logs/*
|
||||
coverage
|
||||
|
||||
+17
-115
@@ -3,137 +3,39 @@
|
||||
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
PORT=3000
|
||||
|
||||
# Security
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long
|
||||
|
||||
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
|
||||
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
|
||||
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
|
||||
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
|
||||
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
|
||||
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
|
||||
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
# Generate with: openssl rand -base64 32
|
||||
#MFA_ENCRYPTION_KEY=
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: 'auto' in production, false in dev (#427)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
||||
# login appears to succeed but the browser silently drops the
|
||||
# cookie, leaving you in a redirect loop. Only set this if you
|
||||
# ALWAYS reach the site via HTTPS)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP. Reads
|
||||
# req.secure from Express which respects X-Forwarded-Proto from a
|
||||
# trusted reverse proxy. This is the default and is the right
|
||||
# choice for most deployments.
|
||||
#
|
||||
# Why 'auto' is the default in production:
|
||||
# - On real HTTPS (reverse proxy with X-Forwarded-Proto), req.secure is
|
||||
# true → Secure flag is still emitted. No security regression vs. true.
|
||||
# - On plain HTTP (LAN access, first-time install before reverse proxy is
|
||||
# wired up), req.secure is false → Secure flag is omitted → login works
|
||||
# instead of silently looping back to /admin/login.
|
||||
#
|
||||
# When you'd set this explicitly:
|
||||
# - COOKIE_SECURE=true → strict HTTPS-only deployments where you want
|
||||
# defense in depth against accidentally serving over HTTP.
|
||||
# - COOKIE_SECURE=false → you intentionally only ever serve over HTTP and
|
||||
# don't want the per-request check (rare).
|
||||
#
|
||||
# Requirements for 'auto' mode to detect HTTPS correctly:
|
||||
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
|
||||
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
|
||||
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
|
||||
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
|
||||
# 192.168.x, link-local). Proxies outside those ranges need custom
|
||||
# trust proxy configuration.
|
||||
# COOKIE_SECURE=auto
|
||||
|
||||
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
|
||||
# COOKIE_SAMESITE=Lax
|
||||
|
||||
# Cookie Domain — set this if serving auth cookies across subdomains.
|
||||
# Leave unset for same-origin setups.
|
||||
# COOKIE_DOMAIN=.example.com
|
||||
|
||||
# URLs (adjust for your domain)
|
||||
ADMIN_URL=https://photos.example.com
|
||||
FRONTEND_URL=https://photos.example.com
|
||||
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
|
||||
|
||||
# API URL for email assets (logos, images in emails)
|
||||
# This must be the publicly accessible URL where recipients can load images
|
||||
# If not set, defaults to http://localhost:3001 which will break images in production emails
|
||||
API_URL=https://photos.example.com/api
|
||||
# URLs
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=localhost
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=your-secure-database-password-change-this
|
||||
DB_PASSWORD=your-secure-database-password
|
||||
DB_NAME=picpeak
|
||||
|
||||
# Email Configuration (Examples for common providers)
|
||||
# Gmail example:
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=your-email@gmail.com
|
||||
# SMTP_PASS=your-app-specific-password
|
||||
|
||||
# SendGrid example:
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
EMAIL_FROM=noreply@example.com
|
||||
SMTP_USER=your-smtp-username
|
||||
SMTP_PASS=your-smtp-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Storage Paths
|
||||
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
|
||||
# Docker deployment:
|
||||
# Storage Paths (Docker)
|
||||
STORAGE_PATH=/app/storage
|
||||
EVENTS_PATH=/app/storage/events
|
||||
ARCHIVE_PATH=/app/storage/events/archived
|
||||
|
||||
# Local development:
|
||||
# STORAGE_PATH=./storage
|
||||
# EVENTS_PATH=./storage/events
|
||||
# ARCHIVE_PATH=./storage/events/archived
|
||||
|
||||
# File watcher (auto-import from the events/active folder, local storage only)
|
||||
# Max photos processed in parallel by the watcher. The boot scan and bulk
|
||||
# folder drops fire one handler per file — this bound keeps thumbnail
|
||||
# generation from exhausting memory on small hosts. Default: 2
|
||||
# FILE_WATCHER_CONCURRENCY=2
|
||||
|
||||
# External-media folder watcher (issue 1187). Reference-mode events can opt in
|
||||
# per event (Event → Source Mode → "Watch folder for new files"); new images in
|
||||
# the folder are then imported without pressing Import. Deleted files are
|
||||
# never removed from the gallery.
|
||||
# EXTERNAL_MEDIA_WATCH=true # global kill switch
|
||||
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
|
||||
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
|
||||
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
|
||||
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
|
||||
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
|
||||
|
||||
# Analytics Backend Configuration (OPTIONAL)
|
||||
# Used for server-side tracking only
|
||||
# Primary configuration should be done through Admin UI > Settings > Analytics
|
||||
# UMAMI_URL=https://analytics.example.com
|
||||
# UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
|
||||
# Analytics (Optional)
|
||||
UMAMI_URL=https://analytics.yourdomain.com
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||
# Encryption material for the backend-only signing key (32+ characters).
|
||||
# Defaults to JWT_SECRET; keep it stable until participation has been deleted.
|
||||
# USAGE_ENCRYPTION_KEY=
|
||||
LOG_LEVEL=info
|
||||
@@ -17,15 +17,7 @@ module.exports = {
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'quotes': ['error', 'single'],
|
||||
'semi': ['error', 'always'],
|
||||
// varsIgnorePattern + ignoreRestSiblings cover the "omit fields via rest
|
||||
// spread" idiom (e.g. adminEvents/helpers.js pulling password hashes out
|
||||
// of ...rest), which is intentional and would otherwise need a disable
|
||||
// comment at every occurrence.
|
||||
'no-unused-vars': ['error', {
|
||||
'argsIgnorePattern': '^_',
|
||||
'varsIgnorePattern': '^_',
|
||||
'ignoreRestSiblings': true
|
||||
}],
|
||||
'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }]
|
||||
}
|
||||
};
|
||||
|
||||
+10
-90
@@ -1,89 +1,26 @@
|
||||
FROM node:22-alpine AS builder
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Add build arguments
|
||||
# Add build argument for cache busting
|
||||
ARG CACHEBUST=1
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
# Add labels for GitHub Container Registry
|
||||
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies (--omit=dev replaces deprecated --only=production)
|
||||
RUN npm ci --omit=dev
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Production stage
|
||||
FROM node:22-alpine
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# knexfile.js picks its config block by NODE_ENV, and the `development` block
|
||||
# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that
|
||||
# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
|
||||
# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while
|
||||
# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in
|
||||
# the same log. The compose files still override this, so nothing changes for
|
||||
# compose users. See #1038.
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
|
||||
# stage's declaration never reached this stage. Consuming it in the RUN below
|
||||
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
|
||||
# image always picks up current Alpine security updates instead of reusing a
|
||||
# stale cached upgrade layer.
|
||||
ARG CACHEBUST=1
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
|
||||
# Remove the npm CLI from the final image. Nothing runs npm here: the
|
||||
# entrypoint is node, runtime deps are COPY'd from the builder stage, and
|
||||
# wait-for-db.sh invokes the migration runners via node directly. npm's
|
||||
# bundled node_modules kept tripping Trivy (sigstore, tar 7.5.19,
|
||||
# brace-expansion 5.0.7 — even npm 12.0.1 still ships the vulnerable
|
||||
# copies), so shipping no npm ends that alert class instead of chasing
|
||||
# per-release patches. Note: `docker exec … npm run <script>` no longer
|
||||
# works in the container — use `node migrations/run-migrations-safe.js`
|
||||
# and friends instead.
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# Install dumb-init for proper signal handling, postgresql-client for database
|
||||
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
|
||||
# privilege drop in wait-for-db.sh (see #484: container starts as root so it
|
||||
# can chown bind-mounted host volumes to UID 1001, then re-execs as nodejs
|
||||
# before running the app). Alpine's ffmpeg package ships both `ffmpeg` and
|
||||
# `ffprobe` built natively against musl libc — the npm
|
||||
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
|
||||
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
|
||||
# pipeline calls via fluent-ffmpeg.ffprobe()).
|
||||
# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that
|
||||
# contain live <text> for the CRM PDFs. Without any font installed, librsvg
|
||||
# renders text as tofu boxes (□) while the vector artwork still draws — i.e.
|
||||
# a "corrupted" logo on invoices/quotes. DejaVu/Liberation provide a broad
|
||||
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
|
||||
# PDFKit + the web UI use) are registered with fontconfig further down so the
|
||||
# logo's text renders in its actual typeface, not a fallback.
|
||||
# poppler-utils provides `pdftoppm`, used to rasterise inbound supplier-invoice
|
||||
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
|
||||
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
|
||||
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
|
||||
# documents (see https://docs.picpeak.app/features/accounting/incoming-invoices).
|
||||
# exiftool extracts the embedded full-res JPEG preview from RAW/DNG uploads
|
||||
# (Apple ProRAW etc.) — sharp's libvips has no raw loader, so the pipeline
|
||||
# thumbnails/displays that preview while keeping the original for download.
|
||||
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
|
||||
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
|
||||
fc-cache -f
|
||||
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
||||
RUN apk add --no-cache dumb-init postgresql-client
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
@@ -92,33 +29,16 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||
COPY --chown=nodejs:nodejs . .
|
||||
|
||||
# Ensure all source files are readable and wait script is executable
|
||||
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
|
||||
|
||||
# Register picpeak's bundled brand fonts (assets/fonts/<Family>/*.ttf — the
|
||||
# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg
|
||||
# rasterises an SVG logo its <text> renders in the actual brand typeface
|
||||
# rather than a DejaVu/Liberation fallback. fontconfig indexes by each font's
|
||||
# internal family name and recurses into the per-family subdirectories.
|
||||
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
|
||||
fc-cache -f /app/assets/fonts
|
||||
# Make wait script executable
|
||||
RUN chmod +x wait-for-db.sh
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
|
||||
chown -R nodejs:nodejs storage data logs
|
||||
|
||||
# No USER directive — the container starts as root so wait-for-db.sh can
|
||||
# chown bind-mounted host directories to UID 1001 before dropping privs
|
||||
# via su-exec. See #484 for the fresh-install restart loop this avoids.
|
||||
USER nodejs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Healthcheck hits the same /health endpoint already used by the e2e
|
||||
# runner and by the docker-compose `depends_on: condition: service_healthy`
|
||||
# checks. wget is part of the Alpine base image. Long start-period covers
|
||||
# the wait-for-db.sh delay before the Node process starts listening.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["./wait-for-db.sh", "node", "server.js"]
|
||||
|
||||
+3
-14
@@ -1,17 +1,9 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
|
||||
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
|
||||
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
|
||||
# exiftool: extract embedded JPEG previews from RAW/DNG uploads (#821) — kept in
|
||||
# sync with the production Dockerfile so dev/native runtimes don't accept a DNG
|
||||
# and then fail it with ENOENT.
|
||||
RUN apk add --no-cache dumb-init ffmpeg exiftool
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
@@ -33,8 +25,5 @@ USER nodejs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -1,229 +0,0 @@
|
||||
# Enhanced Backup System Test Suite
|
||||
|
||||
This directory contains comprehensive tests for the enhanced backup system with S3 support.
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Unit Tests
|
||||
- `services/backupService.enhanced.test.js` - Unit tests for the enhanced backup service
|
||||
- Configuration management
|
||||
- S3 backup functionality
|
||||
- Manifest generation
|
||||
- Error handling and recovery
|
||||
- Backward compatibility (local and rsync)
|
||||
- Service lifecycle management
|
||||
|
||||
### Integration Tests
|
||||
- `integration/backup-s3.test.js` - Integration tests for S3 backups
|
||||
- Real S3/MinIO connection tests
|
||||
- Full backup process with actual files
|
||||
- Incremental backup verification
|
||||
- Manifest storage and retrieval
|
||||
- Error recovery scenarios
|
||||
|
||||
### Manual Integration Test Script
|
||||
- `../scripts/test-backup-integration.js` - Comprehensive manual testing script
|
||||
- Can test against MinIO, AWS S3, or any S3-compatible service
|
||||
- Tests all backup types (S3, local, rsync)
|
||||
- Performance testing with large files
|
||||
- Detailed progress reporting
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **For Unit Tests**: No special setup required, all dependencies are mocked.
|
||||
|
||||
2. **For Integration Tests**: Requires a running S3-compatible service (MinIO recommended)
|
||||
```bash
|
||||
# Start MinIO using Docker
|
||||
docker run -d \
|
||||
-p 9000:9000 \
|
||||
-p 9001:9001 \
|
||||
--name minio-test \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=minioadmin \
|
||||
minio/minio server /data --console-address ":9001"
|
||||
```
|
||||
|
||||
3. **Environment Variables** (for integration tests):
|
||||
```bash
|
||||
# Optional - defaults work with local MinIO
|
||||
export TEST_S3_ENDPOINT=http://localhost:9000
|
||||
export TEST_S3_ACCESS_KEY=minioadmin
|
||||
export TEST_S3_SECRET_KEY=minioadmin
|
||||
|
||||
# Skip S3 tests if no S3 service available
|
||||
export SKIP_S3_TESTS=true
|
||||
```
|
||||
|
||||
### Running Unit Tests
|
||||
|
||||
```bash
|
||||
# Run all backup service tests
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
# Run specific test suite
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js -t "S3 Backup Functionality"
|
||||
|
||||
# Run with coverage
|
||||
npm test -- --coverage __tests__/services/backupService.enhanced.test.js
|
||||
```
|
||||
|
||||
### Running Integration Tests
|
||||
|
||||
```bash
|
||||
# Ensure MinIO is running first!
|
||||
|
||||
# Run S3 integration tests
|
||||
npm test -- __tests__/integration/backup-s3.test.js
|
||||
|
||||
# Run with verbose output
|
||||
npm test -- __tests__/integration/backup-s3.test.js --verbose
|
||||
|
||||
# Skip S3 tests if needed
|
||||
SKIP_S3_TESTS=true npm test -- __tests__/integration/backup-s3.test.js
|
||||
```
|
||||
|
||||
### Running Manual Integration Tests
|
||||
|
||||
```bash
|
||||
# Test with local MinIO (default)
|
||||
node scripts/test-backup-integration.js
|
||||
|
||||
# Test with AWS S3
|
||||
node scripts/test-backup-integration.js \
|
||||
--endpoint https://s3.amazonaws.com \
|
||||
--access-key YOUR_ACCESS_KEY \
|
||||
--secret-key YOUR_SECRET_KEY \
|
||||
--bucket your-test-bucket
|
||||
|
||||
# Test local backup
|
||||
node scripts/test-backup-integration.js --type local
|
||||
|
||||
# Test with cleanup after completion
|
||||
node scripts/test-backup-integration.js --cleanup
|
||||
|
||||
# Verbose output
|
||||
node scripts/test-backup-integration.js --verbose
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
The test suite covers:
|
||||
|
||||
### Configuration
|
||||
- ✅ Database configuration retrieval
|
||||
- ✅ JSON parsing and error handling
|
||||
- ✅ Configuration validation
|
||||
- ✅ Required field validation
|
||||
|
||||
### S3 Functionality
|
||||
- ✅ S3 client initialization
|
||||
- ✅ Connection testing
|
||||
- ✅ File upload with progress tracking
|
||||
- ✅ Large file handling (multipart upload)
|
||||
- ✅ Metadata and custom headers
|
||||
- ✅ Error handling and retries
|
||||
|
||||
### Backup Process
|
||||
- ✅ Full backup execution
|
||||
- ✅ Incremental backup (changed files only)
|
||||
- ✅ File checksum calculation and comparison
|
||||
- ✅ Database backup inclusion
|
||||
- ✅ Archive inclusion toggle
|
||||
- ✅ File size limits
|
||||
|
||||
### Manifest Generation
|
||||
- ✅ Full manifest generation
|
||||
- ✅ Incremental manifest with parent reference
|
||||
- ✅ JSON and YAML format support
|
||||
- ✅ Manifest validation
|
||||
- ✅ S3 manifest storage and retrieval
|
||||
- ✅ Checksum verification
|
||||
|
||||
### Error Handling
|
||||
- ✅ S3 connection failures
|
||||
- ✅ File read errors
|
||||
- ✅ Individual file failure recovery
|
||||
- ✅ Retry logic with exponential backoff
|
||||
- ✅ Email notifications on failure
|
||||
- ✅ Concurrent backup prevention
|
||||
|
||||
### Backward Compatibility
|
||||
- ✅ Local directory backup
|
||||
- ✅ Rsync backup
|
||||
- ✅ Existing manifest format support
|
||||
|
||||
### Service Management
|
||||
- ✅ Cron job scheduling
|
||||
- ✅ Service start/stop
|
||||
- ✅ Manual backup triggering
|
||||
- ✅ Backup history and status
|
||||
|
||||
## Mock Setup
|
||||
|
||||
The unit tests use comprehensive mocking:
|
||||
|
||||
```javascript
|
||||
// Database mocking
|
||||
jest.mock('../../src/database/db');
|
||||
|
||||
// S3 client mocking
|
||||
jest.mock('../../src/services/storage/s3Storage');
|
||||
|
||||
// File system mocking
|
||||
const mockFs = require('mock-fs');
|
||||
|
||||
// Cron job mocking
|
||||
jest.mock('node-cron');
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
To run tests in CI/CD pipeline:
|
||||
|
||||
```yaml
|
||||
# Example GitHub Actions
|
||||
- name: Run Unit Tests
|
||||
run: npm test -- __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker run -d \
|
||||
-p 9000:9000 \
|
||||
--name minio-test \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=minioadmin \
|
||||
minio/minio server /data
|
||||
|
||||
- name: Run Integration Tests
|
||||
run: npm test -- __tests__/integration/backup-s3.test.js
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
```bash
|
||||
# Run tests in debug mode
|
||||
node --inspect-brk ./node_modules/.bin/jest __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
# Run single test with console output
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js -t "should perform S3 backup" --verbose
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Integration tests create real files and S3 objects
|
||||
- Each test run creates a unique S3 bucket to avoid conflicts
|
||||
- Cleanup is automatic but can be disabled for debugging
|
||||
- Large file tests (10MB+) are included but can be slow
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When adding new backup features:
|
||||
|
||||
1. Add unit tests to `backupService.enhanced.test.js`
|
||||
2. Add integration tests to `backup-s3.test.js` if S3-specific
|
||||
3. Update manual test script for comprehensive testing
|
||||
4. Ensure mocks are properly configured
|
||||
5. Document any new environment requirements
|
||||
@@ -1,189 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('Admin settings logo upload flow', () => {
|
||||
let tmpDir;
|
||||
let router;
|
||||
let app;
|
||||
let settingsStore;
|
||||
|
||||
const resetModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetModules();
|
||||
|
||||
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
|
||||
settingsStore = new Map();
|
||||
|
||||
const buildQuery = (table) => {
|
||||
const filters = [];
|
||||
const applyFilters = (rows) => {
|
||||
if (filters.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) =>
|
||||
filters.every(({ column, value }) => row[column] === value)
|
||||
);
|
||||
};
|
||||
|
||||
const makeRow = (row) => ({ ...row });
|
||||
|
||||
return {
|
||||
where(column, value) {
|
||||
filters.push({ column, value });
|
||||
return this;
|
||||
},
|
||||
first() {
|
||||
if (table === 'app_settings') {
|
||||
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
|
||||
return Promise.resolve(rows[0]);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
sum() {
|
||||
return Promise.resolve({ total: 0 });
|
||||
},
|
||||
join() {
|
||||
return this;
|
||||
},
|
||||
groupBy() {
|
||||
return this;
|
||||
},
|
||||
orderBy() {
|
||||
return this;
|
||||
},
|
||||
limit() {
|
||||
return this;
|
||||
},
|
||||
insert(payload) {
|
||||
const rows = Array.isArray(payload) ? payload : [payload];
|
||||
const upsert = (row, overrides = {}) => {
|
||||
if (table === 'app_settings') {
|
||||
const key = row.setting_key;
|
||||
const existing = settingsStore.get(key) || {};
|
||||
settingsStore.set(key, { ...existing, ...row, ...overrides });
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
return {
|
||||
onConflict() {
|
||||
return {
|
||||
merge(overrides) {
|
||||
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const dbMock = jest.fn((table) => buildQuery(table));
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.transaction = async (handler) => handler({
|
||||
commit: async () => {},
|
||||
rollback: async () => {}
|
||||
});
|
||||
|
||||
jest.doMock('../src/database/db', () => ({
|
||||
db: dbMock,
|
||||
logActivity: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/auth', () => ({
|
||||
adminAuth: (req, res, next) => {
|
||||
req.admin = { id: 1, username: 'tester' };
|
||||
next();
|
||||
}
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (req, res, next) => next(),
|
||||
userHasAnyPermission: jest.fn().mockResolvedValue(true)
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/publicSiteService', () => ({
|
||||
clearPublicSiteCache: jest.fn(),
|
||||
getDefaultPublicSitePayload: jest.fn(),
|
||||
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/rateLimitService', () => ({
|
||||
clearSettingsCache: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/maintenance', () => ({
|
||||
maintenanceMiddleware: (req, res, next) => next(),
|
||||
clearMaintenanceCache: jest.fn()
|
||||
}));
|
||||
|
||||
router = require('../src/routes/adminSettings');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/settings', router);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetModules();
|
||||
if (tmpDir) {
|
||||
await fsPromises.rm(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = null;
|
||||
}
|
||||
delete process.env.STORAGE_PATH;
|
||||
});
|
||||
|
||||
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
|
||||
const fileBuffer = Buffer.from('fake image data');
|
||||
|
||||
const uploadResponse = await request(app)
|
||||
.post('/api/admin/settings/logo')
|
||||
.attach('logo', fileBuffer, 'logo.png');
|
||||
|
||||
expect(uploadResponse.status).toBe(200);
|
||||
expect(uploadResponse.body).toHaveProperty('logoUrl');
|
||||
const logoUrl = uploadResponse.body.logoUrl;
|
||||
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
|
||||
|
||||
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
|
||||
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
|
||||
|
||||
await request(app)
|
||||
.put('/api/admin/settings/branding')
|
||||
.send({
|
||||
company_name: 'Test Co',
|
||||
company_tagline: 'Tagline',
|
||||
support_email: 'test@example.com',
|
||||
footer_text: 'Footer',
|
||||
watermark_enabled: false,
|
||||
watermark_position: 'bottom-right',
|
||||
watermark_opacity: 0.5,
|
||||
watermark_size: 'medium',
|
||||
favicon_url: null,
|
||||
logo_url: '',
|
||||
watermark_logo_url: null,
|
||||
logo_size: 'medium',
|
||||
logo_max_height: 120,
|
||||
logo_position: 'left',
|
||||
logo_display_header: true,
|
||||
logo_display_hero: false,
|
||||
logo_display_mode: 'default'
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await expect(fsPromises.access(storedPath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,477 +0,0 @@
|
||||
/**
|
||||
* Restoring an archive must put the photos back into their categories.
|
||||
*
|
||||
* The archive writer already persists `category_name` per photo in
|
||||
* `photos_manifest.json` — that is why the manifest exists, and the comment
|
||||
* above it says so: "(and category linkage) can't be derived from the
|
||||
* extracted files alone". The restore route then read only
|
||||
* `original_filename` from it and kept deriving the category from the ZIP's
|
||||
* first path segment.
|
||||
*
|
||||
* Archives store photos exactly as they sit on disk, so an event whose photos
|
||||
* live in the gallery root produces a FLAT zip. `path.dirname()` is '.' for
|
||||
* every entry, no category is resolved, and every restored photo lands with
|
||||
* `category_id = null` — silently, with a 200 response.
|
||||
*
|
||||
* These pin the manifest as the source of truth, with the directory as the
|
||||
* fallback that keeps foldered and legacy archives working.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('archive restore restores categories (flat archives included)', () => {
|
||||
let tmpDir; let db; let cleanup; let app; let storagePath;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-restore-cat-'));
|
||||
storagePath = path.join(tmpDir, 'storage');
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = storagePath;
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
// bootCrmDb points STORAGE_PATH at its own tmp dir; follow it rather than
|
||||
// fighting it, so the archives the tests write are where the route looks.
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin/archives', require('../../src/routes/adminArchives'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photos').del();
|
||||
await db('photo_categories').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
/** A one-pixel JPEG is enough; the route only stats the extracted file. */
|
||||
const PIXEL = Buffer.from(
|
||||
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a'
|
||||
+ 'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA'
|
||||
+ 'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
async function writeArchive(name, entries) {
|
||||
// Required lazily: the suite calls jest.resetModules() in beforeAll, and
|
||||
// archiver's readable-stream copy does not survive being split across the
|
||||
// two module registries.
|
||||
const archiver = require('archiver');
|
||||
const archivePath = path.join(storagePath, 'archives', name);
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(archivePath);
|
||||
const zip = archiver('zip', { zlib: { level: 0 } });
|
||||
output.on('close', resolve);
|
||||
zip.on('error', reject);
|
||||
zip.pipe(output);
|
||||
for (const [entryName, buffer] of Object.entries(entries)) {
|
||||
zip.append(buffer, { name: entryName });
|
||||
}
|
||||
zip.finalize();
|
||||
});
|
||||
return path.join('archives', name);
|
||||
}
|
||||
|
||||
async function seedArchivedEvent(archiveRelPath, slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-06-27',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
is_archived: 1, // sqlite stores booleans as 0/1, see utils/dbCompat
|
||||
archive_path: archiveRelPath,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
const categoryOf = async (filename) => {
|
||||
const photo = await db('photos').where('filename', filename).first();
|
||||
if (!photo || !photo.category_id) return null;
|
||||
const category = await db('photo_categories').where('id', photo.category_id).first();
|
||||
return category ? category.name : null;
|
||||
};
|
||||
|
||||
it('takes the category from the manifest when the archive is flat', async () => {
|
||||
// Exactly the shape a gallery-root event archives to: no directories.
|
||||
const manifest = JSON.stringify([
|
||||
{ filename: 'a.jpg', original_filename: 'DSC_0001.jpg', category_name: 'Polterabend' },
|
||||
{ filename: 'b.jpg', original_filename: 'DSC_0002.jpg', category_name: 'Ceremony' },
|
||||
]);
|
||||
const archiveRelPath = await writeArchive('flat.zip', {
|
||||
'a.jpg': PIXEL,
|
||||
'b.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'flat-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The whole bug: both of these used to be null.
|
||||
expect(await categoryOf('a.jpg')).toBe('Polterabend');
|
||||
expect(await categoryOf('b.jpg')).toBe('Ceremony');
|
||||
});
|
||||
|
||||
it('reuses an existing category row instead of creating a duplicate', async () => {
|
||||
const archiveRelPath = await writeArchive('reuse.zip', {
|
||||
'c.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'c.jpg', original_filename: 'DSC_0003.jpg', category_name: 'Party' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'reuse-event');
|
||||
await db('photo_categories').insert({
|
||||
event_id: eventId, name: 'Party', slug: 'party', created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('c.jpg')).toBe('Party');
|
||||
const rows = await db('photo_categories').where({ event_id: eventId, name: 'Party' });
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still falls back to the directory for legacy archives with no manifest', async () => {
|
||||
// No manifest at all — the shape every archive had before the manifest
|
||||
// landed. The directory is the only signal left, and it must keep working.
|
||||
//
|
||||
// `individual/` is what a REAL archive contains: entry names are the
|
||||
// storage key minus `events/active/{slug}`, and that layout is
|
||||
// `individual/` / `collages/`. Categories have never been directories, so
|
||||
// the fallback invents a category with that name — not useful, but better
|
||||
// than losing every category, and this pins what actually happens rather
|
||||
// than a category-shaped folder no archive produces.
|
||||
const archiveRelPath = await writeArchive('foldered.zip', {
|
||||
'individual/d.jpg': PIXEL,
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'foldered-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('d.jpg')).toBe('individual');
|
||||
});
|
||||
|
||||
it('reuses a GLOBAL category instead of cloning it into the event', async () => {
|
||||
// Seeded categories (Ceremony, Reception, ...) have event_id NULL. An
|
||||
// event-only lookup misses them, so the restore used to create a second
|
||||
// "Ceremony" — and because is_global defaults to TRUE, that duplicate then
|
||||
// appeared in every other event's category list.
|
||||
const [g] = await db('photo_categories').insert({
|
||||
event_id: null, name: 'Ceremony', slug: 'ceremony', is_global: true, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const globalId = typeof g === 'object' ? g.id : g;
|
||||
|
||||
const archiveRelPath = await writeArchive('global.zip', {
|
||||
'individual/gl.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'gl.jpg', original_filename: 'DSC_1.jpg', category_name: 'Ceremony' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'global-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'gl.jpg').first();
|
||||
expect(photo.category_id).toBe(globalId);
|
||||
// No clone, global or otherwise.
|
||||
const all = await db('photo_categories').where('name', 'Ceremony');
|
||||
expect(all).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not create a GLOBAL category when it has to invent one', async () => {
|
||||
// is_global defaults to true on this column, so an unqualified insert would
|
||||
// leak a restore's category name into every gallery on the instance.
|
||||
const archiveRelPath = await writeArchive('newcat.zip', {
|
||||
'individual/nc.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'nc.jpg', original_filename: 'DSC_2.jpg', category_name: 'Polterabend' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'newcat-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const created = await db('photo_categories').where('name', 'Polterabend').first();
|
||||
expect(created.event_id).toBe(eventId);
|
||||
expect(created.is_global === false || created.is_global === 0).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the manifest when the ZIP was written with original filenames', async () => {
|
||||
// With general_use_original_filenames_for_downloads on at archive time,
|
||||
// archiveService names entries after the ORIGINAL filename while the
|
||||
// manifest stays keyed by photos.filename. Looking up the extracted
|
||||
// basename missed every entry, so categories were lost on exactly those
|
||||
// archives.
|
||||
const archiveRelPath = await writeArchive('original-names.zip', {
|
||||
'individual/DSC_4242.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'stored_9f8e7d.jpg', original_filename: 'DSC_4242.jpg', category_name: 'Drohne' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'original-names-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('DSC_4242.jpg')).toBe('Drohne');
|
||||
});
|
||||
|
||||
it('prefers the event-scoped category when a global shares its name', async () => {
|
||||
// The category API permits both. A single OR-lookup with .first() returned
|
||||
// whichever the engine chose, so a photo could be reassigned to the global
|
||||
// row and lose event-local settings such as allow_downloads.
|
||||
const archiveRelPath = await writeArchive('collide.zip', {
|
||||
'individual/co.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'co.jpg', original_filename: 'DSC_3.jpg', category_name: 'Reception' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'collide-event');
|
||||
|
||||
await db('photo_categories').insert({
|
||||
event_id: null, name: 'Reception', slug: 'reception-global', is_global: true, created_at: new Date(),
|
||||
});
|
||||
const [own] = await db('photo_categories').insert({
|
||||
event_id: eventId, name: 'Reception', slug: 'reception-own', is_global: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const ownId = typeof own === 'object' ? own.id : own;
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'co.jpg').first();
|
||||
expect(photo.category_id).toBe(ownId);
|
||||
});
|
||||
|
||||
it('matches a sanitized original filename, as the ZIP would have written it', async () => {
|
||||
// archiveService runs original names through sanitizeForZipEntry() before
|
||||
// writing the entry, so the emitted name differs from the manifest column.
|
||||
const archiveRelPath = await writeArchive('sanitized.zip', {
|
||||
'individual/od_dr_DSC_5.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'stored_abc.jpg', original_filename: 'od/dr/DSC_5.jpg', category_name: 'Strand' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'sanitized-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('od_dr_DSC_5.jpg')).toBe('Strand');
|
||||
});
|
||||
|
||||
it('ignores a legacy event-owned row when falling back to globals', async () => {
|
||||
// The bug fixed here left rows behind on upgraded instances: event-owned
|
||||
// AND is_global true, because the column defaults true. Matching on the
|
||||
// flag alone would let one event's leftover be adopted by another event's
|
||||
// restore, tying photos to a category that vanishes with someone else's
|
||||
// gallery.
|
||||
const otherEventId = await seedArchivedEvent('archives/none.zip', 'legacy-owner-event');
|
||||
await db('photo_categories').insert({
|
||||
event_id: otherEventId, name: 'Sunset', slug: 'sunset-legacy',
|
||||
is_global: true, created_at: new Date(),
|
||||
});
|
||||
|
||||
const archiveRelPath = await writeArchive('legacy-global.zip', {
|
||||
'individual/lg.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'lg.jpg', original_filename: 'DSC_6.jpg', category_name: 'Sunset' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'legacy-global-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'lg.jpg').first();
|
||||
const cat = await db('photo_categories').where('id', photo.category_id).first();
|
||||
// Its own row, not the other event's leftover.
|
||||
expect(cat.event_id).toBe(eventId);
|
||||
});
|
||||
|
||||
it('drops an ambiguous original-name alias rather than guessing', async () => {
|
||||
// Two photos in different ZIP folders can share an original basename;
|
||||
// archiveService treats the paths as distinct and suffixes neither. Both
|
||||
// would collapse onto one alias, and whichever won would hand the other
|
||||
// photo someone else's category.
|
||||
const archiveRelPath = await writeArchive('ambiguous.zip', {
|
||||
'individual/SHARED.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'a_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Alpha' },
|
||||
{ filename: 'b_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Beta' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'ambiguous-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Falls back to the directory rather than picking Alpha or Beta at random.
|
||||
expect(await categoryOf('SHARED.jpg')).toBe('individual');
|
||||
for (const name of ['Alpha', 'Beta']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it('honours a manifest that says UNCATEGORIZED, instead of inventing one from the directory', async () => {
|
||||
// The case the manifest-first change was for. A real archive puts every
|
||||
// photo under `individual/`, so a photo the manifest records as having no
|
||||
// category used to come back filed under a category called "individual" —
|
||||
// the manifest being authoritative for "category X" but not for "none".
|
||||
const manifest = JSON.stringify([
|
||||
{ filename: 'u.jpg', original_filename: 'DSC_7000.jpg', category_name: null },
|
||||
]);
|
||||
const archiveRelPath = await writeArchive('uncategorized.zip', {
|
||||
'individual/u.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'uncategorized-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('u.jpg')).toBeNull();
|
||||
// And no junk category row was created as a side effect.
|
||||
const rows = await db('photo_categories').where({ event_id: eventId });
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops a canonical filename that two photos claim, rather than guessing', async () => {
|
||||
// photos.filename is not unique within an event: s3AutoImporter takes
|
||||
// path.basename(entry.key) and dedupes by path, so two imported files in
|
||||
// different subfolders both land as IMG_1234.jpg. Both ZIP entries reduce
|
||||
// to the same basename at restore, so keeping the last row seen would give
|
||||
// one photo the other's category.
|
||||
const archiveRelPath = await writeArchive('dup-canonical.zip', {
|
||||
'individual/IMG_1234.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'IMG_1234.jpg', original_filename: 'a.jpg', category_name: 'Alpha' },
|
||||
{ filename: 'IMG_1234.jpg', original_filename: 'b.jpg', category_name: 'Beta' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'dup-canonical-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('IMG_1234.jpg')).toBe('individual');
|
||||
for (const name of ['Alpha', 'Beta']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a name that one row owns canonically and another claims as an alias", async () => {
|
||||
// Undecidable: with original-filename archiving ON the ZIP entry under
|
||||
// this name is the ALIAS owner's file, with it OFF it is the canonical
|
||||
// owner's, and the manifest does not record which mode was used. The
|
||||
// point of the two-pass split is that this now resolves the same way
|
||||
// every run — the archive query has no ORDER BY, so it used to be a coin
|
||||
// flip between dropping the name and overwriting it.
|
||||
const archiveRelPath = await writeArchive('alias-vs-canonical.zip', {
|
||||
'individual/CANON.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'CANON.jpg', original_filename: 'unrelated.jpg', category_name: 'Canonical' },
|
||||
{ filename: 'other_stored.jpg', original_filename: 'CANON.jpg', category_name: 'Aliased' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'alias-vs-canonical-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Falls back to the directory rather than guessing either row.
|
||||
expect(await categoryOf('CANON.jpg')).toBe('individual');
|
||||
for (const name of ['Canonical', 'Aliased']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it('picks the lowest id and warns when two categories share a name', async () => {
|
||||
// Allowed: two event-scoped categories with the same display name and
|
||||
// different slugs. .first() used to pick either, so a re-run could move
|
||||
// photos between them and inherit the wrong allow_downloads.
|
||||
const archiveRelPath = await writeArchive('dupe-category.zip', {
|
||||
'individual/DUPE.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'DUPE.jpg', original_filename: 'DUPE.jpg', category_name: 'Ceremony' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'dupe-category-event');
|
||||
|
||||
const [first] = await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony-a', is_global: 0, event_id: eventId,
|
||||
}).returning('id');
|
||||
await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony-b', is_global: 0, event_id: eventId,
|
||||
});
|
||||
const firstId = typeof first === 'object' ? first.id : first;
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Stable, not arbitrary: the same run twice lands on the same row.
|
||||
const photo = await db('photos').where({ event_id: eventId, filename: 'DUPE.jpg' }).first();
|
||||
expect(photo.category_id).toBe(firstId);
|
||||
// And no third "Ceremony" was invented.
|
||||
expect((await db('photo_categories').where({ event_id: eventId, name: 'Ceremony' })).length)
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
it('does not invent a category for a photo row that already exists', async () => {
|
||||
// archiveEvent retains photo rows, so a restore can skip every insert.
|
||||
// Resolving categories before that check created one from the stale
|
||||
// manifest name that nothing then used — renaming a category while its
|
||||
// event was archived left the old name behind as an empty duplicate.
|
||||
const archiveRelPath = await writeArchive('existing-rows.zip', {
|
||||
'individual/KEPT.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'KEPT.jpg', original_filename: 'KEPT.jpg', category_name: 'OldName' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'existing-rows-event');
|
||||
await db('photos').insert({
|
||||
event_id: eventId, filename: 'KEPT.jpg', path: 'whatever/KEPT.jpg', type: 'jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name: 'OldName' }).first())
|
||||
.toBeFalsy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* 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(120000);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* 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(120000);
|
||||
|
||||
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',
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('Admin photos in reference mode', () => {
|
||||
let tmpDir;
|
||||
let storagePath;
|
||||
let db;
|
||||
let app;
|
||||
let categoryId;
|
||||
|
||||
const resetModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-admin-photos-'));
|
||||
storagePath = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'photo_sharing_test.db');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
try {
|
||||
await fs.promises.unlink(process.env.TEST_DATABASE_PATH);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
process.env.STORAGE_PATH = storagePath;
|
||||
|
||||
resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => {
|
||||
req.admin = { id: 1, username: 'tester' };
|
||||
next();
|
||||
}
|
||||
}));
|
||||
|
||||
// The routes gained requirePermission() after this fixture was written.
|
||||
// It resolves the caller's role through admin_users/roles, which this
|
||||
// minimal schema does not create, so every request died in the RBAC
|
||||
// lookup before reaching the handler. RBAC is not what this suite is
|
||||
// about — stub it out the same way adminAuth already is.
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
|
||||
ensureThumbnail: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/middleware/uploadValidation', () => ({
|
||||
validateUploadedFiles: (_req, _res, next) => next()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/utils/fileSecurityUtils', () => {
|
||||
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
|
||||
return {
|
||||
...actual,
|
||||
validateFileType: () => true,
|
||||
createFileUploadValidator: () => (_req, _res, next) => next()
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
}));
|
||||
|
||||
const dbModule = require('../../src/database/db');
|
||||
db = dbModule.db;
|
||||
|
||||
await db.schema.dropTableIfExists('photo_feedback');
|
||||
await db.schema.dropTableIfExists('photos');
|
||||
await db.schema.dropTableIfExists('photo_categories');
|
||||
await db.schema.dropTableIfExists('events');
|
||||
|
||||
await db.schema.createTable('events', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('slug').notNullable();
|
||||
table.string('event_name').notNullable();
|
||||
table.string('source_mode').notNullable();
|
||||
table.string('external_path');
|
||||
});
|
||||
|
||||
await db.schema.createTable('photo_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.string('slug').notNullable();
|
||||
table.boolean('is_global').defaultTo(true);
|
||||
table.integer('event_id');
|
||||
});
|
||||
|
||||
await db.schema.createTable('photos', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').notNullable();
|
||||
table.string('filename').notNullable();
|
||||
table.string('path').notNullable();
|
||||
table.string('thumbnail_path');
|
||||
table.string('type').notNullable();
|
||||
table.integer('size_bytes');
|
||||
table.integer('category_id');
|
||||
// Mirrors migration 041: the upload route never writes this column, it
|
||||
// relies on the NOT NULL DEFAULT 'managed' to mark managed originals.
|
||||
table.string('source_origin').notNullable().defaultTo('managed');
|
||||
table.string('external_relpath');
|
||||
// Columns the upload insert writes (migrations 048, 062, 071, 085, 193)
|
||||
// and the PATCH handler writes (migration 178). Without them the insert
|
||||
// and the update both fail on "no such column".
|
||||
table.string('original_filename', 512);
|
||||
table.string('source_filename', 255);
|
||||
table.datetime('captured_at').nullable();
|
||||
table.string('media_type').defaultTo('image');
|
||||
table.string('mime_type');
|
||||
table.string('processing_status', 16).notNullable().defaultTo('complete');
|
||||
table.string('upload_id', 64).nullable();
|
||||
table.boolean('auto_categorized');
|
||||
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
||||
table.float('average_rating').defaultTo(0);
|
||||
table.integer('like_count').defaultTo(0);
|
||||
table.integer('favorite_count').defaultTo(0);
|
||||
});
|
||||
|
||||
await db.schema.createTable('photo_feedback', (table) => {
|
||||
table.increments('id');
|
||||
table.integer('photo_id');
|
||||
table.string('feedback_type');
|
||||
table.boolean('is_approved');
|
||||
table.boolean('is_hidden');
|
||||
});
|
||||
|
||||
await db('events').insert({
|
||||
id: 1,
|
||||
slug: 'test-event',
|
||||
event_name: 'Test Event',
|
||||
source_mode: 'reference',
|
||||
external_path: 'external/library'
|
||||
});
|
||||
|
||||
const insertedCategory = await db('photo_categories').insert({
|
||||
name: 'Highlights',
|
||||
slug: 'highlights',
|
||||
is_global: true
|
||||
});
|
||||
categoryId = Array.isArray(insertedCategory) ? insertedCategory[0] : insertedCategory;
|
||||
|
||||
const router = require('../../src/routes/adminPhotos');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/events', router);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.destroy();
|
||||
}
|
||||
resetModules();
|
||||
delete process.env.TEST_DATABASE_PATH;
|
||||
delete process.env.STORAGE_PATH;
|
||||
if (tmpDir) {
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('stores managed uploads with category information and managed origin', async () => {
|
||||
const uploadResponse = await request(app)
|
||||
.post(`/api/admin/events/1/upload`)
|
||||
.field('category_id', String(categoryId))
|
||||
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
|
||||
|
||||
// 202 Accepted since the upload route went async (851744c3): the files are
|
||||
// stored and a pending row is inserted, thumbnails/EXIF follow in the
|
||||
// background worker. This assertion still said 200 from before that.
|
||||
expect(uploadResponse.status).toBe(202);
|
||||
expect(uploadResponse.body).toHaveProperty('photos');
|
||||
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
|
||||
|
||||
const photo = await db('photos').first();
|
||||
expect(photo).toBeTruthy();
|
||||
expect(photo.category_id).toBe(categoryId);
|
||||
expect(photo.source_origin).toBe('managed');
|
||||
expect(photo.external_relpath).toBeNull();
|
||||
});
|
||||
|
||||
it('returns numeric category metadata when listing photos', async () => {
|
||||
await db('photos').insert({
|
||||
event_id: 1,
|
||||
filename: 'external.jpg',
|
||||
path: 'test-event/external.jpg',
|
||||
thumbnail_path: null,
|
||||
type: 'individual',
|
||||
size_bytes: 123,
|
||||
source_origin: 'external',
|
||||
external_relpath: 'individual/external.jpg'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/admin/events/1/photos`)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(response.body.photos)).toBe(true);
|
||||
const managedPhoto = response.body.photos.find((p) => p.category_id === categoryId);
|
||||
expect(managedPhoto).toBeTruthy();
|
||||
expect(managedPhoto.category_name).toBe('Highlights');
|
||||
|
||||
const filtered = await request(app)
|
||||
.get(`/api/admin/events/1/photos`)
|
||||
.query({ category_id: String(categoryId) })
|
||||
.expect(200);
|
||||
|
||||
expect(filtered.body.photos.every((p) => p.category_id === categoryId)).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes category updates', async () => {
|
||||
const photo = await db('photos').first();
|
||||
|
||||
await request(app)
|
||||
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||
.send({ category_id: '0' })
|
||||
.expect(200);
|
||||
|
||||
const updated = await db('photos').where({ id: photo.id }).first();
|
||||
expect(updated.category_id).toBeNull();
|
||||
|
||||
// A real id still round-trips — the '0' guard must not swallow it.
|
||||
await request(app)
|
||||
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||
.send({ category_id: String(categoryId) })
|
||||
.expect(200);
|
||||
expect((await db('photos').where({ id: photo.id }).first()).category_id).toBe(categoryId);
|
||||
|
||||
// Numeric 0 and unparseable input clear the category too, rather than
|
||||
// writing a category id that can never exist.
|
||||
for (const value of [0, 'not-a-category']) {
|
||||
await request(app)
|
||||
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||
.send({ category_id: value })
|
||||
.expect(200);
|
||||
expect((await db('photos').where({ id: photo.id }).first()).category_id).toBeNull();
|
||||
|
||||
await db('photos').where({ id: photo.id }).update({ category_id: categoryId });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* POST /admin/thumbnails/regenerate for external/reference photos (#1129).
|
||||
*
|
||||
* The route used to resolve every source as `storage/events/active/<path>` and
|
||||
* `fs.access` it. External and reference rows do not live there — their
|
||||
* originals sit under `events.external_path` — so every one of them failed the
|
||||
* check and was counted as an error.
|
||||
*
|
||||
* That alone would be inert. What made it destructive is that the tier
|
||||
* deletion runs FIRST (deliberately, so S3 and external rows are not skipped):
|
||||
* on a reference install the button dropped every ?w= tier and rebuilt
|
||||
* nothing, while the UI reported success — the response is sent before the
|
||||
* background loop starts.
|
||||
*
|
||||
* The background work is fired with setImmediate, so every assertion here has
|
||||
* to wait for it to drain rather than trusting the response.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('admin thumbnail regeneration (#1129)', () => {
|
||||
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage; let logInfo;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
// One instance, not a fresh object per call — the route and the
|
||||
// assertions have to be looking at the same mock.
|
||||
jest.doMock('../../src/services/storage', () => {
|
||||
const instance = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
return { getStorage: () => instance };
|
||||
});
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
ensureThumbnail: jest.fn().mockResolvedValue('thumbnails/thumb_ext1_shot.jpg'),
|
||||
ensurePreviewImage: jest.fn().mockResolvedValue('previews/p.jpg'),
|
||||
deleteThumbnailTiers: jest.fn().mockResolvedValue(undefined),
|
||||
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Same module registry as the route, so the spy sees its calls. The
|
||||
// completion line is what drain() below waits for.
|
||||
logInfo = jest.spyOn(require('../../src/utils/logger'), 'info');
|
||||
|
||||
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
|
||||
// success, which ends the jest worker mid-suite.
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
storage = require('../../src/services/storage').getStorage();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin/thumbnails', require('../../src/routes/adminThumbnails'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
const [row] = await db('events').insert({
|
||||
slug: 'nas-wedding', event_type: 'wedding', event_name: 'nas',
|
||||
event_date: '2026-01-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'x', share_link: 'nas-share', expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference', external_path: 'weddings/2026-08',
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function seedPhoto(eventId, overrides = {}) {
|
||||
const [row] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'shot.jpg', path: 'nas-wedding/shot.jpg',
|
||||
type: 'individual', ...overrides,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/**
|
||||
* The work runs in setImmediate, after the response. Wait for the loop's
|
||||
* "regeneration complete" log line rather than a fixed 150 ms: under a
|
||||
* loaded machine (fifteen suites in parallel, each booting a migrated
|
||||
* SQLite) the loop occasionally took longer than that, and the assertions
|
||||
* then ran against a half-finished mock call list.
|
||||
*/
|
||||
const drain = async () => {
|
||||
const deadline = Date.now() + 10000;
|
||||
const done = () => logInfo.mock.calls.some((c) => /regeneration complete/.test(String(c[0])));
|
||||
while (!done() && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
|
||||
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
thumbnail_path: 'thumbnails/stale.jpg',
|
||||
});
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
expect(res.status).toBe(200);
|
||||
await drain();
|
||||
|
||||
// The whole bug: this used to be zero calls and one logged
|
||||
// "Original file not found" per photo.
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('nulls thumbnail_path so the valid-thumbnail short-circuit cannot skip the rebuild', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
thumbnail_path: 'thumbnails/still-on-disk.jpg',
|
||||
});
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// Without this the endpoint is a no-op whenever the OLD thumbnail is still
|
||||
// readable — which is the normal case after a settings change, and exactly
|
||||
// when the admin pressed the button.
|
||||
const [photoArg] = imageProcessor.ensureThumbnail.mock.calls[0];
|
||||
expect(photoArg.thumbnail_path).toBeNull();
|
||||
expect(photoArg.source_origin).toBe('external');
|
||||
// Carried through so ensureThumbnail can resolve off the mount rather than
|
||||
// under events/active.
|
||||
expect(photoArg.external_relpath).toBe('shot.jpg');
|
||||
});
|
||||
|
||||
it('still drops the responsive tiers first', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'external', external_relpath: 'shot.jpg' });
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// They are keyed by width outside thumbnail_path and carry no settings
|
||||
// version, so leaving them serves the old fit to phones indefinitely.
|
||||
expect(imageProcessor.deleteThumbnailTiers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves videos alone rather than handing a container file to Sharp', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'managed', media_type: 'video', filename: 'clip.mp4' });
|
||||
await seedPhoto(eventId, { source_origin: 'managed', filename: 'still.jpg' });
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
expect(imageProcessor.ensureThumbnail.mock.calls[0][0].filename).toBe('still.jpg');
|
||||
});
|
||||
|
||||
/**
|
||||
* On S3, ensureThumbnail downloads the source to a randomly-named temp file,
|
||||
* and for non-RAW input withProcessableImage passes no outputBasename — so
|
||||
* generateThumbnail derives the key from that random name and it differs on
|
||||
* every run. Nulling thumbnail_path hides the old key from everything that
|
||||
* would otherwise clean it up, so each regeneration would strand a full
|
||||
* thumbnail in the bucket, once per photo per run.
|
||||
*/
|
||||
describe('superseded canonical renditions', () => {
|
||||
it('removes the old thumbnail when the key moved', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_OLDRANDOM_shot.jpg',
|
||||
});
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEWRANDOM_shot.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).toHaveBeenCalledWith('thumbnails/thumb_OLDRANDOM_shot.jpg');
|
||||
});
|
||||
|
||||
it('does NOT delete when the key is unchanged — that is the new file', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_stable.jpg',
|
||||
});
|
||||
// Local storage resolves to a stable path, so the key is identical.
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_stable.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a Windows-style legacy path', 'thumbnails\\thumb_ext1_shot.jpg'],
|
||||
['a leading ./', './thumbnails/thumb_ext1_shot.jpg'],
|
||||
['a doubled separator', 'thumbnails//thumb_ext1_shot.jpg'],
|
||||
])('does not delete the file it just wrote when the old path is %s', async (_name, stored) => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'managed', thumbnail_path: stored });
|
||||
// Both storage backends fold these to the same key, so this is the SAME
|
||||
// object — deleting it would remove the freshly generated thumbnail and
|
||||
// leave the row pointing at nothing.
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_ext1_shot.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts the photo as regenerated even if the old object cannot be removed', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_OLD.jpg',
|
||||
});
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEW.jpg');
|
||||
storage.delete.mockRejectedValueOnce(new Error('bucket said no'));
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// Losing the old object is untidy; the regeneration itself succeeded.
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes to one event when asked', async () => {
|
||||
const a = await seedEvent();
|
||||
await seedPhoto(a, { source_origin: 'external', external_relpath: 'a.jpg' });
|
||||
const [b] = await db('events').insert({
|
||||
slug: 'other', event_type: 'wedding', event_name: 'other', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: 'other-share', expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
await seedPhoto(typeof b === 'object' ? b.id : b, { source_origin: 'managed' });
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({ eventId: a });
|
||||
await drain();
|
||||
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,524 +0,0 @@
|
||||
const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Load services
|
||||
const backupService = require('../../src/services/backupService');
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { db, initializeDatabase: initDb } = require('../../src/database/db');
|
||||
const logger = require('../../src/utils/logger');
|
||||
|
||||
// Test configuration
|
||||
// Defaults match the dev MinIO container in docker-compose.dev.yml (port 7104).
|
||||
// Override via TEST_S3_ENDPOINT / TEST_S3_ACCESS_KEY / TEST_S3_SECRET_KEY when running
|
||||
// against a different S3 endpoint (CI, hosted MinIO, real AWS, etc.).
|
||||
const TEST_CONFIG = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
bucket: 'test-backup-bucket-' + Date.now(),
|
||||
region: 'us-east-1'
|
||||
};
|
||||
|
||||
describe('S3 Backup Integration Tests', () => {
|
||||
let s3Client;
|
||||
let testStoragePath;
|
||||
let originalEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Skip if no S3 endpoint configured
|
||||
if (process.env.SKIP_S3_TESTS === 'true') {
|
||||
console.log('Skipping S3 integration tests (SKIP_S3_TESTS=true)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save original environment
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Initialize S3 client for test setup
|
||||
s3Client = new S3Client({
|
||||
endpoint: TEST_CONFIG.endpoint,
|
||||
region: TEST_CONFIG.region,
|
||||
credentials: {
|
||||
accessKeyId: TEST_CONFIG.accessKeyId,
|
||||
secretAccessKey: TEST_CONFIG.secretAccessKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
|
||||
// Create test bucket
|
||||
try {
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: TEST_CONFIG.bucket }));
|
||||
console.log(`Created test bucket: ${TEST_CONFIG.bucket}`);
|
||||
} catch (error) {
|
||||
if (error.name !== 'BucketAlreadyOwnedByYou') {
|
||||
console.error('Failed to create test bucket:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Schema is expected to already be applied by `npm run migrate` against
|
||||
// the dev database. db.migrate.latest() can't be used here because
|
||||
// PicPeak's custom run-migrations.js tracks state in the `migrations`
|
||||
// table (not knex's `knex_migrations`), so knex would try to re-apply
|
||||
// every migration and crash on duplicate-table errors.
|
||||
const ok = await db.schema.hasTable('events')
|
||||
&& await db.schema.hasTable('app_settings')
|
||||
&& await db.schema.hasTable('backup_runs');
|
||||
if (!ok) {
|
||||
throw new Error('Required tables missing — run `npm run migrate` against the dev DB first.');
|
||||
}
|
||||
|
||||
// Create test storage directory
|
||||
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
|
||||
await fs.mkdir(testStoragePath, { recursive: true });
|
||||
process.env.STORAGE_PATH = testStoragePath;
|
||||
|
||||
// Set up test data
|
||||
await setupTestData();
|
||||
|
||||
// Mock logger to reduce noise
|
||||
if (process.env.UNMOCK_LOGGER !== 'true') {
|
||||
logger.info = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
try {
|
||||
// Clean up S3 bucket
|
||||
await cleanupS3Bucket();
|
||||
await s3Client.send(new DeleteBucketCommand({ Bucket: TEST_CONFIG.bucket }));
|
||||
console.log(`Deleted test bucket: ${TEST_CONFIG.bucket}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup S3 bucket:', error);
|
||||
}
|
||||
|
||||
// Clean up test storage
|
||||
await fs.rm(testStoragePath, { recursive: true, force: true });
|
||||
|
||||
// Restore environment
|
||||
process.env = originalEnv;
|
||||
|
||||
// Close database
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean backup tables
|
||||
await db('backup_runs').del();
|
||||
await db('backup_file_states').del();
|
||||
await db('database_backup_runs').del();
|
||||
|
||||
// Configure S3 backup settings
|
||||
await configureS3Backup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Clean up S3 objects created during test
|
||||
await cleanupS3Bucket();
|
||||
});
|
||||
|
||||
describe('S3 Connection and Configuration', () => {
|
||||
it('should successfully connect to S3-compatible storage', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
const s3Adapter = new S3StorageAdapter({
|
||||
...TEST_CONFIG,
|
||||
bucket: TEST_CONFIG.bucket,
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false
|
||||
});
|
||||
|
||||
const connected = await s3Adapter.testConnection();
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate S3 configuration before backup', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Remove required configuration
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_s3_secret_key')
|
||||
.del();
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const lastRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(lastRun.status).toBe('failed');
|
||||
expect(lastRun.error_message).toContain('S3 backup configuration incomplete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full S3 Backup Process', () => {
|
||||
it('should perform complete S3 backup with all file types', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify backup run completed
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.status).toBe('completed');
|
||||
// pg driver returns bigint columns as strings; coerce for the size assertion.
|
||||
expect(Number(backupRun.files_backed_up)).toBeGreaterThan(0);
|
||||
expect(Number(backupRun.total_size_bytes)).toBeGreaterThan(0);
|
||||
|
||||
// Verify files in S3
|
||||
const s3Objects = await listS3Objects();
|
||||
expect(s3Objects.length).toBeGreaterThan(0);
|
||||
|
||||
// Check for expected file types
|
||||
const hasPhotos = s3Objects.some(obj => obj.Key.includes('events/active'));
|
||||
const hasThumbnails = s3Objects.some(obj => obj.Key.includes('thumbnails'));
|
||||
const hasManifest = s3Objects.some(obj => obj.Key.includes('backup-manifest'));
|
||||
const hasSummary = s3Objects.some(obj => obj.Key.includes('backup-summary.json'));
|
||||
|
||||
expect(hasPhotos).toBe(true);
|
||||
expect(hasThumbnails).toBe(true);
|
||||
expect(hasManifest).toBe(true);
|
||||
expect(hasSummary).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle large file uploads with multipart', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a large test file (15MB)
|
||||
const largeFilePath = path.join(testStoragePath, 'events/active/large-photo.jpg');
|
||||
const largeFileSize = 15 * 1024 * 1024; // 15MB
|
||||
const largeFileContent = Buffer.alloc(largeFileSize, 'x');
|
||||
await fs.writeFile(largeFilePath, largeFileContent);
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify large file was uploaded
|
||||
const s3Objects = await listS3Objects();
|
||||
const largeFileUploaded = s3Objects.some(obj =>
|
||||
obj.Key.includes('large-photo.jpg') && obj.Size === largeFileSize
|
||||
);
|
||||
|
||||
expect(largeFileUploaded).toBe(true);
|
||||
});
|
||||
|
||||
it('should include database backup when available', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a mock database backup
|
||||
const dbBackupPath = path.join(testStoragePath, 'backups/db-backup.sql');
|
||||
await fs.mkdir(path.dirname(dbBackupPath), { recursive: true });
|
||||
await fs.writeFile(dbBackupPath, 'CREATE TABLE test (id INT);');
|
||||
|
||||
// Record database backup
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'sqlite',
|
||||
file_path: dbBackupPath,
|
||||
file_size_bytes: 100,
|
||||
checksum: 'test123',
|
||||
statistics: JSON.stringify({ tables: {} }),
|
||||
table_checksums: JSON.stringify({})
|
||||
});
|
||||
|
||||
// Configure to include database
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_include_database')
|
||||
.update({ setting_value: 'true' });
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify database backup in S3
|
||||
const s3Objects = await listS3Objects();
|
||||
const hasDbBackup = s3Objects.some(obj => obj.Key.includes('database/db-backup.sql'));
|
||||
expect(hasDbBackup).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Incremental Backup', () => {
|
||||
it('should only upload changed files in incremental backup', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// First backup - full
|
||||
await backupService.runBackup();
|
||||
|
||||
const firstRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
const firstObjectCount = (await listS3Objects()).length;
|
||||
|
||||
// Wait a moment to ensure different timestamps
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Modify one file
|
||||
const modifiedFile = path.join(testStoragePath, 'events/active/event1/photo1.jpg');
|
||||
await fs.writeFile(modifiedFile, 'modified content');
|
||||
|
||||
// Second backup - incremental
|
||||
await backupService.runBackup();
|
||||
|
||||
const secondRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(secondRun.id).not.toBe(firstRun.id);
|
||||
expect(Number(secondRun.files_backed_up)).toBe(1); // Only modified file
|
||||
|
||||
// Check manifest indicates incremental. The current manifest schema
|
||||
// groups counts under `incremental.changes.*` (added/modified/deleted/
|
||||
// unchanged + size_difference) — see backupManifest.generateIncrementalManifest.
|
||||
if (secondRun.manifest_path) {
|
||||
const manifest = await backupService.getBackupManifest(secondRun.id);
|
||||
expect(manifest.manifest.incremental).toBeDefined();
|
||||
expect(manifest.manifest.incremental.changes).toBeDefined();
|
||||
expect(manifest.manifest.incremental.changes.modified_files_count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should track file states across backups', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Check file states are recorded
|
||||
const fileStates = await db('backup_file_states').select('*');
|
||||
expect(fileStates.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify checksums are stored
|
||||
const hasChecksums = fileStates.every(state => state.checksum !== null);
|
||||
expect(hasChecksums).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 Manifest Storage', () => {
|
||||
it('should upload manifest to S3 and retrieve it', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Configure YAML manifest format
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_manifest_format')
|
||||
.update({ setting_value: '"yaml"' });
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.manifest_path).toMatch(/^s3:\/\//);
|
||||
|
||||
// Retrieve manifest
|
||||
const { manifest, summary } = await backupService.getBackupManifest(backupRun.id);
|
||||
|
||||
expect(manifest).toBeDefined();
|
||||
expect(manifest.backup.id).toBeDefined();
|
||||
expect(summary).toContain('BACKUP MANIFEST SUMMARY');
|
||||
});
|
||||
|
||||
it('should validate manifest integrity', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
const validationResult = await backupService.validateBackupManifest(backupRun.manifest_path);
|
||||
|
||||
expect(validationResult.valid).toBe(true);
|
||||
expect(validationResult.manifest).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Recovery', () => {
|
||||
it('should handle S3 connection failures gracefully', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Configure with invalid endpoint
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_s3_endpoint')
|
||||
.update({ setting_value: '"http://invalid-endpoint:9999"' });
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.status).toBe('failed');
|
||||
expect(backupRun.error_message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should continue backup despite individual file failures', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a file that will be deleted during backup
|
||||
const tempFile = path.join(testStoragePath, 'events/active/temp.jpg');
|
||||
await fs.writeFile(tempFile, 'temporary');
|
||||
|
||||
// Mock file deletion during backup
|
||||
const originalUpload = S3StorageAdapter.prototype.upload;
|
||||
let callCount = 0;
|
||||
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
|
||||
callCount++;
|
||||
if (callCount === 2) {
|
||||
// Delete the temp file to cause an error
|
||||
await fs.unlink(tempFile).catch(() => {});
|
||||
}
|
||||
return originalUpload.call(this, localPath, s3Key, options);
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
// Should complete despite one file error
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(backupRun.files_backed_up).toBeGreaterThan(0);
|
||||
|
||||
// Restore original method
|
||||
S3StorageAdapter.prototype.upload = originalUpload;
|
||||
});
|
||||
|
||||
it('should retry failed uploads with exponential backoff', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Mock S3 upload to fail twice then succeed
|
||||
const originalUpload = S3StorageAdapter.prototype.upload;
|
||||
let attemptCount = 0;
|
||||
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
|
||||
attemptCount++;
|
||||
if (attemptCount <= 2) {
|
||||
const error = new Error('Network timeout');
|
||||
error.code = 'ETIMEDOUT';
|
||||
throw error;
|
||||
}
|
||||
return originalUpload.call(this, localPath, s3Key, options);
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
// Should succeed after retries
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(attemptCount).toBeGreaterThan(2);
|
||||
|
||||
// Restore original method
|
||||
S3StorageAdapter.prototype.upload = originalUpload;
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
|
||||
async function setupTestData() {
|
||||
// Create test directory structure
|
||||
const dirs = [
|
||||
'events/active/event1',
|
||||
'events/active/event2',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'uploads'
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
await fs.mkdir(path.join(testStoragePath, dir), { recursive: true });
|
||||
}
|
||||
|
||||
// Create test files
|
||||
const files = [
|
||||
{ path: 'events/active/event1/photo1.jpg', content: 'photo1 content' },
|
||||
{ path: 'events/active/event1/photo2.jpg', content: 'photo2 content' },
|
||||
{ path: 'events/active/event2/photo3.jpg', content: 'photo3 content' },
|
||||
{ path: 'events/archived/old-event.zip', content: 'archived content' },
|
||||
{ path: 'thumbnails/thumb1.jpg', content: 'thumbnail content' },
|
||||
{ path: 'uploads/logo.png', content: 'logo content' }
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
await fs.writeFile(
|
||||
path.join(testStoragePath, file.path),
|
||||
file.content
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function configureS3Backup() {
|
||||
const settings = [
|
||||
{ setting_key: 'backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: `"${TEST_CONFIG.bucket}"` },
|
||||
{ setting_key: 'backup_s3_region', setting_value: `"${TEST_CONFIG.region}"` },
|
||||
{ setting_key: 'backup_s3_endpoint', setting_value: `"${TEST_CONFIG.endpoint}"` },
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: `"${TEST_CONFIG.accessKeyId}"` },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: `"${TEST_CONFIG.secretAccessKey}"` },
|
||||
{ setting_key: 'backup_s3_force_path_style', setting_value: 'true' },
|
||||
{ setting_key: 'backup_s3_ssl_enabled', setting_value: 'false' },
|
||||
{ setting_key: 'backup_include_archived', setting_value: 'true' },
|
||||
{ setting_key: 'backup_incremental', setting_value: 'true' },
|
||||
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
|
||||
];
|
||||
|
||||
// Schema drift: app_settings has no created_at column anymore and the
|
||||
// unique constraint is on setting_key alone, not (setting_type, key).
|
||||
for (const setting of settings) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_type: 'backup',
|
||||
...setting,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge();
|
||||
}
|
||||
}
|
||||
|
||||
async function listS3Objects() {
|
||||
const response = await s3Client.send(new ListObjectsV2Command({
|
||||
Bucket: TEST_CONFIG.bucket
|
||||
}));
|
||||
return response.Contents || [];
|
||||
}
|
||||
|
||||
async function cleanupS3Bucket() {
|
||||
try {
|
||||
const objects = await listS3Objects();
|
||||
if (objects.length > 0) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: TEST_CONFIG.bucket,
|
||||
Delete: {
|
||||
Objects: objects.map(obj => ({ Key: obj.Key }))
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup S3 objects:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Backup credential exposure regression tests.
|
||||
*
|
||||
* The generic settings reads (GET /admin/settings, GET /admin/settings/:type)
|
||||
* masked the recaptcha/umami/rybbit keys but returned backup_s3_secret_key
|
||||
* and backup_rsync_ssh_key (an SSH PRIVATE KEY) in plaintext to any
|
||||
* settings.view holder; GET /admin/backup/config returned them too. Both now
|
||||
* mask, and PUT /admin/backup/config skips the mask sentinel so the edit
|
||||
* form round-trips without clobbering stored credentials.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => {
|
||||
req.admin = { id: 1, username: 'test-admin' };
|
||||
next();
|
||||
},
|
||||
}));
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
requireSuperAdmin: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
describe('backup credential masking', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
// Upsert: several backup_* keys are pre-seeded by the backup migrations.
|
||||
const seed = [
|
||||
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('s3'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_endpoint', setting_value: JSON.stringify('https://s3.example.com'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: JSON.stringify('backups'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: JSON.stringify('AKIAEXAMPLE'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: JSON.stringify('super-secret-s3-key'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_rsync_ssh_key', setting_value: JSON.stringify('-----BEGIN OPENSSH PRIVATE KEY-----abc'), setting_type: 'backup' },
|
||||
];
|
||||
for (const row of seed) {
|
||||
await db('app_settings').insert(row).onConflict('setting_key').merge();
|
||||
}
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
|
||||
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('masks the credentials in GET /admin/backup/config', async () => {
|
||||
const res = await request(app).get('/api/admin/backup/config').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
// Non-secret fields stay readable for the form.
|
||||
expect(res.body.backup_s3_bucket).toBe('backups');
|
||||
});
|
||||
|
||||
it('masks the credentials in the generic GET /admin/settings/:type read', async () => {
|
||||
const res = await request(app).get('/api/admin/settings/backup').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('masks the credentials in the generic GET /admin/settings read', async () => {
|
||||
const res = await request(app).get('/api/admin/settings').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('PUT /admin/backup/config keeps the stored secret when the sentinel round-trips', async () => {
|
||||
await request(app)
|
||||
.put('/api/admin/backup/config')
|
||||
.send({
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_endpoint: 'https://s3.example.com',
|
||||
backup_s3_bucket: 'renamed-bucket',
|
||||
backup_s3_access_key: 'AKIAEXAMPLE',
|
||||
backup_s3_secret_key: '••••••••',
|
||||
backup_rsync_ssh_key: '••••••••',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
||||
expect(JSON.parse(secret.setting_value)).toBe('super-secret-s3-key');
|
||||
const sshKey = await db('app_settings').where({ setting_key: 'backup_rsync_ssh_key' }).first();
|
||||
expect(JSON.parse(sshKey.setting_value)).toBe('-----BEGIN OPENSSH PRIVATE KEY-----abc');
|
||||
const bucket = await db('app_settings').where({ setting_key: 'backup_s3_bucket' }).first();
|
||||
expect(JSON.parse(bucket.setting_value)).toBe('renamed-bucket');
|
||||
});
|
||||
|
||||
it('PUT /admin/backup/config stores a genuinely new secret', async () => {
|
||||
await request(app)
|
||||
.put('/api/admin/backup/config')
|
||||
.send({ backup_s3_secret_key: 'rotated-s3-key' })
|
||||
.expect(200);
|
||||
|
||||
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
||||
expect(JSON.parse(secret.setting_value)).toBe('rotated-s3-key');
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -1,379 +0,0 @@
|
||||
/**
|
||||
* 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(120000);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
// Issue #871 — the "What to Backup" checkboxes were stored but never read.
|
||||
describe('UI opt-out toggles (issue #871)', () => {
|
||||
it('unchecking Thumbnails excludes thumbnails/', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_thumbnails: false,
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
|
||||
});
|
||||
|
||||
it('unchecking Photos excludes events/active', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_photos: false,
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('thumbnails/E1/thumb.jpg');
|
||||
expect(rels).not.toContain('events/active/E1/photo.jpg');
|
||||
});
|
||||
|
||||
it('defaults to including everything when the keys were never saved', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('thumbnails/E1/thumb.jpg');
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
});
|
||||
|
||||
it("accepts the UI's plural backup_include_archives for the archived gate", async () => {
|
||||
seedFile('events/archived/E4/archived.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_archives: true,
|
||||
});
|
||||
expect(files.map((f) => f.relativePath)).toContain('events/archived/E4/archived.jpg');
|
||||
});
|
||||
|
||||
it('the UI plural key beats the migration-seeded singular key', async () => {
|
||||
// Migration seeds backup_include_archived=true on every install; the
|
||||
// form only ever writes the plural key, so unchecking Archives must
|
||||
// win over the stale seeded value.
|
||||
seedFile('events/archived/E5/archived.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_archived: true, // seeded default
|
||||
backup_include_archives: false, // what the admin actually chose
|
||||
});
|
||||
expect(files.map((f) => f.relativePath)).not.toContain('events/archived/E5/archived.jpg');
|
||||
});
|
||||
|
||||
it('rsync gets the de-selected paths and noise filters as --exclude args', async () => {
|
||||
const excluded = await backupService.resolveExcludedBackupPaths({
|
||||
backup_include_thumbnails: false,
|
||||
backup_include_archives: false,
|
||||
});
|
||||
expect(excluded.map((r) => r.path)).toEqual(
|
||||
expect.arrayContaining(['thumbnails', 'events/archived'])
|
||||
);
|
||||
|
||||
const args = backupService.buildRsyncArgs(
|
||||
{ backup_rsync_host: 'backup.example.com', backup_rsync_path: '/srv/backups' },
|
||||
excluded.map((r) => `/${r.path}/`)
|
||||
);
|
||||
const excludes = args
|
||||
.map((a, i) => (a === '--exclude' ? args[i + 1] : null))
|
||||
.filter(Boolean);
|
||||
expect(excludes).toEqual(expect.arrayContaining([
|
||||
'.nfs*',
|
||||
'/thumbnails/',
|
||||
'/events/archived/',
|
||||
]));
|
||||
});
|
||||
|
||||
it('rows toggled off via include_in_default also become rsync excludes', async () => {
|
||||
// The enabled-only loader hides these rows from the walker, but rsync
|
||||
// syncs the whole storage root, so they must still appear as excludes.
|
||||
await db('backup_paths').where('path', 'previews').update({
|
||||
include_in_default: false,
|
||||
});
|
||||
|
||||
const excluded = await backupService.resolveExcludedBackupPaths({});
|
||||
expect(excluded.map((r) => r.path)).toContain('previews');
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #871 — .nfs* silly-rename artifacts were uploaded to S3.
|
||||
it('never backs up filesystem noise (.nfs*, .DS_Store)', async () => {
|
||||
seedFile('thumbnails/E1/.nfs000000000000006600000008');
|
||||
seedFile('events/active/E1/.DS_Store');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels.some((r) => r.includes('.nfs'))).toBe(false);
|
||||
expect(rels.some((r) => r.includes('.DS_Store'))).toBe(false);
|
||||
});
|
||||
|
||||
it('the walker honors backup_exclude_patterns (previously rsync-only)', async () => {
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
seedFile('events/active/E1/scratch.tmp');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_exclude_patterns: ['*.tmp'],
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).not.toContain('events/active/E1/scratch.tmp');
|
||||
});
|
||||
|
||||
it('glob patterns are literal outside the star (.nfs* must not eat anfs-…)', async () => {
|
||||
seedFile('events/active/E1/anfs-photo.jpg');
|
||||
seedFile('events/active/E1/notes-tmp');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_exclude_patterns: ['*.tmp'],
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
// '.nfs*' used to compile to /^.nfs.*$/ whose dot matched any char;
|
||||
// '*.tmp' used to compile to /^.*.tmp$/ which also matched 'notes-tmp'.
|
||||
expect(rels).toContain('events/active/E1/anfs-photo.jpg');
|
||||
expect(rels).toContain('events/active/E1/notes-tmp');
|
||||
});
|
||||
|
||||
// Issue #871 — weekly schedules silently ran daily, and the dashboard's
|
||||
// "next backup" was a hardcoded "tomorrow 02:00".
|
||||
describe('schedule resolution + next run (issue #871)', () => {
|
||||
it('a named label beats the stray default cron the UI used to send', () => {
|
||||
expect(backupService.resolveScheduleCron({
|
||||
backup_schedule: 'weekly',
|
||||
backup_schedule_cron: '0 3 * * *', // old UI default, sent unconditionally
|
||||
})).toBe('0 3 * * 0');
|
||||
});
|
||||
|
||||
it('custom schedules use the cron field', () => {
|
||||
expect(backupService.resolveScheduleCron({
|
||||
backup_schedule: 'custom',
|
||||
backup_schedule_cron: '15 5 * * 2',
|
||||
})).toBe('15 5 * * 2');
|
||||
});
|
||||
|
||||
it('falls back to the default daily cron', () => {
|
||||
expect(backupService.resolveScheduleCron({})).toBe('0 2 * * *');
|
||||
});
|
||||
|
||||
it('getNextScheduledRun is null when backups are disabled', () => {
|
||||
expect(backupService.getNextScheduledRun(null)).toBeNull();
|
||||
expect(backupService.getNextScheduledRun({ backup_enabled: false })).toBeNull();
|
||||
});
|
||||
|
||||
it('getNextScheduledRun returns the real next weekly fire time', () => {
|
||||
const iso = backupService.getNextScheduledRun({
|
||||
backup_enabled: true,
|
||||
backup_schedule: 'weekly',
|
||||
backup_schedule_cron: '0 3 * * *',
|
||||
});
|
||||
const next = new Date(iso);
|
||||
expect(Number.isNaN(next.getTime())).toBe(false);
|
||||
expect(next.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(next.getDay()).toBe(0); // Sunday
|
||||
expect(next.getHours()).toBe(3); // 03:00
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #871 — "Backup Size: 167.6 TB": file_size_bytes is a bigInteger
|
||||
// column, node-postgres returns int8 as a string, and the S3 path did
|
||||
// `backedUpSize += size` — string concatenation.
|
||||
it('getDatabaseBackupInfo coerces file_size_bytes to a number', async () => {
|
||||
await db('database_backup_runs').del();
|
||||
await db('database_backup_runs').insert({
|
||||
backup_type: 'full',
|
||||
status: 'completed',
|
||||
file_path: '/backups/db/dump.sql.gz',
|
||||
// Simulate the PG int8-as-string driver behaviour (sqlite stores
|
||||
// whatever it is handed, so the string round-trips).
|
||||
file_size_bytes: '421988',
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const info = await backupService.getDatabaseBackupInfo();
|
||||
expect(typeof info.size).toBe('number');
|
||||
expect(info.size).toBe(421988);
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* 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(120000);
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* 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(120000);
|
||||
|
||||
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.
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Smoke tests for backupService's config resolution + file-collection
|
||||
* and manifest validation paths — safety net ahead of the god-file
|
||||
* decomposition.
|
||||
*
|
||||
* Uses the same real-SQLite harness as
|
||||
* backupService.configurableWalker.test.js (bootCrmDb + a temp
|
||||
* STORAGE_PATH) rather than the broken deep-mock approach in
|
||||
* backupService.enhanced.test.js.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — config + file collection + manifest (smoke)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
let backupManifest;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
backupManifest = require('../../src/services/backupManifest');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').del();
|
||||
// Reset the storage tree so each test starts from a pristine walk.
|
||||
await fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
});
|
||||
|
||||
function seedFile(relPath, content = 'dummy bytes') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
return abs;
|
||||
}
|
||||
|
||||
async function insertBackupSetting(key, value) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: value,
|
||||
setting_type: 'backup',
|
||||
});
|
||||
}
|
||||
|
||||
describe('getBackupConfig', () => {
|
||||
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
|
||||
await insertBackupSetting('backup_enabled', 'true');
|
||||
await insertBackupSetting('backup_include_archived', 'false');
|
||||
await insertBackupSetting('backup_retention_days', '30');
|
||||
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
|
||||
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
|
||||
// Non-backup settings must not leak into the backup config.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'general_site_name',
|
||||
setting_value: 'PicPeak',
|
||||
setting_type: 'general',
|
||||
});
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config.backup_enabled).toBe(true);
|
||||
expect(config.backup_include_archived).toBe(false);
|
||||
expect(config.backup_retention_days).toBe(30);
|
||||
expect(config.backup_destination_path).toBe('/backups/picpeak');
|
||||
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
|
||||
expect(config).not.toHaveProperty('general_site_name');
|
||||
// Raw (unparsed) values are preserved on the non-enumerable __raw.
|
||||
expect(String(config.__raw.backup_retention_days)).toBe('30');
|
||||
});
|
||||
|
||||
it('returns an empty config object (not null) when nothing is configured', async () => {
|
||||
const config = await backupService.getBackupConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(Object.keys(config)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilesToBackup', () => {
|
||||
it('returns an empty list on a pristine storage tree', async () => {
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
|
||||
const content = 'not really a jpeg';
|
||||
const abs = seedFile('events/active/E9/pic.jpg', content);
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.path).toBe(abs);
|
||||
expect(entry.size).toBe(Buffer.byteLength(content));
|
||||
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
|
||||
// realm under Jest and fails the cross-realm instanceof check.
|
||||
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBackupManifest', () => {
|
||||
it('round-trips a generated manifest as valid', async () => {
|
||||
seedFile('events/active/E1/a.jpg', 'aaa');
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
|
||||
const manifest = await backupManifest.generateManifest({
|
||||
backupType: 'full',
|
||||
backupPath: '/backup/run-1',
|
||||
files,
|
||||
});
|
||||
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
|
||||
await backupManifest.saveManifest(manifest, manifestPath, 'json');
|
||||
|
||||
const result = await backupService.validateBackupManifest(manifestPath);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.manifest.backup.type).toBe('full');
|
||||
expect(result.manifest.files.count).toBe(files.length);
|
||||
expect(result.manifest.verification.total_checksum).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags a manifest missing required sections as invalid', async () => {
|
||||
const badPath = path.join(storagePath, 'manifest-broken.json');
|
||||
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
|
||||
|
||||
const result = await backupService.validateBackupManifest(badPath);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toMatch(/Missing required section/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* 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(120000);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* PUT /api/admin/business-profile — email signature fields (migration 198).
|
||||
*
|
||||
* The two new columns are boolean + free text, which is exactly the shape
|
||||
* that goes wrong quietly: `optional({ values: 'falsy' })` on the boolean
|
||||
* would silently drop `false`, leaving the admin unable to switch the
|
||||
* signature back off. The round-trip below is what pins that.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bpsig-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 || 'bpsig-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
describe('business profile — email signature round-trip', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let token;
|
||||
|
||||
const put = (payload) => request(app)
|
||||
.put('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(payload);
|
||||
|
||||
const get = () => request(app)
|
||||
.get('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
// GET returns the snapshot at the top level; PUT wraps it in
|
||||
// successResponse's `data` envelope. Read either.
|
||||
const profileOf = (res) => (res.body.data || res.body).profile;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('defaults to off with an empty legal line', async () => {
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const profile = profileOf(res);
|
||||
expect(profile.emailSignatureEnabled).toBe(false);
|
||||
expect(profile.emailSignatureExtra).toBe('');
|
||||
});
|
||||
|
||||
it('persists the toggle and the legal line', async () => {
|
||||
const res = await put({
|
||||
emailSignatureEnabled: true,
|
||||
emailSignatureExtra: 'Handelsregister Vaduz FL-0002.123.456-7',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const profile = profileOf(await get());
|
||||
expect(profile.emailSignatureEnabled).toBe(true);
|
||||
expect(profile.emailSignatureExtra).toBe('Handelsregister Vaduz FL-0002.123.456-7');
|
||||
});
|
||||
|
||||
it('switches the toggle back off — `false` is not dropped as falsy', async () => {
|
||||
await put({ emailSignatureEnabled: true });
|
||||
const res = await put({ emailSignatureEnabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the legal line with an empty string', async () => {
|
||||
await put({ emailSignatureExtra: 'something' });
|
||||
const res = await put({ emailSignatureExtra: '' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(profileOf(await get()).emailSignatureExtra).toBe('');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace off the legal line', async () => {
|
||||
await put({ emailSignatureExtra: ' Registered in Vaduz ' });
|
||||
expect(profileOf(await get()).emailSignatureExtra).toBe('Registered in Vaduz');
|
||||
});
|
||||
|
||||
// Codex review: express-validator's isBoolean() accepts the STRINGS
|
||||
// 'false' and '0', and Boolean('false') is true — so a form-encoded client
|
||||
// trying to switch the signature OFF switched it on instead.
|
||||
it.each([['false'], ['0']])('treats the string %s as off, not on', async (value) => {
|
||||
await put({ emailSignatureEnabled: true });
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
|
||||
|
||||
const res = await put({ emailSignatureEnabled: value });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each([['true'], ['1']])('treats the string %s as on', async (value) => {
|
||||
await put({ emailSignatureEnabled: false });
|
||||
const res = await put({ emailSignatureEnabled: value });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a non-boolean toggle and a legal line over 500 chars', async () => {
|
||||
expect((await put({ emailSignatureEnabled: 'yes please' })).status).toBe(400);
|
||||
expect((await put({ emailSignatureExtra: 'x'.repeat(501) })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('does not let an unmapped column ride in on the payload', async () => {
|
||||
// ALLOWED_PROFILE_FIELDS is the whitelist; the route's camel→snake map
|
||||
// is the second gate. Neither should pass a raw snake_case key through.
|
||||
const before = await db('business_profile').where({ id: 1 }).first();
|
||||
await put({ email_signature_enabled: true, id: 999 });
|
||||
const after = await db('business_profile').where({ id: 1 }).first();
|
||||
|
||||
expect(after.id).toBe(before.id);
|
||||
expect(after.email_signature_enabled).toBe(before.email_signature_enabled);
|
||||
});
|
||||
|
||||
it('invalidates the wrapper signature cache on write', async () => {
|
||||
const { wrapEmailHtml } = require('../../src/services/emailProcessor');
|
||||
|
||||
await put({ emailSignatureEnabled: false });
|
||||
expect(await wrapEmailHtml('<p>x</p>', 'S')).not.toContain('Bahnhofstrasse 9');
|
||||
|
||||
// Same request cycle, well inside the 60 s memo window: the PUT must
|
||||
// clear the cache or the operator sees a stale footer for a minute.
|
||||
await put({ emailSignatureEnabled: true, addressLine1: 'Bahnhofstrasse 9' });
|
||||
expect(await wrapEmailHtml('<p>x</p>', 'S')).toContain('Bahnhofstrasse 9');
|
||||
});
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* Backfilling captured_at on a library imported before #1172.
|
||||
*
|
||||
* The point of the endpoint, rather than a migration: it resolves originals
|
||||
* through resolvePhotoFilePath, which is the only path that reaches an
|
||||
* external row. The thumbnail regenerator resolves under
|
||||
* storage/events/active/<photo.path>, which never exists for those (#1129) —
|
||||
* so it cannot be the model.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const sharp = require('sharp');
|
||||
|
||||
describe('capture date backfill (#1172)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot;
|
||||
|
||||
const writeJpegWithExif = async (abs, iso) => {
|
||||
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
|
||||
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 9, g: 9, b: 9 } } })
|
||||
.withExif({ IFD2: { DateTimeOriginal: exifDate } }).jpeg().toFile(abs);
|
||||
};
|
||||
|
||||
const settle = async () => { for (let i = 0; i < 60; i++) { await new Promise((r) => setTimeout(r, 50)); const s = await status(); if (!s.body.isRunning) return s; } throw new Error('backfill did not settle'); };
|
||||
const status = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capfill-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capfill-secret';
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seed({ relpath, exifIso, writeFile = true, archived = false }) {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
const [e] = await db('events').insert({
|
||||
slug: 'capfill', event_type: 'wedding', event_name: 'capfill', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: `capfill-${Math.random()}`, expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference', external_path: 'trip', is_archived: archived,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
if (writeFile) await writeJpegWithExif(path.join(mediaRoot, 'trip', relpath), exifIso);
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: path.basename(relpath), path: `capfill/${path.basename(relpath)}`,
|
||||
// Root-relative, as this branch stores it (#1163) — the file lives at
|
||||
// <mediaRoot>/trip/<relpath>.
|
||||
type: 'individual', source_origin: 'external', external_relpath: `trip/${relpath}`,
|
||||
uploaded_at: new Date().toISOString(), captured_at: null,
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
it('fills captured_at for an external photo the thumbnail regenerator cannot reach', async () => {
|
||||
const { photoId } = await seed({ relpath: 'a.jpg', exifIso: '2026-06-01T09:45:03Z' });
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(1);
|
||||
const done = await settle();
|
||||
|
||||
expect(done.body.lastResult.success).toBe(1);
|
||||
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('counts a photo with no EXIF separately from a failure', async () => {
|
||||
// "The mount is broken" and "these files carry no date" need different
|
||||
// answers from an operator, so they are not the same number.
|
||||
await db('photos').del(); await db('events').del();
|
||||
const { photoId } = await seed({ relpath: 'plain.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
|
||||
await sharp({ create: { width: 40, height: 30, channels: 3, background: { r: 1, g: 1, b: 1 } } })
|
||||
.jpeg().toFile(path.join(mediaRoot, 'trip', 'plain.jpg'));
|
||||
|
||||
await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
const done = await settle();
|
||||
|
||||
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 1, failed: 0 });
|
||||
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
|
||||
});
|
||||
|
||||
it('counts an unreachable original as a failure, not as missing EXIF', async () => {
|
||||
await seed({ relpath: 'gone.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
|
||||
|
||||
await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
const done = await settle();
|
||||
|
||||
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 0, failed: 1 });
|
||||
});
|
||||
|
||||
it('reports nothing to do once every photo has a date', async () => {
|
||||
const { photoId } = await seed({ relpath: 'b.jpg', exifIso: '2026-06-02T09:00:00Z' });
|
||||
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
|
||||
expect(res.body.count).toBe(0);
|
||||
expect((await status()).body.withoutCaptureDate).toBe(0);
|
||||
});
|
||||
|
||||
it('skips a watcher-imported video, which carries media_type "image"', async () => {
|
||||
// fileWatcher.processNewPhoto sets type='video' and a video/* mime but
|
||||
// never media_type (fileWatcher.js:128-130), so the row keeps the 'image'
|
||||
// default from migration 048. Filtering on media_type alone queued it every
|
||||
// run: extractCaptureDate returns null for a video, captured_at stays null,
|
||||
// and the backlog never cleared.
|
||||
const { eventId } = await seed({ relpath: 'clip.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
|
||||
await db('photos').del();
|
||||
await db('photos').insert({
|
||||
event_id: eventId, filename: 'clip.mp4', path: 'capfill/clip.mp4',
|
||||
type: 'video', media_type: 'image', mime_type: 'video/mp4',
|
||||
source_origin: 'external', external_relpath: 'trip/clip.mp4',
|
||||
uploaded_at: new Date().toISOString(), captured_at: null,
|
||||
});
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
expect(res.body.count).toBe(0);
|
||||
|
||||
const s = await status();
|
||||
// And it is not counted as a permanent backlog either.
|
||||
expect(s.body.total).toBe(0);
|
||||
expect(s.body.withoutCaptureDate).toBe(0);
|
||||
});
|
||||
|
||||
it('never reports more dated photos than it has photos', async () => {
|
||||
// Both counts come from one aggregate; as two queries an import committing
|
||||
// between them produced withCaptureDate > total and a negative backlog.
|
||||
const { photoId } = await seed({ relpath: 'counted.jpg', exifIso: '2026-06-05T08:00:00Z' });
|
||||
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
|
||||
|
||||
const s = await status();
|
||||
expect(s.body.total).toBe(1);
|
||||
expect(s.body.withCaptureDate).toBe(1);
|
||||
expect(s.body.withoutCaptureDate).toBe(0);
|
||||
expect(s.body.withoutCaptureDate).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('skips archived events instead of failing them on every run', async () => {
|
||||
// Archiving deletes the originals and keeps the rows, so an archived photo
|
||||
// can never get a date. Counting it would fail it every pass and leave the
|
||||
// status endpoint permanently reporting a backlog.
|
||||
await seed({ relpath: 'archived.jpg', exifIso: '2026-06-04T09:00:00Z', archived: true });
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
|
||||
expect(res.body.count).toBe(0);
|
||||
const s = await status();
|
||||
expect(s.body.total).toBe(0);
|
||||
expect(s.body.withoutCaptureDate).toBe(0);
|
||||
expect(s.body.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('does not overwrite a date written while it was running', async () => {
|
||||
// whereNull on the update: an import or a replacement finishing mid-run has
|
||||
// already written a better value than this pass would.
|
||||
const { photoId } = await seed({ relpath: 'c.jpg', exifIso: '2026-06-03T09:00:00Z' });
|
||||
const claimed = '2020-01-01T00:00:00.000Z';
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
expect(res.body.count).toBe(1);
|
||||
await db('photos').where({ id: photoId }).update({ captured_at: claimed });
|
||||
const done = await settle();
|
||||
|
||||
expect(new Date((await db('photos').where({ id: photoId }).first()).captured_at).toISOString()).toBe(claimed);
|
||||
expect(done.body.lastResult.success).toBe(0);
|
||||
// Read but not written, so it is accounted for rather than dropped.
|
||||
expect(done.body.lastResult.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('does not date a row whose file was replaced while it was reading (#1201)', async () => {
|
||||
// replacePhoto swaps a NEW file under an existing row and rewrites
|
||||
// path/filename (reachable from replace_by_name). The replacement carries
|
||||
// no date of its own, so captured_at is still NULL and the whereNull guard
|
||||
// alone would let the previous file's EXIF date land on it. The write is
|
||||
// fenced on the identity that was read, so the row is skipped instead —
|
||||
// and not counted as updated either.
|
||||
const { photoId } = await seed({ relpath: 'orig.jpg', exifIso: '2026-06-03T09:00:00Z' });
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
expect(res.body.count).toBe(1);
|
||||
// Simulate the replacement landing before the loop writes.
|
||||
await db('photos').where({ id: photoId })
|
||||
.update({ path: 'capfill/replaced.jpg', filename: 'replaced.jpg' });
|
||||
const done = await settle();
|
||||
|
||||
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
|
||||
expect(done.body.lastResult.success).toBe(0);
|
||||
// Not an error and not "no EXIF" — the date was found, another writer just
|
||||
// got there first. It stays in the backlog for the next run.
|
||||
expect(done.body.lastResult).toMatchObject({ noExif: 0, failed: 0, skipped: 1 });
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* Layered per-event category ordering (#782).
|
||||
*
|
||||
* Two ordering layers, resolved per event:
|
||||
* - GLOBAL default — photo_categories.display_order (migration 159),
|
||||
* set via POST /reorder-global; applies everywhere.
|
||||
* - PER-EVENT override — event_category_order (migration 160), set via
|
||||
* POST /reorder; overrides the default for one gallery.
|
||||
* - DELETE /reorder/:eventId clears an event's override.
|
||||
*
|
||||
* Verified against a real SQLite DB with the full core-migration set applied.
|
||||
*/
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('category ordering (#782)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let token;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const auth = (r) => r.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
async function insertEvent(slug) {
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x',
|
||||
expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug, share_link: slug,
|
||||
event_name: slug, event_date: '2026-01-01',
|
||||
});
|
||||
return (await db('events').where({ slug }).first()).id;
|
||||
}
|
||||
|
||||
async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) {
|
||||
const res = await db('photo_categories').insert({
|
||||
name,
|
||||
slug: name.toLowerCase().replace(/\s+/g, '-'),
|
||||
is_global: is_global ? 1 : 0,
|
||||
event_id,
|
||||
display_order,
|
||||
}).returning('id');
|
||||
return res[0]?.id ?? res[0];
|
||||
}
|
||||
|
||||
const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200);
|
||||
|
||||
describe('migration 159 backfill', () => {
|
||||
it('seeds display_order from alphabetical order, scoped per event', async () => {
|
||||
const eventId = await insertEvent('backfill-ev');
|
||||
await insertCat('Reception', { event_id: eventId });
|
||||
await insertCat('Ceremony', { event_id: eventId });
|
||||
await insertCat('Pre-Ceremony', { event_id: eventId });
|
||||
|
||||
// Re-run the migration: addColumn is guarded (no-op); the backfill loop
|
||||
// re-runs and assigns per-scope alphabetical order — what an upgrade does.
|
||||
await require('../../migrations/core/159_add_category_display_order').up(db);
|
||||
|
||||
const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc');
|
||||
expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']);
|
||||
expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global default order (POST /reorder-global)', () => {
|
||||
it('reverses the global order and every non-customised event follows it', async () => {
|
||||
const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body;
|
||||
expect(before.length).toBeGreaterThan(1);
|
||||
const reversedIds = before.map((c) => c.id).reverse();
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories/reorder-global'))
|
||||
.send({ orderedIds: reversedIds })
|
||||
.expect(200);
|
||||
expect(res.body.map((c) => c.id)).toEqual(reversedIds);
|
||||
|
||||
// A fresh event (no override) shows globals in the new global order.
|
||||
const eventId = await insertEvent('follows-global');
|
||||
const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id);
|
||||
expect(globalsInEvent).toEqual(reversedIds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-event override (POST /reorder)', () => {
|
||||
it('pins a custom order for one event without affecting another', async () => {
|
||||
const eventA = await insertEvent('override-a');
|
||||
const eventB = await insertEvent('override-b');
|
||||
const a1 = await insertCat('A-Ceremony', { event_id: eventA });
|
||||
const a2 = await insertCat('A-Reception', { event_id: eventA });
|
||||
|
||||
// Current resolved list for A (globals + A's two categories).
|
||||
const listA = (await getEvent(eventA)).body;
|
||||
// Put A-Reception first, then A-Ceremony, then the globals in their order.
|
||||
const globalsA = listA.filter((c) => c.is_global).map((c) => c.id);
|
||||
const desired = [a2, a1, ...globalsA];
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventA, orderedIds: desired })
|
||||
.expect(200);
|
||||
expect(res.body.map((c) => c.id)).toEqual(desired);
|
||||
// override_position is set on every row for a customised event.
|
||||
expect(res.body.every((c) => c.override_position != null)).toBe(true);
|
||||
|
||||
// Event B is untouched — no override, follows the global default.
|
||||
const listB = (await getEvent(eventB)).body;
|
||||
expect(listB.every((c) => c.override_position == null)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts global ids but rejects another event’s category', async () => {
|
||||
const eventId = await insertEvent('scope-ev');
|
||||
const own = await insertCat('Own', { event_id: eventId });
|
||||
const global = (await db('photo_categories').where('is_global', 1).first()).id;
|
||||
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
|
||||
|
||||
// A global id is allowed (globals can be arranged per event).
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, global] })
|
||||
.expect(200);
|
||||
|
||||
// A foreign event's category is out of scope.
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, foreign] })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset (DELETE /reorder/:eventId)', () => {
|
||||
it('clears the override and reverts to the global default', async () => {
|
||||
const eventId = await insertEvent('reset-ev');
|
||||
const c1 = await insertCat('R-One', { event_id: eventId });
|
||||
const list = (await getEvent(eventId)).body;
|
||||
const globals = list.filter((c) => c.is_global).map((c) => c.id);
|
||||
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
|
||||
.expect(200);
|
||||
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
|
||||
|
||||
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
|
||||
expect(res.body.every((c) => c.override_position == null)).toBe(true);
|
||||
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('event ownership (PR #790 review)', () => {
|
||||
let limitedToken;
|
||||
let foreignEventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
// A non-super_admin role that DOES hold settings.view + settings.edit —
|
||||
// the exact case the review flagged (settings.edit is grantable).
|
||||
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
|
||||
const roleId = roleRes[0]?.id ?? roleRes[0];
|
||||
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
|
||||
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
|
||||
|
||||
const a2 = await db('admin_users').insert({
|
||||
username: 'limited', email: 'limited@example.com',
|
||||
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
|
||||
must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
|
||||
|
||||
// An event owned by a DIFFERENT admin (the seeded super_admin).
|
||||
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x',
|
||||
expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
|
||||
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
|
||||
});
|
||||
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
|
||||
});
|
||||
|
||||
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
|
||||
|
||||
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
|
||||
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
|
||||
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
|
||||
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST / (create) appends to the end of its scope', () => {
|
||||
it('assigns display_order = max + 1 within the event', async () => {
|
||||
const eventId = await insertEvent('append-ev');
|
||||
await insertCat('First', { event_id: eventId, display_order: 1 });
|
||||
await insertCat('Second', { event_id: eventId, display_order: 2 });
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories'))
|
||||
.send({ name: 'Third', is_global: false, event_id: eventId })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.display_order).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* CRM mint-and-send paths — integration tests (#587).
|
||||
*
|
||||
* Pins the three document "mint" flows end-to-end through the real
|
||||
* HTTP → route → service → DB → email-queue → file pipeline:
|
||||
*
|
||||
* 1. POST /api/admin/quotes/:id/send (draft → sent + PDF + token + email)
|
||||
* 2. POST /api/admin/invoices/:id/cancel (issued → cancelled + Storno row)
|
||||
* — the issue spec named this /:id/storno; the real route is
|
||||
* /:id/cancel (invoiceService.cancelInvoice → createStorno).
|
||||
* 3. POST /api/admin/contracts/:id/countersign
|
||||
* (signed_by_customer → fully_signed + stamped PDF + sha256 + email)
|
||||
*
|
||||
* Real SQLite with the full core-migration run (helpers/crmDb), real
|
||||
* pdfkit/pdf-lib rendering — no mock-fs, no network.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
// Full migration run + cold-requiring pdfService/emailProcessor is slow
|
||||
// under CI load; match the other CRM integration suites.
|
||||
jest.setTimeout(120000);
|
||||
|
||||
const CUSTOMER_EMAIL = 'customer@example.com';
|
||||
|
||||
// 1x1 transparent PNG — smallest valid signature pad output.
|
||||
const SIGNATURE_DATA_URL = 'data:image/png;base64,'
|
||||
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
|
||||
// SQLite round-trips dates inconsistently (epoch ms number, numeric
|
||||
// string, or ISO string) — parse robustly before comparing.
|
||||
const toMillis = (v) => {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'string' && /^\d+$/.test(v)) return Number(v);
|
||||
return Date.parse(v);
|
||||
};
|
||||
|
||||
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
||||
|
||||
// Count embedded image XObjects per page via pdf-lib — used to prove BOTH
|
||||
// signature stamps (customer + admin) made it into the final PDF instead of
|
||||
// only asserting file existence/hash (codex review of #850 round 2).
|
||||
async function countImagesPerPage(pdfPath) {
|
||||
const { PDFDocument, PDFName, PDFDict } = require('pdf-lib');
|
||||
const doc = await PDFDocument.load(fs.readFileSync(pdfPath));
|
||||
return doc.getPages().map((page) => {
|
||||
const resources = page.node.Resources();
|
||||
const xobjects = resources && resources.lookupMaybe(PDFName.of('XObject'), PDFDict);
|
||||
if (!xobjects) return 0;
|
||||
let images = 0;
|
||||
for (const [, ref] of xobjects.entries()) {
|
||||
const stream = page.doc.context.lookup(ref);
|
||||
const subtype = stream && stream.dict && stream.dict.get(PDFName.of('Subtype'));
|
||||
if (subtype && subtype.toString() === '/Image') images += 1;
|
||||
}
|
||||
return images;
|
||||
});
|
||||
}
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
// Real (symlink-resolved) storage root — on macOS os.tmpdir() returns
|
||||
// /var/... while the services persist under process.cwd() which
|
||||
// resolves to /private/var/....
|
||||
let storageRoot;
|
||||
let adminId;
|
||||
let customerId;
|
||||
let token;
|
||||
let quoteApp;
|
||||
let invoiceApp;
|
||||
let contractApp;
|
||||
let quoteService;
|
||||
let invoiceService;
|
||||
let contractService;
|
||||
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
const auth = { get Authorization() { return `Bearer ${token}`; } };
|
||||
|
||||
async function enableFlag(key) {
|
||||
const updated = await db('feature_flags').where({ key }).update({ value: true });
|
||||
if (!updated) await db('feature_flags').insert({ key, value: true });
|
||||
}
|
||||
|
||||
// ----- per-path seed helpers -----------------------------------------
|
||||
|
||||
async function seedQuote() {
|
||||
const id = await quoteService.createQuote({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
eventName: 'Testshooting',
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Photo package', unit_price_minor: 150000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function seedIssuedInvoice(status = 'sent') {
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 7.7,
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Wedding coverage', unit_price_minor: 200000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId);
|
||||
const id = invoiceIds[0];
|
||||
// Fast-forward past the send step — Storno only applies to issued
|
||||
// documents (sent/paid/overdue), and rendering+sending the original
|
||||
// is covered by the quote path already.
|
||||
await db('invoices').where({ id }).update({
|
||||
status, sent_at: new Date(), updated_at: new Date(),
|
||||
});
|
||||
return db('invoices').where({ id }).first();
|
||||
}
|
||||
|
||||
async function seedCustomerSignedContract() {
|
||||
const id = await contractService.createContract({
|
||||
customerAccountId: customerId,
|
||||
title: 'Fotografie-Vertrag',
|
||||
}, adminId);
|
||||
// Real send + customer-sign flow (codex review of #850): a direct
|
||||
// status UPDATE skipped the customer's signature asset and stamped
|
||||
// PDF, so countersign exercised its unsigned-PDF fallback and a
|
||||
// regression dropping the customer's signature would stay green.
|
||||
const { token } = await contractService.sendContract(id, adminId);
|
||||
await contractService.recordCustomerSignature({
|
||||
token,
|
||||
name: 'Custo Mer',
|
||||
ip: '127.0.0.1',
|
||||
signatureDataUrl: SIGNATURE_DATA_URL,
|
||||
accepted: true,
|
||||
});
|
||||
return db('contracts').where({ id }).first();
|
||||
}
|
||||
|
||||
// ----- suite ----------------------------------------------------------
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
// Business-doc PDFs (quotes/invoices/contracts) persist under
|
||||
// `getStoragePath()/business-docs/...`, and safePath also allows a
|
||||
// `process.cwd()/storage/business-docs/...` root — chdir into the temp
|
||||
// dir so every test artifact lands isolated and gets cleaned up.
|
||||
process.chdir(tmpDir);
|
||||
// Mirror what the services store: the raw STORAGE_PATH bootCrmDb
|
||||
// exported, NOT a symlink-resolved variant. On macOS os.tmpdir() is
|
||||
// /var/... while realpath is /private/var/..., so canonicalizing here
|
||||
// would make every stored path fail the prefix check.
|
||||
storageRoot = path.join(process.env.STORAGE_PATH, 'business-docs');
|
||||
|
||||
// Fail-fast on the pre-existing logActivity-inside-transaction
|
||||
// deadlock: createContract and createStorno call logActivity() from
|
||||
// inside a knex transaction WITHOUT passing the trx as executor, so
|
||||
// the audit insert tries to grab a second connection from the
|
||||
// single-connection SQLite pool while the trx holds it. In
|
||||
// production that stalls each call for the full 60 s acquire
|
||||
// timeout (the error is then swallowed by logActivity's catch);
|
||||
// here we shrink the timeout so the same swallowed failure costs
|
||||
// 2 s instead of blowing the per-test budget. Behaviour under test
|
||||
// is unchanged — the mint paths themselves never wait on this.
|
||||
db.client.pool.acquireTimeoutMillis = 2000;
|
||||
|
||||
// node-sqlite3 detects Date bind params via `InstanceOf(global.Date)`
|
||||
// against the NATIVE realm's Date — under jest's vm sandbox the
|
||||
// service code's `new Date()` is a different constructor, the check
|
||||
// fails, and the value stringifies to the literal "[object Object]"
|
||||
// (the exact pathology helpers/crmDb.js documents for
|
||||
// createPublicToken). Normalize Date bindings to ISO strings before
|
||||
// they reach the driver so the real service inserts round-trip the
|
||||
// same way they do outside jest.
|
||||
// Patch on the prototype — knex mints transaction clients via
|
||||
// Object.create(prototype), so an instance-level patch would miss
|
||||
// every query issued inside a db.transaction().
|
||||
const clientProto = Object.getPrototypeOf(db.client);
|
||||
const origQuery = clientProto._query;
|
||||
clientProto._query = function patchedQuery(connection, obj) {
|
||||
if (obj && Array.isArray(obj.bindings)) {
|
||||
obj.bindings = obj.bindings.map(
|
||||
(b) => (b && typeof b === 'object' && typeof b.toISOString === 'function' ? b.toISOString() : b),
|
||||
);
|
||||
}
|
||||
return origQuery.call(this, connection, obj);
|
||||
};
|
||||
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
// CRM surfaces are feature-flagged; migration 107 seeds them OFF.
|
||||
await enableFlag('quotes');
|
||||
await enableFlag('bills');
|
||||
await enableFlag('contracts');
|
||||
|
||||
quoteService = require('../../src/services/quoteService');
|
||||
invoiceService = require('../../src/services/invoiceService');
|
||||
contractService = require('../../src/services/contractService');
|
||||
|
||||
quoteApp = buildRouteApp('/api/admin/quotes', require('../../src/routes/adminQuotes'));
|
||||
invoiceApp = buildRouteApp('/api/admin/invoices', require('../../src/routes/adminInvoices'));
|
||||
contractApp = buildRouteApp('/api/admin/contracts', require('../../src/routes/adminContracts'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
process.chdir(prevCwd);
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('POST /api/admin/quotes/:id/send', () => {
|
||||
test('draft quote: 200 → sent + sent_at + PDF on disk + action token + quote_sent email', async () => {
|
||||
const quoteId = await seedQuote();
|
||||
await db('email_queue').del();
|
||||
|
||||
const res = await request(quoteApp)
|
||||
.post(`/api/admin/quotes/${quoteId}/send`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.sent).toBe(true);
|
||||
expect(res.body.token).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
// DB state
|
||||
const quote = await db('quotes').where({ id: quoteId }).first();
|
||||
expect(quote.status).toBe('sent');
|
||||
expect(quote.sent_at).toBeTruthy();
|
||||
|
||||
// PDF persisted inside the isolated storage root
|
||||
expect(quote.pdf_path).toBeTruthy();
|
||||
expect(quote.pdf_path.startsWith(path.join(storageRoot, 'quote'))).toBe(true);
|
||||
expect(fs.existsSync(quote.pdf_path)).toBe(true);
|
||||
expect(fs.statSync(quote.pdf_path).size).toBeGreaterThan(0);
|
||||
|
||||
// Action token row: right quote, future expiry
|
||||
const tokenRow = await db('quote_action_tokens').where({ token: res.body.token }).first();
|
||||
expect(tokenRow).toBeTruthy();
|
||||
expect(tokenRow.quote_id).toBe(quoteId);
|
||||
expect(toMillis(tokenRow.expires_at)).toBeGreaterThan(Date.now());
|
||||
|
||||
// Email queued to the customer's primary address
|
||||
const emails = await db('email_queue').where({ email_type: 'quote_sent' });
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0].recipient_email).toBe(CUSTOMER_EMAIL);
|
||||
const emailData = JSON.parse(emails[0].email_data);
|
||||
expect(emailData.quote_number).toBe(quote.quote_number);
|
||||
});
|
||||
|
||||
test('already-sent quote: 409 (spec said 400; service throws 409)', async () => {
|
||||
const quoteId = await seedQuote();
|
||||
await request(quoteApp).post(`/api/admin/quotes/${quoteId}/send`).set(auth).expect(200);
|
||||
|
||||
const res = await request(quoteApp)
|
||||
.post(`/api/admin/quotes/${quoteId}/send`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/cannot send a quote with status 'sent'/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/invoices/:id/cancel (Storno mint)', () => {
|
||||
test('sent invoice: original cancelled, Storno row minted with negated totals + lineage', async () => {
|
||||
const original = await seedIssuedInvoice('sent');
|
||||
await db('email_queue').del();
|
||||
|
||||
const res = await request(invoiceApp)
|
||||
.post(`/api/admin/invoices/${original.id}/cancel`)
|
||||
.set(auth);
|
||||
// Route responds via successResponse default — 200, not the 201
|
||||
// the issue spec assumed.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cancelled).toBe(true);
|
||||
expect(res.body.stornoId).toBeGreaterThan(0);
|
||||
|
||||
const storno = await db('invoices').where({ id: res.body.stornoId }).first();
|
||||
expect(storno.kind).toBe('storno');
|
||||
expect(storno.cancels_invoice_id).toBe(original.id);
|
||||
expect(storno.deal_uuid).toBe(original.deal_uuid);
|
||||
|
||||
// Negated amounts
|
||||
expect(storno.net_amount_minor).toBe(-original.net_amount_minor);
|
||||
expect(storno.vat_amount_minor).toBe(-original.vat_amount_minor);
|
||||
expect(storno.total_amount_minor).toBe(-original.total_amount_minor);
|
||||
|
||||
// Freshly sequenced number from the same series
|
||||
expect(typeof storno.invoice_number).toBe('string');
|
||||
expect(storno.invoice_number.length).toBeGreaterThan(0);
|
||||
expect(storno.invoice_number).not.toBe(original.invoice_number);
|
||||
|
||||
// Line items snapshotted onto the Storno
|
||||
const originalItems = await db('invoice_line_items').where({ invoice_id: original.id });
|
||||
const stornoItems = await db('invoice_line_items').where({ invoice_id: storno.id });
|
||||
expect(stornoItems).toHaveLength(originalItems.length);
|
||||
|
||||
// Original flipped + back-linked
|
||||
const refreshed = await db('invoices').where({ id: original.id }).first();
|
||||
expect(refreshed.status).toBe('cancelled');
|
||||
expect(refreshed.cancellation_storno_id).toBe(storno.id);
|
||||
|
||||
// sendStorno side effects (codex review of #850): cancelInvoice
|
||||
// swallows a sendStorno failure by design, so without these
|
||||
// assertions a broken render/persist/queue leg would stay green.
|
||||
const sentStorno = await db('invoices').where({ id: storno.id }).first();
|
||||
expect(sentStorno.status).toBe('sent');
|
||||
expect(sentStorno.pdf_path).toBeTruthy();
|
||||
expect(fs.existsSync(sentStorno.pdf_path)).toBe(true);
|
||||
const stornoEmails = await db('email_queue').where({ email_type: 'storno_issued' });
|
||||
expect(stornoEmails.length).toBeGreaterThanOrEqual(1);
|
||||
expect(stornoEmails[0].recipient_email).toBe(CUSTOMER_EMAIL);
|
||||
});
|
||||
|
||||
test('paid invoice can be cancelled via Storno too (refund document leg)', async () => {
|
||||
const original = await seedIssuedInvoice('paid');
|
||||
|
||||
const res = await request(invoiceApp)
|
||||
.post(`/api/admin/invoices/${original.id}/cancel`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.stornoId).toBeGreaterThan(0);
|
||||
|
||||
const refreshed = await db('invoices').where({ id: original.id }).first();
|
||||
expect(refreshed.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
test('already-cancelled invoice: 409 ALREADY_CANCELLED', async () => {
|
||||
const original = await seedIssuedInvoice('sent');
|
||||
await request(invoiceApp).post(`/api/admin/invoices/${original.id}/cancel`).set(auth).expect(200);
|
||||
|
||||
const res = await request(invoiceApp)
|
||||
.post(`/api/admin/invoices/${original.id}/cancel`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('ALREADY_CANCELLED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/contracts/:id/countersign', () => {
|
||||
test('customer-signed contract: 200 → fully_signed + stamped PDF + sha256 + signature asset + email with attachment', async () => {
|
||||
const contract = await seedCustomerSignedContract();
|
||||
await db('email_queue').del();
|
||||
|
||||
const res = await request(contractApp)
|
||||
.post(`/api/admin/contracts/${contract.id}/countersign`)
|
||||
.set(auth)
|
||||
.send({ name: 'Admin Tester', signatureDataUrl: SIGNATURE_DATA_URL });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('fully_signed');
|
||||
|
||||
const row = await db('contracts').where({ id: contract.id }).first();
|
||||
expect(row.status).toBe('fully_signed');
|
||||
expect(row.signed_admin_name).toBe('Admin Tester');
|
||||
expect(row.signed_by_admin_at).toBeTruthy();
|
||||
|
||||
// The customer's own signature (from the real sign flow in the seed)
|
||||
// must survive countersigning — layered, not replaced.
|
||||
expect(row.signed_customer_signature_path).toBeTruthy();
|
||||
expect(fs.existsSync(row.signed_customer_signature_path)).toBe(true);
|
||||
expect(row.signed_customer_name).toBe('Custo Mer');
|
||||
|
||||
// Admin signature image persisted under the storage root
|
||||
expect(row.signed_admin_signature_path).toBeTruthy();
|
||||
expect(row.signed_admin_signature_path.startsWith(
|
||||
path.join(storageRoot, 'contract', 'signatures'),
|
||||
)).toBe(true);
|
||||
expect(fs.existsSync(row.signed_admin_signature_path)).toBe(true);
|
||||
|
||||
// Stamped, fully-signed PDF written and hashed. The issue spec
|
||||
// called this `integrity_hash`; the real column is
|
||||
// `signed_pdf_sha256` (plus `pdf_sha256` for the unsigned base).
|
||||
expect(row.signed_pdf_render_failed_at).toBeFalsy();
|
||||
expect(row.signed_pdf_path).toBeTruthy();
|
||||
expect(fs.existsSync(row.signed_pdf_path)).toBe(true);
|
||||
expect(row.signed_pdf_sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(sha256(fs.readFileSync(row.signed_pdf_path))).toBe(row.signed_pdf_sha256);
|
||||
|
||||
// BOTH stamps must be embedded in the final document — a regression
|
||||
// stamping the admin onto the unsigned base PDF would keep every
|
||||
// path/hash assertion above green (codex review of #850 round 2).
|
||||
const imagesPerPage = await countImagesPerPage(row.signed_pdf_path);
|
||||
const maxImagesOnAPage = Math.max(...imagesPerPage);
|
||||
expect(maxImagesOnAPage).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// contract_fully_signed email to the customer's primary address,
|
||||
// carrying the signed PDF as attachment (plus the audit cert).
|
||||
const emails = await db('email_queue').where({ email_type: 'contract_fully_signed' });
|
||||
const customerCopy = emails.find((e) => e.recipient_email === CUSTOMER_EMAIL);
|
||||
expect(customerCopy).toBeTruthy();
|
||||
const emailData = JSON.parse(customerCopy.email_data);
|
||||
expect(emailData.contract_number).toBe(contract.contract_number);
|
||||
expect(Array.isArray(emailData.attachments)).toBe(true);
|
||||
const pdfAttachment = emailData.attachments.find(
|
||||
(a) => a.filename === `${contract.contract_number}-signed.pdf`,
|
||||
);
|
||||
expect(pdfAttachment).toBeTruthy();
|
||||
expect(pdfAttachment.contentType).toBe('application/pdf');
|
||||
expect(fs.existsSync(pdfAttachment.contentPath)).toBe(true);
|
||||
});
|
||||
|
||||
test('draft contract: 409 — countersign requires sent/signed_by_customer', async () => {
|
||||
const draftId = await contractService.createContract({
|
||||
customerAccountId: customerId,
|
||||
title: 'Noch nicht versendet',
|
||||
}, adminId);
|
||||
|
||||
const res = await request(contractApp)
|
||||
.post(`/api/admin/contracts/${draftId}/countersign`)
|
||||
.set(auth)
|
||||
.send({ name: 'Admin Tester' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/cannot counter-sign a contract with status 'draft'/i);
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* 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(120000);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* Download resolutions (#858).
|
||||
*
|
||||
* Pins the contracts that are easy to break later:
|
||||
*
|
||||
* - the global → per-event cascade, including NULL = inherit
|
||||
* - the picker never offers a size ABOVE the standard (a photographer who
|
||||
* lowers the standard is not silently handing out full-res), and 'Original'
|
||||
* only reappears when the admin explicitly allows it
|
||||
* - `fit: 'inside'` + no-upscaling resize semantics, which is exactly what
|
||||
* the requester asked for on the issue
|
||||
* - a guest-supplied resolution is validated against the policy rather than
|
||||
* trusted
|
||||
*/
|
||||
|
||||
const sharp = require('sharp');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Both modules under test pull in src/database/db.js transitively. bootCrmDb
|
||||
// only works when it runs BEFORE the first require of db.js (it sets
|
||||
// TEST_DATABASE_PATH, which knexfile reads at module-init time), so these are
|
||||
// required lazily in beforeAll rather than at module scope — otherwise knex
|
||||
// binds to the shared default SQLite file and every run after the first one
|
||||
// fails with "table `migrations` already exists".
|
||||
let resolveEventDownloadPolicy;
|
||||
let pickRequestedResolution;
|
||||
let parseResolution;
|
||||
let invalidateDownloadGlobals;
|
||||
let resizeToBox;
|
||||
|
||||
describe('Download resolutions (#858)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
const setGlobal = async (key, value) => {
|
||||
await db('app_settings').where({ setting_key: key }).del();
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'download',
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
invalidateDownloadGlobals();
|
||||
};
|
||||
|
||||
const PRESETS = [
|
||||
{ label: 'Large', width: 3000, height: 2000 },
|
||||
{ label: 'Medium', width: 1500, height: 1000 },
|
||||
{ label: 'Small', width: 800, height: 600 },
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
({
|
||||
resolveEventDownloadPolicy,
|
||||
pickRequestedResolution,
|
||||
parseResolution,
|
||||
invalidateDownloadGlobals,
|
||||
} = require('../../src/utils/downloadResolutions'));
|
||||
({ resizeToBox } = require('../../src/services/imageProcessor'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await setGlobal('download_resolutions', PRESETS);
|
||||
await setGlobal('download_standard_resolution', 'original');
|
||||
await setGlobal('download_resolution_picker_enabled', false);
|
||||
await setGlobal('download_allow_original', false);
|
||||
});
|
||||
|
||||
describe('cascade', () => {
|
||||
it('inherits the global standard when the event has no override', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({ download_standard_resolution: null });
|
||||
expect(policy.standard).toBe('1500x1000');
|
||||
expect(policy.standardBox).toEqual({ width: 1500, height: 1000 });
|
||||
});
|
||||
|
||||
it('lets an event override the global standard', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({ download_standard_resolution: '800x600' });
|
||||
expect(policy.standard).toBe('800x600');
|
||||
});
|
||||
|
||||
it('treats a NULL picker flag as inherit and an explicit false as override', async () => {
|
||||
await setGlobal('download_resolution_picker_enabled', true);
|
||||
expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: null })).pickerEnabled).toBe(true);
|
||||
expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: false })).pickerEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('choice list', () => {
|
||||
it('never offers a size larger than the standard', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices.map((c) => c.id)).toEqual(['1500x1000', '800x600']);
|
||||
// The regression that matters: 3000x2000 must not be reachable.
|
||||
expect(choices.some((c) => c.id === '3000x2000')).toBe(false);
|
||||
});
|
||||
|
||||
it('bounds EACH dimension, not the pixel area (codex review round 2)', async () => {
|
||||
// 2000x700 is 1.4MP — under 1500x1000's 1.5MP — so an area comparison
|
||||
// would offer it and hand back a 2000px-wide file despite a 1500px cap.
|
||||
await setGlobal('download_resolutions', [
|
||||
...PRESETS,
|
||||
{ label: 'Wide', width: 2000, height: 700 },
|
||||
]);
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices.some((c) => c.id === '2000x700')).toBe(false);
|
||||
});
|
||||
|
||||
it('omits Original when the standard is capped and the admin has not allowed it', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices.some((c) => c.id === 'original')).toBe(false);
|
||||
});
|
||||
|
||||
it('re-adds Original when the admin explicitly allows it', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
await setGlobal('download_allow_original', true);
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices[0].id).toBe('original');
|
||||
});
|
||||
|
||||
it('offers Original when the standard already is original', async () => {
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices[0].id).toBe('original');
|
||||
expect(choices.map((c) => c.id)).toContain('3000x2000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request validation', () => {
|
||||
it('falls back to the standard when nothing is requested', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({});
|
||||
expect(pickRequestedResolution(policy, undefined)).toBe('1500x1000');
|
||||
});
|
||||
|
||||
it('refuses any explicit request while the picker is off', async () => {
|
||||
const policy = await resolveEventDownloadPolicy({});
|
||||
expect(policy.pickerEnabled).toBe(false);
|
||||
expect(pickRequestedResolution(policy, '800x600')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a size that is not on the offered list', async () => {
|
||||
await setGlobal('download_resolution_picker_enabled', true);
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({});
|
||||
// Above the standard → not offered → rejected rather than silently served.
|
||||
expect(pickRequestedResolution(policy, '3000x2000')).toBeNull();
|
||||
expect(pickRequestedResolution(policy, '9999x9999')).toBeNull();
|
||||
expect(pickRequestedResolution(policy, '800x600')).toBe('800x600');
|
||||
});
|
||||
|
||||
it('parses only well-formed resolution ids', () => {
|
||||
expect(parseResolution('original')).toBeNull();
|
||||
expect(parseResolution(null)).toBeNull();
|
||||
expect(parseResolution('abc')).toBeNull();
|
||||
expect(parseResolution('0x0')).toBeNull();
|
||||
expect(parseResolution('1500x1000')).toEqual({ width: 1500, height: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('job dedup identity (codex review round 1)', () => {
|
||||
// The leak this pins: a PIN client's archive contains hidden photos. If the
|
||||
// dedup key ignored the visibility scope, a guest asking for the same size
|
||||
// would be handed the client's job token — and the delivery route only
|
||||
// checked the event id.
|
||||
let jobService;
|
||||
|
||||
beforeAll(() => {
|
||||
jobService = require('../../src/services/downloadJobService');
|
||||
});
|
||||
|
||||
it('separates client and guest archives of the same size and photo set', () => {
|
||||
const guest = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public');
|
||||
const client = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'hidden');
|
||||
expect(guest).not.toBe(client);
|
||||
});
|
||||
|
||||
it('keys on the RESOLVED photo set, so a stale archive is not reused', () => {
|
||||
const before = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public');
|
||||
const afterUpload = jobService.dedupKey(1, '1500x1000', [1, 2, 3, 4], false, 'public');
|
||||
const afterHide = jobService.dedupKey(1, '1500x1000', [1, 2], false, 'public');
|
||||
expect(new Set([before, afterUpload, afterHide]).size).toBe(3);
|
||||
});
|
||||
|
||||
it('is order-independent for the same set', () => {
|
||||
expect(jobService.dedupKey(1, 'original', [3, 1, 2], true, 'public'))
|
||||
.toBe(jobService.dedupKey(1, 'original', [1, 2, 3], true, 'public'));
|
||||
});
|
||||
|
||||
it('maps access levels onto the two visibility scopes', () => {
|
||||
expect(jobService.visibilityScopeFor('client')).toBe('hidden');
|
||||
expect(jobService.visibilityScopeFor('guest')).toBe('public');
|
||||
expect(jobService.visibilityScopeFor(undefined)).toBe('public');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resize semantics', () => {
|
||||
const make = (w, h) => sharp({
|
||||
create: { width: w, height: h, channels: 3, background: { r: 10, g: 100, b: 200 } },
|
||||
}).jpeg().toBuffer();
|
||||
|
||||
const box = { width: 1500, height: 1000 };
|
||||
|
||||
it('fits a 3:2 photo exactly into a 3:2 box', async () => {
|
||||
const out = await sharp(await resizeToBox(await make(6000, 4000), box)).metadata();
|
||||
expect([out.width, out.height]).toEqual([1500, 1000]);
|
||||
});
|
||||
|
||||
it('treats the box as an "up to" bound for other aspect ratios', async () => {
|
||||
// Portrait: height is the binding edge, width comes out smaller.
|
||||
const portrait = await sharp(await resizeToBox(await make(4000, 6000), box)).metadata();
|
||||
expect(portrait.height).toBe(1000);
|
||||
expect(portrait.width).toBeLessThan(1500);
|
||||
|
||||
const fourThree = await sharp(await resizeToBox(await make(4000, 3000), box)).metadata();
|
||||
expect(fourThree.height).toBe(1000);
|
||||
expect(fourThree.width).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
it('never upscales an image already smaller than the box', async () => {
|
||||
const out = await sharp(await resizeToBox(await make(800, 600), box)).metadata();
|
||||
expect([out.width, out.height]).toEqual([800, 600]);
|
||||
});
|
||||
|
||||
it('passes the buffer through untouched for the original size', async () => {
|
||||
const src = await make(4000, 3000);
|
||||
expect(await resizeToBox(src, null)).toBe(src);
|
||||
});
|
||||
|
||||
it('keeps the source format so the filename and mime type stay honest', async () => {
|
||||
// A .gif re-encoded as JPEG would ship mislabelled bytes, since the
|
||||
// download routes keep the original filename and mime type.
|
||||
const gif = await sharp({
|
||||
create: { width: 4000, height: 3000, channels: 3, background: { r: 1, g: 2, b: 3 } },
|
||||
}).gif().toBuffer();
|
||||
const out = await sharp(await resizeToBox(gif, box)).metadata();
|
||||
expect(out.format).toBe('gif');
|
||||
expect(out.width).toBe(1333);
|
||||
});
|
||||
|
||||
it('returns the input rather than throwing on an undecodable source', async () => {
|
||||
const junk = Buffer.from('not an image');
|
||||
expect(await resizeToBox(junk, box)).toBe(junk);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
/**
|
||||
* Global email footer signature (migration 198, issue #1264).
|
||||
*
|
||||
* The signature is rendered by `wrapEmailHtml` and nowhere else, which is
|
||||
* the whole point of the design: every template, preview, test mail and
|
||||
* manual send passes through that one wrapper, so none of them needed a
|
||||
* per-template change. These tests pin that contract at the wrapper.
|
||||
*
|
||||
* The load-bearing case is the DISABLED one — an upgraded install must keep
|
||||
* a byte-identical footer until an admin opts in.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('wrapEmailHtml — business-profile signature footer', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let wrapEmailHtml;
|
||||
let renderEmailSignatureText;
|
||||
let businessProfileService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ wrapEmailHtml, renderEmailSignatureText } = require('../../src/services/emailProcessor'));
|
||||
businessProfileService = require('../../src/services/businessProfileService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('business_profile').where({ id: 1 }).update({
|
||||
company_name: null,
|
||||
address_line1: null,
|
||||
address_line2: null,
|
||||
postal_code: null,
|
||||
city: null,
|
||||
country_code: null,
|
||||
country_name: null,
|
||||
phone: null,
|
||||
mobile: null,
|
||||
email: null,
|
||||
website: null,
|
||||
vat_id: null,
|
||||
email_signature_enabled: false,
|
||||
email_signature_extra: null,
|
||||
});
|
||||
businessProfileService.invalidateEmailSignatureCache();
|
||||
});
|
||||
|
||||
const fullProfile = {
|
||||
company_name: 'Müller Fotografie GmbH',
|
||||
address_line1: 'Bahnhofstrasse 1',
|
||||
address_line2: 'Postfach 42',
|
||||
postal_code: '9494',
|
||||
city: 'Schaan',
|
||||
country_code: 'li',
|
||||
country_name: 'Liechtenstein',
|
||||
phone: '+41 79 123 45 67',
|
||||
mobile: '+41 78 000 11 22',
|
||||
email: 'hello@example.com',
|
||||
website: 'example.com',
|
||||
vat_id: 'CHE-123.456.789',
|
||||
email_signature_enabled: true,
|
||||
email_signature_extra: 'Handelsregister Vaduz\nFL-0002.123.456-7',
|
||||
};
|
||||
|
||||
async function enable(overrides = {}) {
|
||||
await db('business_profile').where({ id: 1 }).update({ ...fullProfile, ...overrides });
|
||||
businessProfileService.invalidateEmailSignatureCache();
|
||||
}
|
||||
|
||||
it('renders nothing extra when the toggle is off', async () => {
|
||||
// Profile fully populated, signature switched OFF: the operator's
|
||||
// address must not leak into mail just because they filled in the
|
||||
// invoice issuer block.
|
||||
await enable({ email_signature_enabled: false });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).not.toContain('Bahnhofstrasse 1');
|
||||
expect(html).not.toContain('hello@example.com');
|
||||
expect(html).not.toContain('CHE-123.456.789');
|
||||
});
|
||||
|
||||
it('produces a byte-identical footer to a no-profile install when disabled', async () => {
|
||||
const withEmptyProfile = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
await enable({ email_signature_enabled: false });
|
||||
const withDisabledSignature = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(withDisabledSignature).toBe(withEmptyProfile);
|
||||
});
|
||||
|
||||
it('renders address, contacts, VAT id and the legal line when enabled', async () => {
|
||||
await enable();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).toContain('Müller Fotografie GmbH');
|
||||
expect(html).toContain('Bahnhofstrasse 1');
|
||||
expect(html).toContain('Postfach 42');
|
||||
// "LI-9494 Schaan / Liechtenstein" — same shape as the PDF issuer block.
|
||||
expect(html).toContain('LI-9494 Schaan / Liechtenstein');
|
||||
expect(html).toContain('VAT ID: CHE-123.456.789');
|
||||
expect(html).toContain('Handelsregister Vaduz<br />FL-0002.123.456-7');
|
||||
});
|
||||
|
||||
it('links phone, mobile, email and website with safe schemes', async () => {
|
||||
await enable();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
// Separators stripped from the tel: href, kept in the visible text.
|
||||
expect(html).toContain('href="tel:+41791234567"');
|
||||
expect(html).toContain('href="tel:+41780001122"');
|
||||
expect(html).toContain('href="mailto:hello@example.com"');
|
||||
// A bare hostname is promoted to https:// rather than left relative.
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('keeps an already-absolute website URL as typed', async () => {
|
||||
await enable({ website: 'http://legacy.example.org/studio' });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).toContain('href="http://legacy.example.org/studio"');
|
||||
});
|
||||
|
||||
it('neutralises a javascript: website into an inert https URL', async () => {
|
||||
await enable({ website: 'javascript:alert(1)' });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).not.toContain('href="javascript:');
|
||||
expect(html).toContain('href="https://javascript:alert(1)"');
|
||||
});
|
||||
|
||||
it('HTML-escapes every signature field', async () => {
|
||||
await enable({
|
||||
company_name: '<script>alert(1)</script>',
|
||||
address_line1: 'Rue "des" Fleurs & Co',
|
||||
vat_id: '<img src=x onerror=alert(1)>',
|
||||
email_signature_extra: '</p><script>alert(2)</script>',
|
||||
});
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
// Escaped, so the markup is inert text — the tags never open.
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('<script>alert(2)</script>');
|
||||
expect(html).not.toContain('<img src=x');
|
||||
expect(html).toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
||||
expect(html).toContain('Rue "des" Fleurs & Co');
|
||||
});
|
||||
|
||||
it('does not repeat the branding company name', async () => {
|
||||
// Footer already prints the branding name; the profile name is only
|
||||
// added when the operator gave a different legal name.
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'branding_company_name', setting_value: JSON.stringify('Müller Fotografie GmbH'), setting_type: 'branding' })
|
||||
.onConflict('setting_key')
|
||||
.merge();
|
||||
await enable();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html.match(/Müller Fotografie GmbH/g).length).toBe(
|
||||
// header alt, footer alt, footer name line, copyright line — the
|
||||
// signature must not add a fifth.
|
||||
(await wrapEmailHtml('<p>Body</p>', 'Subject', 'en')).match(/Müller Fotografie GmbH/g).length
|
||||
);
|
||||
expect(html.match(/Müller Fotografie GmbH/g).length).toBe(4);
|
||||
|
||||
await db('app_settings').where({ setting_key: 'branding_company_name' }).del();
|
||||
});
|
||||
|
||||
it('uses the German VAT label for a German mail', async () => {
|
||||
await enable();
|
||||
|
||||
const de = await wrapEmailHtml('<p>Body</p>', 'Subject', 'de');
|
||||
const en = await wrapEmailHtml('<p>Body</p>', 'Subject', 'en');
|
||||
|
||||
expect(de).toContain('USt-IdNr.: CHE-123.456.789');
|
||||
expect(en).toContain('VAT ID: CHE-123.456.789');
|
||||
});
|
||||
|
||||
it('omits empty fields instead of rendering blank rows', async () => {
|
||||
await enable({
|
||||
address_line2: null, mobile: null, website: null, vat_id: null, email_signature_extra: null,
|
||||
});
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).toContain('hello@example.com');
|
||||
expect(html).not.toContain('VAT ID:');
|
||||
expect(html).not.toMatch(/·\s*·/);
|
||||
});
|
||||
|
||||
it('renders no signature block when enabled but the profile is blank', async () => {
|
||||
await db('business_profile').where({ id: 1 }).update({ email_signature_enabled: true });
|
||||
businessProfileService.invalidateEmailSignatureCache();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
// The signature <div> is the only element carrying this margin.
|
||||
expect(html).not.toContain('<div style="margin:15px 0 5px;');
|
||||
});
|
||||
|
||||
// Codex review: sendTemplateEmail uses a template's own body_text when it
|
||||
// has one — every seeded template does — so the text/plain alternative is
|
||||
// NOT derived from the wrapped HTML and carried no signature at all.
|
||||
describe('plain-text alternative', () => {
|
||||
it('renders the signature as plain text', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
const text = renderEmailSignatureText(signature, { brandingCompanyName: 'PicPeak', language: 'en' });
|
||||
|
||||
expect(text).toContain('Bahnhofstrasse 1');
|
||||
expect(text).toContain('hello@example.com');
|
||||
expect(text).toContain('VAT ID: CHE-123.456.789');
|
||||
expect(text).toContain('Handelsregister Vaduz');
|
||||
// A separator, the text equivalent of the footer's top border.
|
||||
expect(text).toMatch(/^\n\n--\n/);
|
||||
});
|
||||
|
||||
it('carries no HTML markup or entities', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
const text = renderEmailSignatureText(signature, { brandingCompanyName: 'PicPeak', language: 'en' });
|
||||
|
||||
expect(text).not.toContain('<');
|
||||
expect(text).not.toContain('·');
|
||||
expect(text).not.toContain('&');
|
||||
});
|
||||
|
||||
it('is empty when the signature is disabled', () => {
|
||||
expect(renderEmailSignatureText(null, {})).toBe('');
|
||||
});
|
||||
|
||||
it('uses the German VAT label for a German mail', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
expect(renderEmailSignatureText(signature, { language: 'de' })).toContain('USt-IdNr.');
|
||||
expect(renderEmailSignatureText(signature, { language: 'en' })).toContain('VAT ID');
|
||||
});
|
||||
|
||||
it('does not repeat the branding company name', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
const text = renderEmailSignatureText(signature, {
|
||||
brandingCompanyName: 'Müller Fotografie GmbH', language: 'en',
|
||||
});
|
||||
expect(text).not.toContain('Müller Fotografie GmbH');
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the plain-text part free of signature markup', async () => {
|
||||
const { htmlToText } = require('../../src/services/emailProcessor');
|
||||
await enable();
|
||||
|
||||
const text = htmlToText(await wrapEmailHtml('<p>Body</p>', 'Subject'));
|
||||
|
||||
expect(text).toContain('Bahnhofstrasse 1');
|
||||
expect(text).not.toContain('<p');
|
||||
expect(text).not.toContain('style=');
|
||||
expect(text).not.toContain('·');
|
||||
// The separator survives as a real character, not an entity.
|
||||
expect(text).toContain('Bahnhofstrasse 1 \u00b7 Postfach 42');
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Catalog-driven event-type defaults (#800 follow-up).
|
||||
*
|
||||
* The contract→event conversion used to hardcode `event_type: 'wedding'` and
|
||||
* the v1 API validated against a fixed whitelist. Both now follow the live
|
||||
* event_types catalog; these tests pin the shared resolver.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('resolveDefaultEventType follows the catalog', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventTypeService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Require AFTER bootCrmDb so the service shares this db instance
|
||||
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it("prefers the 'other' catch-all while it is active", async () => {
|
||||
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
|
||||
});
|
||||
|
||||
it('falls over to the first active type when other is deactivated', async () => {
|
||||
const other = await db('event_types').where({ slug_prefix: 'other' }).first();
|
||||
await db('event_types').where({ id: other.id }).update({ is_active: 0 });
|
||||
|
||||
const resolved = await eventTypeService.resolveDefaultEventType();
|
||||
expect(resolved).not.toBe('other');
|
||||
expect(await db('event_types').where({ slug_prefix: resolved }).first()).toBeTruthy();
|
||||
|
||||
await db('event_types').where({ id: other.id }).update({ is_active: 1 });
|
||||
});
|
||||
|
||||
it("returns the literal 'other' only for an empty catalog", async () => {
|
||||
const rows = await db('event_types').select('*');
|
||||
await db('event_types').del();
|
||||
|
||||
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
|
||||
|
||||
await db('event_types').insert(rows);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* 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(120000);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Setup-window event type deletion (#800).
|
||||
*
|
||||
* The first-run setup wizard may delete the seeded SYSTEM event types —
|
||||
* but ONLY while the `setup_wizard_completed` flag is unset (migration 161
|
||||
* seeds it false on a fresh install, true when an admin already exists).
|
||||
* These tests pin the whole contract:
|
||||
*
|
||||
* - fresh install → flag false → system types deletable (in-use checks
|
||||
* still apply), and the per-type reminder template goes with the type
|
||||
* - reminder-template self-heal does NOT resurrect templates for slugs
|
||||
* that no longer exist in the catalog
|
||||
* - after markSetupWizardCompleted() → system deletion is refused again
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('event type deletion during the setup window (#800)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventTypeService;
|
||||
let setupService;
|
||||
let ensureEventReminderTemplatesSeeded;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Require AFTER bootCrmDb so every service shares this db instance
|
||||
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ ensureEventReminderTemplatesSeeded } = require('../../src/services/eventReminderTemplates'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('migration 161 seeds the flag false on a fresh (admin-less) install', async () => {
|
||||
const row = await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(JSON.parse(row.setting_value)).toBe(false);
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to delete a system type that events already use, even in the window', async () => {
|
||||
const corporate = await db('event_types').where({ slug_prefix: 'corporate' }).first();
|
||||
await db('events').insert({
|
||||
slug: 'corporate-test-2026-01-01',
|
||||
event_name: 'Test',
|
||||
event_type: 'corporate',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: 'share-corporate-test',
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
});
|
||||
|
||||
await expect(eventTypeService.deleteEventType(corporate.id))
|
||||
.rejects.toMatchObject({ code: 'IN_USE' });
|
||||
});
|
||||
|
||||
it('deletes an unused system type in the window, taking its reminder template along', async () => {
|
||||
// Seed the per-type reminder templates first so there is something to clean up.
|
||||
await ensureEventReminderTemplatesSeeded(db);
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeTruthy();
|
||||
|
||||
const wedding = await db('event_types').where({ slug_prefix: 'wedding' }).first();
|
||||
expect(wedding.is_system).toBeTruthy();
|
||||
|
||||
const result = await eventTypeService.deleteEventType(wedding.id);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy();
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
|
||||
|
||||
// The deleted slug must NOT stay creatable through the legacy fallback —
|
||||
// the live catalog is authoritative while it has rows.
|
||||
expect(await eventTypeService.isValidEventType('wedding')).toBe(false);
|
||||
expect(await eventTypeService.isValidEventType('birthday')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => {
|
||||
// The seeder caches success per process — reset the module to force a
|
||||
// genuine second pass, exactly what a backend restart would run.
|
||||
jest.resetModules();
|
||||
const fresh = require('../../src/services/eventReminderTemplates');
|
||||
await fresh.ensureEventReminderTemplatesSeeded(db);
|
||||
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
|
||||
// Types still in the catalog keep their templates.
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_birthday' }).first()).toBeTruthy();
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_default' }).first()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('re-locks system types once the wizard is marked complete', async () => {
|
||||
await setupService.markSetupWizardCompleted();
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(true);
|
||||
|
||||
const birthday = await db('event_types').where({ slug_prefix: 'birthday' }).first();
|
||||
await expect(eventTypeService.deleteEventType(birthday.id))
|
||||
.rejects.toMatchObject({ code: 'SYSTEM_TYPE' });
|
||||
|
||||
// Custom (non-system) types remain deletable as before.
|
||||
const custom = await eventTypeService.createEventType({ name: 'Family', slug_prefix: 'family' });
|
||||
const result = await eventTypeService.deleteEventType(custom.id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when the completion marker row is missing', async () => {
|
||||
// A portable-backup restore can replace app_settings with a set that
|
||||
// predates migration 161 (which will not rerun) — absence must mean
|
||||
// "configured instance", never an open deletion window.
|
||||
await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(true);
|
||||
await setupService.markSetupWizardCompleted();
|
||||
});
|
||||
|
||||
it('refuses to delete the last remaining event type', async () => {
|
||||
// Reduce the catalog to a single custom type via direct db writes (the
|
||||
// service paths are already covered above), then hit the guard.
|
||||
const solo = await eventTypeService.createEventType({ name: 'Solo', slug_prefix: 'solo' });
|
||||
await db('events').del();
|
||||
await db('event_types').whereNot('id', solo.id).del();
|
||||
|
||||
await expect(eventTypeService.deleteEventType(solo.id))
|
||||
.rejects.toMatchObject({ code: 'LAST_TYPE' });
|
||||
|
||||
// Deactivating it would empty the ACTIVE catalog just the same.
|
||||
await expect(eventTypeService.updateEventType(solo.id, { is_active: false }))
|
||||
.rejects.toMatchObject({ code: 'LAST_ACTIVE' });
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* External imports must record captured_at (#1172).
|
||||
*
|
||||
* Managed uploads get it from photoProcessor, which external media never goes
|
||||
* through — so every externally imported photo carried captured_at NULL, and
|
||||
* the gallery's "Date Taken" sort fell back to uploaded_at through its
|
||||
* COALESCE. On a library imported in two batches that ordered a 12-day trip by
|
||||
* which folder was imported first: the reporter's first two days landed at
|
||||
* positions 4204-5296 of 5555.
|
||||
*
|
||||
* Driven through the real route against real files carrying real EXIF, because
|
||||
* the whole question is whether the import reads the file it already has open.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const sharp = require('sharp');
|
||||
|
||||
describe('external import capture dates (#1172)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot;
|
||||
|
||||
/**
|
||||
* A real JPEG carrying DateTimeOriginal.
|
||||
*
|
||||
* IFD2, not IFD0 — DateTimeOriginal lives in the Exif IFD, and exifr does not
|
||||
* see it anywhere else (IFD0 takes plain DateTime, which surfaces as
|
||||
* ModifyDate instead).
|
||||
*/
|
||||
const writeJpegWithExif = async (rel, iso) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
|
||||
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 10, g: 20, b: 30 } } })
|
||||
.withExif({ IFD2: { DateTimeOriginal: exifDate } })
|
||||
.jpeg()
|
||||
.toFile(full);
|
||||
return full;
|
||||
};
|
||||
|
||||
const writeJpegNoExif = async (rel) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 200, g: 10, b: 10 } } })
|
||||
.jpeg().toFile(full);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capdate-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capdate-secret';
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/services/imageProcessor', () => {
|
||||
const actual = jest.requireActual('../../src/services/imageProcessor');
|
||||
return { ...actual, generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'), ensureThumbnail: jest.fn() };
|
||||
});
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
const [e] = await db('events').insert({
|
||||
slug: `capdate-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding', event_name: 'capdate', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: `capdate-${Math.random()}`, expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
const runImport = (eventId, external_path) => request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path, recursive: true });
|
||||
|
||||
it('records the EXIF capture date on import', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await writeJpegWithExif('trip/a.jpg', '2026-06-01T09:45:03Z');
|
||||
|
||||
await runImport(eventId, 'trip');
|
||||
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(photo.captured_at).toBeTruthy();
|
||||
// NOT asserted as an absolute instant. EXIF carries a naive wall-clock
|
||||
// time and exifr resolves it against the HOST timezone, so the stored UTC
|
||||
// value differs between a CEST developer machine and a UTC runner. What
|
||||
// this fix is about is that the field is populated and orders correctly;
|
||||
// that captured_at is not a true instant is a separate, pre-existing
|
||||
// problem shared with managed uploads (#1172's own footnote).
|
||||
expect(new Date(photo.captured_at).getUTCFullYear()).toBe(2026);
|
||||
expect(new Date(photo.captured_at).getUTCMonth()).toBe(5); // June
|
||||
});
|
||||
|
||||
it('imports a photo with no EXIF date rather than failing it', async () => {
|
||||
// Plenty of sources carry none; that must stay an import, not an error.
|
||||
const eventId = await seedEvent();
|
||||
await writeJpegNoExif('trip/plain.jpg');
|
||||
|
||||
const res = await runImport(eventId, 'trip');
|
||||
|
||||
expect(res.body.imported).toBe(1);
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(photo.captured_at).toBeNull();
|
||||
});
|
||||
|
||||
it('orders a two-batch import by capture time, not by batch', async () => {
|
||||
// The reported shape: the FIRST days of the trip imported second. Sorting
|
||||
// on COALESCE(captured_at, uploaded_at) put them after the last days,
|
||||
// because uploaded_at is the import timestamp.
|
||||
const eventId = await seedEvent();
|
||||
await writeJpegWithExif('late/day12.jpg', '2026-06-12T10:00:00Z');
|
||||
await runImport(eventId, 'late');
|
||||
await writeJpegWithExif('early/day01.jpg', '2026-06-01T10:00:00Z');
|
||||
await runImport(eventId, 'early');
|
||||
|
||||
const rows = await db('photos')
|
||||
.where({ event_id: eventId })
|
||||
.orderByRaw('COALESCE(captured_at, uploaded_at) asc')
|
||||
.select('filename');
|
||||
|
||||
expect(rows.map((r) => r.filename)).toEqual(['day01.jpg', 'day12.jpg']);
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* Two overlapping external imports insert every file twice (#1162).
|
||||
*
|
||||
* The route checked for an existing external_relpath and then inserted, with
|
||||
* an fs.stat and a `sharp().metadata()` read sitting in between. A reporter
|
||||
* double-clicked a slow import of a 6012-file tree and got 8004 rows.
|
||||
*
|
||||
* Both halves of the fix are driven here through the real route:
|
||||
*
|
||||
* - the in-flight guard, which turns the second click into a 409 instead of
|
||||
* a second full walk of the tree;
|
||||
* - convergence when the guard cannot help (another replica, another
|
||||
* process), which is the unique index from migration 186 firing and the
|
||||
* loop counting a skip rather than dying or duplicating.
|
||||
*
|
||||
* The second is exercised by inserting a competing row from inside the mocked
|
||||
* `sharp().metadata()` call — literally inside the window the bug lived in.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('concurrent external imports (#1162)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot;
|
||||
// When set, the mocked sharp metadata read inserts this row first — the
|
||||
// other run winning the race between our SELECT and our INSERT.
|
||||
let stealDuringMetadata = null;
|
||||
let thumbnailDelayMs = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extdup-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
|
||||
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
|
||||
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extdup-secret';
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// The window. In production this is a real decode of a NAS-hosted file —
|
||||
// hundreds of milliseconds during which the row we just proved absent can
|
||||
// appear. Standing in for the other run here makes that deterministic.
|
||||
jest.doMock('sharp', () => () => ({
|
||||
metadata: async () => {
|
||||
if (stealDuringMetadata) {
|
||||
const { db: liveDb } = require('../../src/database/db');
|
||||
await liveDb('photos').insert(stealDuringMetadata);
|
||||
stealDuringMetadata = null;
|
||||
}
|
||||
return { width: 100, height: 200 };
|
||||
},
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn(async () => {
|
||||
if (thumbnailDelayMs) await new Promise((r) => setTimeout(r, thumbnailDelayMs));
|
||||
return 'thumbnails/mock.jpg';
|
||||
}),
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
stealDuringMetadata = null;
|
||||
thumbnailDelayMs = 0;
|
||||
const [e] = await db('events').insert({
|
||||
slug: `extdup-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'extdup',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `extdup-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
const runImport = (eventId) => request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path: 'nas', recursive: true });
|
||||
|
||||
async function relpathCounts(eventId) {
|
||||
const rows = await db('photos').where({ event_id: eventId }).select('external_relpath');
|
||||
const counts = new Map();
|
||||
for (const r of rows) counts.set(r.external_relpath, (counts.get(r.external_relpath) || 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
it('rejects a second import while the first is still running', async () => {
|
||||
const eventId = await seedEvent();
|
||||
// Enough to keep the first request inside its loop while the second
|
||||
// arrives — the "slow import looks hung, so I clicked again" case.
|
||||
thumbnailDelayMs = 20;
|
||||
|
||||
const [first, second] = await Promise.all([runImport(eventId), runImport(eventId)]);
|
||||
|
||||
const statuses = [first.status, second.status].sort();
|
||||
expect(statuses).toEqual([200, 409]);
|
||||
const rejected = first.status === 409 ? first : second;
|
||||
expect(rejected.body.error).toMatch(/already running/i);
|
||||
});
|
||||
|
||||
it('leaves exactly one row per file after both runs', async () => {
|
||||
const eventId = await seedEvent();
|
||||
thumbnailDelayMs = 20;
|
||||
|
||||
await Promise.all([runImport(eventId), runImport(eventId)]);
|
||||
|
||||
const counts = await relpathCounts(eventId);
|
||||
expect(counts.size).toBe(3);
|
||||
expect([...counts.values()]).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it('releases the event once the import finishes, so a re-import still works', async () => {
|
||||
const eventId = await seedEvent();
|
||||
|
||||
expect((await runImport(eventId)).status).toBe(200);
|
||||
// Not 409 — the guard is per run, not a permanent lock on the event.
|
||||
const second = await runImport(eventId);
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.imported).toBe(0);
|
||||
expect(second.body.skipped).toBe(3);
|
||||
});
|
||||
|
||||
it('converges when another writer wins the race mid-file', async () => {
|
||||
// The guard is in-process, so it cannot see a second replica. This is what
|
||||
// the unique index is for: the insert bounces, and the file is counted as
|
||||
// skipped rather than duplicated or lost to a 500.
|
||||
const eventId = await seedEvent();
|
||||
stealDuringMetadata = {
|
||||
event_id: eventId,
|
||||
filename: 'a.jpg',
|
||||
path: 'x/a.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
// Root-relative, as the route now writes it (#1163) — the competing
|
||||
// writer has to target the same value for the race to be real.
|
||||
external_relpath: path.join('nas', 'individual', 'a.jpg'),
|
||||
};
|
||||
|
||||
const res = await runImport(eventId);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const counts = await relpathCounts(eventId);
|
||||
expect(counts.get(path.join('nas', 'individual', 'a.jpg'))).toBe(1);
|
||||
// Two imported by us, one lost to the other writer and reported honestly.
|
||||
expect(res.body.imported).toBe(2);
|
||||
expect(res.body.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('does not let one contended file abort the rest of the import', async () => {
|
||||
const eventId = await seedEvent();
|
||||
stealDuringMetadata = {
|
||||
event_id: eventId,
|
||||
filename: 'a.jpg',
|
||||
path: 'x/a.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
// Root-relative, as the route now writes it (#1163) — the competing
|
||||
// writer has to target the same value for the race to be real.
|
||||
external_relpath: path.join('nas', 'individual', 'a.jpg'),
|
||||
};
|
||||
|
||||
await runImport(eventId);
|
||||
|
||||
// All three files present — the contended one via the other writer's row.
|
||||
expect((await relpathCounts(eventId)).size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* Importing a second folder must not move the photos already in the event (#1163).
|
||||
*
|
||||
* events.external_path is overwritten by every import, and external_relpath
|
||||
* used to be stored relative to it — so a second import silently rebased every
|
||||
* existing row onto the new folder. The reporter had 7547 of 8004 originals
|
||||
* pointing at files that do not exist, and nothing said so: thumbnails are
|
||||
* written to local storage during the import while the base path is still
|
||||
* correct, so the grid carries on rendering.
|
||||
*
|
||||
* Driven through the real route and the real resolver, against a real
|
||||
* directory tree — the failure is entirely about whether a file is where the
|
||||
* app looks for it.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('a second external import (#1163)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot; let resolvePhotoFilePath;
|
||||
|
||||
const touch = async (rel) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.promises.writeFile(full, 'not-a-real-jpeg');
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-ext2nd-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'ext2nd-secret';
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('sharp', () => () => ({ metadata: async () => ({ width: 100, height: 200 }) }));
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'),
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
({ resolvePhotoFilePath } = require('../../src/services/photoResolver'));
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
const [e] = await db('events').insert({
|
||||
slug: `ext2nd-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'ext2nd',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `ext2nd-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
const runImport = (eventId, external_path) => request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path, recursive: true });
|
||||
|
||||
/** Where the app would go looking for this photo's original, right now. */
|
||||
async function resolved(eventId, filename) {
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const photo = await db('photos').where({ event_id: eventId, filename }).first();
|
||||
return resolvePhotoFilePath(event, photo);
|
||||
}
|
||||
|
||||
it('stores paths relative to the media root, not to the imported folder', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Leknes/old.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(photo.external_relpath).toBe(path.join('Trip', 'Leknes', 'old.jpg'));
|
||||
});
|
||||
|
||||
it('leaves the first folder’s originals reachable after a second import', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Leknes/old.jpg');
|
||||
await touch('Trip/Sub/new.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
const before = await resolved(eventId, 'old.jpg');
|
||||
await runImport(eventId, 'Trip/Sub');
|
||||
const after = await resolved(eventId, 'old.jpg');
|
||||
|
||||
// The regression: `after` used to be <root>/Trip/Sub/Leknes/old.jpg.
|
||||
expect(after).toBe(before);
|
||||
expect(fs.existsSync(after)).toBe(true);
|
||||
});
|
||||
|
||||
it('every original in the event is still on disk afterwards', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Leknes/a.jpg');
|
||||
await touch('Trip/Leknes/b.jpg');
|
||||
await touch('Trip/Sub/c.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
await runImport(eventId, 'Trip/Sub');
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos).toHaveLength(3);
|
||||
for (const photo of photos) {
|
||||
expect(fs.existsSync(resolvePhotoFilePath(event, photo))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not re-insert a file the first import already took', async () => {
|
||||
// The dedupe check compares stored paths, so it has to be comparing the
|
||||
// same shape the insert writes.
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Sub/c.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
const second = await runImport(eventId, 'Trip/Sub');
|
||||
|
||||
expect(second.body.imported).toBe(0);
|
||||
expect(second.body.skipped).toBe(1);
|
||||
expect(await db('photos').where({ event_id: eventId }).count('* as c').first()).toEqual({ c: 1 });
|
||||
});
|
||||
|
||||
it('resolves a subfolder that repeats its parent’s name', async () => {
|
||||
// The old resolver stripped the relpath's first segment when it matched the
|
||||
// base path's last one, which broke exactly this layout.
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Trip/x.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(resolvePhotoFilePath(event, photo)).toBe(path.join(mediaRoot, 'Trip', 'Trip', 'x.jpg'));
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* PostgreSQL integration test for the external-path fold (#1163).
|
||||
*
|
||||
* Gated the same way as picpeakRestorePg: runs only when PICPEAK_PG_TEST_URL
|
||||
* points at a throwaway Postgres DB, e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_fold_test" \
|
||||
* npx jest __tests__/integration/externalRelpathFoldPg.test.js
|
||||
*
|
||||
* This exists because of a defect SQLite could not have caught. The two-pass
|
||||
* rewrite parks each row on a temporary value, and that value was first written
|
||||
* with a leading NUL. SQLite stores NUL in TEXT without complaint; Postgres
|
||||
* rejects it outright ("invalid byte sequence for encoding UTF8"), so migration
|
||||
* 187 would have rolled back on exactly the installs needing the repair — and
|
||||
* only on the engine most of them run.
|
||||
*
|
||||
* The staging value is therefore an engine-level contract, not an
|
||||
* implementation detail, and it is pinned here on the engine that constrains it.
|
||||
*/
|
||||
|
||||
const knex = require('knex');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('external relpath fold on Postgres', () => {
|
||||
let pgDb; let mediaRoot; let fold;
|
||||
|
||||
const touch = async (rel, bytes) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.promises.writeFile(full, Buffer.alloc(bytes));
|
||||
return bytes;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-foldpg-'));
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
jest.resetModules();
|
||||
({ foldExternalRelpaths: fold } = require('../../src/services/externalRelpathFold'));
|
||||
|
||||
pgDb = knex({ client: 'pg', connection: PG_URL });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (pgDb) await pgDb.destroy();
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true }).catch(() => {});
|
||||
delete process.env.EXTERNAL_MEDIA_ROOT;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await pgDb.raw('DROP TABLE IF EXISTS photos, events, app_settings CASCADE');
|
||||
await pgDb.schema.createTable('events', (t) => {
|
||||
t.increments('id');
|
||||
t.text('external_path');
|
||||
});
|
||||
await pgDb.schema.createTable('photos', (t) => {
|
||||
t.increments('id');
|
||||
t.integer('event_id');
|
||||
t.text('external_relpath');
|
||||
t.bigInteger('size_bytes');
|
||||
t.string('source_origin').defaultTo('managed');
|
||||
});
|
||||
await pgDb.schema.createTable('app_settings', (t) => {
|
||||
t.increments('id');
|
||||
t.string('setting_key');
|
||||
t.text('setting_value');
|
||||
t.string('setting_type');
|
||||
t.string('updated_at');
|
||||
});
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
});
|
||||
|
||||
const relpaths = async () =>
|
||||
(await pgDb('photos').orderBy('id').select('external_relpath')).map((r) => r.external_relpath);
|
||||
|
||||
it('completes the two-pass repair that a NUL staging value would abort', async () => {
|
||||
// The exact shape that forces staging: `photo.jpg` repairs up to
|
||||
// `Trip/photo.jpg`, while the row already holding `Trip/photo.jpg` folds
|
||||
// deeper. Every final value is distinct, but a final value equals another
|
||||
// row's current one, so the rewrite has to park first.
|
||||
const a = await touch('Trip/photo.jpg', 11);
|
||||
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
||||
await pgDb('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'photo.jpg', size_bytes: a, source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'Trip/photo.jpg', size_bytes: b, source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await fold(pgDb);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
|
||||
});
|
||||
|
||||
it('leaves no staging value behind', async () => {
|
||||
await touch('Trip/a.jpg', 8);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
|
||||
|
||||
await fold(pgDb);
|
||||
|
||||
const rows = await relpaths();
|
||||
expect(rows).toEqual(['Trip/a.jpg']);
|
||||
expect(rows.some((r) => r.includes('staging'))).toBe(false);
|
||||
});
|
||||
|
||||
it('folds and marks in one transaction', async () => {
|
||||
await touch('Trip/a.jpg', 8);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
|
||||
|
||||
await fold(pgDb);
|
||||
// Second run is a no-op: the marker committed with the rewrites.
|
||||
await fold(pgDb);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/a.jpg']);
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Auto-category rule engine (#1074 phase 3).
|
||||
*
|
||||
* The rules themselves are simple enough to read. What needs testing is the
|
||||
* promise around them: this engine may only ever fill an EMPTY category, and
|
||||
* everything it touches must be reversible. A photographer's own assignment
|
||||
* is a decision; this is a heuristic, and the heuristic never wins.
|
||||
*/
|
||||
|
||||
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-autocat-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'autocat-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let engine;
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** A scanned photo with `faceCount` faces, each `faceSide` px square. */
|
||||
async function addScannedPhoto(eventId, faceCount, { faceSide = 400, categoryId = null } = {}) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
processing_status: 'complete',
|
||||
face_status: 'done',
|
||||
face_count: faceCount,
|
||||
category_id: categoryId,
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
for (let i = 0; i < faceCount; i++) {
|
||||
await db('photo_faces').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
bbox_x: 10, bbox_y: 10, bbox_w: faceSide, bbox_h: faceSide,
|
||||
det_score: 0.95,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return photoId;
|
||||
}
|
||||
|
||||
async function enable(on) {
|
||||
const existing = await db('app_settings')
|
||||
.where('setting_key', 'face_auto_categorize_enabled').first();
|
||||
if (existing) {
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'face_auto_categorize_enabled')
|
||||
.update({ setting_value: JSON.stringify(on) });
|
||||
}
|
||||
}
|
||||
|
||||
async function categoryOf(photoId) {
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
if (!photo.category_id) return null;
|
||||
const cat = await db('photo_categories').where({ id: photo.category_id }).first();
|
||||
return cat?.slug ?? null;
|
||||
}
|
||||
|
||||
describe('faceAutoCategories (#1074 phase 3)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
engine = require('../../src/services/faceAutoCategories');
|
||||
await enable(true);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('rules', () => {
|
||||
it('sorts by face count, and by face size for portraits', async () => {
|
||||
const eventId = await seedEvent('rules');
|
||||
// 400px face in a 1000x1000 frame = 16% of the frame, over the 8% floor.
|
||||
const portrait = await addScannedPhoto(eventId, 1, { faceSide: 400 });
|
||||
const details = await addScannedPhoto(eventId, 0);
|
||||
const small = await addScannedPhoto(eventId, 3);
|
||||
const group = await addScannedPhoto(eventId, 9);
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(await categoryOf(details)).toBe('details');
|
||||
expect(await categoryOf(portrait)).toBe('portraits');
|
||||
expect(await categoryOf(small)).toBe('small-groups');
|
||||
expect(await categoryOf(group)).toBe('groups');
|
||||
});
|
||||
|
||||
it('does not call a distant single face a portrait', async () => {
|
||||
// One person in a wide landscape is not a portrait of them. 60px in a
|
||||
// 1000x1000 frame is 0.36% — far below the 8% floor.
|
||||
const eventId = await seedEvent('small-face');
|
||||
const distant = await addScannedPhoto(eventId, 1, { faceSide: 60 });
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(await categoryOf(distant)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores photos that have not been scanned', async () => {
|
||||
const eventId = await seedEvent('unscanned');
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'u.jpg', path: '/tmp/u.jpg', type: 'individual',
|
||||
processing_status: 'complete', face_status: 'pending',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
expect(await categoryOf(photoId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the promise', () => {
|
||||
it('NEVER overwrites a category a person chose', async () => {
|
||||
// The single most important behaviour in this file.
|
||||
const eventId = await seedEvent('no-overwrite');
|
||||
const [c] = await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony', is_global: false, event_id: eventId,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const ceremonyId = typeof c === 'object' ? c.id : c;
|
||||
|
||||
// 9 faces — the rules would call this "groups" if they were allowed to.
|
||||
const claimed = await addScannedPhoto(eventId, 9, { categoryId: ceremonyId });
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(await categoryOf(claimed)).toBe('ceremony');
|
||||
const row = await db('photos').where({ id: claimed }).first();
|
||||
expect(row.auto_categorized).toBeFalsy();
|
||||
});
|
||||
|
||||
it('marks only what it assigned, so undo is exact', async () => {
|
||||
const eventId = await seedEvent('undo');
|
||||
const [c] = await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony-2', is_global: false, event_id: eventId,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const ceremonyId = typeof c === 'object' ? c.id : c;
|
||||
|
||||
const manual = await addScannedPhoto(eventId, 4, { categoryId: ceremonyId });
|
||||
const auto = await addScannedPhoto(eventId, 4);
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
expect(await categoryOf(auto)).toBe('small-groups');
|
||||
|
||||
const result = await engine.undoEvent(eventId);
|
||||
|
||||
expect(result.cleared).toBe(1);
|
||||
// The automatic one is cleared...
|
||||
expect(await categoryOf(auto)).toBeNull();
|
||||
// ...and the photographer's own choice survives untouched.
|
||||
expect(await categoryOf(manual)).toBe('ceremony-2');
|
||||
});
|
||||
|
||||
it('is a no-op while the setting is off', async () => {
|
||||
const eventId = await seedEvent('disabled');
|
||||
const photoId = await addScannedPhoto(eventId, 0);
|
||||
|
||||
await enable(false);
|
||||
const result = await engine.categorizeEvent(eventId);
|
||||
await enable(true);
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(await categoryOf(photoId)).toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent — a second run assigns nothing new', async () => {
|
||||
const eventId = await seedEvent('idempotent');
|
||||
await addScannedPhoto(eventId, 0);
|
||||
await addScannedPhoto(eventId, 7);
|
||||
|
||||
const first = await engine.categorizeEvent(eventId);
|
||||
const second = await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(first.assigned).toBe(2);
|
||||
expect(second.assigned).toBe(0);
|
||||
});
|
||||
|
||||
it('reuses one category per slug rather than creating duplicates', async () => {
|
||||
const eventId = await seedEvent('reuse');
|
||||
await addScannedPhoto(eventId, 0);
|
||||
await addScannedPhoto(eventId, 0);
|
||||
await addScannedPhoto(eventId, 0);
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
const details = await db('photo_categories')
|
||||
.where({ slug: 'details' })
|
||||
.where(function () { this.where('event_id', eventId).orWhere('is_global', true); });
|
||||
expect(details).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,325 +0,0 @@
|
||||
/**
|
||||
* Clustering engine (#1074).
|
||||
*
|
||||
* Uses synthetic embeddings with known identities rather than real faces: the
|
||||
* question here is whether the ALGORITHM groups vectors correctly, which is
|
||||
* separable from whether the model produces good vectors. Model quality is
|
||||
* the spike's job.
|
||||
*/
|
||||
|
||||
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-faceclust-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceclust-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering;
|
||||
|
||||
/** Deterministic unit vector for identity `id`, jittered by `variant`. */
|
||||
function makeEmbedding(id, variant = 0, dim = 64) {
|
||||
const vec = new Float32Array(dim);
|
||||
for (let i = 0; i < dim; i++) {
|
||||
vec[i] = Math.sin((i + 1) * (id + 1) * 0.7) + variant * 0.02 * Math.cos(i * 3.1);
|
||||
}
|
||||
let norm = 0;
|
||||
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
|
||||
norm = Math.sqrt(norm);
|
||||
for (let i = 0; i < dim; i++) vec[i] /= norm;
|
||||
return vec;
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function insertFace(eventId, embedding, overrides = {}) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const row = {
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(embedding),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
const [f] = await db('photo_faces').insert(row).returning('id');
|
||||
return { ...row, id: typeof f === 'object' ? f.id : f };
|
||||
}
|
||||
|
||||
describe('faceClustering (#1074)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('embedding round-trip', () => {
|
||||
it('survives pack/unpack through the BLOB column exactly', async () => {
|
||||
const original = makeEmbedding(1);
|
||||
const eventId = await seedEvent('roundtrip');
|
||||
const face = await insertFace(eventId, original);
|
||||
|
||||
const stored = await db('photo_faces').where({ id: face.id }).first();
|
||||
const restored = clustering.unpackEmbedding(stored.embedding);
|
||||
|
||||
expect(restored).toHaveLength(original.length);
|
||||
for (let i = 0; i < original.length; i++) {
|
||||
expect(restored[i]).toBeCloseTo(original[i], 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null for a corrupt blob rather than throwing', () => {
|
||||
expect(clustering.unpackEmbedding(Buffer.from([1, 2, 3]))).toBeNull();
|
||||
expect(clustering.unpackEmbedding(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignment', () => {
|
||||
it('groups the same identity and separates different ones', async () => {
|
||||
const eventId = await seedEvent('grouping');
|
||||
const faces = [];
|
||||
// Three identities, four shots each, interleaved so assignment order
|
||||
// is not conveniently grouped.
|
||||
for (let variant = 0; variant < 4; variant++) {
|
||||
for (const identity of [1, 2, 3]) {
|
||||
faces.push(await insertFace(eventId, makeEmbedding(identity, variant)));
|
||||
}
|
||||
}
|
||||
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId });
|
||||
expect(people).toHaveLength(3);
|
||||
|
||||
// Every face of one identity must share a person id.
|
||||
const rows = await db('photo_faces').where({ event_id: eventId }).select('id', 'person_id');
|
||||
const byPerson = new Map();
|
||||
for (const r of rows) {
|
||||
byPerson.set(r.person_id, (byPerson.get(r.person_id) || 0) + 1);
|
||||
}
|
||||
expect([...byPerson.values()].sort()).toEqual([4, 4, 4]);
|
||||
});
|
||||
|
||||
it('leaves low-quality faces unassigned instead of spawning junk people', async () => {
|
||||
const eventId = await seedEvent('quality-floor');
|
||||
const good = await insertFace(eventId, makeEmbedding(5));
|
||||
// Tiny bbox — below the 40px floor.
|
||||
const tiny = await insertFace(eventId, makeEmbedding(6), { bbox_w: 12, bbox_h: 12 });
|
||||
// Weak detection score.
|
||||
const weak = await insertFace(eventId, makeEmbedding(7), { det_score: 0.2 });
|
||||
|
||||
await clustering.assignFaces(eventId, [good, tiny, weak]);
|
||||
|
||||
const rows = await db('photo_faces')
|
||||
.whereIn('id', [good.id, tiny.id, weak.id])
|
||||
.select('id', 'person_id');
|
||||
const map = Object.fromEntries(rows.map((r) => [r.id, r.person_id]));
|
||||
|
||||
expect(map[good.id]).not.toBeNull();
|
||||
// Still stored — they show in "this photo contains" — just unassigned.
|
||||
expect(map[tiny.id]).toBeNull();
|
||||
expect(map[weak.id]).toBeNull();
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never mixes embedding spaces from different model versions', async () => {
|
||||
const eventId = await seedEvent('model-version');
|
||||
const a = await insertFace(eventId, makeEmbedding(9), { model_version: 'v1' });
|
||||
await clustering.assignFaces(eventId, [a]);
|
||||
|
||||
// Same vector, different model. Comparable numerically, meaningless
|
||||
// semantically — it must NOT join the v1 cluster.
|
||||
const b = await insertFace(eventId, makeEmbedding(9), { model_version: 'v2' });
|
||||
await clustering.assignFaces(eventId, [b]);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId });
|
||||
expect(people).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge and split', () => {
|
||||
it('merge moves every face and removes the source person', async () => {
|
||||
const eventId = await seedEvent('merge');
|
||||
const f1 = await insertFace(eventId, makeEmbedding(11));
|
||||
const f2 = await insertFace(eventId, makeEmbedding(21));
|
||||
await clustering.assignFaces(eventId, [f1, f2]);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
expect(people).toHaveLength(2);
|
||||
|
||||
await clustering.mergePeople(eventId, [people[1].id], people[0].id);
|
||||
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
|
||||
const remaining = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(remaining.face_count_total).toBe(2);
|
||||
const orphaned = await db('photo_faces')
|
||||
.where({ event_id: eventId }).whereNull('person_id');
|
||||
expect(orphaned).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('split pulls the named faces into a new person', async () => {
|
||||
const eventId = await seedEvent('split');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 4; v++) faces.push(await insertFace(eventId, makeEmbedding(13, v)));
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(person.face_count_total).toBe(4);
|
||||
|
||||
const newId = await clustering.splitPerson(eventId, person.id, [faces[0].id, faces[1].id]);
|
||||
expect(newId).toBeTruthy();
|
||||
|
||||
const original = await db('event_people').where({ id: person.id }).first();
|
||||
const created = await db('event_people').where({ id: newId }).first();
|
||||
expect(original.face_count_total).toBe(2);
|
||||
expect(created.face_count_total).toBe(2);
|
||||
});
|
||||
|
||||
it('deletes a person left with no faces rather than keeping a ghost', async () => {
|
||||
const eventId = await seedEvent('empty-person');
|
||||
const f = await insertFace(eventId, makeEmbedding(15));
|
||||
await clustering.assignFaces(eventId, [f]);
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
|
||||
await db('photo_faces').where({ id: f.id }).update({ person_id: null });
|
||||
await clustering.recomputeCentroid(person.id);
|
||||
|
||||
expect(await db('event_people').where({ id: person.id }).first()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('regressions from external review', () => {
|
||||
it('merge carries a name and suppression onto the survivor', async () => {
|
||||
// A merge used to move the faces and delete the source outright, so a
|
||||
// photographer-entered name vanished and a person they had hidden came
|
||||
// back guest-visible.
|
||||
const eventId = await seedEvent('merge-metadata');
|
||||
const a = await insertFace(eventId, makeEmbedding(61));
|
||||
const b = await insertFace(eventId, makeEmbedding(62));
|
||||
await clustering.assignFaces(eventId, [a, b]);
|
||||
|
||||
const [p1, p2] = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
// Target is unnamed and visible; the SOURCE carries the human state.
|
||||
await db('event_people').where({ id: p2.id }).update({ label: 'Anna', is_hidden: true });
|
||||
|
||||
await clustering.mergePeople(eventId, [p2.id], p1.id);
|
||||
|
||||
const survivor = await db('event_people').where({ id: p1.id }).first();
|
||||
expect(survivor.label).toBe('Anna');
|
||||
expect(!!survivor.is_hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('recluster keeps hidden/ignored on people that were never named', async () => {
|
||||
// The old query remembered only rows with a label, so a suppressed
|
||||
// bystander came back visible after one "Re-group people".
|
||||
const eventId = await seedEvent('recluster-suppression');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) faces.push(await insertFace(eventId, makeEmbedding(71, v)));
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(person.label).toBeNull();
|
||||
await db('event_people').where({ id: person.id }).update({ is_ignored: true });
|
||||
|
||||
await clustering.recluster(eventId);
|
||||
|
||||
const after = await db('event_people').where({ event_id: eventId });
|
||||
expect(after.length).toBeGreaterThan(0);
|
||||
expect(after.every((p) => !!p.is_ignored)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recluster', () => {
|
||||
it('re-derives clusters and preserves photographer-assigned names', async () => {
|
||||
// This is the property that makes re-clustering safe to offer as a
|
||||
// button: without it, one click silently discards every typed name.
|
||||
const eventId = await seedEvent('recluster');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push(await insertFace(eventId, makeEmbedding(31, v)));
|
||||
faces.push(await insertFace(eventId, makeEmbedding(32, v)));
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
expect(people).toHaveLength(2);
|
||||
await db('event_people').where({ id: people[0].id }).update({ label: 'Anna' });
|
||||
await db('event_people').where({ id: people[1].id }).update({ label: 'Ben' });
|
||||
|
||||
const count = await clustering.recluster(eventId);
|
||||
expect(count).toBe(2);
|
||||
|
||||
const after = await db('event_people').where({ event_id: eventId });
|
||||
const labels = after.map((p) => p.label).filter(Boolean).sort();
|
||||
expect(labels).toEqual(['Anna', 'Ben']);
|
||||
});
|
||||
|
||||
it('is stable across repeated runs', async () => {
|
||||
const eventId = await seedEvent('recluster-stable');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
for (const id of [41, 42]) faces.push(await insertFace(eventId, makeEmbedding(id, v)));
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const first = await clustering.recluster(eventId);
|
||||
const second = await clustering.recluster(eventId);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consolidate', () => {
|
||||
it('refuses to merge two people the photographer named differently', async () => {
|
||||
// A human assertion this heuristic does not get to overrule.
|
||||
const eventId = await seedEvent('consolidate-labels');
|
||||
const a = await insertFace(eventId, makeEmbedding(51));
|
||||
await clustering.assignFaces(eventId, [a]);
|
||||
const first = await db('event_people').where({ event_id: eventId }).first();
|
||||
|
||||
// A near-identical centroid that would otherwise merge.
|
||||
const [inserted] = await db('event_people').insert({
|
||||
event_id: eventId,
|
||||
centroid: clustering.packEmbedding(makeEmbedding(51, 0.01)),
|
||||
face_count_total: 1,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const secondId = typeof inserted === 'object' ? inserted.id : inserted;
|
||||
|
||||
await db('event_people').where({ id: first.id }).update({ label: 'Anna' });
|
||||
await db('event_people').where({ id: secondId }).update({ label: 'Ben' });
|
||||
|
||||
await clustering.consolidate(eventId);
|
||||
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* External imports are queued for face scanning, in the right order (#1090).
|
||||
*
|
||||
* Managed uploads are enqueued by photoProcessor, which writes face_status
|
||||
* 'pending' once a photo is processed (photoProcessor.js:573 — "the only
|
||||
* correct place to enqueue"). External media never goes through photoProcessor:
|
||||
* adminExternalMedia inserts rows directly, so they stayed NULL and were only
|
||||
* ever picked up by a manual Re-scan.
|
||||
*
|
||||
* The ordering matters as much as the enqueue. events.external_path is written
|
||||
* only AFTER the whole import loop, so marking rows 'pending' as they are
|
||||
* inserted publishes claimable work while the event still points at the old
|
||||
* directory — or none at all, on a first import. The face worker polls
|
||||
* continuously, would resolve those photos against the wrong path, and mark
|
||||
* them permanently 'failed', a state only an explicit Re-scan clears.
|
||||
*
|
||||
* This drives the real route rather than re-implementing it, so removing the
|
||||
* enqueue fails the first test and moving it back onto the insert fails the
|
||||
* second.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('external import queues faces (#1090)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot;
|
||||
// Recorded from inside the per-photo thumbnail call, i.e. mid-loop.
|
||||
let pendingSeenDuringLoop = 0;
|
||||
let externalPathDuringLoop;
|
||||
// When set to an event id, the mocked thumbnail call turns detection on
|
||||
// mid-loop, standing in for an admin flipping the toggle during an import.
|
||||
let flipFacesOnDuringLoop = null;
|
||||
// Stands in for a concurrent Re-scan completing a row mid-import.
|
||||
let markDoneDuringLoop = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extenq-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
|
||||
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
|
||||
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extenq-secret';
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// Runs once per photo, inside the import loop — the only hook that can
|
||||
// observe the intermediate state the ordering bug would expose.
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn(async () => {
|
||||
const { db: liveDb } = require('../../src/database/db');
|
||||
const rows = await liveDb('photos').where({ face_status: 'pending' });
|
||||
pendingSeenDuringLoop += rows.length;
|
||||
const ev = await liveDb('events').first();
|
||||
externalPathDuringLoop = ev ? ev.external_path : undefined;
|
||||
if (markDoneDuringLoop) {
|
||||
const rows = await liveDb('photos').orderBy('id', 'asc').limit(1);
|
||||
if (rows.length) {
|
||||
await liveDb('photos').where({ id: rows[0].id }).update({ face_status: 'done' });
|
||||
}
|
||||
}
|
||||
if (flipFacesOnDuringLoop) {
|
||||
await liveDb('events').where({ id: flipFacesOnDuringLoop })
|
||||
.update({ face_recognition_enabled: true });
|
||||
}
|
||||
return 'thumbnails/mock.jpg';
|
||||
}),
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
// bootCrmDb runs every migrations/core/*.up() directly — knex's Migrator
|
||||
// deadlocks on 001_init's nested initializeDatabase() call.
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent({ facesEnabled, flagOn }) {
|
||||
await db('feature_flags').insert({ key: 'faces', value: flagOn })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: flagOn }); });
|
||||
// The flag read is TTL-cached (requireFeatureFlag.js:26-34); production
|
||||
// invalidates after every write, and so must this.
|
||||
require('../../src/middleware/requireFeatureFlag').invalidateFeatureFlagCache();
|
||||
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
const [e] = await db('events').insert({
|
||||
slug: `extenq-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'extenq',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `extenq-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: facesEnabled,
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
|
||||
pendingSeenDuringLoop = 0;
|
||||
externalPathDuringLoop = undefined;
|
||||
markDoneDuringLoop = false;
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
async function runImport(eventId) {
|
||||
return request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path: 'nas', recursive: true });
|
||||
}
|
||||
|
||||
it('queues imported photos when detection is on', async () => {
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
|
||||
const res = await runImport(eventId);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
// The regression: these stayed NULL and waited for a manual Re-scan.
|
||||
expect(photos.every((p) => p.face_status === 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not publish claimable rows before events.external_path is written', async () => {
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
|
||||
await runImport(eventId);
|
||||
|
||||
// Observed from inside the loop: nothing is claimable yet. The event path
|
||||
// is already committed (see the test above), so this is no longer load
|
||||
// bearing for correctness — but keeping the enqueue at the end is what lets
|
||||
// the feature setting be read after the loop, so the invariant stays.
|
||||
expect(pendingSeenDuringLoop).toBe(0);
|
||||
|
||||
// ...and afterwards both are in place.
|
||||
const ev = await db('events').where({ id: eventId }).first();
|
||||
expect(ev.external_path).toBe('nas');
|
||||
expect((await db('photos').where({ event_id: eventId, face_status: 'pending' })).length)
|
||||
.toBe((await db('photos').where({ event_id: eventId })).length);
|
||||
});
|
||||
|
||||
it('honours a toggle flipped DURING the import', async () => {
|
||||
// The setting is read after the loop, not before: on a large library the
|
||||
// loop runs for minutes, and the toggle endpoint only queues rows that
|
||||
// already existed when it fired. Reading it up front would strand every
|
||||
// photo imported after that moment at NULL forever.
|
||||
const eventId = await seedEvent({ facesEnabled: false, flagOn: true });
|
||||
flipFacesOnDuringLoop = eventId;
|
||||
|
||||
await runImport(eventId);
|
||||
flipFacesOnDuringLoop = null;
|
||||
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos.every((p) => p.face_status === 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
it('commits events.external_path before the first row is inserted', async () => {
|
||||
// enqueueEvent accepts processing_status NULL (faceProcessor.js:243-246),
|
||||
// which these inserts leave unset — so a toggle or Re-scan firing mid-import
|
||||
// can queue partial rows. If the event still pointed at the old directory
|
||||
// they would resolve against it and burn to 'failed'. Setting the path
|
||||
// first also means a half-finished import leaves rows that still resolve,
|
||||
// instead of rows stranded against the previous path.
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
|
||||
await runImport(eventId);
|
||||
|
||||
// Sampled from inside the per-photo thumbnail call, i.e. while rows are
|
||||
// still being inserted.
|
||||
expect(externalPathDuringLoop).toBe('nas');
|
||||
});
|
||||
|
||||
it('does not re-queue rows a concurrent scan already handled', async () => {
|
||||
// Committing the event path before the loop means a toggle or Re-scan
|
||||
// firing mid-import can now genuinely queue and even finish some of these
|
||||
// rows. A blanket update at the end would drag 'done' rows back to
|
||||
// 'pending' for a duplicate sidecar scan and knock 'processing' rows out
|
||||
// from under the worker.
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
markDoneDuringLoop = true;
|
||||
|
||||
await runImport(eventId);
|
||||
markDoneDuringLoop = false;
|
||||
|
||||
const done = await db('photos').where({ event_id: eventId, face_status: 'done' });
|
||||
expect(done.length).toBeGreaterThan(0); // the concurrent scan's work survived
|
||||
});
|
||||
|
||||
it('leaves face_status untouched when the per-event toggle is off', async () => {
|
||||
const eventId = await seedEvent({ facesEnabled: false, flagOn: true });
|
||||
await runImport(eventId);
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos.every((p) => p.face_status === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves face_status untouched when the global flag is off', async () => {
|
||||
// Installs without the feature must never accumulate face_status rows —
|
||||
// the same invariant photoProcessor's guard protects.
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: false });
|
||||
await runImport(eventId);
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos.every((p) => p.face_status === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* External / reference photos are scannable (#1090).
|
||||
*
|
||||
* faceProcessor used to short-circuit every photo with source_origin
|
||||
* 'external' or 'reference' to 'skipped', because resolvePhotoStorageKey
|
||||
* returns null for anything outside managed storage and ensurePreviewImage
|
||||
* could not build a preview for it. #1078 removed that limitation —
|
||||
* ensurePreviewImage now reads externals straight off the mount and writes
|
||||
* the preview into managed storage — but the guard stayed, so the whole
|
||||
* feature was a no-op on external-media installs. The reporter's gallery sat
|
||||
* at 0/3230 with every row 'skipped' and no error.
|
||||
*
|
||||
* These pin both halves: the guard is gone, and a photo whose source is
|
||||
* genuinely missing still fails rather than being quietly skipped — the blanket
|
||||
* skip used to absorb that case too, so a real breakage looked like an
|
||||
* unsupported one.
|
||||
*/
|
||||
|
||||
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-faceext-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceext-test-secret';
|
||||
// A real, existing media root. getExternalMediaRoot only honours the env var
|
||||
// if the directory exists and caches it on first call, so this has to be set
|
||||
// up before anything requires externalMediaService.
|
||||
process.env.EXTERNAL_MEDIA_ROOT = path.join(path.dirname(process.env.TEST_DATABASE_PATH), 'media');
|
||||
fs.mkdirSync(path.join(process.env.EXTERNAL_MEDIA_ROOT, 'share', 'individual'), { recursive: true });
|
||||
// Non-empty on purpose: an empty directory is read as an unmounted share
|
||||
// (faceTransientSource.test.js), so a "healthy storage, dead photo" fixture
|
||||
// needs a sibling present or it defers instead of failing.
|
||||
fs.writeFileSync(path.join(process.env.EXTERNAL_MEDIA_ROOT, 'share', 'individual', 'sibling.jpg'), 'x');
|
||||
|
||||
const sharp = require('sharp');
|
||||
|
||||
let mockPreviewBuffer;
|
||||
// Set per-test: what ensurePreviewImage returns for the photo under test.
|
||||
let previewKeyResult; // eslint-disable-line prefer-const
|
||||
const mockEnsurePreviewImage = jest.fn(async () => previewKeyResult);
|
||||
const mockDetectFaces = jest.fn();
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => ({
|
||||
...jest.requireActual('../../src/services/imageProcessor'),
|
||||
ensurePreviewImage: (...args) => mockEnsurePreviewImage(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({
|
||||
getStorage: () => ({ get: async () => mockPreviewBuffer }),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/faceClient', () => ({
|
||||
detectFaces: (...args) => mockDetectFaces(...args),
|
||||
SidecarUnavailableError: class extends Error {},
|
||||
}));
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceProcessor;
|
||||
|
||||
async function seedPhoto({ sourceOrigin = 'managed', sourceMode = 'managed' } = {}) {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `ext-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'ext',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `ext-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
source_mode: sourceMode,
|
||||
external_path: 'share',
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'ext.jpg',
|
||||
path: '/tmp/ext.jpg',
|
||||
type: 'individual',
|
||||
width: 1920,
|
||||
height: 1440,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
source_origin: sourceOrigin,
|
||||
external_relpath: sourceOrigin === 'managed' ? null : 'individual/ext.jpg',
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
describe('face scanning of external/reference photos (#1090)', () => {
|
||||
beforeAll(async () => {
|
||||
mockPreviewBuffer = await sharp({
|
||||
create: { width: 1920, height: 1440, channels: 3, background: { r: 20, g: 40, b: 80 } },
|
||||
}).jpeg().toBuffer();
|
||||
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(() => {
|
||||
mockEnsurePreviewImage.mockClear();
|
||||
mockDetectFaces.mockClear();
|
||||
previewKeyResult = 'previews/preview_ext.jpg';
|
||||
mockDetectFaces.mockResolvedValue({
|
||||
model_version: 'test-v1',
|
||||
faces: [{
|
||||
bbox: [100, 100, 50, 50],
|
||||
score: 0.99,
|
||||
landmarks: [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
|
||||
yaw: 0, pitch: 0, blur: 500,
|
||||
embedding: Array.from({ length: 64 }, (_, i) => (i === 0 ? 1 : 0)),
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['external', 'reference'])('scans a %s photo instead of skipping it', async (origin) => {
|
||||
const { photoId } = await seedPhoto({ sourceOrigin: origin, sourceMode: 'reference' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
// The regression: this used to return 'skipped' without ever building a
|
||||
// preview or contacting the sidecar.
|
||||
expect(result.status).not.toBe('skipped');
|
||||
expect(mockEnsurePreviewImage).toHaveBeenCalled();
|
||||
expect(mockDetectFaces).toHaveBeenCalled();
|
||||
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).toBe('done');
|
||||
expect(await db('photo_faces').where({ photo_id: photoId }).first()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fails, not skips, when the external source is genuinely gone', async () => {
|
||||
// A missing file is a property of that photo, so it should be visible as a
|
||||
// failure the admin can act on — not silently absorbed the way the old
|
||||
// blanket skip did.
|
||||
//
|
||||
// The containing directory exists here on purpose. An absent directory is
|
||||
// a dropped mount, which defers rather than fails
|
||||
// (faceTransientSource.test.js); this is the other case — healthy storage,
|
||||
// dead photo.
|
||||
previewKeyResult = null;
|
||||
const { photoId } = await seedPhoto({ sourceOrigin: 'external', sourceMode: 'reference' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(mockDetectFaces).not.toHaveBeenCalled();
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).toBe('failed');
|
||||
expect(photo.face_error).toMatch(/preview/i);
|
||||
});
|
||||
});
|
||||
@@ -1,452 +0,0 @@
|
||||
/**
|
||||
* Automatic consolidation reporting and the suggestion band (#1107).
|
||||
*
|
||||
* Centroids are built to an EXACT cosine similarity rather than jittered
|
||||
* towards one, because every assertion here is about which side of a threshold
|
||||
* a pair falls on. `pairAtSimilarity` returns two unit vectors whose dot
|
||||
* product is the requested number to floating-point precision, and each pair
|
||||
* is built on its own orthogonal basis so two different pairs are never
|
||||
* accidentally similar to each other.
|
||||
*/
|
||||
|
||||
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-facesuggest-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facesuggest-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering;
|
||||
|
||||
// Mirrors the service: merge at match + 0.08, so with a 0.60 floor the
|
||||
// suggestion band is [0.60, 0.68).
|
||||
const THRESHOLDS = {
|
||||
face_match_threshold: 0.6,
|
||||
face_quality_min_score: 0.7,
|
||||
face_quality_min_px: 40,
|
||||
};
|
||||
|
||||
const DIM = 64;
|
||||
|
||||
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
|
||||
function pairAtSimilarity(target, basis) {
|
||||
const a = new Float32Array(DIM);
|
||||
const b = new Float32Array(DIM);
|
||||
const orth = Math.sqrt(1 - target * target);
|
||||
a[basis] = 1;
|
||||
b[basis] = target;
|
||||
b[basis + 1] = orth;
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function insertPerson(eventId, centroid, overrides = {}) {
|
||||
const [row] = await db('event_people').insert({
|
||||
event_id: eventId,
|
||||
centroid: clustering.packEmbedding(centroid),
|
||||
face_count_total: 5,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** One person with one real face, so merge/split have something to move. */
|
||||
async function insertPersonWithFace(eventId, centroid, overrides = {}) {
|
||||
const personId = await insertPerson(eventId, centroid, overrides);
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
await db('photo_faces').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
person_id: personId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(centroid),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
return personId;
|
||||
}
|
||||
|
||||
/** An additional face on an existing person, so a split has something to move. */
|
||||
async function addFaceTo(eventId, personId, centroid) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
person_id: personId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(centroid),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof f === 'object' ? f.id : f;
|
||||
}
|
||||
|
||||
const suggest = (eventId) => clustering.suggestMerges(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
describe('face merge suggestions (#1107)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('the band', () => {
|
||||
it('suggests a pair between the match and auto-merge thresholds', async () => {
|
||||
const eventId = await seedEvent('band-inside');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
const out = await suggest(eventId);
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
expect([out[0].person_a_id, out[0].person_b_id].sort()).toEqual([idA, idB].sort());
|
||||
expect(out[0].score).toBeCloseTo(0.64, 4);
|
||||
});
|
||||
|
||||
it('stays silent above the auto-merge threshold — consolidate() owns that pair', async () => {
|
||||
const eventId = await seedEvent('band-above');
|
||||
const [a, b] = pairAtSimilarity(0.75, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('stays silent below the match threshold — further apart than one face would join', async () => {
|
||||
const eventId = await seedEvent('band-below');
|
||||
const [a, b] = pairAtSimilarity(0.5, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what it refuses to ask about', () => {
|
||||
it('never questions two people the photographer named differently', async () => {
|
||||
const eventId = await seedEvent('named-apart');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a, { label: 'Anna' });
|
||||
await insertPerson(eventId, b, { label: 'Beatrix' });
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('still asks when only one of the two is named', async () => {
|
||||
const eventId = await seedEvent('one-named');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a, { label: 'Anna' });
|
||||
await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips a person marked "not a real person" — that answer was already given', async () => {
|
||||
const eventId = await seedEvent('ignored');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b, { is_ignored: true });
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('never crosses embedding spaces', async () => {
|
||||
const eventId = await seedEvent('model-skew');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b, { model_version: 'test-v2' });
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissal', () => {
|
||||
it('stops suggesting a pair the photographer rejected, and survives a repeat', async () => {
|
||||
const eventId = await seedEvent('dismissal');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toHaveLength(1);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idB, idA); // reversed on purpose
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
|
||||
// A second dismissal hits the UNIQUE constraint. Dismissing twice is a
|
||||
// double-click, not an error.
|
||||
await expect(clustering.dismissMergeSuggestion(eventId, idA, idB)).resolves.toEqual({
|
||||
dismissed: true,
|
||||
});
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The swallow-the-duplicate branch has to discriminate, because the failure
|
||||
* it must NOT swallow looks identical to the caller: returning
|
||||
* "kept separate" for a decision that was never written means the pair
|
||||
* silently comes back after the next scan.
|
||||
*
|
||||
* Tested on the predicate directly — provoking a read-only database or a
|
||||
* dropped table mid-suite would corrupt the shared fixture for every other
|
||||
* case in this file.
|
||||
*/
|
||||
it.each([
|
||||
['postgres unique violation', { code: '23505', message: 'duplicate key value violates unique constraint' }, true],
|
||||
['sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT', message: 'UNIQUE constraint failed: event_people_merge_dismissals.event_id' }, true],
|
||||
['better-sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT_UNIQUE', message: 'UNIQUE constraint failed' }, true],
|
||||
['sqlite foreign-key violation', { code: 'SQLITE_CONSTRAINT', message: 'FOREIGN KEY constraint failed' }, false],
|
||||
['sqlite busy', { code: 'SQLITE_BUSY', message: 'database is locked' }, false],
|
||||
['missing table', { code: 'SQLITE_ERROR', message: 'no such table: event_people_merge_dismissals' }, false],
|
||||
['postgres read-only transaction', { code: '25006', message: 'cannot execute INSERT in a read-only transaction' }, false],
|
||||
['no error at all', null, false],
|
||||
])('%s → swallowed: %s', (_name, err, expected) => {
|
||||
expect(clustering.isUniqueViolation(err)).toBe(expected);
|
||||
});
|
||||
|
||||
/**
|
||||
* The dismissal read is the only thing standing between the automatic pass
|
||||
* and a pair the photographer explicitly separated. If it fails open, a
|
||||
* timeout silently restores the merge that "Not the same" was supposed to
|
||||
* prevent — so anything other than a missing table must stop the pass.
|
||||
*/
|
||||
it('refuses to consolidate when the dismissal list cannot be read', async () => {
|
||||
const eventId = await seedEvent('dismissals-unreadable');
|
||||
// Well above the auto-merge threshold, so only a refusal keeps them apart.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
await insertPersonWithFace(eventId, a);
|
||||
await insertPersonWithFace(eventId, b);
|
||||
|
||||
// Break the read for real rather than mocking knex: dropping a selected
|
||||
// column makes the query fail with something that is NOT "missing
|
||||
// table", which is exactly the class that must not fail open.
|
||||
await db.schema.alterTable('event_people_merge_dismissals', (t) => t.dropColumn('person_b_id'));
|
||||
try {
|
||||
await expect(clustering.consolidate(eventId, { thresholds: THRESHOLDS }))
|
||||
.rejects.toThrow();
|
||||
|
||||
// Nothing merged: the pass gave up rather than overriding a decision
|
||||
// it could not read.
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
} finally {
|
||||
await db.schema.alterTable('event_people_merge_dismissals', (t) => {
|
||||
t.integer('person_b_id').notNullable().defaultTo(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['postgres undefined_table', { code: '42P01', message: 'relation "x" does not exist' }, true],
|
||||
['sqlite missing table', { code: 'SQLITE_ERROR', message: 'no such table: x' }, true],
|
||||
// The one that matters: a missing COLUMN is a broken query, not a
|
||||
// pre-migration install, and must NOT be allowed to fail open.
|
||||
['postgres undefined_column', { code: '42703', message: 'column "x" does not exist' }, false],
|
||||
['sqlite missing column', { code: 'SQLITE_ERROR', message: 'no such column: x' }, false],
|
||||
['statement timeout', { code: '57014', message: 'canceling statement due to statement timeout' }, false],
|
||||
])('missing-table check — %s → %s', (_name, err, expected) => {
|
||||
expect(clustering.isMissingTable(err)).toBe(expected);
|
||||
});
|
||||
|
||||
it('normalizes the pair so one row covers both orderings', async () => {
|
||||
const eventId = await seedEvent('dismissal-normalized');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idB, idA);
|
||||
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].person_a_id).toBe(Math.min(idA, idB));
|
||||
expect(rows[0].person_b_id).toBe(Math.max(idA, idB));
|
||||
});
|
||||
});
|
||||
|
||||
describe('one suggestion per person per round', () => {
|
||||
it('does not offer A-B, A-C and B-C for a three-way fragment', async () => {
|
||||
const eventId = await seedEvent('three-way');
|
||||
// Three mutually similar centroids, all inside the band.
|
||||
const base = new Float32Array(DIM); base[0] = 1;
|
||||
const people = [];
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const v = new Float32Array(DIM);
|
||||
v[0] = 0.9;
|
||||
v[1 + k] = Math.sqrt(1 - 0.81);
|
||||
people.push(await insertPerson(eventId, v));
|
||||
}
|
||||
await insertPerson(eventId, base);
|
||||
|
||||
const out = await suggest(eventId);
|
||||
|
||||
// Every returned pair must name people not already spoken for: accepting
|
||||
// the first suggestion must never leave a second one pointing at a person
|
||||
// that the merge just deleted.
|
||||
const seen = new Set();
|
||||
for (const s of out) {
|
||||
expect(seen.has(s.person_a_id)).toBe(false);
|
||||
expect(seen.has(s.person_b_id)).toBe(false);
|
||||
seen.add(s.person_a_id);
|
||||
seen.add(s.person_b_id);
|
||||
}
|
||||
});
|
||||
|
||||
it('offers the most similar pair first', async () => {
|
||||
const eventId = await seedEvent('ordering');
|
||||
const [a1, b1] = pairAtSimilarity(0.62, 0);
|
||||
const [a2, b2] = pairAtSimilarity(0.67, 10);
|
||||
await insertPerson(eventId, a1);
|
||||
await insertPerson(eventId, b1);
|
||||
await insertPerson(eventId, a2);
|
||||
await insertPerson(eventId, b2);
|
||||
|
||||
const out = await suggest(eventId);
|
||||
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0].score).toBeGreaterThan(out[1].score);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual splits survive the automatic pass', () => {
|
||||
/**
|
||||
* The regression that matters most once consolidation runs on every scan:
|
||||
* a photographer splitting a wrongly-merged cluster produces two people
|
||||
* who are look-alikes BY CONSTRUCTION, so their centroids sit above the
|
||||
* merge threshold and the very next scan would put them straight back.
|
||||
*/
|
||||
it('records a split as a separation, so consolidation leaves it alone', async () => {
|
||||
const eventId = await seedEvent('split-protected');
|
||||
const base = new Float32Array(DIM); base[0] = 1;
|
||||
|
||||
// One cluster holding two near-identical faces.
|
||||
const personId = await insertPersonWithFace(eventId, base);
|
||||
const extraFaceId = await addFaceTo(eventId, personId, base);
|
||||
|
||||
const newPersonId = await clustering.splitPerson(eventId, personId, [extraFaceId]);
|
||||
expect(newPersonId).toBeTruthy();
|
||||
|
||||
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect([rows[0].person_a_id, rows[0].person_b_id].sort())
|
||||
.toEqual([personId, newPersonId].sort());
|
||||
|
||||
// Identical centroids — nothing but the recorded separation can stop
|
||||
// this merge.
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
expect(merged).toEqual([]);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consolidation reporting', () => {
|
||||
it('records what an automatic pass merged, so it is not silent', async () => {
|
||||
const eventId = await seedEvent('report-merged');
|
||||
// 0.97 is above the 0.68 auto-merge threshold — consolidate() acts.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
await insertPersonWithFace(eventId, a);
|
||||
await insertPersonWithFace(eventId, b);
|
||||
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
expect(merged).toHaveLength(1);
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
expect(Number(event.faces_last_consolidated_count)).toBe(1);
|
||||
expect(event.faces_last_consolidated_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('never absorbs an ignored cluster — that would mark a real person ignored', async () => {
|
||||
const eventId = await seedEvent('consolidate-ignored');
|
||||
// Well above the auto-merge threshold: only the is_ignored flag can
|
||||
// stop this pair.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const real = await insertPersonWithFace(eventId, a);
|
||||
const junk = await insertPersonWithFace(eventId, b, { is_ignored: true });
|
||||
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
expect(merged).toEqual([]);
|
||||
// Both still standing, and the real person is still guest-visible —
|
||||
// mergePeople ORs is_ignored onto the survivor, so absorbing the junk
|
||||
// cluster would have hidden a real person from the gallery.
|
||||
const survivors = await db('event_people').where({ event_id: eventId }).select('id', 'is_ignored');
|
||||
expect(survivors.map((p) => p.id).sort()).toEqual([real, junk].sort());
|
||||
const realRow = survivors.find((p) => p.id === real);
|
||||
expect(realRow.is_ignored === true || realRow.is_ignored === 1).toBe(false);
|
||||
});
|
||||
|
||||
it('never merges a pair the photographer said was not the same person', async () => {
|
||||
const eventId = await seedEvent('consolidate-dismissed');
|
||||
// Also above the auto-merge threshold: the dismissal is the only thing
|
||||
// standing between these two, which is the point — a human "no" has to
|
||||
// outrank the automatic pass, not just the suggestion list.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPersonWithFace(eventId, a);
|
||||
const idB = await insertPersonWithFace(eventId, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
expect(merged).toEqual([]);
|
||||
expect(await db('event_people').where({ event_id: eventId }).count({ c: '*' }).first())
|
||||
.toEqual(expect.objectContaining({ c: 2 }));
|
||||
});
|
||||
|
||||
// NOT covered by a test: reporting what a pass merged before it died
|
||||
// partway. `consolidate` calls `mergePeople` through the module-local
|
||||
// binding, so a spy on the export cannot intercept it, and no realistic
|
||||
// database failure lands on the second merge only. The recording therefore
|
||||
// sits in a `finally` — each mergePeople is its own transaction, so a pass
|
||||
// that throws has still committed what it did, and the alternative is a
|
||||
// real merge going unreported. Verified by reading, not by assertion.
|
||||
|
||||
it('clears a previous count when a later pass merges nothing', async () => {
|
||||
const eventId = await seedEvent('report-cleared');
|
||||
await db('events').where({ id: eventId }).update({ faces_last_consolidated_count: 7 });
|
||||
|
||||
const [a, b] = pairAtSimilarity(0.5, 0);
|
||||
await insertPersonWithFace(eventId, a);
|
||||
await insertPersonWithFace(eventId, b);
|
||||
|
||||
await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
expect(Number(event.faces_last_consolidated_count)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,415 +0,0 @@
|
||||
/**
|
||||
* Privacy and visibility guarantees for face recognition (#1074).
|
||||
*
|
||||
* These are the tests that matter most in this feature. Two of them cover
|
||||
* defects that would be invisible in normal use:
|
||||
*
|
||||
* - The people strip is computed from face rows, which have no concept of
|
||||
* photo visibility. Handing a guest a raw count leaks how many hidden
|
||||
* photos someone appears in, and a cover face picked without scoping
|
||||
* renders a crop of a photo the guest may not open.
|
||||
*
|
||||
* - Face embeddings are biometric data. They must not ride along in a
|
||||
* .picpeak export, which gets handed to clients and moved between
|
||||
* operators.
|
||||
*/
|
||||
|
||||
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-faceprivacy-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceprivacy-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering; let peopleService; let faceProcessor;
|
||||
|
||||
function makeEmbedding(id, variant = 0, dim = 64) {
|
||||
const vec = new Float32Array(dim);
|
||||
for (let i = 0; i < dim; i++) {
|
||||
vec[i] = Math.sin((i + 1) * (id + 1) * 0.7) + variant * 0.02 * Math.cos(i * 3.1);
|
||||
}
|
||||
let norm = 0;
|
||||
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
|
||||
norm = Math.sqrt(norm);
|
||||
for (let i = 0; i < dim; i++) vec[i] /= norm;
|
||||
return vec;
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function addPhotoWithFace(eventId, embedding, { visibility = 'visible', score = 0.99 } = {}) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
visibility,
|
||||
processing_status: 'complete',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const row = {
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: score,
|
||||
embedding: clustering.packEmbedding(embedding),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
const [f] = await db('photo_faces').insert(row).returning('id');
|
||||
return { photoId, face: { ...row, id: typeof f === 'object' ? f.id : f } };
|
||||
}
|
||||
|
||||
describe('face privacy and visibility (#1074)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
peopleService = require('../../src/services/facePeopleService');
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('visibility scoping', () => {
|
||||
it('counts only photos the audience can actually see', async () => {
|
||||
const eventId = await seedEvent('visibility-count');
|
||||
const faces = [];
|
||||
// Same person: 3 visible photos, 4 hidden ones.
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(1, v))).face);
|
||||
}
|
||||
for (let v = 3; v < 7; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(1, v), { visibility: 'hidden' })).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
const clientView = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
|
||||
expect(guestView).toHaveLength(1);
|
||||
// The leak this test exists to prevent: 3, never 7.
|
||||
expect(guestView[0].face_count).toBe(3);
|
||||
expect(clientView[0].face_count).toBe(7);
|
||||
});
|
||||
|
||||
it('never returns face_count_total to a guest', async () => {
|
||||
const eventId = await seedEvent('no-total-leak');
|
||||
const { face } = await addPhotoWithFace(eventId, makeEmbedding(2));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
|
||||
const [person] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(person).not.toHaveProperty('total_face_count');
|
||||
expect(person).not.toHaveProperty('is_hidden');
|
||||
});
|
||||
|
||||
it('picks a cover face from a photo the guest may open', async () => {
|
||||
const eventId = await seedEvent('cover-scoping');
|
||||
// The BEST face (highest score) is in a hidden photo — a naive
|
||||
// implementation would hand its crop to the guest.
|
||||
const hidden = await addPhotoWithFace(eventId, makeEmbedding(3, 0), {
|
||||
visibility: 'hidden', score: 0.99,
|
||||
});
|
||||
const visible = await addPhotoWithFace(eventId, makeEmbedding(3, 1), {
|
||||
visibility: 'visible', score: 0.80,
|
||||
});
|
||||
await clustering.assignFaces(eventId, [hidden.face, visible.face]);
|
||||
|
||||
const [guestPerson] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestPerson.cover.photo_id).toBe(visible.photoId);
|
||||
expect(guestPerson.cover.photo_id).not.toBe(hidden.photoId);
|
||||
});
|
||||
|
||||
it('prefers the cover the photographer chose (#1096)', async () => {
|
||||
const eventId = await seedEvent('chosen-cover');
|
||||
// The auto-pick would take the 0.99 face. The photographer picked the
|
||||
// other one — without this the PATCH saved, the toast said so, and the
|
||||
// avatar reverted on the very next read.
|
||||
const best = await addPhotoWithFace(eventId, makeEmbedding(9, 0), { score: 0.99 });
|
||||
const chosen = await addPhotoWithFace(eventId, makeEmbedding(9, 1), { score: 0.70 });
|
||||
await clustering.assignFaces(eventId, [best.face, chosen.face]);
|
||||
|
||||
const [before] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
expect(before.cover.photo_id).toBe(best.photoId);
|
||||
// Clustering must not have written one: an automatic seed here would be
|
||||
// indistinguishable from a real choice the moment listPeople honours it.
|
||||
const seeded = await db('event_people').where({ id: before.id }).first();
|
||||
expect(seeded.cover_face_id).toBeFalsy();
|
||||
|
||||
await db('event_people').where({ id: before.id }).update({ cover_face_id: chosen.face.id });
|
||||
|
||||
const [after] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
expect(after.cover.photo_id).toBe(chosen.photoId);
|
||||
});
|
||||
|
||||
it('carries a chosen cover through a merge', async () => {
|
||||
const eventId = await seedEvent('cover-merge');
|
||||
const a = await addPhotoWithFace(eventId, makeEmbedding(20, 0), { score: 0.90 });
|
||||
const b = await addPhotoWithFace(eventId, makeEmbedding(60, 0), { score: 0.95 });
|
||||
await clustering.assignFaces(eventId, [a.face]);
|
||||
await clustering.assignFaces(eventId, [b.face]);
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
expect(people.length).toBeGreaterThan(1);
|
||||
|
||||
// The SOURCE carries the choice; the target has none.
|
||||
await db('event_people').where({ id: people[1].id }).update({ cover_face_id: b.face.id });
|
||||
await clustering.mergePeople(eventId, [people[1].id], people[0].id);
|
||||
|
||||
const target = await db('event_people').where({ id: people[0].id }).first();
|
||||
expect(target.cover_face_id).toBe(b.face.id);
|
||||
});
|
||||
|
||||
it('carries a chosen cover through a recluster', async () => {
|
||||
const eventId = await seedEvent('cover-recluster');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(21, v), { score: 0.9 - v * 0.1 })).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
const [person] = await db('event_people').where({ event_id: eventId });
|
||||
// Pick the WORST-scoring face, so an automatic re-pick would differ.
|
||||
const chosen = faces[2].id;
|
||||
await db('event_people').where({ id: person.id }).update({ cover_face_id: chosen });
|
||||
|
||||
await clustering.recluster(eventId);
|
||||
|
||||
const after = await db('event_people').where({ event_id: eventId }).whereNotNull('cover_face_id');
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0].cover_face_id).toBe(chosen);
|
||||
});
|
||||
|
||||
it('falls back to a visible face when the chosen cover is hidden from this audience', async () => {
|
||||
const eventId = await seedEvent('chosen-cover-hidden');
|
||||
// Choosing a cover must never override the visibility scoping — that
|
||||
// would hand a guest a crop of a photo they cannot open.
|
||||
const hidden = await addPhotoWithFace(eventId, makeEmbedding(10, 0), {
|
||||
visibility: 'hidden', score: 0.99,
|
||||
});
|
||||
const visible = await addPhotoWithFace(eventId, makeEmbedding(10, 1), { score: 0.70 });
|
||||
await clustering.assignFaces(eventId, [hidden.face, visible.face]);
|
||||
|
||||
const [person] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
await db('event_people').where({ id: person.id }).update({ cover_face_id: hidden.face.id });
|
||||
|
||||
const [guestView] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestView.cover.photo_id).toBe(visible.photoId);
|
||||
expect(guestView.cover.photo_id).not.toBe(hidden.photoId);
|
||||
});
|
||||
|
||||
it('drops a person entirely when all their photos are hidden', async () => {
|
||||
const eventId = await seedEvent('all-hidden');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(4, v), { visibility: 'hidden' })).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestView).toHaveLength(0);
|
||||
const clientView = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
expect(clientView).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('omits hidden and ignored people from the guest response', async () => {
|
||||
const eventId = await seedEvent('hidden-people');
|
||||
const a = (await addPhotoWithFace(eventId, makeEmbedding(5))).face;
|
||||
const b = (await addPhotoWithFace(eventId, makeEmbedding(6))).face;
|
||||
await clustering.assignFaces(eventId, [a, b]);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
await db('event_people').where({ id: people[0].id }).update({ is_hidden: true });
|
||||
await db('event_people').where({ id: people[1].id }).update({ is_ignored: true });
|
||||
|
||||
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestView).toHaveLength(0);
|
||||
const adminView = await peopleService.listPeople(eventId, { isClient: true, forAdmin: true });
|
||||
expect(adminView).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not attach a hidden person to a photo a guest can see', async () => {
|
||||
const eventId = await seedEvent('person-ids-hidden');
|
||||
const { photoId, face } = await addPhotoWithFace(eventId, makeEmbedding(7));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
await db('event_people').where({ id: person.id }).update({ is_hidden: true });
|
||||
|
||||
const guestMap = await peopleService.getPersonIdsByPhoto(eventId, [photoId], { forAdmin: false });
|
||||
expect(guestMap.get(photoId)).toBeUndefined();
|
||||
|
||||
const adminMap = await peopleService.getPersonIdsByPhoto(eventId, [photoId], { forAdmin: true });
|
||||
expect(adminMap.get(photoId)).toEqual([person.id]);
|
||||
});
|
||||
|
||||
it('respects the minimum cluster size so one-off bystanders stay out', async () => {
|
||||
const eventId = await seedEvent('min-cluster');
|
||||
const solo = (await addPhotoWithFace(eventId, makeEmbedding(8))).face;
|
||||
const crowd = [];
|
||||
for (let v = 0; v < 4; v++) {
|
||||
crowd.push((await addPhotoWithFace(eventId, makeEmbedding(9, v))).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, [solo, ...crowd]);
|
||||
|
||||
const people = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 3 });
|
||||
expect(people).toHaveLength(1);
|
||||
expect(people[0].face_count).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('erasure', () => {
|
||||
it('purgeEvent removes every face row and resets the photos', async () => {
|
||||
const eventId = await seedEvent('purge');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(10, v))).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
await db('photos').where({ event_id: eventId }).update({ face_status: 'done', face_count: 1 });
|
||||
|
||||
expect(await db('photo_faces').where({ event_id: eventId })).not.toHaveLength(0);
|
||||
expect(await db('event_people').where({ event_id: eventId })).not.toHaveLength(0);
|
||||
|
||||
await faceProcessor.purgeEvent(eventId);
|
||||
|
||||
expect(await db('photo_faces').where({ event_id: eventId })).toHaveLength(0);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.every((p) => p.face_status === null && p.face_count === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('purgePhotoFaces removes face rows WITHOUT relying on the FK cascade', async () => {
|
||||
// The regression this guards: PicPeak does not enable
|
||||
// `PRAGMA foreign_keys` on SQLite, so ON DELETE CASCADE never fires
|
||||
// there and biometric embeddings outlived the photo. The pragma is
|
||||
// explicitly OFF here so the assertion can only pass if the deletion
|
||||
// path purges the rows itself.
|
||||
await db.raw('PRAGMA foreign_keys = OFF');
|
||||
|
||||
const eventId = await seedEvent('purge-no-cascade');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(20, v))).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(person.face_count_total).toBe(3);
|
||||
|
||||
const victim = faces[0];
|
||||
await faceProcessor.purgePhotoFaces(victim.photo_id);
|
||||
|
||||
expect(await db('photo_faces').where({ photo_id: victim.photo_id })).toHaveLength(0);
|
||||
// …and the person it belonged to was rebuilt, not left with a stale count.
|
||||
const after = await db('event_people').where({ id: person.id }).first();
|
||||
expect(after.face_count_total).toBe(2);
|
||||
});
|
||||
|
||||
it('purging the last face of a person removes the person too', async () => {
|
||||
await db.raw('PRAGMA foreign_keys = OFF');
|
||||
const eventId = await seedEvent('purge-last-face');
|
||||
const { face, photoId } = await addPhotoWithFace(eventId, makeEmbedding(21));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
|
||||
|
||||
await faceProcessor.purgePhotoFaces(photoId);
|
||||
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deleting an event removes its people and faces', async () => {
|
||||
await db.raw('PRAGMA foreign_keys = ON');
|
||||
const eventId = await seedEvent('event-delete');
|
||||
const { face } = await addPhotoWithFace(eventId, makeEmbedding(11));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
|
||||
await db('photos').where({ event_id: eventId }).del();
|
||||
await db('events').where({ id: eventId }).del();
|
||||
|
||||
expect(await db('photo_faces').where({ event_id: eventId })).toHaveLength(0);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all-in-one image block (#1042 / PR #1068)', () => {
|
||||
// Blocked for performance: the AIO image runs backend, frontend, SQLite
|
||||
// and every worker in one container, with no ML sidecar to talk to. The
|
||||
// failure there would not be loud — just a slow install that looks
|
||||
// broken — so the gate is asserted rather than assumed.
|
||||
const faceSettings = require('../../src/services/faceSettings');
|
||||
|
||||
afterEach(() => { delete process.env.PICPEAK_SINGLE_CONTAINER; });
|
||||
|
||||
it('reports the feature off regardless of the flag row', async () => {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(true);
|
||||
// Even with the flag ON in the database.
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => {
|
||||
await db('feature_flags').where({ key: 'faces' }).update({ value: true });
|
||||
});
|
||||
expect(await faceSettings.isFeatureEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses per-event detection too', async () => {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
|
||||
const eventId = await seedEvent('aio-block');
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
expect(event.face_recognition_enabled).toBeTruthy();
|
||||
expect(await faceSettings.isEnabledForEvent(event)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts only explicit truthy markers', () => {
|
||||
for (const v of ['true', '1', 'yes', 'TRUE']) {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = v;
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(true);
|
||||
}
|
||||
for (const v of ['false', '0', '', 'no']) {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = v;
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(false);
|
||||
}
|
||||
delete process.env.PICPEAK_SINGLE_CONTAINER;
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('export and backup exclusion', () => {
|
||||
it('excludes both face tables from .picpeak exports', () => {
|
||||
const { EXCLUDED_TABLES } = require('../../src/services/picpeakExportService');
|
||||
expect(EXCLUDED_TABLES.has('photo_faces')).toBe(true);
|
||||
expect(EXCLUDED_TABLES.has('event_people')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes both face tables from the database backup table list', async () => {
|
||||
const databaseBackup = require('../../src/services/databaseBackup');
|
||||
const service = databaseBackup.DatabaseBackupService
|
||||
? new databaseBackup.DatabaseBackupService()
|
||||
: databaseBackup;
|
||||
if (typeof service.getTables !== 'function') return; // shape differs; covered by the export test
|
||||
|
||||
const tables = await service.getTables();
|
||||
expect(tables).not.toContain('photo_faces');
|
||||
expect(tables).not.toContain('event_people');
|
||||
// Sanity: the filter didn't eat everything.
|
||||
expect(tables).toContain('events');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Bounding-box coordinate space (#1074).
|
||||
*
|
||||
* The sidecar reports boxes in the pixel space of the image it was HANDED —
|
||||
* the ≤1920px preview — while every consumer (the strip's avatar crop, the
|
||||
* admin manager, the auto-category portrait rule) compares them against
|
||||
* photos.width/height, the ORIGINAL dimensions. faceProcessor scales once so
|
||||
* everything downstream can assume original-image coordinates.
|
||||
*
|
||||
* This is the defect that survived longest in review, and it is invisible on
|
||||
* any photo already under 1920px — the entire demo gallery was 750px, so the
|
||||
* scale factor was always exactly 1.0 and the correction never ran. Verified
|
||||
* by hand afterwards on a real 4000x3000 upload (stored box moved from
|
||||
* 1493,204 to 3110,426 — a factor of 2.083, exactly 4000/1920). This test
|
||||
* exists so that verification does not have to be repeated by hand.
|
||||
*/
|
||||
|
||||
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-facescale-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facescale-test-secret';
|
||||
|
||||
// A 1920x1440 JPEG standing in for the preview rendition. faceProcessor reads
|
||||
// its dimensions with sharp to derive the scale, so it must be a real image.
|
||||
const sharp = require('sharp');
|
||||
|
||||
let mockPreviewBuffer;
|
||||
const mockSidecarBox = [1493, 204, 131, 161]; // what the sidecar sees on the preview
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => ({
|
||||
...jest.requireActual('../../src/services/imageProcessor'),
|
||||
ensurePreviewImage: jest.fn(async () => 'previews/preview_test.jpg'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({
|
||||
getStorage: () => ({ get: async () => mockPreviewBuffer }),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/faceClient', () => ({
|
||||
detectFaces: jest.fn(async () => ({
|
||||
model_version: 'test-v1',
|
||||
faces: [{
|
||||
bbox: mockSidecarBox,
|
||||
score: 0.99,
|
||||
landmarks: [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
|
||||
yaw: 0, pitch: 0, blur: 500,
|
||||
embedding: Array.from({ length: 64 }, (_, i) => (i === 0 ? 1 : 0)),
|
||||
}],
|
||||
})),
|
||||
SidecarUnavailableError: class extends Error {},
|
||||
}));
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceProcessor;
|
||||
|
||||
async function seedPhoto(width, height) {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `scale-${width}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'scale',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `scale-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'big.jpg',
|
||||
path: '/tmp/big.jpg',
|
||||
type: 'individual',
|
||||
width,
|
||||
height,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
describe('face bbox coordinate space (#1074)', () => {
|
||||
beforeAll(async () => {
|
||||
mockPreviewBuffer = await sharp({
|
||||
create: { width: 1920, height: 1440, channels: 3, background: { r: 20, g: 40, b: 80 } },
|
||||
}).jpeg().toBuffer();
|
||||
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// The faces flag gates everything; turn it on for this suite.
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('scales preview-space boxes into ORIGINAL image coordinates', async () => {
|
||||
// 4000px original, 1920px preview -> every coordinate must grow by 4000/1920.
|
||||
const { photoId } = await seedPhoto(4000, 3000);
|
||||
await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
const face = await db('photo_faces').where({ photo_id: photoId }).first();
|
||||
const scale = 4000 / 1920;
|
||||
|
||||
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0] * scale, 1);
|
||||
expect(face.bbox_y).toBeCloseTo(mockSidecarBox[1] * scale, 1);
|
||||
expect(face.bbox_w).toBeCloseTo(mockSidecarBox[2] * scale, 1);
|
||||
expect(face.bbox_h).toBeCloseTo(mockSidecarBox[3] * scale, 1);
|
||||
|
||||
// The regression this guards: the raw preview-space value being stored.
|
||||
expect(face.bbox_x).not.toBeCloseTo(mockSidecarBox[0], 1);
|
||||
// And a sanity check that it lands inside the original frame.
|
||||
expect(face.bbox_x + face.bbox_w).toBeLessThanOrEqual(4000);
|
||||
});
|
||||
|
||||
it('leaves boxes untouched when the photo is already preview-sized', async () => {
|
||||
// The case that hid the bug: no downscale, so scale is exactly 1 and the
|
||||
// stored box equals what the sidecar reported.
|
||||
const { photoId } = await seedPhoto(1920, 1440);
|
||||
await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
const face = await db('photo_faces').where({ photo_id: photoId }).first();
|
||||
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0], 1);
|
||||
expect(face.bbox_w).toBeCloseTo(mockSidecarBox[2], 1);
|
||||
});
|
||||
|
||||
it('falls back to unscaled rather than corrupting when width is unknown', async () => {
|
||||
// Pre-dimension-migration rows have no width. Storing a box scaled by
|
||||
// NaN/0 would be worse than storing an unscaled one.
|
||||
const { photoId } = await seedPhoto(null, null);
|
||||
await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
const face = await db('photo_faces').where({ photo_id: photoId }).first();
|
||||
expect(Number.isFinite(face.bbox_x)).toBe(true);
|
||||
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0], 1);
|
||||
});
|
||||
});
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* A deferred photo must not stall the queue.
|
||||
*
|
||||
* claimNextPhoto orders by id ascending, and the queue defaults to a single
|
||||
* worker. So returning an unreachable photo to 'pending' — the obvious way to
|
||||
* say "try again later" — makes that same row the oldest pending one forever:
|
||||
* the worker reclaims it after every backoff and never reaches a higher id.
|
||||
* One dead mount would stall face scanning for the entire install, including
|
||||
* unrelated events and fresh uploads.
|
||||
*
|
||||
* The row is instead left parked in 'processing' with its face_started_at
|
||||
* intact. It is not claimable, so the worker advances; the existing janitor
|
||||
* returns it to 'pending' after STUCK_TIMEOUT_MS, which is the retry.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-defer-'));
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'defer-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceQueue; let faceProcessor;
|
||||
|
||||
describe('deferred photos do not block the queue', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
faceQueue = require('../../src/services/faceQueue');
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('exports TransientSourceError for the queue to branch on', () => {
|
||||
// The queue imports this from faceProcessor; if the export is dropped the
|
||||
// instanceof check silently becomes false and every deferral turns back
|
||||
// into a permanent failure.
|
||||
expect(typeof faceProcessor.TransientSourceError).toBe('function');
|
||||
expect(new faceProcessor.TransientSourceError(1, 'x'))
|
||||
.toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('does NOT return a deferred row to pending', () => {
|
||||
// Source inspection, deliberately. workerLoop is an unexported infinite
|
||||
// loop, so the branch cannot be driven directly, and asserting on database
|
||||
// state alone does not distinguish the fix from the bug — a version that
|
||||
// re-queues the row passes every state assertion in this file. What
|
||||
// actually matters is that this one branch does not call releaseToPending,
|
||||
// so that is what is pinned. Same approach as the contract tests added for
|
||||
// #596.
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'services', 'faceQueue.js'), 'utf8'
|
||||
);
|
||||
|
||||
const marker = 'if (err instanceof TransientSourceError) {';
|
||||
const start = src.indexOf(marker);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
|
||||
// The branch body, up to its closing brace.
|
||||
const body = src.slice(start, src.indexOf('\n }', start));
|
||||
expect(body).not.toMatch(/releaseToPending/);
|
||||
expect(body).toMatch(/continue/);
|
||||
|
||||
// And the sidecar branch, which SHOULD still release, so this test fails
|
||||
// if the two branches are ever collapsed back together.
|
||||
const sideStart = src.indexOf('if (err instanceof SidecarUnavailableError) {');
|
||||
expect(sideStart).toBeGreaterThan(-1);
|
||||
const sideBody = src.slice(sideStart, src.indexOf('\n }', sideStart));
|
||||
expect(sideBody).toMatch(/releaseToPending/);
|
||||
});
|
||||
|
||||
it('leaves a deferred row claimable-later, not claimable-now', async () => {
|
||||
// A row parked in 'processing' is invisible to claimNextPhoto, which only
|
||||
// ever selects face_status='pending' — that is what lets the worker move
|
||||
// past it instead of spinning on it.
|
||||
const [e] = await db('events').insert({
|
||||
slug: `defer-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'defer',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `defer-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [stuck] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'stuck.jpg',
|
||||
path: 'd/stuck.jpg',
|
||||
type: 'individual',
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
face_started_at: new Date().toISOString(),
|
||||
source_origin: 'external',
|
||||
}).returning('id');
|
||||
const stuckId = typeof stuck === 'object' ? stuck.id : stuck;
|
||||
|
||||
const parked = await db('photos')
|
||||
.where({ id: stuckId, face_status: 'pending' })
|
||||
.first();
|
||||
expect(parked).toBeUndefined(); // not claimable while parked
|
||||
|
||||
// The janitor's contract is what turns the park into a retry: it resets
|
||||
// 'processing' rows whose face_started_at is older than the stuck timeout.
|
||||
// Backdate past it and the row becomes claimable again.
|
||||
const longAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
await db('photos').where({ id: stuckId }).update({ face_started_at: longAgo });
|
||||
|
||||
const cutoff = new Date(Date.now() - 600000).toISOString();
|
||||
const reset = await db('photos')
|
||||
.where('face_status', 'processing')
|
||||
.where('face_started_at', '<', cutoff)
|
||||
.update({ face_status: 'pending', face_started_at: null });
|
||||
|
||||
expect(reset).toBeGreaterThan(0);
|
||||
const after = await db('photos').where({ id: stuckId }).first();
|
||||
expect(after.face_status).toBe('pending');
|
||||
});
|
||||
|
||||
it('claimNextPhoto skips events inside their backoff window', async () => {
|
||||
// The per-event cooldown is what stops the janitor handing a whole dead
|
||||
// gallery back every sweep. Without the exclusion the worker walks all of
|
||||
// it again — one slow stat per photo against a possibly hard-mounted
|
||||
// share — before reaching any healthy event.
|
||||
const mk = async (name) => {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `cd-${name}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: name,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `cd-${name}-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
const [p2] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${name}.jpg`,
|
||||
path: `cd/${name}.jpg`,
|
||||
type: 'individual',
|
||||
processing_status: 'complete',
|
||||
face_status: 'pending',
|
||||
source_origin: 'external',
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p2 === 'object' ? p2.id : p2 };
|
||||
};
|
||||
|
||||
await db('photos').del();
|
||||
const dead = await mk('dead'); // lower id -> would win the FIFO
|
||||
const healthy = await mk('healthy');
|
||||
|
||||
// Without exclusion the dead event's row is claimed first...
|
||||
const first = await faceQueue.claimNextPhoto([]);
|
||||
expect(first.id).toBe(dead.photoId);
|
||||
await db('photos').where({ id: dead.photoId }).update({ face_status: 'pending' });
|
||||
|
||||
// ...and with it, the worker reaches the healthy event instead.
|
||||
const second = await faceQueue.claimNextPhoto([dead.eventId]);
|
||||
expect(second.id).toBe(healthy.photoId);
|
||||
});
|
||||
|
||||
it('backoff spares managed rows in a mixed-source event', async () => {
|
||||
// A reference event can hold managed uploads alongside imported external
|
||||
// ones. Excluding the whole event id would leave those unscanned for as
|
||||
// long as external rows keep renewing the cooldown — indefinitely, during
|
||||
// a real outage — even though their local source is fine.
|
||||
await db('photos').del();
|
||||
const [e] = await db('events').insert({
|
||||
slug: `mix-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'mix',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `mix-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const add = async (origin, name) => {
|
||||
const [p2] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: name,
|
||||
path: `mix/${name}`,
|
||||
type: 'individual',
|
||||
processing_status: 'complete',
|
||||
face_status: 'pending',
|
||||
source_origin: origin,
|
||||
}).returning('id');
|
||||
return typeof p2 === 'object' ? p2.id : p2;
|
||||
};
|
||||
await add('external', 'ext.jpg'); // lower id, would win the FIFO
|
||||
const managedId = await add('managed', 'man.jpg');
|
||||
|
||||
// Event is in backoff: the external row is skipped, the managed one is not.
|
||||
const claimed = await faceQueue.claimNextPhoto([eventId]);
|
||||
expect(claimed).toBeTruthy();
|
||||
expect(claimed.id).toBe(managedId);
|
||||
});
|
||||
|
||||
it('startQueue is exported and does not throw on import', () => {
|
||||
// faceQueue requires faceProcessor for TransientSourceError while
|
||||
// faceProcessor is itself required by the routes — a circular require here
|
||||
// would surface as an undefined export rather than a crash, so assert the
|
||||
// module actually loaded something usable.
|
||||
expect(faceQueue).toBeTruthy();
|
||||
expect(Object.keys(faceQueue).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* "The scan finished" is not a thing this queue is told (#1107).
|
||||
*
|
||||
* It claims photos one at a time, so a backfill is just a lot of independent
|
||||
* claims and the only available signal is a worker finding nothing left. That
|
||||
* signal is NOT sufficient on its own — with concurrency above one the other
|
||||
* workers may still be busy, and a photo released back to `pending` by a down
|
||||
* sidecar is still owed — so the drain is tested against the queue directly.
|
||||
*
|
||||
* These are the cases that decide whether consolidation runs too early (a
|
||||
* wasted pass over half-formed clusters) or never (the feature silently does
|
||||
* nothing, which is the state #1107 was filed about).
|
||||
*/
|
||||
|
||||
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-facedrain-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facedrain-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceQueue; let clustering;
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
// The drain rechecks this before consolidating, so the fixture has to be
|
||||
// a gallery that actually has detection on.
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** Both halves of the "two deliberate actions" rule have to be on. */
|
||||
async function enableFacesGlobally() {
|
||||
const existing = await db('feature_flags').where({ key: 'faces' }).first();
|
||||
if (existing) await db('feature_flags').where({ key: 'faces' }).update({ value: true });
|
||||
else await db('feature_flags').insert({ key: 'faces', value: true });
|
||||
}
|
||||
|
||||
async function insertPhoto(eventId, faceStatus) {
|
||||
const [row] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
face_status: faceStatus,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
describe('faceQueue drain consolidation (#1107)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
faceQueue = require('../../src/services/faceQueue');
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
await enableFacesGlobally();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(() => {
|
||||
faceQueue.touchedEvents.clear();
|
||||
faceQueue.consolidationRetryAt.clear();
|
||||
faceQueue.inFlightByEvent.clear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does nothing at all when no photo has been scanned', async () => {
|
||||
const spy = jest.spyOn(clustering, 'consolidate');
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits while the event still has photos queued', async () => {
|
||||
const eventId = await seedEvent('drain-pending');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await insertPhoto(eventId, 'pending');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate');
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
// Still owed, so it must keep its place for the next idle tick — dropping
|
||||
// it here would mean the gallery never consolidates at all.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it('waits while a photo is still being processed by another worker', async () => {
|
||||
const eventId = await seedEvent('drain-processing');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await insertPhoto(eventId, 'processing');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate');
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it('consolidates once the queue is empty, and does not repeat itself', async () => {
|
||||
const eventId = await seedEvent('drain-empty');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await insertPhoto(eventId, 'failed');
|
||||
await insertPhoto(eventId, 'skipped');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith(eventId);
|
||||
// Drained and handled, so a second idle tick must not pay for it again.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
|
||||
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a failing consolidation never propagates into the worker loop, and is retried', async () => {
|
||||
const eventId = await seedEvent('drain-throws');
|
||||
await insertPhoto(eventId, 'done');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(faceQueue.drainConsolidation()).resolves.toBeUndefined();
|
||||
|
||||
// A transient database error must not cost the gallery its consolidation
|
||||
// outright — the event keeps its place so a later tick retries.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
|
||||
// ...but not on the very next tick. The worker idles every couple of
|
||||
// seconds, so an immediate retry would hot-loop a permanently broken event
|
||||
// and warn every time.
|
||||
expect(faceQueue.consolidationRetryAt.get(eventId)).toBeGreaterThan(Date.now());
|
||||
const callsBefore = spy.mock.calls.length;
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).toHaveBeenCalledTimes(callsBefore);
|
||||
|
||||
// Once the backoff elapses it really does try again, and succeeds.
|
||||
faceQueue.consolidationRetryAt.set(eventId, Date.now() - 1);
|
||||
spy.mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
|
||||
expect(faceQueue.consolidationRetryAt.has(eventId)).toBe(false);
|
||||
});
|
||||
|
||||
it('waits while another worker is still inside processPhotoFaces', async () => {
|
||||
const eventId = await seedEvent('drain-inflight');
|
||||
// Every row already reads as drained: the last photo is committed 'done'
|
||||
// inside the transaction, and auto-categorisation runs afterwards. Only
|
||||
// the in-flight count knows a worker is still there.
|
||||
await insertPhoto(eventId, 'done');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
faceQueue.inFlightByEvent.set(eventId, 1);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
// Consolidating here would record its count, and the busy worker would
|
||||
// then re-mark the event — the next pass merges nothing and overwrites the
|
||||
// real number with zero.
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
|
||||
faceQueue.inFlightByEvent.delete(eventId);
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not consolidate an event whose detection was switched off mid-drain', async () => {
|
||||
const eventId = await seedEvent('drain-disabled');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await db('events').where({ id: eventId }).update({ face_recognition_enabled: false });
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
// An earlier photo legitimately marked the event before the toggle went
|
||||
// off. Merging someone's clusters just after they disabled the feature is
|
||||
// not a thing to do quietly.
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
// Dropped rather than retried — it is not coming back on its own.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats events independently — a busy gallery does not hold up a finished one', async () => {
|
||||
const busy = await seedEvent('drain-busy');
|
||||
const done = await seedEvent('drain-done');
|
||||
await insertPhoto(busy, 'pending');
|
||||
await insertPhoto(done, 'done');
|
||||
faceQueue.touchedEvents.add(busy);
|
||||
faceQueue.touchedEvents.add(done);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith(done);
|
||||
expect(faceQueue.touchedEvents.has(busy)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,567 +0,0 @@
|
||||
/**
|
||||
* "Not the same person" has to outlive re-derivation (#1132).
|
||||
*
|
||||
* The decision used to be stored as a pair of event_people.id, and neither
|
||||
* person ids nor face ids survive:
|
||||
*
|
||||
* - recluster() deletes every person and re-assigns, so person ids die but
|
||||
* photo_faces.id survives
|
||||
* - a full re-scan replaces a photo's faces outright, so FACE ids die too
|
||||
*
|
||||
* The embedding is the only stable handle, so that is what the separation is
|
||||
* keyed on. These tests simulate both kinds of re-derivation by destroying the
|
||||
* ids and rebuilding from the same vectors — which is exactly what the real
|
||||
* paths do — and assert the constraint still binds.
|
||||
*/
|
||||
|
||||
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-sep-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sep-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering;
|
||||
|
||||
const THRESHOLDS = { face_match_threshold: 0.6, face_quality_min_score: 0.7, face_quality_min_px: 40 };
|
||||
const DIM = 64;
|
||||
|
||||
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
|
||||
function pairAtSimilarity(target, basis) {
|
||||
const a = new Float32Array(DIM);
|
||||
const b = new Float32Array(DIM);
|
||||
a[basis] = 1;
|
||||
b[basis] = target;
|
||||
b[basis + 1] = Math.sqrt(1 - target * target);
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug, event_type: 'wedding', event_name: slug, event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: `${slug}-share`, expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function insertPerson(eventId, centroid, overrides = {}) {
|
||||
const [row] = await db('event_people').insert({
|
||||
event_id: eventId,
|
||||
centroid: clustering.packEmbedding(centroid),
|
||||
face_count_total: 1,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** The mirror of pairAtSimilarity's second vector: same similarity, other side. */
|
||||
function mirrorAtSimilarity(target, basis) {
|
||||
const b = new Float32Array(DIM);
|
||||
b[basis] = target;
|
||||
b[basis + 1] = -Math.sqrt(1 - target * target);
|
||||
return b;
|
||||
}
|
||||
|
||||
async function insertFaceWithPhoto(eventId, personId, centroid) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/x.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId, event_id: eventId, person_id: personId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(centroid),
|
||||
model_version: 'test-v1', created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return { faceId: typeof f === 'object' ? f.id : f, photoId };
|
||||
}
|
||||
|
||||
async function insertFace(eventId, personId, centroid) {
|
||||
const { faceId } = await insertFaceWithPhoto(eventId, personId, centroid);
|
||||
return faceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a re-scan does to identity: the people are gone and the faces come back
|
||||
* with brand-new ids. Same vectors, nothing else preserved.
|
||||
*/
|
||||
async function simulateRescan(eventId, vectors) {
|
||||
await db('photo_faces').where({ event_id: eventId }).del();
|
||||
await db('event_people').where({ event_id: eventId }).del();
|
||||
const ids = [];
|
||||
for (const vec of vectors) {
|
||||
const personId = await insertPerson(eventId, vec);
|
||||
await insertFace(eventId, personId, vec);
|
||||
ids.push(personId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe('separations survive re-derivation (#1132)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('the matcher', () => {
|
||||
it('binds a pair that still looks like the one that was separated', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
|
||||
});
|
||||
|
||||
it('binds regardless of which way round the candidates arrive', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
// Neither the stored pair nor the candidate pair has a meaningful order.
|
||||
expect(clustering.separationForbids(b, a, [{ a, b }])).toBe(true);
|
||||
});
|
||||
|
||||
it('lapses once a side has drifted past recognition', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
// A cluster reshaped far enough is no longer the cluster the
|
||||
// photographer pointed at, so the constraint should stop applying rather
|
||||
// than bind something they never saw.
|
||||
const drifted = new Float32Array(DIM);
|
||||
drifted[10] = 1;
|
||||
expect(clustering.separationForbids(drifted, b, [{ a, b }])).toBe(false);
|
||||
});
|
||||
|
||||
it('does not bind two clusters that are both the SAME side', () => {
|
||||
// A split leaves two halves of one cluster, so the pair it records is
|
||||
// often similar to itself — here 0.95. Two candidates that are plainly
|
||||
// both side A (0.97 to each other) each clear the bar against BOTH
|
||||
// stored sides, so a test that only asks "does each side match
|
||||
// something" says yes and refuses to let that person cluster with
|
||||
// itself. It fragments into singletons — the person the split was not
|
||||
// even about.
|
||||
const [a, b] = pairAtSimilarity(0.95, 0);
|
||||
const x = new Float32Array(DIM); x[0] = 1;
|
||||
const y = mirrorAtSimilarity(0.97, 0);
|
||||
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
|
||||
// The pair it was actually about still binds.
|
||||
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a separation recorded under a different embedding model', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
// Vectors from another model are meaningless here, not merely stale —
|
||||
// the same rule assignment and consolidation apply to person centroids.
|
||||
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v2' }],
|
||||
{ modelVersion: 'test-v1' })).toBe(false);
|
||||
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v1' }],
|
||||
{ modelVersion: 'test-v1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores an unrelated pair entirely', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const [x, y] = pairAtSimilarity(0.64, 20);
|
||||
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('across a re-scan', () => {
|
||||
it('still refuses to merge the pair after every id has changed', async () => {
|
||||
const eventId = await seedEvent('sep-rescan');
|
||||
// Well above the auto-merge threshold: only the separation keeps them apart.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await insertFace(eventId, idB, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
const newIds = await simulateRescan(eventId, [a, b]);
|
||||
// The premise: nothing the old row named still exists.
|
||||
expect(newIds).not.toContain(idA);
|
||||
expect(newIds).not.toContain(idB);
|
||||
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
expect(merged).toEqual([]);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the pair out of the suggestion list too', async () => {
|
||||
const eventId = await seedEvent('sep-rescan-suggest');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0); // inside the suggestion band
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await simulateRescan(eventId, [a, b]);
|
||||
|
||||
expect(await clustering.suggestMerges(eventId, { thresholds: THRESHOLDS })).toEqual([]);
|
||||
});
|
||||
|
||||
it('a split still binds after the ids it recorded are gone', async () => {
|
||||
const eventId = await seedEvent('sep-split-rescan');
|
||||
// Two faces that look alike enough to have been clustered together, but
|
||||
// are not the same vector — which is what a split is FOR, and the only
|
||||
// case it can survive re-derivation in. Two byte-identical embeddings
|
||||
// carry no information about which side is which, so a separation
|
||||
// between them has nothing to key on once the ids are gone.
|
||||
const [base, other] = pairAtSimilarity(0.96, 0);
|
||||
const personId = await insertPerson(eventId, base);
|
||||
await insertFace(eventId, personId, base);
|
||||
const extra = await insertFace(eventId, personId, other);
|
||||
|
||||
const newPersonId = await clustering.splitPerson(eventId, personId, [extra]);
|
||||
expect(newPersonId).toBeTruthy();
|
||||
|
||||
// The snapshot must have been taken AFTER recomputeCentroid — before it,
|
||||
// the new person has no centroid at all.
|
||||
const row = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
||||
expect(row.centroid_a).toBeTruthy();
|
||||
expect(row.centroid_b).toBeTruthy();
|
||||
|
||||
await simulateRescan(eventId, [base, other]);
|
||||
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a photo is hard-deleted', () => {
|
||||
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
||||
|
||||
it('drops the separation when one side has no photos left', async () => {
|
||||
const eventId = await seedEvent('sep-purge-gone');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
// Person B is gone with its only photo. The row held a COPY of its
|
||||
// centroid, so leaving it standing would keep a vector derived from a
|
||||
// deleted photo alive in a table nothing else touches.
|
||||
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the constraint when a side still has another cluster on it', async () => {
|
||||
const eventId = await seedEvent('sep-purge-descendant');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// Re-derivation can leave one stored side represented by more than one
|
||||
// current person. Deleting the photo behind ONE of them must not throw
|
||||
// the whole decision away — the other still stands for that side, and the
|
||||
// pair would be free to merge again.
|
||||
const twin = new Float32Array(DIM);
|
||||
for (let i = 0; i < DIM; i++) twin[i] = 0.98 * b[i];
|
||||
twin[6] = Math.sqrt(1 - 0.98 ** 2);
|
||||
const survivor = await insertPerson(eventId, twin);
|
||||
await insertFace(eventId, survivor, twin);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
|
||||
|
||||
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
|
||||
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
|
||||
expect(rows).toHaveLength(1);
|
||||
// Re-anchored onto the survivor, so it still binds.
|
||||
expect(clustering.separationForbids(a, twin, [{
|
||||
a: clustering.unpackEmbedding(rows[0].centroid_a),
|
||||
b: clustering.unpackEmbedding(rows[0].centroid_b),
|
||||
}])).toBe(true);
|
||||
});
|
||||
|
||||
it('re-takes the snapshot from what is left when the person survives', async () => {
|
||||
const eventId = await seedEvent('sep-purge-survives');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await insertFace(eventId, idB, b);
|
||||
// A second face on B, close enough that B stays recognisably B — so
|
||||
// purging it moves B's centroid rather than deleting the person, and the
|
||||
// side still resolves to B afterwards.
|
||||
const other = new Float32Array(DIM);
|
||||
for (let i = 0; i < DIM; i++) other[i] = 0.95 * b[i];
|
||||
other[5] = Math.sqrt(1 - 0.95 ** 2);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, idB, other);
|
||||
await clustering.recomputeCentroid(idB);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
const before = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
||||
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
const after = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
||||
expect(after).toBeTruthy();
|
||||
expect(Buffer.from(after.centroid_b).equals(Buffer.from(before.centroid_b))).toBe(false);
|
||||
// It now equals the recomputed centroid — nothing of the deleted face left.
|
||||
const person = await db('event_people').where({ id: idB }).first();
|
||||
expect(Buffer.from(after.centroid_b).equals(Buffer.from(person.centroid))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the photographer changes their mind', () => {
|
||||
it('a manual merge clears the separation between the merged people', async () => {
|
||||
const eventId = await seedEvent('sep-merge-overrules');
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await insertFace(eventId, idB, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
// ...and then decides they ARE the same person after all.
|
||||
await clustering.mergePeople(eventId, [idB], idA);
|
||||
|
||||
// The row is keyed on the centroids as well as the ids, so leaving it
|
||||
// would survive the ids it names: the next recluster would recognise
|
||||
// those two sides and pull the merge apart again.
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
|
||||
await simulateRescan(eventId, [a, b]);
|
||||
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup after the ids have already died', () => {
|
||||
// The rows these paths must find are exactly the ones whose person ids no
|
||||
// longer resolve — that is the state this whole feature creates. Matching
|
||||
// on ids alone walks past them, which is worse than not cleaning up at
|
||||
// all: the surviving row still enforces its vectors.
|
||||
|
||||
it('a merge clears a separation that had already outlived its ids', async () => {
|
||||
const eventId = await seedEvent('sep-merge-stale');
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// A recluster: same vectors, brand-new people. The row now names nobody.
|
||||
const [newA, newB] = await simulateRescan(eventId, [a, b]);
|
||||
expect([newA, newB]).not.toContain(idA);
|
||||
|
||||
await clustering.mergePeople(eventId, [newB], newA);
|
||||
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
// And it stays merged through the next re-derivation.
|
||||
await simulateRescan(eventId, [a, b]);
|
||||
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a purge clears a separation that had already outlived its ids', async () => {
|
||||
const eventId = await seedEvent('sep-purge-stale');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// Same recluster, then hard-delete the photo behind the B side.
|
||||
await db('photo_faces').where({ event_id: eventId }).del();
|
||||
await db('event_people').where({ event_id: eventId }).del();
|
||||
const newA = await insertPerson(eventId, a);
|
||||
await insertFace(eventId, newA, a);
|
||||
const newB = await insertPerson(eventId, b);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, newB, b);
|
||||
|
||||
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
expect(await db('event_people').where({ id: newB }).first()).toBeUndefined();
|
||||
// The row named idA/idB, neither of which exists — but its centroid_b is
|
||||
// a copy of a vector derived from the photo that was just destroyed.
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the whole gallery is deleted', () => {
|
||||
it('deleteEventCascade clears the separations too', () => {
|
||||
// Source inspection, deliberately. deleteEventCascade takes an admin
|
||||
// context and does filesystem cleanup, so driving it here would test the
|
||||
// scaffolding rather than the contract. The contract is narrow and
|
||||
// absolute: this table now holds centroid BLOBs, it has no event FK by
|
||||
// design, and nothing else in the codebase would ever reach it — so the
|
||||
// one delete has to be in the cascade or the embeddings outlive the
|
||||
// gallery. Same approach as the contract tests added for #596.
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'routes', 'adminEvents', 'helpers.js'), 'utf8'
|
||||
);
|
||||
const body = src.slice(src.indexOf('async function deleteEventCascade'));
|
||||
expect(body).toContain('event_people_merge_dismissals\').where(\'event_id\', eventId).del()');
|
||||
// Guarded, not caught: a failed statement aborts the transaction on PG.
|
||||
expect(body).toContain('hasTable(\'event_people_merge_dismissals\')');
|
||||
});
|
||||
|
||||
it('permanent archive deletion clears the face data too', () => {
|
||||
// Same contract, second door. This route deletes the event row directly
|
||||
// and leans on the FK cascade, which is inert on SQLite — and no FK
|
||||
// reaches the dismissals table on either engine. archiveEvent's purge is
|
||||
// nonfatal, so an event really can arrive here still holding embeddings.
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'routes', 'adminArchives.js'), 'utf8'
|
||||
);
|
||||
expect(src).toContain('event_people_merge_dismissals');
|
||||
expect(src).toContain('db(\'photo_faces\').where(\'event_id\', req.params.id).del()');
|
||||
expect(src).toContain('db(\'event_people\').where(\'event_id\', req.params.id).del()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('during assignment', () => {
|
||||
it('will not put a new face into a cluster it was separated from', async () => {
|
||||
const eventId = await seedEvent('sep-assign');
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// A face that looks like side B arrives. Its nearest centroid is A (0.97,
|
||||
// far above the 0.6 match threshold), and before #1132 it would simply
|
||||
// have joined — reforming the pair the photographer pulled apart, because
|
||||
// assignment consulted no separations at all.
|
||||
await db('event_people').where({ id: idB }).del();
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'new.jpg', path: '/tmp/n.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(b), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const faceId = typeof f === 'object' ? f.id : f;
|
||||
|
||||
const assignments = await clustering.assignFaces(
|
||||
eventId, [{ id: faceId, embedding: clustering.packEmbedding(b), model_version: 'test-v1',
|
||||
det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
||||
{ thresholds: THRESHOLDS },
|
||||
);
|
||||
|
||||
expect(assignments).toHaveLength(1);
|
||||
expect(assignments[0].personId).not.toBe(idA);
|
||||
// It opened its own person rather than being forced into the wrong one.
|
||||
expect(assignments[0].personId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('holds back a face that is only loosely like the side it belongs to', async () => {
|
||||
const eventId = await seedEvent('sep-assign-loose');
|
||||
// The separated sides are CENTROIDS; an individual face sits well below
|
||||
// its own centroid — that is why faces join at 0.6 and not at 0.92. A
|
||||
// face 0.85-like its own side would clear no strict bar against it, and
|
||||
// before this it walked straight into the other person during a
|
||||
// recluster, which is the exact merge the photographer undid.
|
||||
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
|
||||
const idA = await insertPerson(eventId, sideA);
|
||||
const idB = await insertPerson(eventId, sideB);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await db('event_people').where({ id: idB }).del();
|
||||
|
||||
// 0.65 to side A — above the 0.6 match threshold, so it would join A —
|
||||
// and 0.85 to side B, which is where it actually belongs.
|
||||
const face = new Float32Array(DIM);
|
||||
face[0] = 0.65; face[1] = 0.553; face[2] = Math.sqrt(1 - 0.65 ** 2 - 0.553 ** 2);
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'loose.jpg', path: '/tmp/l.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(face), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
|
||||
const assignments = await clustering.assignFaces(
|
||||
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(face),
|
||||
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
||||
{ thresholds: THRESHOLDS },
|
||||
);
|
||||
|
||||
expect(assignments[0].personId).not.toBe(idA);
|
||||
expect(assignments[0].personId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('binds while the clusters are still being rebuilt one face at a time', async () => {
|
||||
const eventId = await seedEvent('sep-assign-rebuild');
|
||||
// recluster() empties event_people and re-assigns from scratch, so for
|
||||
// the first faces of a batch the "person" on the other side of the
|
||||
// comparison is a cluster of ONE. A settled centroid it is not, and
|
||||
// holding it to the strict threshold meant the pair was already merged
|
||||
// by the time the constraint could bind — with nothing left to split it.
|
||||
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
|
||||
const idA = await insertPerson(eventId, sideA);
|
||||
const idB = await insertPerson(eventId, sideB);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await db('event_people').where({ event_id: eventId }).del();
|
||||
|
||||
// Two faces, one per side, each a little off its own side's centroid —
|
||||
// 0.91, just under the strict bar — and 0.66 to each other, over the
|
||||
// match threshold. Exactly the pair that must not re-form.
|
||||
const off = Math.sqrt(1 - 0.91 ** 2);
|
||||
const faceA = new Float32Array(DIM);
|
||||
faceA[0] = 0.91; faceA[3] = off;
|
||||
const faceB = new Float32Array(DIM);
|
||||
faceB[0] = 0.91 * 0.7; faceB[1] = 0.91 * Math.sqrt(1 - 0.7 ** 2); faceB[3] = off;
|
||||
|
||||
const rows = [];
|
||||
for (const vec of [faceA, faceB]) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/r.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(vec), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
rows.push({ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(vec),
|
||||
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 });
|
||||
}
|
||||
|
||||
// The premise: they are close enough to each other to cluster together.
|
||||
expect(clustering.dot(faceA, faceB)).toBeGreaterThan(THRESHOLDS.face_match_threshold);
|
||||
|
||||
const assignments = await clustering.assignFaces(eventId, rows, { thresholds: THRESHOLDS });
|
||||
expect(assignments[0].personId).not.toBe(assignments[1].personId);
|
||||
});
|
||||
|
||||
it('leaves ordinary assignment alone when no separation applies', async () => {
|
||||
const eventId = await seedEvent('sep-assign-clean');
|
||||
const base = new Float32Array(DIM); base[0] = 1;
|
||||
const personId = await insertPerson(eventId, base);
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'x.jpg', path: '/tmp/x.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(base), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
|
||||
const assignments = await clustering.assignFaces(
|
||||
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(base),
|
||||
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
||||
{ thresholds: THRESHOLDS },
|
||||
);
|
||||
|
||||
// The whole point of the strict threshold: a constraint that fires when
|
||||
// it should not would quietly wreck ordinary clustering.
|
||||
expect(assignments[0].personId).toBe(personId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* A dropped mount defers a scan; a dead photo fails it.
|
||||
*
|
||||
* ensurePreviewImage returns null for both "this JPEG is corrupt" and "the
|
||||
* NFS share is gone", and #1090 made that distinction matter: external
|
||||
* libraries now reach this path, and network mounts drop far more often than
|
||||
* local disks. Failing on an outage strands the photo — faceQueue only ever
|
||||
* claims 'pending', and nothing re-queues a failure automatically, so a mount
|
||||
* that blinked mid-scan would cost an entire gallery a manual Re-scan.
|
||||
*
|
||||
* The probe checks the containing DIRECTORY rather than the file, because that
|
||||
* is what separates the two cases: a missing file inside a healthy directory
|
||||
* is a broken photo, an unreachable directory is broken storage.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-transient-'));
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'transient-test-secret';
|
||||
// Created BEFORE anything requires externalMediaService: getExternalMediaRoot
|
||||
// only honours the env var if the directory already exists, and caches the
|
||||
// result on first call — set it later and every path silently resolves
|
||||
// against a fallback root instead.
|
||||
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpRoot, 'media');
|
||||
fs.mkdirSync(process.env.EXTERNAL_MEDIA_ROOT, { recursive: true });
|
||||
|
||||
let previewKeyResult = null;
|
||||
const mockEnsurePreviewImage = jest.fn(async () => previewKeyResult);
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => ({
|
||||
...jest.requireActual('../../src/services/imageProcessor'),
|
||||
ensurePreviewImage: (...args) => mockEnsurePreviewImage(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/faceClient', () => ({
|
||||
detectFaces: jest.fn(async () => ({ model_version: 'test-v1', faces: [] })),
|
||||
SidecarUnavailableError: class extends Error {},
|
||||
}));
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceProcessor;
|
||||
|
||||
async function seedExternalPhoto({ externalPath, relpath = 'individual/a.jpg' }) {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `tr-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'tr',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `tr-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
source_mode: 'reference',
|
||||
external_path: externalPath,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'a.jpg',
|
||||
path: 'tr/a.jpg',
|
||||
type: 'individual',
|
||||
width: 4000,
|
||||
height: 3000,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
source_origin: 'external',
|
||||
// Stored relative to EXTERNAL_MEDIA_ROOT (#1163), which is what the import
|
||||
// route writes — `relpath` above is expressed relative to the event's
|
||||
// folder only because that reads better at the call sites.
|
||||
external_relpath: path.join(externalPath, relpath),
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
describe('transient source vs dead photo', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
previewKeyResult = null; // i.e. ensurePreviewImage could not build one
|
||||
mockEnsurePreviewImage.mockClear();
|
||||
});
|
||||
|
||||
it('defers, not fails, when the source directory is unreachable', async () => {
|
||||
// Nothing was ever created under EXTERNAL_MEDIA_ROOT for this path, so the
|
||||
// directory does not resolve — the shape a dropped mount presents.
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'vanished-share' });
|
||||
|
||||
await expect(faceProcessor.processPhotoFaces(photoId))
|
||||
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
|
||||
|
||||
// Critically: still claimable. A 'failed' here is what stranded the photo.
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).not.toBe('failed');
|
||||
});
|
||||
|
||||
it('defers when the event root survives an unmount but is empty', async () => {
|
||||
// The common NFS/SMB shape: unmounting leaves the mountpoint behind as an
|
||||
// ordinary empty directory, so fs.access succeeds on storage that is
|
||||
// entirely gone. The EVENT ROOT is the thing that goes empty — the photo's
|
||||
// own subdirectory vanishes with it.
|
||||
const emptyRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'unmounted');
|
||||
await fs.promises.mkdir(emptyRoot, { recursive: true });
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'unmounted' });
|
||||
|
||||
await expect(faceProcessor.processPhotoFaces(photoId))
|
||||
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
|
||||
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).not.toBe('failed');
|
||||
});
|
||||
|
||||
it('fails when the directory is healthy but the file is gone', async () => {
|
||||
// Directory exists, file does not — a genuinely broken photo, which should
|
||||
// surface as a failure the admin can see rather than retry forever.
|
||||
const live = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'live-share', 'individual');
|
||||
await fs.promises.mkdir(live, { recursive: true });
|
||||
// Non-empty: an empty directory is now read as an unmounted share, so the
|
||||
// "healthy storage, dead photo" case needs a sibling file present.
|
||||
await fs.promises.writeFile(path.join(live, 'sibling.jpg'), 'x');
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'live-share' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).toBe('failed');
|
||||
expect(photo.face_error).toMatch(/preview/i);
|
||||
});
|
||||
|
||||
it('fails a missing subdirectory rather than deferring the whole event', async () => {
|
||||
// individual/ deleted while collages/ is fine. Probing only the photo's own
|
||||
// directory reports ENOENT and would read as a mount-wide outage, deferring
|
||||
// the event and starving every healthy sibling folder. The root is
|
||||
// populated, so the mount is up and this is a broken path.
|
||||
const root = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'partial');
|
||||
await fs.promises.mkdir(path.join(root, 'collages'), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(root, 'collages', 'kept.jpg'), 'x');
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'partial' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('defers a file that exists but cannot be read', async () => {
|
||||
// EACCES / EIO / ESTALE on the file itself, with the mount up: a transient
|
||||
// condition wearing a per-file disguise. Only ENOENT means genuinely gone.
|
||||
const root = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'locked');
|
||||
const dir = path.join(root, 'individual');
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const file = path.join(dir, 'a.jpg');
|
||||
await fs.promises.writeFile(file, 'x');
|
||||
await fs.promises.chmod(file, 0o000);
|
||||
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'locked' });
|
||||
try {
|
||||
await expect(faceProcessor.processPhotoFaces(photoId))
|
||||
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
|
||||
} finally {
|
||||
await fs.promises.chmod(file, 0o644).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('still fails managed photos without probing the mount', async () => {
|
||||
// The probe is scoped to external/reference rows: a managed photo with no
|
||||
// preview is broken, and there is no mount to blame.
|
||||
const [e] = await db('events').insert({
|
||||
slug: `tr-m-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'trm',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `tr-m-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: typeof e === 'object' ? e.id : e,
|
||||
filename: 'm.jpg',
|
||||
path: 'trm/m.jpg',
|
||||
type: 'individual',
|
||||
width: 100,
|
||||
height: 100,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
source_origin: 'managed',
|
||||
}).returning('id');
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(typeof p === 'object' ? p.id : p);
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
@@ -1,300 +0,0 @@
|
||||
/**
|
||||
* Guest filters must respect show_feedback_to_guests (#1044 follow-up).
|
||||
*
|
||||
* Every filter token on /photos is an OR of two halves: what THIS viewer
|
||||
* marked, and what ANYONE marked. The response fields built from the second
|
||||
* half — like_count, comment_count, color_label_count — are all gated on
|
||||
* show_feedback_to_guests. The FILTER was not.
|
||||
*
|
||||
* So with the setting off, the numbers were hidden but `?filter=liked` still
|
||||
* returned exactly the photos other people had liked: the same information as
|
||||
* a set instead of a count, one token at a time. These tests pin the gate on
|
||||
* every token, and pin that the viewer's own half is never gated — filtering
|
||||
* by what you yourself marked is yours to do regardless.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'filter-visibility-secret';
|
||||
|
||||
const SLUG = 'filter-visibility-event';
|
||||
const ME = 'guest-me-identifier';
|
||||
const SOMEONE_ELSE = 'guest-other-identifier';
|
||||
|
||||
describe('guest filters and show_feedback_to_guests (#1044)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let mine;
|
||||
let theirs;
|
||||
let myGuestRowId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const setVisibility = (visible) => db('event_feedback_settings')
|
||||
.where({ event_id: eventId })
|
||||
.update({ show_feedback_to_guests: visible });
|
||||
|
||||
// A real verified guest, which is how the viewer's own feedback is actually
|
||||
// identified — NOT the `guest_id` query parameter the frontend invents.
|
||||
const guestToken = () => jwt.sign(
|
||||
{ type: 'guest', guestId: myGuestRowId, eventId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
// The photo payload itself, not just the filtered id list — `is_liked` and
|
||||
// the aggregate counts live here (#1286).
|
||||
const payload = async ({ as = 'me' } = {}) => {
|
||||
const req = request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
if (as === 'me') req.set('x-guest-token', guestToken());
|
||||
const res = await req;
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return Object.fromEntries((photos || []).map((p) => [p.id, p]));
|
||||
};
|
||||
|
||||
const filter = async (token, { as = 'me', claimGuestId } = {}) => {
|
||||
const req = request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.query({ filter: token, ...(claimGuestId ? { guest_id: claimGuestId } : {}) })
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
if (as === 'me') req.set('x-guest-token', guestToken());
|
||||
const res = await req;
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Filter Visibility',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'filter-visibility-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const addPhoto = async (name) => {
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: name,
|
||||
path: `events/filter/${name}`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return p[0]?.id ?? p[0];
|
||||
};
|
||||
mine = await addPhoto('mine.jpg');
|
||||
theirs = await addPhoto('theirs.jpg');
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
feedback_enabled: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_ratings: true,
|
||||
allow_favorites: true,
|
||||
allow_color_labels: true,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
const guestRow = await db('gallery_guests').insert({
|
||||
event_id: eventId,
|
||||
name: 'Me',
|
||||
identifier: ME,
|
||||
created_at: new Date().toISOString(),
|
||||
last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
}).returning('id');
|
||||
myGuestRowId = guestRow[0]?.id ?? guestRow[0];
|
||||
|
||||
const feedback = (photoId, who, type, extra = {}) => db('photo_feedback').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
guest_identifier: who,
|
||||
// Submission links to the per-person guest row when one is present, and
|
||||
// that is the column the viewer's own half resolves through.
|
||||
guest_id: who === ME ? myGuestRowId : null,
|
||||
feedback_type: type,
|
||||
is_approved: true,
|
||||
is_hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
// Everything on `theirs` belongs to somebody else; `mine` is this viewer's.
|
||||
await feedback(mine, ME, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'favorite');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'comment', { comment_text: 'lovely' });
|
||||
await feedback(theirs, SOMEONE_ELSE, 'rating', { rating: 5 });
|
||||
await feedback(theirs, SOMEONE_ELSE, 'color_label', { color_label: 'green' });
|
||||
|
||||
// The denormalized counters the aggregate half of the filter reads.
|
||||
await db('photos').where('id', theirs).update({
|
||||
like_count: 1, favorite_count: 1, comment_count: 1, average_rating: 5, color_label_count: 1,
|
||||
});
|
||||
await db('photos').where('id', mine).update({ like_count: 1 });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('with feedback visible to guests', () => {
|
||||
beforeAll(() => setVisibility(true));
|
||||
|
||||
it('shows other people\'s marks through every token, as before', async () => {
|
||||
expect(await filter('liked')).toEqual([mine, theirs].sort((a, b) => a - b));
|
||||
expect(await filter('favorited')).toEqual([theirs]);
|
||||
expect(await filter('rated')).toEqual([theirs]);
|
||||
expect(await filter('commented')).toEqual([theirs]);
|
||||
expect(await filter('color:green')).toEqual([theirs]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with feedback hidden from guests', () => {
|
||||
beforeAll(() => setVisibility(false));
|
||||
|
||||
it('stops every token from selecting on other people\'s marks', async () => {
|
||||
// `theirs` is the photo only other guests marked. It must not come back
|
||||
// through any token — a filter that selects on hidden feedback reports
|
||||
// that feedback just as surely as a count would.
|
||||
expect(await filter('favorited')).toEqual([]);
|
||||
expect(await filter('rated')).toEqual([]);
|
||||
expect(await filter('commented')).toEqual([]);
|
||||
expect(await filter('color:green')).toEqual([]);
|
||||
});
|
||||
|
||||
it('still filters by what the viewer marked themselves', async () => {
|
||||
// The viewer's own half is never gated: this is their own action, and
|
||||
// hiding it would break "show me the ones I liked" for no privacy gain.
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('drops the viewer\'s own feedback once an admin hides it', async () => {
|
||||
// Moderation has to reach the filter too. getPhotoFeedback excludes
|
||||
// hidden rows for the guest's OWN feedback, so a photo matching here
|
||||
// would come back with nothing visible on it to explain why.
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
expect(await filter('liked')).toEqual([]);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: false });
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('ignores a guest_id supplied by the caller', async () => {
|
||||
// The own-half is resolved from the request identity. If it honoured the
|
||||
// query string instead, anyone holding another guest's identifier could
|
||||
// read that guest's hidden memberships one token at a time — straight
|
||||
// back through the gate this file exists to pin.
|
||||
expect(await filter('favorited', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
|
||||
expect(await filter('color:green', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
|
||||
// And an anonymous caller claiming to be me gets nothing of mine.
|
||||
expect(await filter('liked', { as: 'anon', claimGuestId: ME })).toEqual([]);
|
||||
});
|
||||
});
|
||||
// #1286 — the viewer's OWN like is not other people's feedback.
|
||||
describe("a guest's own likes with feedback hidden (#1286)", () => {
|
||||
beforeAll(() => setVisibility(false));
|
||||
|
||||
it('still reports is_liked on the photo the viewer liked', async () => {
|
||||
// The regression: every heart came back empty on a gallery with
|
||||
// sharing off, so the grid looked like it had discarded the guest's
|
||||
// choices on every reload.
|
||||
const photos = await payload();
|
||||
expect(photos[mine].is_liked).toBe(true);
|
||||
});
|
||||
|
||||
it("does not report is_liked for someone else's like", async () => {
|
||||
const photos = await payload();
|
||||
expect(photos[theirs].is_liked).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the aggregate like_count hidden', async () => {
|
||||
// The count IS other people's feedback and must stay gated — the fix
|
||||
// must not leak it back through the same payload.
|
||||
const photos = await payload();
|
||||
expect(photos[mine].like_count).toBe(0);
|
||||
expect(photos[theirs].like_count).toBe(0);
|
||||
expect(photos[theirs].has_feedback).toBe(false);
|
||||
});
|
||||
|
||||
it('reports nothing as liked for a viewer who liked nothing', async () => {
|
||||
const photos = await payload({ as: 'anon' });
|
||||
expect(photos[mine].is_liked).toBe(false);
|
||||
expect(photos[theirs].is_liked).toBe(false);
|
||||
});
|
||||
|
||||
it("still respects an admin hiding the viewer's own like (#1150)", async () => {
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
const photos = await payload();
|
||||
expect(photos[mine].is_liked).toBe(false);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: false });
|
||||
});
|
||||
|
||||
it('matches what the liked filter already returned', async () => {
|
||||
// The filter half was never gated; the payload flag was. After the fix
|
||||
// the two agree, which is what makes the grid and the Likes chip show
|
||||
// the same set.
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
const photos = await payload();
|
||||
const flagged = Object.values(photos).filter((p) => p.is_liked).map((p) => p.id);
|
||||
expect(flagged).toEqual([mine]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with feedback visible again (#1286 regression guard)', () => {
|
||||
beforeAll(() => setVisibility(true));
|
||||
|
||||
it('is_liked and the counts both come back', async () => {
|
||||
const photos = await payload();
|
||||
expect(photos[mine].is_liked).toBe(true);
|
||||
expect(photos[theirs].is_liked).toBe(false);
|
||||
expect(photos[theirs].like_count).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Gallery password invisible-Unicode fallback (#654).
|
||||
*
|
||||
* Passwords relayed through chat apps (Instagram DMs especially) pick up
|
||||
* invisible characters on copy-paste — zero-width space/joiners, word
|
||||
* joiner, BOM, soft hyphen — which fail the byte-exact bcrypt compare and
|
||||
* surface as "incorrect password" for a correct password. The verify route
|
||||
* retries the compare with those characters stripped, in the SAME request,
|
||||
* so the fallback costs no reCAPTCHA token and no failed-attempt quota.
|
||||
*
|
||||
* Pins the contract:
|
||||
* - exact submitted bytes always win first, so stored passwords that
|
||||
* legitimately contain these characters (e.g. ZWJ emoji sequences)
|
||||
* keep working
|
||||
* - paste artifacts (mid-string ZWSP, leading BOM, trailing space) are
|
||||
* rescued by the sanitized fallback compare
|
||||
* - the fallback never invents a match (missing ZWJ still 401s), and a
|
||||
* rescued login records no failed attempt
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sanitize-test-secret';
|
||||
|
||||
const PLAIN_SLUG = 'sanitize-plain-event';
|
||||
const ZWJ_SLUG = 'sanitize-zwj-event';
|
||||
const PLAIN_PASSWORD = 'wedding2026';
|
||||
// Stored password legitimately containing a ZWJ emoji sequence.
|
||||
const ZWJ_PASSWORD = 'Family\u{1F468}\u200D\u{1F469}Aa1';
|
||||
|
||||
describe('gallery/verify invisible-Unicode fallback (#654)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
|
||||
const makeEvent = async (slug, password) => {
|
||||
const inserted = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: `Sanitize ${slug}`,
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: await bcrypt.hash(password, 4),
|
||||
share_link: `/gallery/${slug}/share`,
|
||||
share_token: `${slug}-share`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
};
|
||||
let plainEventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
plainEventId = await makeEvent(PLAIN_SLUG, PLAIN_PASSWORD);
|
||||
await makeEvent(ZWJ_SLUG, ZWJ_PASSWORD);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/auth', require('../../src/routes/auth'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const verify = (slug, password) =>
|
||||
request(app).post('/api/auth/gallery/verify').send({ slug, password });
|
||||
|
||||
it('accepts the exact password', async () => {
|
||||
const res = await verify(PLAIN_SLUG, PLAIN_PASSWORD);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rescues a mid-string zero-width space from chat-app copy-paste', async () => {
|
||||
const res = await verify(PLAIN_SLUG, 'wedding\u200B2026');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rescues leading BOM + trailing space paste artifacts', async () => {
|
||||
const res = await verify(PLAIN_SLUG, `\uFEFF${PLAIN_PASSWORD} `);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('records no login_fail for a rescued login (single-request fallback)', async () => {
|
||||
await verify(PLAIN_SLUG, 'wedding\u200B2026').expect(200);
|
||||
const failed = await db('access_logs')
|
||||
.where({ event_id: plainEventId, action: 'login_fail' });
|
||||
expect(failed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still accepts a stored password that legitimately contains a ZWJ', async () => {
|
||||
const res = await verify(ZWJ_SLUG, ZWJ_PASSWORD);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not invent a match when the ZWJ is missing from the input', async () => {
|
||||
const res = await verify(ZWJ_SLUG, 'Family\u{1F468}\u{1F469}Aa1');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a plain wrong password', async () => {
|
||||
const res = await verify(PLAIN_SLUG, 'not-the-password');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
/**
|
||||
* HTTP-level tests for the `/s/:shortSlug` public resolver (#699).
|
||||
*
|
||||
* Verifies the contract the public route is expected to honour:
|
||||
* - Browser UA → 302 to target_path
|
||||
* - Social crawler UA → 200 with OG <meta>, canonical = /s/<slug>
|
||||
* - Soft-deleted slug → 410 Gone (intentional-delete signal)
|
||||
* - Unknown slug → 404 Not Found
|
||||
* - Hit count increments after successful resolutions (both shapes)
|
||||
*
|
||||
* Mirrors the production server.js wiring but doesn't load the whole
|
||||
* server — the surrounding middleware (CORS, helmet, rate limiters)
|
||||
* isn't part of this route's contract.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db; let cleanup; let service; let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
// Persist a business_profile + business_name so buildOgMetadata's
|
||||
// settings-based fields populate consistently.
|
||||
const { upsertAppSetting } = require('../../src/utils/appSettings');
|
||||
await upsertAppSetting('branding_company_name', JSON.stringify('Test Studio'), 'string');
|
||||
|
||||
service = require('../../src/services/galleryShortUrlService');
|
||||
const {
|
||||
isSocialCrawler, buildOgMetadata, renderOgHtml,
|
||||
} = require('../../src/services/galleryOgService');
|
||||
|
||||
app = express();
|
||||
app.get('/s/:shortSlug', async (req, res) => {
|
||||
try {
|
||||
const row = await service.findByShortSlug(req.params.shortSlug);
|
||||
if (!row) return res.status(404).type('text/plain').send('Short URL not found');
|
||||
if (row.deleted_at) return res.status(410).type('text/plain').send('Short URL has been removed');
|
||||
|
||||
if (isSocialCrawler(req.get('user-agent'))) {
|
||||
const event = await db('events').where({ id: row.event_id }).first('slug');
|
||||
if (event?.slug) {
|
||||
const meta = await buildOgMetadata(event.slug, req.originalUrl);
|
||||
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
meta.url = `${base}/s/${row.short_slug}`;
|
||||
res.set('Cache-Control', 'public, max-age=300');
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(renderOgHtml(meta));
|
||||
service.recordHit(row.id).catch(() => {});
|
||||
return;
|
||||
}
|
||||
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
|
||||
}
|
||||
|
||||
service.recordHit(row.id).catch(() => {});
|
||||
return res.redirect(302, row.target_path);
|
||||
} catch (err) {
|
||||
return res.status(500).type('text/plain').send(err.message);
|
||||
}
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
async function seedEventAndShortUrl({ slug = `evt-${Date.now()}`, shortSlug }) {
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [eventId] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Event',
|
||||
event_date: '2026-06-05',
|
||||
password_hash: 'x',
|
||||
expires_at: farFuture,
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
share_link: slug,
|
||||
share_token: `tok${Math.random().toString(36).slice(2, 12)}`,
|
||||
welcome_message: null,
|
||||
});
|
||||
const row = await service.createShortUrl({
|
||||
eventId, customSlug: shortSlug,
|
||||
});
|
||||
return { eventId, shortUrl: row };
|
||||
}
|
||||
|
||||
// User-agent strings the production `isSocialCrawler` helper matches.
|
||||
// Snapshot known-true samples here so the test stays in sync if the
|
||||
// helper's allowlist evolves.
|
||||
const BOT_UA_WHATSAPP = 'WhatsApp/2.23.20.0';
|
||||
const BOT_UA_FACEBOOK = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
|
||||
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
|
||||
|
||||
describe('GET /s/:shortSlug — browser (302 redirect)', () => {
|
||||
it('redirects to the snapshotted target_path with a 302', async () => {
|
||||
const { shortUrl } = await seedEventAndShortUrl({
|
||||
slug: 'browser-redirect', shortSlug: 'go-here',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/go-here')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe(shortUrl.target_path);
|
||||
expect(res.headers.location).toMatch(/^\/gallery\//);
|
||||
});
|
||||
|
||||
it('increments hit_count on a browser hit (fire-and-forget — wait briefly)', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'hit-browser', shortSlug: 'hit-from-browser',
|
||||
});
|
||||
await request(app).get('/s/hit-from-browser').set('User-Agent', BROWSER_UA);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const row = await service.findByShortSlug('hit-from-browser');
|
||||
expect(row.hit_count).toBe(1);
|
||||
expect(row.last_hit_at).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /s/:shortSlug — social crawler (OG metadata)', () => {
|
||||
it('returns 200 with OG HTML for WhatsApp UA', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'whatsapp-og', shortSlug: 'wa-preview',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/wa-preview')
|
||||
.set('User-Agent', BOT_UA_WHATSAPP);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/html/);
|
||||
expect(res.text).toContain('<meta');
|
||||
expect(res.text).toMatch(/og:title/);
|
||||
expect(res.text).toMatch(/og:url/);
|
||||
});
|
||||
|
||||
it('og:url canonical points at /s/<slug>, not the underlying gallery URL', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'canonical-test', shortSlug: 'canonical-short',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/canonical-short')
|
||||
.set('User-Agent', BOT_UA_FACEBOOK);
|
||||
expect(res.status).toBe(200);
|
||||
// The og:url meta tag must contain the short-URL path, not the
|
||||
// /gallery/<slug> path — this is the cache-key invariant from #699.
|
||||
expect(res.text).toMatch(/property="og:url"\s+content="[^"]*\/s\/canonical-short"/);
|
||||
expect(res.text).not.toMatch(
|
||||
/property="og:url"\s+content="[^"]*\/gallery\/canonical-test"/
|
||||
);
|
||||
});
|
||||
|
||||
it('sets a short cache header so scrapers can re-fetch when admin rotates the preview', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'cache-header', shortSlug: 'cache-test',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/cache-test')
|
||||
.set('User-Agent', BOT_UA_WHATSAPP);
|
||||
expect(res.headers['cache-control']).toMatch(/public/);
|
||||
expect(res.headers['cache-control']).toMatch(/max-age=300/);
|
||||
});
|
||||
|
||||
it('increments hit_count on a crawler hit as well', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'hit-bot', shortSlug: 'hit-from-bot',
|
||||
});
|
||||
await request(app).get('/s/hit-from-bot').set('User-Agent', BOT_UA_WHATSAPP);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const row = await service.findByShortSlug('hit-from-bot');
|
||||
expect(row.hit_count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /s/:shortSlug — error states', () => {
|
||||
it('404 for an unknown slug', async () => {
|
||||
const res = await request(app)
|
||||
.get('/s/never-existed')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('410 for a soft-deleted slug (intentional-delete signal)', async () => {
|
||||
const { shortUrl } = await seedEventAndShortUrl({
|
||||
slug: 'gone-test', shortSlug: 'gone-slug',
|
||||
});
|
||||
await service.softDelete(shortUrl.id, null);
|
||||
const res = await request(app)
|
||||
.get('/s/gone-slug')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
|
||||
it('410 if the event was hard-deleted but the short URL row somehow survives', async () => {
|
||||
const { eventId } = await seedEventAndShortUrl({
|
||||
slug: 'orphan-test', shortSlug: 'orphan-slug',
|
||||
});
|
||||
// Hard-delete the event row (FK CASCADE would normally clean up the
|
||||
// short URL too — but if CASCADE didn't fire for whatever reason
|
||||
// (e.g. SQLite foreign_keys pragma off in a particular runtime), the
|
||||
// resolver should still degrade safely).
|
||||
// SQLite's foreign_keys pragma is OFF by default; the migration
|
||||
// doesn't toggle it, so this delete leaves the short URL row.
|
||||
await db('events').where({ id: eventId }).delete();
|
||||
const res = await request(app)
|
||||
.get('/s/orphan-slug')
|
||||
.set('User-Agent', BOT_UA_WHATSAPP);
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
|
||||
it('404 for a malformed slug (rejected at validation, no DB hit)', async () => {
|
||||
const res = await request(app)
|
||||
.get('/s/UPPER_CASE')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression — existing URL paths must still respond the same', () => {
|
||||
// The /s/* namespace is additive: it must NOT shadow /gallery/*
|
||||
// or any of the OG routes. We don't load the whole app here, but we
|
||||
// can at least pin that the route param doesn't accept slashes —
|
||||
// i.e. /s/foo/bar must NOT be matched by our handler.
|
||||
it('the /s/:shortSlug route does not match nested paths', async () => {
|
||||
const res = await request(app)
|
||||
.get('/s/foo/bar')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
// Express returns its default 404 when no route matches the path.
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* 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(120000);
|
||||
|
||||
let db; let cleanup; let service; let adminId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
// Minimal admin for created_by audit.
|
||||
const adminInsert = await db('admin_users').insert({
|
||||
username: 'shorturl-test',
|
||||
email: 'shorturl@example.com',
|
||||
password_hash: 'x',
|
||||
must_change_password: false,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
adminId = adminInsert[0]?.id ?? adminInsert[0];
|
||||
|
||||
service = require('../../src/services/galleryShortUrlService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
// Each test seeds a fresh event so collisions / counter state don't leak.
|
||||
async function seedEvent(overrides = {}) {
|
||||
const slug = overrides.slug || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [id] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: overrides.event_name || 'Test Wedding',
|
||||
event_date: overrides.event_date || '2026-06-05',
|
||||
password_hash: 'x',
|
||||
expires_at: farFuture,
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
share_link: slug,
|
||||
share_token: overrides.share_token || `tok${Math.random().toString(36).slice(2, 12)}`,
|
||||
welcome_message: null,
|
||||
});
|
||||
const event = await db('events').where({ id }).first();
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('createShortUrl — custom slug', () => {
|
||||
it('creates with a custom slug', async () => {
|
||||
const event = await seedEvent({ slug: 'sofia-grad-1' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'sofia-graduation-1',
|
||||
createdBy: adminId,
|
||||
});
|
||||
expect(row.short_slug).toBe('sofia-graduation-1');
|
||||
expect(row.target_path).toBe(`/gallery/${event.slug}`);
|
||||
expect(row.event_id).toBe(event.id);
|
||||
expect(row.hit_count).toBe(0);
|
||||
});
|
||||
|
||||
it('lowercases the input — operators pasting mixed-case still get a clean slug', async () => {
|
||||
const event = await seedEvent({ slug: 'sofia-grad-2' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'Sofia-GraduAtion-2', // mixed case
|
||||
createdBy: adminId,
|
||||
});
|
||||
expect(row.short_slug).toBe('sofia-graduation-2');
|
||||
});
|
||||
|
||||
it('rejects an invalid slug with INVALID_SLUG code', async () => {
|
||||
const event = await seedEvent({ slug: 'invalid-test' });
|
||||
await expect(service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'invalid slug with spaces',
|
||||
createdBy: adminId,
|
||||
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
|
||||
});
|
||||
|
||||
it('rejects a reserved slug with INVALID_SLUG code', async () => {
|
||||
const event = await seedEvent({ slug: 'reserved-test' });
|
||||
await expect(service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'admin',
|
||||
createdBy: adminId,
|
||||
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
|
||||
});
|
||||
|
||||
it('rejects a duplicate slug with SLUG_TAKEN + suggested fallback', async () => {
|
||||
const event1 = await seedEvent({ slug: 'dup-test-1' });
|
||||
const event2 = await seedEvent({ slug: 'dup-test-2' });
|
||||
await service.createShortUrl({ eventId: event1.id, customSlug: 'collide-me' });
|
||||
await expect(service.createShortUrl({
|
||||
eventId: event2.id, customSlug: 'collide-me',
|
||||
})).rejects.toMatchObject({
|
||||
code: 'SLUG_TAKEN',
|
||||
suggested: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws EVENT_NOT_FOUND when the event id does not exist', async () => {
|
||||
await expect(service.createShortUrl({
|
||||
eventId: 9999999, customSlug: 'no-event',
|
||||
})).rejects.toMatchObject({ code: 'EVENT_NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createShortUrl — auto-generated slug', () => {
|
||||
it('uses event slug + year when no custom slug provided', async () => {
|
||||
const event = await seedEvent({
|
||||
slug: 'autogen-wedding', event_date: '2026-06-05',
|
||||
});
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
createdBy: adminId,
|
||||
});
|
||||
// First-choice candidate is just the slug; takes that.
|
||||
expect(row.short_slug).toBe('autogen-wedding');
|
||||
});
|
||||
|
||||
it('falls back to slug-year when the bare slug is already taken', async () => {
|
||||
// Both events SHARE the same canonical slug so the first-choice
|
||||
// bare-slug candidate is burned, forcing autoGen to try the
|
||||
// year-suffixed variant.
|
||||
const event1 = await seedEvent({
|
||||
slug: 'collide-base', event_date: '2026-07-01',
|
||||
});
|
||||
await service.createShortUrl({
|
||||
eventId: event1.id, customSlug: 'collide-base',
|
||||
});
|
||||
const event2 = await seedEvent({
|
||||
slug: 'collide-base-2', event_date: '2026-07-01',
|
||||
});
|
||||
// Force the bare candidate of event2 to also collide by burning it.
|
||||
await service.createShortUrl({
|
||||
eventId: event1.id, customSlug: 'collide-base-2',
|
||||
});
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event2.id, // No custom — auto-gen from event2.slug
|
||||
});
|
||||
// Bare candidate `collide-base-2` is taken → year-suffixed picks.
|
||||
expect(row.short_slug).toBe('collide-base-2-2026');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createShortUrl — target_path snapshotting (#699 backward-compat)', () => {
|
||||
it('uses /gallery/<slug> when the global short-URLs setting is OFF (default)', async () => {
|
||||
const event = await seedEvent({ slug: 'snapshot-off' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'snap-off',
|
||||
});
|
||||
expect(row.target_path).toBe(`/gallery/${event.slug}`);
|
||||
});
|
||||
|
||||
it('uses /gallery/<share_token> when the global setting is ON at create time', async () => {
|
||||
// Persist the setting.
|
||||
const { upsertAppSetting } = require('../../src/utils/appSettings');
|
||||
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(true), 'system');
|
||||
try {
|
||||
const event = await seedEvent({ slug: 'snapshot-on', share_token: 'tokenAbc123' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'snap-on',
|
||||
});
|
||||
expect(row.target_path).toBe(`/gallery/${event.share_token}`);
|
||||
|
||||
// CRITICAL backward-compat invariant: now flip the setting OFF.
|
||||
// Existing short URLs must still resolve to the same target_path
|
||||
// they were created with — operator's existing share links don't
|
||||
// silently change behaviour.
|
||||
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
|
||||
const refetched = await service.findByShortSlug('snap-on');
|
||||
expect(refetched.target_path).toBe(`/gallery/${event.share_token}`);
|
||||
} finally {
|
||||
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByShortSlug + listForEvent', () => {
|
||||
it('returns null for an unknown slug', async () => {
|
||||
expect(await service.findByShortSlug('does-not-exist-xyz')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a malformed slug (no DB hit)', async () => {
|
||||
expect(await service.findByShortSlug('UPPER_CASE')).toBeNull();
|
||||
expect(await service.findByShortSlug('with spaces')).toBeNull();
|
||||
expect(await service.findByShortSlug('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns soft-deleted rows (caller decides 410 vs 404)', async () => {
|
||||
const event = await seedEvent({ slug: 'softdel-find' });
|
||||
const created = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'find-deleted',
|
||||
});
|
||||
await service.softDelete(created.id, adminId);
|
||||
const fetched = await service.findByShortSlug('find-deleted');
|
||||
expect(fetched).not.toBeNull();
|
||||
expect(fetched.deleted_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('listForEvent excludes soft-deleted rows', async () => {
|
||||
const event = await seedEvent({ slug: 'list-test' });
|
||||
const live = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'list-live',
|
||||
});
|
||||
const deleted = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'list-deleted',
|
||||
});
|
||||
await service.softDelete(deleted.id, adminId);
|
||||
const list = await service.listForEvent(event.id);
|
||||
const ids = list.map((r) => r.id);
|
||||
expect(ids).toContain(live.id);
|
||||
expect(ids).not.toContain(deleted.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDelete', () => {
|
||||
it('returns true on first call, false on second (idempotent admin clicks)', async () => {
|
||||
const event = await seedEvent({ slug: 'softdel-idem' });
|
||||
const created = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'idem-delete',
|
||||
});
|
||||
expect(await service.softDelete(created.id, adminId)).toBe(true);
|
||||
expect(await service.softDelete(created.id, adminId)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unknown id (caller maps to 404)', async () => {
|
||||
expect(await service.softDelete(9999999, adminId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createShortUrl after soft-delete — slug rotation', () => {
|
||||
it('re-creating a soft-deleted slug succeeds (purges the deleted row)', async () => {
|
||||
const event = await seedEvent({ slug: 'rotate' });
|
||||
const first = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'rotate-me',
|
||||
});
|
||||
await service.softDelete(first.id, adminId);
|
||||
// The slug is now reclaimable for a fresh row.
|
||||
const second = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'rotate-me',
|
||||
});
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(second.short_slug).toBe('rotate-me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordHit', () => {
|
||||
it('increments hit_count + stamps last_hit_at', async () => {
|
||||
const event = await seedEvent({ slug: 'hit-counter' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'count-me',
|
||||
});
|
||||
await service.recordHit(row.id);
|
||||
await service.recordHit(row.id);
|
||||
const fetched = await service.findByShortSlug('count-me');
|
||||
expect(fetched.hit_count).toBe(2);
|
||||
expect(fetched.last_hit_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is fire-and-forget — invalid id does not throw', async () => {
|
||||
await expect(service.recordHit(9999999)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* Spotting one person registered twice (#1210).
|
||||
*
|
||||
* Guest registration always inserts. A client whose token expired, or who
|
||||
* opens the gallery on a second device, becomes a new gallery_guests row and
|
||||
* their likes and favourites split across the copies — so the photographer's
|
||||
* "final selection" is only trustworthy after someone notices two Tinas with
|
||||
* half the picks each and merges them.
|
||||
*
|
||||
* Merging already worked. This is the half that was missing: saying which rows
|
||||
* are the same person, so the admin does not have to find them by eye.
|
||||
*
|
||||
* Detection only — the registration path is deliberately untouched. Reusing a
|
||||
* row because someone typed a matching email would let anyone who knows that
|
||||
* email inherit the identity and its selections, and answering differently for
|
||||
* a known email would leak which addresses are in the gallery, which is
|
||||
* exactly what /guest/recover goes out of its way to avoid.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
describe('duplicate guest detection (#1210)', () => {
|
||||
let db; let cleanup; let app; let eventId;
|
||||
|
||||
const listGuests = async () => {
|
||||
const res = await request(app).get(`/api/admin/events/${eventId}/guests`);
|
||||
expect(res.status).toBe(200);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
const addGuest = async (name, email, extra = {}) => {
|
||||
const [g] = await db('gallery_guests').insert({
|
||||
event_id: eventId, name, email,
|
||||
identifier: `id-${name}-${Math.random()}`,
|
||||
created_at: new Date().toISOString(),
|
||||
last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
...extra,
|
||||
}).returning('id');
|
||||
return typeof g === 'object' ? g.id : g;
|
||||
};
|
||||
|
||||
const byId = (body, id) => body.guests.find((g) => g.id === id);
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: 'dupe-guests', event_type: 'wedding', event_name: 'Dupe Guests',
|
||||
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'x', share_link: '/gallery/dupe-guests/share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin', require('../../src/routes/adminGuests'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('gallery_guests').where({ event_id: eventId }).del();
|
||||
});
|
||||
|
||||
it('groups duplicates under a shared key', async () => {
|
||||
const first = await addGuest('Tina', 'tina@example.com');
|
||||
const second = await addGuest('Tina', 'tina@example.com');
|
||||
const other = await addGuest('Marc', 'marc@example.com');
|
||||
|
||||
const body = await listGuests();
|
||||
|
||||
// A shared group key rather than a list of sibling ids: the payload stays
|
||||
// linear in the number of guests, and the case/whitespace folding lives in
|
||||
// one place instead of being reimplemented on the client.
|
||||
expect(byId(body, first).duplicate_group).toBe('tina@example.com');
|
||||
expect(byId(body, second).duplicate_group).toBe('tina@example.com');
|
||||
expect(byId(body, other).duplicate_group).toBeNull();
|
||||
});
|
||||
|
||||
it('counts the groups and the rows in them', async () => {
|
||||
await addGuest('Tina', 'tina@example.com');
|
||||
await addGuest('Tina', 'tina@example.com');
|
||||
await addGuest('Ben', 'ben@example.com');
|
||||
await addGuest('Ben', 'ben@example.com');
|
||||
await addGuest('Ben', 'ben@example.com');
|
||||
await addGuest('Marc', 'marc@example.com');
|
||||
|
||||
expect((await listGuests()).duplicates).toEqual({ groups: 2, guests: 5 });
|
||||
});
|
||||
|
||||
it('matches the same address typed with different capitals or a stray space', async () => {
|
||||
// The same person on a different day. Both read as distinct rows in the
|
||||
// admin list, which is precisely why they need catching here.
|
||||
const a = await addGuest('Tina', 'tina@example.com');
|
||||
const b = await addGuest('Tina', 'Tina@Example.com ');
|
||||
|
||||
const body = await listGuests();
|
||||
expect(byId(body, a).duplicate_group).toBe('tina@example.com');
|
||||
expect(byId(body, b).duplicate_group).toBe('tina@example.com');
|
||||
expect(body.duplicates).toEqual({ groups: 1, guests: 2 });
|
||||
});
|
||||
|
||||
it('does not treat two guests without an email as the same person', async () => {
|
||||
// require_name_email is off by default, so a shared gallery link produces
|
||||
// plenty of these. Grouping them would merge strangers.
|
||||
const a = await addGuest('Anon', null);
|
||||
const b = await addGuest('Anon', null);
|
||||
|
||||
const body = await listGuests();
|
||||
expect(byId(body, a).duplicate_group).toBeNull();
|
||||
expect(byId(body, b).duplicate_group).toBeNull();
|
||||
expect(body.duplicates).toEqual({ groups: 0, guests: 0 });
|
||||
});
|
||||
|
||||
it('does not treat a shared name as evidence of anything', async () => {
|
||||
const a = await addGuest('Anna', 'anna.k@example.com');
|
||||
const b = await addGuest('Anna', 'anna.m@example.com');
|
||||
|
||||
const body = await listGuests();
|
||||
expect(byId(body, a).duplicate_group).toBeNull();
|
||||
expect(byId(body, b).duplicate_group).toBeNull();
|
||||
});
|
||||
|
||||
it('moves a pending invite to the survivor instead of stranding it', async () => {
|
||||
// Creating an invite inserts a real gallery_guests row, so an admin who
|
||||
// pre-mints one and then sees the guest self-register has two rows — and
|
||||
// this feature now points that pair out and offers the merge. Redemption
|
||||
// resolves guest_invites.guest_id with `is_deleted: false`, so merging
|
||||
// without moving the invite leaves the emailed link returning 404
|
||||
// guest_missing while the invite dialog still shows it as Pending.
|
||||
const placeholder = await addGuest('Tina', 'tina@example.com');
|
||||
const selfRegistered = await addGuest('Tina', 'tina@example.com');
|
||||
const [inv] = await db('guest_invites').insert({
|
||||
event_id: eventId, guest_id: placeholder, token: 'invite-token-1',
|
||||
created_by_admin_id: 1, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const inviteId = typeof inv === 'object' ? inv.id : inv;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${eventId}/guests/${selfRegistered}/merge`)
|
||||
.send({ mergeIds: [placeholder] });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const invite = await db('guest_invites').where({ id: inviteId }).first();
|
||||
expect(invite.guest_id).toBe(selfRegistered);
|
||||
// And it still resolves: the survivor is not soft-deleted.
|
||||
const target = await db('gallery_guests').where({ id: invite.guest_id }).first();
|
||||
expect(Boolean(target.is_deleted)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a spent invite pointing at what it actually redeemed', async () => {
|
||||
// A redeemed invite is a record of who redeemed what. Retargeting it would
|
||||
// rewrite that history to name a guest who was never on the other end.
|
||||
const old = await addGuest('Tina', 'tina@example.com');
|
||||
const kept = await addGuest('Tina', 'tina@example.com');
|
||||
const [inv] = await db('guest_invites').insert({
|
||||
event_id: eventId, guest_id: old, token: 'invite-token-2',
|
||||
created_by_admin_id: 1, redeemed_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const inviteId = typeof inv === 'object' ? inv.id : inv;
|
||||
|
||||
await request(app)
|
||||
.post(`/api/admin/events/${eventId}/guests/${kept}/merge`)
|
||||
.send({ mergeIds: [old] });
|
||||
|
||||
expect((await db('guest_invites').where({ id: inviteId }).first()).guest_id).toBe(old);
|
||||
});
|
||||
|
||||
it('canonicalises the survivor\'s address so recovery can still find them', async () => {
|
||||
// Grouping folds case and whitespace, so a merge can be proposed between a
|
||||
// clean address and a legacy one that is not. /guest/recover lowercases
|
||||
// what the guest types and matches on equality, so a survivor left holding
|
||||
// the raw value becomes permanently unrecoverable by email.
|
||||
const legacy = await addGuest('Tina', 'Tina@Example.com ');
|
||||
const other = await addGuest('Tina', 'tina@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${eventId}/guests/${legacy}/merge`)
|
||||
.send({ mergeIds: [other] });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect((await db('gallery_guests').where({ id: legacy }).first()).email).toBe('tina@example.com');
|
||||
});
|
||||
|
||||
it('leaves an already-canonical survivor untouched', async () => {
|
||||
const kept = await addGuest('Tina', 'tina@example.com');
|
||||
const dupe = await addGuest('Tina', 'Tina@Example.com');
|
||||
|
||||
await request(app)
|
||||
.post(`/api/admin/events/${eventId}/guests/${kept}/merge`)
|
||||
.send({ mergeIds: [dupe] });
|
||||
|
||||
expect((await db('gallery_guests').where({ id: kept }).first()).email).toBe('tina@example.com');
|
||||
});
|
||||
|
||||
it('ignores a removed guest', async () => {
|
||||
const kept = await addGuest('Tina', 'tina@example.com');
|
||||
await addGuest('Tina', 'tina@example.com', { is_deleted: true });
|
||||
|
||||
const body = await listGuests();
|
||||
// The deleted row is not listed at all, so the survivor is not a duplicate
|
||||
// of something the admin cannot see or merge.
|
||||
expect(body.guests.map((g) => g.id)).toEqual([kept]);
|
||||
expect(byId(body, kept).duplicate_group).toBeNull();
|
||||
expect(body.duplicates).toEqual({ groups: 0, guests: 0 });
|
||||
});
|
||||
});
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* Test harness for CRM integration tests.
|
||||
*
|
||||
* Boots a temp-SQLite database, runs every `migrations/core/*.up()`
|
||||
* directly (bypassing knex's Migrator — its exclusive write lock
|
||||
* deadlocks 001_init's nested `initializeDatabase()` call), and
|
||||
* exposes a small helper for seeding the minimal row set that the
|
||||
* quote/contract/invoice services need to operate.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
*
|
||||
* beforeAll(async () => {
|
||||
* ({ db, cleanup } = await bootCrmDb());
|
||||
* ({ adminId, customerId } = await seedMinimal(db));
|
||||
* });
|
||||
* afterAll(async () => { await cleanup(); });
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
async function runCoreMigrations(db) {
|
||||
await db.schema.createTable('migrations', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('filename').unique().notNullable();
|
||||
t.timestamp('applied_at').defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
const coreDir = path.resolve(__dirname, '..', '..', '..', 'migrations', 'core');
|
||||
const files = (await fs.promises.readdir(coreDir))
|
||||
.filter((f) => f.endsWith('.js'))
|
||||
.sort();
|
||||
|
||||
for (const f of files) {
|
||||
const mod = require(path.join(coreDir, f));
|
||||
if (typeof mod.up === 'function') {
|
||||
await mod.up(db);
|
||||
}
|
||||
await db('migrations').insert({ filename: f });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a clean test DB. Returns { db, cleanup, tmpDir }.
|
||||
* Caller must invoke cleanup() in afterAll to release the SQLite file
|
||||
* and the temp directory.
|
||||
*/
|
||||
async function bootCrmDb() {
|
||||
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-crm-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'crm.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
|
||||
// No jest.resetModules() — every service the test later requires
|
||||
// must share THIS db instance. Two module copies on one SQLite file
|
||||
// each open their own knex pool and the SQLite write lock deadlocks
|
||||
// the second one acquiring a connection. Caller is responsible for
|
||||
// setting TEST_DATABASE_PATH before the first require of db.js
|
||||
// (which knexfile reads at module-init time); bootCrmDb only works
|
||||
// when invoked before any service import.
|
||||
const { db } = require('../../../src/database/db');
|
||||
|
||||
await runCoreMigrations(db);
|
||||
|
||||
return {
|
||||
db,
|
||||
tmpDir,
|
||||
cleanup: async () => {
|
||||
try { await db.destroy(); } catch (_) {}
|
||||
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the minimal row set that quote/contract/invoice services
|
||||
* dereference on creation: an admin user, an active customer, a
|
||||
* business_profile row, and the app_settings keys the services read.
|
||||
*
|
||||
* Returns the ids the caller will pass into service calls.
|
||||
*/
|
||||
async function seedMinimal(db) {
|
||||
const passwordHash = await bcrypt.hash('test-pass', 4); // low rounds = fast
|
||||
|
||||
const adminInsert = await db('admin_users').insert({
|
||||
username: 'tester', email: 'tester@example.com',
|
||||
password_hash: passwordHash, must_change_password: false,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const adminId = adminInsert[0]?.id ?? adminInsert[0];
|
||||
|
||||
// business_profile is a singleton; the row is seeded by migration 107
|
||||
// for fresh installs. Defensive: insert if missing.
|
||||
const profile = await db('business_profile').first();
|
||||
if (!profile) {
|
||||
await db('business_profile').insert({
|
||||
legal_name: 'Test Studio',
|
||||
default_currency: 'CHF',
|
||||
default_locale: 'de',
|
||||
});
|
||||
}
|
||||
|
||||
const customerInsert = await db('customer_accounts').insert({
|
||||
email: 'customer@example.com',
|
||||
display_name: 'Test Customer',
|
||||
password_hash: passwordHash,
|
||||
preferred_language: 'de',
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const customerId = customerInsert[0]?.id ?? customerInsert[0];
|
||||
|
||||
return { adminId, customerId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Route-test helpers (#570) — building blocks for the CRM HTTP layer
|
||||
// tests. Kept here so every supertest suite shares the same minting +
|
||||
// app-wiring shape and a refactor lands in one place.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const crypto = require('crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
|
||||
/**
|
||||
* Promote a seeded admin into a role (default `super_admin`) so
|
||||
* `requirePermission(...)` checks pass. seedMinimal creates an admin
|
||||
* without a role — that's good for negative tests (expect 403) but
|
||||
* happy-path tests need the role assignment.
|
||||
*
|
||||
* Returns the role id the admin was assigned to.
|
||||
*/
|
||||
async function assignAdminRole(db, adminId, roleName = 'super_admin') {
|
||||
const role = await db('roles').where({ name: roleName }).first();
|
||||
if (!role) {
|
||||
throw new Error(`Role '${roleName}' not seeded — check the test DB`);
|
||||
}
|
||||
await db('admin_users').where({ id: adminId }).update({ role_id: role.id });
|
||||
return role.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint an admin JWT in the same shape adminAuth middleware expects.
|
||||
* The tests inject this via `Authorization: Bearer <token>`.
|
||||
*/
|
||||
function mintAdminToken(adminId, { expiresIn = '1h', extraClaims = {} } = {}) {
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
return jwt.sign(
|
||||
{ id: adminId, type: 'admin', iat: Math.floor(Date.now() / 1000), ...extraClaims },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn, issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a row into one of the public-token tables for testing the
|
||||
* loadActionToken guard outcomes. Returns the generated 64-hex token.
|
||||
*
|
||||
* Usage:
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id });
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: pastDate });
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, used_at: new Date() });
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: null });
|
||||
*/
|
||||
async function createPublicToken(db, tableName, opts = {}) {
|
||||
const token = opts.token || crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = opts.expires_at === null
|
||||
? null
|
||||
: (opts.expires_at || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000));
|
||||
// Serialise Date → ISO string. Bare Date objects round-tripped
|
||||
// inconsistently through knex+SQLite — sometimes as epoch ms,
|
||||
// sometimes via .toString() → literal "[object Object]" which then
|
||||
// parses back to NaN and silently defeats the expiry guard.
|
||||
const toStorable = (v) => (v instanceof Date ? v.toISOString() : v);
|
||||
const row = {
|
||||
...opts,
|
||||
token,
|
||||
expires_at: toStorable(expiresAt),
|
||||
created_at: toStorable(new Date()),
|
||||
};
|
||||
await db(tableName).insert(row);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an Express app with the requested route file mounted. Mirrors
|
||||
* the production app's middleware shape (json + cookies) but skips
|
||||
* everything else (CORS, helmet, rate limiters) — route tests pin the
|
||||
* handler's contract, not the surrounding cross-cutting concerns.
|
||||
*
|
||||
* Example:
|
||||
* const app = buildRouteApp('/api/public/quotes',
|
||||
* require('../../src/routes/publicQuotes'));
|
||||
*/
|
||||
function buildRouteApp(mount, router) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(mount, router);
|
||||
// Catch-all error handler. Mirrors the real middleware/errorHandler:
|
||||
// AppError subclasses (ValidationError, NotFoundError, etc.) use
|
||||
// `.statusCode` (NOT `.status` — getting that wrong silently maps
|
||||
// every 400 / 404 / 410 to 500 in tests).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
const statusCode = err.statusCode || err.status || 500;
|
||||
res.status(statusCode).json({
|
||||
error: err.message || 'Internal error',
|
||||
code: err.code,
|
||||
...(err.details ? { details: err.details } : {}),
|
||||
});
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bootCrmDb,
|
||||
seedMinimal,
|
||||
assignAdminRole,
|
||||
mintAdminToken,
|
||||
createPublicToken,
|
||||
buildRouteApp,
|
||||
};
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* Minimal in-process OIDC provider for integration tests (#798).
|
||||
*
|
||||
* Serves just enough of the spec for openid-client's full validation to
|
||||
* pass: discovery, JWKS (RS256), authorization endpoint (immediate redirect,
|
||||
* no login UI), and token endpoint (authorization_code + PKCE). Claims for
|
||||
* the next login are scripted per test via `setNextUser()`.
|
||||
*
|
||||
* Runs on an ephemeral localhost port over plain http — the service allows
|
||||
* that in NODE_ENV=test only.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const { URL } = require('url');
|
||||
|
||||
function b64url(input) {
|
||||
return Buffer.from(input).toString('base64url');
|
||||
}
|
||||
|
||||
class MockOidcProvider {
|
||||
constructor() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
this.privateKey = privateKey;
|
||||
this.publicJwk = publicKey.export({ format: 'jwk' });
|
||||
this.publicJwk.kid = 'test-key-1';
|
||||
this.publicJwk.alg = 'RS256';
|
||||
this.publicJwk.use = 'sig';
|
||||
|
||||
this.clientId = 'picpeak-test';
|
||||
this.clientSecret = 'test-client-secret';
|
||||
this.codes = new Map(); // code -> { nonce, redirectUri, codeChallenge, user }
|
||||
this.nextUser = { sub: 'user-1', email: 'sso@example.com', email_verified: true };
|
||||
// Test hooks:
|
||||
this.tamperNonce = false; // sign the ID token with a WRONG nonce
|
||||
this.emailViaUserinfoOnly = false; // omit email from the ID token; serve it on /userinfo
|
||||
this.advertiseEndSession = true; // include end_session_endpoint in discovery (#798 phase 3)
|
||||
this.accessTokens = new Map(); // access_token -> user (for /userinfo)
|
||||
this.server = null;
|
||||
this.issuer = null;
|
||||
}
|
||||
|
||||
setNextUser(user) {
|
||||
this.nextUser = user;
|
||||
}
|
||||
|
||||
signIdToken({ sub, nonce, extraClaims = {} }) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = { alg: 'RS256', kid: this.publicJwk.kid, typ: 'JWT' };
|
||||
const payload = {
|
||||
iss: this.issuer,
|
||||
aud: this.clientId,
|
||||
sub,
|
||||
iat: now,
|
||||
exp: now + 300,
|
||||
nonce,
|
||||
...extraClaims,
|
||||
};
|
||||
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
|
||||
const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), this.privateKey);
|
||||
return `${signingInput}.${signature.toString('base64url')}`;
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.server = http.createServer((req, res) => this.handle(req, res));
|
||||
await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve));
|
||||
this.issuer = `http://127.0.0.1:${this.server.address().port}`;
|
||||
return this.issuer;
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.server) await new Promise((resolve) => this.server.close(resolve));
|
||||
}
|
||||
|
||||
handle(req, res) {
|
||||
const url = new URL(req.url, this.issuer);
|
||||
const json = (status, body) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
if (url.pathname === '/.well-known/openid-configuration') {
|
||||
return json(200, {
|
||||
issuer: this.issuer,
|
||||
authorization_endpoint: `${this.issuer}/authorize`,
|
||||
token_endpoint: `${this.issuer}/token`,
|
||||
userinfo_endpoint: `${this.issuer}/userinfo`,
|
||||
jwks_uri: `${this.issuer}/jwks`,
|
||||
...(this.advertiseEndSession ? { end_session_endpoint: `${this.issuer}/logout` } : {}),
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/jwks') {
|
||||
return json(200, { keys: [this.publicJwk] });
|
||||
}
|
||||
|
||||
if (url.pathname === '/authorize') {
|
||||
// "Log in" instantly: mint a code bound to this request's params and
|
||||
// bounce back to the redirect_uri like a real IdP would.
|
||||
const code = crypto.randomBytes(16).toString('base64url');
|
||||
this.codes.set(code, {
|
||||
nonce: url.searchParams.get('nonce'),
|
||||
redirectUri: url.searchParams.get('redirect_uri'),
|
||||
codeChallenge: url.searchParams.get('code_challenge'),
|
||||
user: this.nextUser,
|
||||
});
|
||||
const back = new URL(url.searchParams.get('redirect_uri'));
|
||||
back.searchParams.set('code', code);
|
||||
back.searchParams.set('state', url.searchParams.get('state'));
|
||||
res.writeHead(302, { location: back.href });
|
||||
return res.end();
|
||||
}
|
||||
|
||||
if (url.pathname === '/token' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
const params = new URLSearchParams(body);
|
||||
const stored = this.codes.get(params.get('code'));
|
||||
if (!stored) return json(400, { error: 'invalid_grant' });
|
||||
this.codes.delete(params.get('code'));
|
||||
|
||||
// PKCE check — S256(code_verifier) must match the challenge.
|
||||
const verifier = params.get('code_verifier') || '';
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
if (challenge !== stored.codeChallenge) {
|
||||
return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
|
||||
}
|
||||
|
||||
const { sub, ...extraClaims } = stored.user;
|
||||
// Spec-compliant providers may keep profile/email claims OFF the ID
|
||||
// token and serve them from /userinfo only — this hook simulates that.
|
||||
const idTokenClaims = this.emailViaUserinfoOnly ? {} : extraClaims;
|
||||
const idToken = this.signIdToken({
|
||||
sub,
|
||||
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
|
||||
extraClaims: idTokenClaims,
|
||||
});
|
||||
const accessToken = crypto.randomBytes(16).toString('base64url');
|
||||
this.accessTokens.set(accessToken, stored.user);
|
||||
return json(200, {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 300,
|
||||
id_token: idToken,
|
||||
});
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (url.pathname === '/userinfo') {
|
||||
const auth = req.headers.authorization || '';
|
||||
const user = this.accessTokens.get(auth.replace(/^Bearer\s+/i, ''));
|
||||
if (!user) return json(401, { error: 'invalid_token' });
|
||||
return json(200, { ...user });
|
||||
}
|
||||
|
||||
return json(404, { error: 'not_found' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MockOidcProvider };
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* Hidden feedback, seen from the guest who left it (#1150).
|
||||
*
|
||||
* Everything in the system treats a hidden row as absent: getPhotoFeedback
|
||||
* drops it even for the guest's own feedback, the /photos filters drop it, and
|
||||
* updatePhotoFeedbackStats does not count it. Two places disagreed — the
|
||||
* per-viewer `is_liked` heart and the `my_color_label` badge — so a like the
|
||||
* photographer had hidden still showed as liked on a photo whose like_count
|
||||
* was zero.
|
||||
*
|
||||
* Making those two agree exposes the second half: the duplicate check that
|
||||
* powers like/favorite toggling did NOT skip hidden rows, so the now-empty
|
||||
* heart, when clicked, found the hidden row and toggled it OFF. The click
|
||||
* appeared to do nothing and it took two more to get back to a filled heart.
|
||||
*
|
||||
* Hiding a non-comment is deliberate, not an accident of the raw route: #839
|
||||
* and #1044 both ship it, with tests asserting that a hidden reaction or
|
||||
* colour label stops counting. So the fix is to make hidden mean absent
|
||||
* consistently — not to stop admins hiding these.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-feedback-secret';
|
||||
|
||||
const SLUG = 'hidden-own-feedback';
|
||||
const ME = 'guest-me-identifier';
|
||||
|
||||
describe('a guest\'s own hidden feedback (#1150)', () => {
|
||||
let db; let cleanup; let app; let feedbackService;
|
||||
let eventId; let photoId; let myGuestRowId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
const guestToken = () => jwt.sign(
|
||||
{ type: 'guest', guestId: myGuestRowId, eventId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const getPhoto = async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.set('x-guest-token', guestToken());
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).find((p) => p.id === photoId);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
feedbackService = require('../../src/services/feedbackService');
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Hidden Own Feedback',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'hidden-own-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'shot.jpg', path: 'events/hidden/shot.jpg',
|
||||
type: 'individual', uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const [g] = await db('gallery_guests').insert({
|
||||
event_id: eventId, name: 'Me', identifier: ME,
|
||||
created_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
}).returning('id');
|
||||
myGuestRowId = typeof g === 'object' ? g.id : g;
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId, feedback_enabled: true, allow_likes: true,
|
||||
allow_color_labels: true, moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const like = () => db('photo_feedback').insert({
|
||||
photo_id: photoId, event_id: eventId, guest_identifier: ME,
|
||||
guest_id: myGuestRowId, feedback_type: 'like',
|
||||
is_approved: true, is_hidden: false, created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photo_feedback').where({ photo_id: photoId }).del();
|
||||
await db('photos').where('id', photoId).update({ like_count: 0, color_label_count: 0 });
|
||||
});
|
||||
|
||||
describe('the read surfaces agree with each other', () => {
|
||||
it('un-fills the heart once the like is hidden', async () => {
|
||||
await like();
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
expect((await getPhoto()).is_liked).toBe(true);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
|
||||
const photo = await getPhoto();
|
||||
// like_count already ignored hidden rows, so the heart was the only
|
||||
// thing still claiming this photo was liked.
|
||||
expect(photo.like_count).toBe(0);
|
||||
expect(photo.is_liked).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a hidden colour label from the badge', async () => {
|
||||
await db('photo_feedback').insert({
|
||||
photo_id: photoId, event_id: eventId, guest_identifier: ME,
|
||||
guest_id: myGuestRowId, feedback_type: 'color_label', color_label: 'green',
|
||||
is_approved: true, is_hidden: true, created_at: new Date().toISOString(),
|
||||
});
|
||||
expect((await getPhoto()).my_color_label).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and every other surface agrees', () => {
|
||||
it('keeps a hidden like out of /my-feedback', async () => {
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/my-feedback`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.set('x-guest-token', guestToken());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// In guest identity mode the Liked/Favorited/Rated chips and their
|
||||
// filters are built from THIS array, not from is_liked — so a hidden
|
||||
// like left an empty heart while the chip still counted it.
|
||||
expect(res.body.filter((f) => f.feedback_type === 'like')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not count a hidden row against the guest cap', async () => {
|
||||
await db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ max_likes_per_guest: 1 });
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
// The hidden row is room, not an occupant: the guest sees an empty
|
||||
// heart, and meeting that click with limit_reached leaves the control
|
||||
// dead until they un-like something they can still see.
|
||||
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
||||
});
|
||||
expect(result.limit_reached).toBeUndefined();
|
||||
|
||||
await db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ max_likes_per_guest: null });
|
||||
});
|
||||
|
||||
it('keeps the hidden record when the guest changes their replacement', async () => {
|
||||
// A hidden colour label and a visible replacement now coexist. The
|
||||
// toggle/switch and rating-clear paths DELETE over the guest-scoped set,
|
||||
// so an unfiltered scope took the admin's record with it — leaving
|
||||
// nothing to review or unhide.
|
||||
const [orig] = await db('photo_feedback').insert({
|
||||
photo_id: photoId, event_id: eventId, guest_identifier: ME,
|
||||
guest_id: myGuestRowId, feedback_type: 'color_label', color_label: 'red',
|
||||
is_approved: true, is_hidden: true, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const hiddenId = typeof orig === 'object' ? orig.id : orig;
|
||||
|
||||
// The guest, seeing no label, picks green, then switches to blue, then
|
||||
// toggles blue off — every mutation the single-value path offers.
|
||||
const opts = { feedback_type: 'color_label', guest_identifier: ME, guest_id: myGuestRowId };
|
||||
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'green' });
|
||||
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'blue' });
|
||||
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'blue' });
|
||||
|
||||
const survivor = await db('photo_feedback').where('id', hiddenId).first();
|
||||
expect(survivor).toBeTruthy();
|
||||
expect(survivor.is_hidden).toBeTruthy();
|
||||
expect(survivor.color_label).toBe('red');
|
||||
});
|
||||
|
||||
it('leaves other anonymous rows alone when there is no identity to scope by', async () => {
|
||||
// With neither guest_id nor guest_identifier the collapse scope degrades
|
||||
// to `guest_identifier IS NULL` — every identifier-less row on the
|
||||
// photo, i.e. other people's. Verified: knex renders that as `is null`.
|
||||
const anon = (extra) => ({
|
||||
photo_id: photoId, event_id: eventId, feedback_type: 'like',
|
||||
is_approved: true, created_at: new Date().toISOString(), ...extra,
|
||||
});
|
||||
const [h] = await db('photo_feedback').insert(anon({ is_hidden: true })).returning('id');
|
||||
const hiddenId = typeof h === 'object' ? h.id : h;
|
||||
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
||||
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
||||
|
||||
await feedbackService.moderateFeedback(hiddenId, 'approve', 1);
|
||||
|
||||
// All three survive: two unrelated visitors plus the unhidden one.
|
||||
expect(await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false }))
|
||||
.toHaveLength(3);
|
||||
});
|
||||
|
||||
it('collapses the replacement when an admin unhides the original', async () => {
|
||||
await like();
|
||||
const original = await db('photo_feedback').where({ photo_id: photoId }).first();
|
||||
await db('photo_feedback').where('id', original.id).update({ is_hidden: true });
|
||||
|
||||
// The guest, seeing an empty heart, likes again — a second row.
|
||||
await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
||||
});
|
||||
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(2);
|
||||
|
||||
await feedbackService.moderateFeedback(original.id, 'approve', 1);
|
||||
|
||||
// Two visible rows for one guest would double-count in the tallies and
|
||||
// need two toggles to clear, since each deletes a single row.
|
||||
const visible = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
||||
expect(visible).toHaveLength(1);
|
||||
expect(visible[0].id).toBe(original.id);
|
||||
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
expect((await db('photos').where('id', photoId).first()).like_count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and clicking still works afterwards', () => {
|
||||
it('re-liking creates a fresh row instead of toggling the hidden one off', async () => {
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
// What the guest sees is an empty heart, so this is an ADD.
|
||||
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like',
|
||||
guest_identifier: ME,
|
||||
guest_id: myGuestRowId,
|
||||
});
|
||||
|
||||
// Before this, the duplicate check found the hidden row and deleted it —
|
||||
// `removed: true` — so the click did nothing visible and the moderation
|
||||
// was silently undone.
|
||||
expect(result.removed).toBeUndefined();
|
||||
|
||||
const visible = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
||||
expect(visible).toHaveLength(1);
|
||||
expect((await getPhoto()).is_liked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
|
||||
const storageModule = require('../../src/services/storage');
|
||||
|
||||
// Stub out the DB so getThumbnailSettings falls into its catch and uses defaults.
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => {
|
||||
throw new Error('db disabled in this test');
|
||||
},
|
||||
}));
|
||||
|
||||
const TEST_S3 = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
region: 'us-east-1',
|
||||
};
|
||||
|
||||
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
|
||||
|
||||
function backendCases() {
|
||||
const cases = [
|
||||
{
|
||||
name: 'LocalFsStorage',
|
||||
async setup() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-'));
|
||||
const storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
return { storage, cleanup: () => fs.rm(root, { recursive: true, force: true }) };
|
||||
},
|
||||
},
|
||||
];
|
||||
if (!skipS3) {
|
||||
cases.push({
|
||||
name: 'S3StorageBackend (MinIO)',
|
||||
async setup() {
|
||||
const bucket = `picpeak-imgproc-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
|
||||
const s3Client = new S3Client({
|
||||
endpoint: TEST_S3.endpoint,
|
||||
region: TEST_S3.region,
|
||||
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
const storage = new S3StorageBackend({
|
||||
bucket,
|
||||
region: TEST_S3.region,
|
||||
endpoint: TEST_S3.endpoint,
|
||||
accessKeyId: TEST_S3.accessKeyId,
|
||||
secretAccessKey: TEST_S3.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false,
|
||||
});
|
||||
await storage.init();
|
||||
return {
|
||||
storage,
|
||||
async cleanup() {
|
||||
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
|
||||
if (list.Contents?.length) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
|
||||
}));
|
||||
}
|
||||
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
async function makeSourceJpeg(targetDir, name) {
|
||||
const localPath = path.join(targetDir, name);
|
||||
// 800x600 random RGB image so sharp has something realistic to thumbnail.
|
||||
const width = 800;
|
||||
const height = 600;
|
||||
const buf = Buffer.alloc(width * height * 3);
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
|
||||
await sharp(buf, { raw: { width, height, channels: 3 } })
|
||||
.jpeg({ quality: 90 })
|
||||
.toFile(localPath);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
|
||||
let storage;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let imageProcessor;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ storage, cleanup } = await setup());
|
||||
storageModule.setStorageForTesting(storage);
|
||||
// Require AFTER setStorageForTesting so the module sees our injection.
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-src-'));
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
test('generateThumbnail writes through storage and returns a relative key', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'sample.jpg');
|
||||
const key = await imageProcessor.generateThumbnail(src);
|
||||
expect(key).toBe('thumbnails/thumb_sample.jpg');
|
||||
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
const stat = await storage.stat(key);
|
||||
expect(stat.size).toBeGreaterThan(100);
|
||||
|
||||
// Verify the bytes are a valid JPEG by re-parsing with sharp on local mode.
|
||||
if (storage.kind() === 'local') {
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
expect(meta.format).toBe('jpeg');
|
||||
expect(meta.width).toBeLessThanOrEqual(300);
|
||||
}
|
||||
});
|
||||
|
||||
test('generateHeroImage writes through storage and returns a relative key', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'hero-source.jpg');
|
||||
const key = await imageProcessor.generateHeroImage(src);
|
||||
expect(key).toBe('heroes/hero_hero-source.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
});
|
||||
|
||||
test('generatePreviewImage writes to /previews and skips enlargement of small originals', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'preview-source.jpg');
|
||||
const key = await imageProcessor.generatePreviewImage(src);
|
||||
expect(key).toBe('previews/preview_preview-source.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
|
||||
if (storage.kind() === 'local') {
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
expect(meta.format).toBe('jpeg');
|
||||
// Source is 800x600 and default longEdge is 1920 with
|
||||
// withoutEnlargement: true → preview must NOT be upscaled.
|
||||
expect(meta.width).toBe(800);
|
||||
expect(meta.height).toBe(600);
|
||||
}
|
||||
});
|
||||
|
||||
test('generatePreviewImage shrinks oversized images to fit longEdge while preserving aspect', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'preview-shrink.jpg');
|
||||
const key = await imageProcessor.generatePreviewImage(src, { longEdge: 400 });
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
if (storage.kind() === 'local') {
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
// 800x600 → fit:'inside' inside 400×400 → 400×300.
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height).toBe(300);
|
||||
}
|
||||
});
|
||||
|
||||
test('isPreviewValid returns true for a real preview and false for a missing key', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'preview-valid.jpg');
|
||||
const key = await imageProcessor.generatePreviewImage(src);
|
||||
expect(await imageProcessor.isPreviewValid(key)).toBe(true);
|
||||
expect(await imageProcessor.isPreviewValid('previews/does-not-exist.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
|
||||
const key = await imageProcessor.generateThumbnail(src);
|
||||
expect(await imageProcessor.isThumbnailValid(key)).toBe(true);
|
||||
expect(await imageProcessor.isThumbnailValid('thumbnails/does-not-exist.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
test('generateVideoPlaceholder writes a thumbnail entirely from buffer', async () => {
|
||||
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4');
|
||||
expect(key).toBe('thumbnails/thumb_demo.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
});
|
||||
|
||||
test('withLocalCopy yields a usable local path on both backends', async () => {
|
||||
const sourceKey = 'fixture/withlocal.jpg';
|
||||
const src = await makeSourceJpeg(tmpDir, 'withlocal.jpg');
|
||||
const buf = await fs.readFile(src);
|
||||
await storage.put(sourceKey, buf, { contentType: 'image/jpeg' });
|
||||
|
||||
const seenSize = await imageProcessor.withLocalCopy(sourceKey, async (localPath) => {
|
||||
const meta = await sharp(localPath).metadata();
|
||||
return meta.width;
|
||||
});
|
||||
expect(seenSize).toBe(800);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user