Files
picpeak/backend/__tests__
Paul Nothaft 5c85e0c0e4 fix(guests): surface duplicate guest registrations, and stop making so many (#1210) (#1216)
* fix(guests): surface duplicate guest registrations, and stop making so many (#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. The photographer's 'final
selection' is then only trustworthy if somebody notices two Tinas with half the
picks each.

Two halves, neither of which touches the registration path.

**Say which rows are the same person.** Merging already worked, endpoint and UI
both; nothing said WHICH rows to merge. The guests list now marks each row with
the others sharing its email and returns a count for the banner, and the admin
list offers the group straight to the merge mode that already exists.
Case-folded and trimmed, because the same person types Tina@ one day and tina@
the next and both read as distinct rows. Email only — two guests called Anna
are not evidence of anything, and rows without an email are not grouped at all
since require_name_email is off by default and a shared link produces plenty of
them.

It preselects rather than merges: which row survives decides the name and
verification state the merged guest keeps, and that is the admin's call.

**Create fewer of them.** The guest token was 24h and every call site took that
default, so even the same browser lost its identity after a day of inactivity.
Now 30 days, GUEST_TOKEN_TTL to override. A guest token is scoped to one event,
carries no admin capability, and the gallery is already behind whatever
protects it — 30 days is the shape of a real proofing cycle.

Deliberately NOT done: reusing a guest row when a typed email matches, which
the report suggests first. It would let anyone who knows an address inherit
that person's identity and selections, and answering differently for a known
email would leak which addresses are in the gallery — the thing
/guest/recover already goes out of its way to avoid. Prevention at the entry
path needs the verification round-trip, which is a separate decision about
friction.

13 tests; 8 of the 9 backend ones fail without the change. The frontend ones
caught a real bug while being written — the new useMemo sat after the loading
early-return, so the hook count changed between renders.

* fix(guests): merge must not strand a pending invite (#1210)

Three findings from external review of #1216.

**A merge could kill an emailed invite link.** 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 sharing an email — which this feature now points out
and offers to merge. Redemption resolves guest_invites.guest_id with
is_deleted: false, so merging soft-deleted the row the link pointed at: the
client got 404 guest_missing while the invite dialog still showed the invite as
Pending. Nothing anywhere said the link was dead. Unredeemed, unrevoked invites
now move to the survivor first. Spent ones stay put — a redeemed invite records
who redeemed what, and retargeting it would rewrite that.

**The preselection silently chose the survivor.** performMerge keeps
mergeSelection[0], and the group was handed over in API order, which is
newest-first — so Review then Merge discarded an older, email-verified row
holding most of the picks in favour of a fresh re-registration. The proposal is
now ordered deliberately: verified first, then whoever holds the most feedback,
then the oldest. Still only a proposal, and the confirmation now names the
survivor by email as well as name, because duplicates share a name and 'Merge 2
guests into Tina?' said nothing.

**duplicate_of was quadratic.** Every row carried the other n-1 ids, so a group
of n serialised n² of them — and nothing consumed the list: the UI asked only
whether a row was in a group, then regrouped by email itself. Replaced with
duplicate_group, the normalised email, which keeps the payload linear and the
case/whitespace folding in one place instead of reimplemented on the client.

Two new backend tests for the invite paths, one frontend test asserting the
merge call keeps the verified row. The invite test fails against the un-fixed
code.

* fix(guests): keep guest-controlled input out of who survives a merge (#1210)

Round 2 of external review on #1216.

**The survivor ranking used an attacker-controlled signal.** Preferring
whoever holds the most feedback looked like the obvious tiebreak and is exactly
the wrong one: registration does not verify the address, so anyone who knows a
guest's email can register with it, mark enough photos to out-rank the real
person, and be preselected as the survivor. An admin accepting a confirmation
between two rows with the same name and email would then move the victim's
picks onto an identity whose token the visitor still holds. distinct_photos is
guest-controlled and has no business deciding this. The ranking is now
email_verified_at then created_at — both server-set.

**A merge could make the survivor unrecoverable.** Rows are grouped with case
and whitespace folded out, so a merge can be proposed between tina@example.com
and Tina@Example.com. /guest/recover lowercases what the guest types and then
matches on equality, so a survivor left holding the raw value can never be
recovered by email again. The kept row's address is now canonicalised during
the merge. Both write paths normalise today, so this covers rows that predate
that — which are exactly the rows case-folded grouping surfaces.

Two more backend tests. The residual, stated plainly: an admin can still merge
two unverified rows in either order. What is gone is the tool ranking them by
something a visitor controls.

* fix(compose): pass GUEST_TOKEN_TTL through to the backend (#1210)

The override was documented in .env.example and could never take effect: the
backend service takes an explicit environment list, so a variable not named
there never reaches the container. An operator following the documentation
would have shortened the guest session and seen nothing change.

docker-compose.production.yml uses env_file: .env and already passed it
through; docker-compose.dev.yml is gitignored, so only this file needs it.

* fix(guests): the admin picks the merge survivor, the tool does not (#1210)

Fourth review round on the same point, and the right conclusion is that there
is no correct automatic answer.

Every rule tried was wrong somewhere. Most-feedback is guest-controlled — the
address is never verified at registration, so anyone who knows it can register
and mark photos until they out-rank the real person. Oldest-first, the
replacement, is worse for the ordinary case: when a token expires the OLD row
is the dead identity and the new one is the visitor's live session, so keeping
the oldest deletes the identity they are actually using, and the frontend holds
that deleted guest in sessionStorage without clearing it on a 401. Registration
timing is visitor-controlled too.

The data does not say which row is really the person. So the UI asks: merge
mode gains a Keep column, the button stays disabled until a row is nominated,
and only rows included in the merge can be nominated. The group is still
preselected — finding the duplicates was always the point — but nothing about
who survives is decided by sort order any more.

This also makes the claim in the PR description true. It said the admin decides
which row survives; until now the preselection quietly decided it for them.

Two rewritten frontend tests: the merge is blocked until a survivor is chosen
and then keeps exactly that row, and a row outside the group cannot be
nominated. The test i18n mock now interpolates, so aria-labels are queryable by
their rendered text.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:27:15 +02:00
..

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)

    # 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):

    # 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

# 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

# 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

# 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:

// 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:

# 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

# 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