From f0e6d2dfb12460cb1d003802f346e2026fa1c016 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:34:09 +0200 Subject: [PATCH] fix: enforce gallery access and consolidate gallery workflows (#1357) Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs. --- .env.example | 11 + .github/workflows/tests.yml | 38 +- .../integration/webhookDelivery.test.js | 22 +- .../securityHardeningBatch2.test.js | 17 +- .../eventsTimestampContract.test.js | 40 + .../migrations/freshPostgresGallery.test.js | 57 + .../routes/authSession.symmetry.test.js | 534 +-- .../routes/galleryAccessPolicy.test.js | 203 + .../__tests__/routes/galleryTokenJti.test.js | 40 + .../routes/secureImageTokenBinding.test.js | 7 +- .../services/emailWebhookTransport.test.js | 4 +- .../__tests__/services/scheduledTask.test.js | 25 + .../secureImageService.lifecycle.test.js | 15 + .../__tests__/utils/photoContentType.test.js | 2 +- backend/__tests__/utils/pinnedRequest.test.js | 79 + .../__tests__/utils/requestLogPath.test.js | 28 + .../utils/testListenerAddress.test.js | 13 + backend/jest.setup.js | 23 + .../migrations/core/210_events_updated_at.js | 16 + backend/server.js | 107 +- ...verifyGalleryAccess.customerRevoke.test.js | 18 +- backend/src/middleware/apiRequestLogger.js | 11 + backend/src/middleware/auth.js | 109 +- backend/src/middleware/csrf.js | 15 + backend/src/middleware/customerAuth.js | 84 +- backend/src/middleware/errorHandler.js | 5 +- backend/src/middleware/feedbackRateLimit.js | 6 +- backend/src/middleware/gallery.js | 303 +- backend/src/middleware/ownership.js | 8 +- backend/src/middleware/permissions.js | 5 +- .../src/middleware/secureImageMiddleware.js | 22 +- backend/src/middleware/sessionTimeout.js | 3 +- backend/src/routes/adminEvents/crud.js | 592 +-- backend/src/routes/adminEvents/helpers.js | 423 +-- backend/src/routes/auth.js | 131 +- backend/src/routes/customer.js | 12 +- backend/src/routes/gallery.js | 3369 +---------------- backend/src/routes/gallery/downloads.js | 945 +++++ backend/src/routes/gallery/media.js | 639 ++++ backend/src/routes/gallery/metadata.js | 249 ++ backend/src/routes/gallery/photos.js | 192 + backend/src/routes/gallery/slideshow.js | 293 ++ backend/src/routes/gallery/stats.js | 44 + backend/src/routes/gallery/styles.js | 41 + backend/src/routes/gallery/uploads.js | 211 ++ backend/src/routes/protectedImages.js | 32 +- backend/src/routes/secureImages.js | 12 + .../routes/v1/__tests__/events.create.test.js | 349 +- backend/src/routes/v1/events.js | 266 +- backend/src/services/chunkedUploadService.js | 11 +- .../src/services/downloadJobCleanupService.js | 17 +- backend/src/services/downloadZipService.js | 7 + backend/src/services/emailIntakeService.js | 11 +- backend/src/services/emailProcessor.js | 36 +- backend/src/services/emailWebhookTransport.js | 4 + backend/src/services/eventCreationService.js | 615 +++ .../src/services/eventCreationValidation.js | 47 + backend/src/services/eventService.js | 170 +- backend/src/services/eventSettings.js | 403 ++ backend/src/services/expirationChecker.js | 14 +- backend/src/services/fileWatcher.js | 23 +- backend/src/services/galleryAccessService.js | 57 + backend/src/services/galleryModel.js | 30 + backend/src/services/galleryPhotoQuery.js | 37 + backend/src/services/galleryQueryService.js | 567 +++ .../src/services/invoiceSchedulerService.js | 33 +- backend/src/services/rateLimitService.js | 10 +- backend/src/services/revealScheduler.js | 12 +- backend/src/services/s3AutoImporter.js | 27 +- backend/src/services/scheduledTask.js | 29 + backend/src/services/secureImageService.js | 46 +- backend/src/services/serviceShutdown.js | 28 + backend/src/services/sessionAccessService.js | 74 + .../src/services/transferCleanupService.js | 13 +- backend/src/services/webhookDeliveryWorker.js | 25 +- backend/src/services/whatsappProcessor.js | 28 +- backend/src/services/workerManager.js | 23 +- backend/src/utils/authSecurity.js | 11 +- backend/src/utils/cleanupTempUploads.js | 21 +- backend/src/utils/dateNormalize.js | 13 +- backend/src/utils/galleryLifecycle.js | 38 + backend/src/utils/networkValidation.js | 43 +- backend/src/utils/pinnedRequest.js | 28 + backend/src/utils/rateLimitSecurity.js | 9 +- backend/src/utils/requestLogPath.js | 12 + backend/src/utils/requestOrigin.js | 33 +- backend/src/utils/routeHelpers.js | 3 +- backend/src/utils/tokenRevocation.js | 13 +- docs/usage-coverage.v5.json | 107 +- frontend/eslint.config.js | 2 +- frontend/nginx.conf | 12 + frontend/nginx.dev.conf | 14 +- frontend/package-lock.json | 816 ++-- frontend/package.json | 18 +- .../src/components/admin/AdminPhotoViewer.tsx | 30 +- frontend/src/components/admin/CMSEditor.tsx | 52 +- .../components/admin/EmailTemplateEditor.tsx | 52 +- .../src/components/gallery/GalleryView.tsx | 265 +- .../gallery/PhotoGridWithLayouts.tsx | 10 +- .../gallery/UploadProcessingNotice.tsx | 17 + .../__tests__/CarouselLifecycle.test.tsx | 18 + .../__tests__/galleryUploadRefresh.test.ts | 169 +- .../gallery/hooks/useGalleryFiltering.ts | 138 + .../gallery/hooks/useGallerySelection.ts | 16 + .../gallery/hooks/useGalleryUpload.ts | 91 + .../gallery/layouts/CarouselGalleryLayout.tsx | 10 +- .../__tests__/usePhotoSelection.test.tsx | 15 + frontend/src/hooks/useGallery.ts | 2 +- frontend/src/hooks/usePhotoSelection.ts | 26 + frontend/src/pages/admin/EventDetailsPage.tsx | 2 - .../event-details/EventInformationCard.tsx | 24 +- .../src/pages/admin/event-details/types.ts | 2 - .../__tests__/gallery.pagination.test.ts | 66 + frontend/src/services/gallery.service.ts | 44 +- frontend/src/types/index.ts | 1 + nginx/nginx.conf | 16 +- nginx/nginx.conf.example | 14 +- package-lock.json | 1169 +----- package.json | 3 +- tests/nginx/test_request_logging.py | 160 + 120 files changed, 7147 insertions(+), 8525 deletions(-) create mode 100644 backend/__tests__/migrations/eventsTimestampContract.test.js create mode 100644 backend/__tests__/migrations/freshPostgresGallery.test.js create mode 100644 backend/__tests__/routes/galleryAccessPolicy.test.js create mode 100644 backend/__tests__/routes/galleryTokenJti.test.js create mode 100644 backend/__tests__/services/scheduledTask.test.js create mode 100644 backend/__tests__/services/secureImageService.lifecycle.test.js create mode 100644 backend/__tests__/utils/pinnedRequest.test.js create mode 100644 backend/__tests__/utils/requestLogPath.test.js create mode 100644 backend/__tests__/utils/testListenerAddress.test.js create mode 100644 backend/migrations/core/210_events_updated_at.js create mode 100644 backend/src/middleware/apiRequestLogger.js create mode 100644 backend/src/middleware/csrf.js create mode 100644 backend/src/routes/gallery/downloads.js create mode 100644 backend/src/routes/gallery/media.js create mode 100644 backend/src/routes/gallery/metadata.js create mode 100644 backend/src/routes/gallery/photos.js create mode 100644 backend/src/routes/gallery/slideshow.js create mode 100644 backend/src/routes/gallery/stats.js create mode 100644 backend/src/routes/gallery/styles.js create mode 100644 backend/src/routes/gallery/uploads.js create mode 100644 backend/src/services/eventCreationService.js create mode 100644 backend/src/services/eventCreationValidation.js create mode 100644 backend/src/services/eventSettings.js create mode 100644 backend/src/services/galleryAccessService.js create mode 100644 backend/src/services/galleryModel.js create mode 100644 backend/src/services/galleryPhotoQuery.js create mode 100644 backend/src/services/galleryQueryService.js create mode 100644 backend/src/services/scheduledTask.js create mode 100644 backend/src/services/serviceShutdown.js create mode 100644 backend/src/services/sessionAccessService.js create mode 100644 backend/src/utils/galleryLifecycle.js create mode 100644 backend/src/utils/pinnedRequest.js create mode 100644 backend/src/utils/requestLogPath.js create mode 100644 frontend/src/components/gallery/UploadProcessingNotice.tsx create mode 100644 frontend/src/components/gallery/__tests__/CarouselLifecycle.test.tsx create mode 100644 frontend/src/components/gallery/hooks/useGalleryFiltering.ts create mode 100644 frontend/src/components/gallery/hooks/useGallerySelection.ts create mode 100644 frontend/src/components/gallery/hooks/useGalleryUpload.ts create mode 100644 frontend/src/hooks/__tests__/usePhotoSelection.test.tsx create mode 100644 frontend/src/hooks/usePhotoSelection.ts create mode 100644 frontend/src/services/__tests__/gallery.pagination.test.ts create mode 100644 tests/nginx/test_request_logging.py diff --git a/.env.example b/.env.example index 0a55d867..1cb297e1 100644 --- a/.env.example +++ b/.env.example @@ -125,6 +125,11 @@ DB_NAME=picpeak_prod # 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. # @@ -353,3 +358,9 @@ LOGS=./logs # 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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 15f0d0a4..9bb54921 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,11 +6,6 @@ name: Tests # calendar) plus the photo / settings / OG / auth surface — wiring them # into CI makes regressions visible at PR time instead of post-merge. # -# Six backend suites are excluded via --testPathIgnorePatterns. They -# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM -# regressions). Excluding them here keeps CI green from day 1; revisit -# each individually as its own fix. -# # Triggers on any change that could affect either suite. The backend # job intentionally omits frontend paths and vice versa so unrelated # PRs don't pay both build costs. @@ -89,18 +84,7 @@ jobs: # Un-gates the real-Postgres cases in the .picpeak restore suites # (see the `services:` note above). Absent it they silently skip. PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test' - run: | - # Excluded suites — fail on upstream/beta too, tracked - # separately as test-infra debt: - # adminSettings.logo — supertest fixture - # integration/adminPhotos.reference — supertest fixture - # integration/webhookDelivery — supertest fixture - # services/backupService.enhanced — knex mock chain - # routes/__tests__/adminAuth — supertest fixture - # (adminNotifications was excluded; #597 fix re-enables it.) - npx jest \ - --testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \ - --ci + run: npx jest --ci frontend: runs-on: ubuntu-latest @@ -121,10 +105,30 @@ jobs: 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 diff --git a/backend/__tests__/integration/webhookDelivery.test.js b/backend/__tests__/integration/webhookDelivery.test.js index 35de5a18..5f3c6deb 100644 --- a/backend/__tests__/integration/webhookDelivery.test.js +++ b/backend/__tests__/integration/webhookDelivery.test.js @@ -5,9 +5,10 @@ process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true'; process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50'; const http = require('http'); -const { db } = require('../../src/database/db'); -const webhookService = require('../../src/services/webhookService'); -const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker'); +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); +let db, cleanup, adminId; +let webhookService; +let __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker; // Local-only test stub: matches what dev/webhook-receiver/server.js does // in the docker-compose flow but spun up inside the Jest process so the @@ -43,7 +44,7 @@ async function insertWebhook(url, events = ['event.published'], extras = {}) { secret_preview: preview, events: JSON.stringify(events), active: extras.active !== false, - created_by: 1, + created_by: adminId, }).returning('id'); const id = insert[0]?.id || insert[0]; return { id, secret: plaintext }; @@ -56,16 +57,15 @@ async function clearWebhooks() { describe('webhook delivery worker (#327)', () => { beforeAll(async () => { - // Schema is expected to already be applied by `npm run migrate`. We - // just verify the webhooks tables exist; if not, the test harness has - // missed running migration 082. - const ok = await db.schema.hasTable('webhooks'); - if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first'); + ({ db, cleanup } = await bootCrmDb()); + ({ adminId } = await seedMinimal(db)); + webhookService = require('../../src/services/webhookService'); + ({ __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker')); }, 30000); afterAll(async () => { - stopWebhookDeliveryWorker(); - await db.destroy(); + await stopWebhookDeliveryWorker(); + await cleanup(); }); beforeEach(async () => { diff --git a/backend/__tests__/middleware/securityHardeningBatch2.test.js b/backend/__tests__/middleware/securityHardeningBatch2.test.js index e255c4a0..2cbb700d 100644 --- a/backend/__tests__/middleware/securityHardeningBatch2.test.js +++ b/backend/__tests__/middleware/securityHardeningBatch2.test.js @@ -20,6 +20,7 @@ const fake = { maintenance: 'true', revoked: false, beforeCutoff: false, admin: jest.mock('../../src/database/db', () => { const db = jest.fn((table) => { const q = { + leftJoin: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), first: jest.fn(async () => { @@ -27,6 +28,7 @@ jest.mock('../../src/database/db', () => { return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance }; } if (table === 'admin_users') return fake.admin; + if (table === 'events') return { id: 1, slug: 'preview', created_by: 1, is_active: 1 }; return null; }), }; @@ -34,6 +36,7 @@ jest.mock('../../src/database/db', () => { }); return { db, withRetry: (fn) => fn() }; }); +jest.mock('../../src/middleware/permissions', () => ({ userHasAllPermissions: jest.fn().mockResolvedValue(true) })); jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() })); jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn(async () => fake.revoked) })); jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn(async () => fake.beforeCutoff) })); @@ -75,7 +78,7 @@ describe('general rate limiter skip', () => { }); describe('admin preview requires a live admin session', () => { - const req = (token) => ({ query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} }); + const req = (token) => ({ params: { slug: 'preview' }, query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} }); beforeEach(() => { fake.revoked = false; fake.beforeCutoff = false; fake.admin = { id: 1, password_changed_at: null }; }); it('passes for a live session and sets req.isAdminPreview', async () => { @@ -108,13 +111,23 @@ describe('multipart origin gate', () => { const req = (headers) => ({ headers: { host: 'photos.example.com', ...headers } }); it('accepts same-origin, same-site and non-browser requests', () => { expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-origin' }))).toBe(true); - expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(true); + expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(false); expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'none' }))).toBe(true); expect(multipartOriginAllowed(req({}))).toBe(true); expect(multipartOriginAllowed(req({ origin: 'https://photos.example.com' }))).toBe(true); // Same-origin install without FRONTEND_URL: Origin matches the Host. expect(multipartOriginAllowed({ headers: { host: 'gallery.local', origin: 'http://gallery.local' } })).toBe(true); }); + it('trusts Fetch Metadata same-origin before the Origin/scheme comparison', () => { + // TLS terminated upstream without X-Forwarded-Proto: req.protocol is http + // while the browser's Origin is https. Login must still work. + // gallery.local is not in the configured allowlist, so only the Host/scheme + // comparison or Fetch Metadata can admit it. + const proxied = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local', 'sec-fetch-site': 'same-origin' } }; + expect(multipartOriginAllowed(proxied)).toBe(true); + const legacyBrowser = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local' } }; + expect(multipartOriginAllowed(legacyBrowser)).toBe(false); + }); it('rejects cross-site form posts', () => { expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false); expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false); diff --git a/backend/__tests__/migrations/eventsTimestampContract.test.js b/backend/__tests__/migrations/eventsTimestampContract.test.js new file mode 100644 index 00000000..c10bf5ce --- /dev/null +++ b/backend/__tests__/migrations/eventsTimestampContract.test.js @@ -0,0 +1,40 @@ +const knex = require('knex'); +const migration = require('../../migrations/core/210_events_updated_at'); +const { toTimestamp } = require('../../src/utils/dateNormalize'); +const { randomUUID } = require('crypto'); + +const engines = [['sqlite', null], ...(process.env.PICPEAK_PG_TEST_URL ? [['pg', process.env.PICPEAK_PG_TEST_URL]] : [])]; +describe.each(engines)('event timestamp migration contract (%s)', (engine, connection) => { + let db, owner, schema; + beforeEach(async () => { + if (engine === 'pg') { + schema = `event_contract_${randomUUID().replace(/-/g, '')}`; + owner = knex({ client: 'pg', connection }); + await owner.schema.createSchema(schema); + db = knex({ client: 'pg', connection, searchPath: [schema] }); + } else db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + }); + afterEach(async () => { + await db.destroy(); + if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); } + }); + it('upgrades legacy data, is repeatable and preserves subsequent edits', async () => { + await db.schema.createTable('events', table => { + table.increments('id'); table.timestamp('created_at').defaultTo(db.fn.now()); table.boolean('is_active').defaultTo(true); + }); + const created = '2026-01-02T03:04:05.000Z'; + await db('events').insert({ created_at: created }); + await migration.up(db); await migration.up(db); + let row = await db('events').first(); + expect(toTimestamp(row.updated_at)).toBe(Date.parse(created)); + await db('events').where({ id: row.id }).update({ updated_at: db.fn.now(), is_active: engine === 'pg' ? false : 0 }); + const changed = (await db('events').first()).updated_at; + await migration.up(db); row = await db('events').first(); + expect(toTimestamp(row.updated_at)).toBe(toTimestamp(changed)); expect([false, 0]).toContain(row.is_active); + }); + it('handles a fresh table and an already present updated_at column', async () => { + await db.schema.createTable('events', table => { table.increments('id'); table.timestamp('created_at'); table.timestamp('updated_at'); }); + await migration.up(db); + expect(await db.schema.hasColumn('events', 'updated_at')).toBe(true); + }); +}); diff --git a/backend/__tests__/migrations/freshPostgresGallery.test.js b/backend/__tests__/migrations/freshPostgresGallery.test.js new file mode 100644 index 00000000..4db57545 --- /dev/null +++ b/backend/__tests__/migrations/freshPostgresGallery.test.js @@ -0,0 +1,57 @@ +const knex = require('knex'); +const { randomUUID } = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); +const os = require('os'); +const request = require('supertest'); +const pgUrl = process.env.PICPEAK_PG_TEST_URL; +(pgUrl ? describe : describe.skip)('fresh PostgreSQL gallery contract', () => { + let owner, db, schema, tmpDir, cleanup, previousClient; + beforeAll(async () => { + schema = `fresh_gallery_${randomUUID().replace(/-/g, '')}`; + owner = knex({ client: 'pg', connection: pgUrl }); + await owner.schema.createSchema(schema); + previousClient = process.env.DATABASE_CLIENT; + process.env.DATABASE_CLIENT = 'pg'; + process.env.JWT_SECRET = 'fresh-pg-gallery-test-secret-at-least-32-characters'; + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-fresh-pg-')); + process.env.STORAGE_PATH = path.join(tmpDir, 'storage'); + jest.doMock('../../knexfile', () => ({ client: 'pg', connection: pgUrl, searchPath: [schema] })); + ({ db } = require('../../src/database/db')); + // bootCrmDb runs the complete core chain against the shared db singleton. + ({ cleanup } = await require('../integration/helpers/crmDb').bootCrmDb()); + }, 120000); + afterAll(async () => { + await require('../../src/services/serviceShutdown').stopServices(); + if (cleanup) await cleanup(); else if (db) await db.destroy(); + if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); } + if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }); + if (previousClient === undefined) delete process.env.DATABASE_CLIENT; else process.env.DATABASE_CLIENT = previousClient; + jest.dontMock('../../knexfile'); + }); + it('creates through the real admin route, then toggles a typed boolean and timestamp', async () => { + const { seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp } = require('../integration/helpers/crmDb'); + const { adminId } = await seedMinimal(db); + await assignAdminRole(db, adminId); + const app = buildRouteApp('/api/admin/events', require('../../src/routes/adminEvents')); + const bearer = `Bearer ${mintAdminToken(adminId)}`; + const created = await request(app).post('/api/admin/events').set('Authorization', bearer).send({ + event_type: 'wedding', event_name: 'Fresh PostgreSQL', event_date: '2026-10-01', + customer_name: 'Customer', customer_email: 'customer@example.test', admin_email: 'admin@example.test', + password: 'Strong-Test-Photo-Pass-924!', expiration_days: 30, feedback_enabled: true, + }); + expect(created.status).toBe(200); + const event = await db('events').where({ event_name: 'Fresh PostgreSQL' }).first(); + expect(event.created_by).toBe(adminId); + expect(event.is_active).toBe(true); + expect(event.updated_at).toBeInstanceOf(Date); + expect(await db('event_feedback_settings').where({ event_id: event.id }).first()).toBeTruthy(); + const toggled = await request(app).post(`/api/admin/events/${event.id}/toggle-status`).set('Authorization', bearer).send({}); + expect(toggled.status).toBe(200); + const row = await db('events').where({ id: event.id }).first(); + expect(row.is_active).toBe(false); + expect(row.updated_at).toBeInstanceOf(Date); + await require('../../migrations/core/210_events_updated_at').up(db); + expect((await db('events').where({ id: event.id }).first()).updated_at).toEqual(row.updated_at); + }); +}); diff --git a/backend/__tests__/routes/authSession.symmetry.test.js b/backend/__tests__/routes/authSession.symmetry.test.js index 5c48adf8..122a33e0 100644 --- a/backend/__tests__/routes/authSession.symmetry.test.js +++ b/backend/__tests__/routes/authSession.symmetry.test.js @@ -1,460 +1,82 @@ -/** - * Regression test for the /admin/login → /admin/dashboard → /admin/login - * redirect loop reported on v3.32.4-beta.0. - * - * Cause: GET /auth/session was less strict than the adminAuth middleware. - * The session endpoint accepted tokens that the protected endpoints - * subsequently rejected with 401, which the frontend's interceptor - * translated into a hard redirect to /admin/login. /auth/session then - * said "valid: true" again on the next page load and the cycle closed. - * - * /auth/session must reject the same admin tokens adminAuth would - * reject, specifically: deactivated admin user, deleted admin user, - * password changed since iat. Same for gallery: archived event. - */ - -const express = require('express'); +/** Session restoration uses the same live policy as protected routes. */ const request = require('supertest'); const jwt = require('jsonwebtoken'); - -process.env.JWT_SECRET = 'session-symmetry-test-secret'; - -const fakeDb = { - adminUsers: [], - events: [], - revokedTokens: [], -}; - -jest.mock('../../src/database/db', () => { - const formatBoolean = (v) => (v ? 1 : 0); - void formatBoolean; - function dbFn(table) { - if (table === 'admin_users') { - let rowFilter = () => true; - return { - // The session route joins roles for the adminUser payload (#798); - // fake rows carry no role fields, so the join is a pass-through. - leftJoin() { - return this; - }, - where(criteria) { - rowFilter = (row) => { - return Object.entries(criteria).every(([rawKey, v]) => { - // Joined queries prefix columns ('admin_users.id') — the fake - // rows use bare names. - const k = rawKey.replace(/^admin_users\./, ''); - if (k === 'is_active') return Boolean(row.is_active) === Boolean(v); - return row[k] === v; - }); - }; - return this; - }, - select(...cols) { - this._cols = cols; - return this; - }, - async first() { - const row = fakeDb.adminUsers.find(rowFilter); - if (!row) return undefined; - if (!this._cols) return row; - const out = {}; - for (const c of this._cols) { - // Support 'table.col' and 'table.col as alias' shapes. - const [source, alias] = c.split(/\s+as\s+/i); - const bare = source.includes('.') ? source.split('.').pop() : source; - out[alias || bare] = row[bare]; - } - return out; - }, - }; - } - if (table === 'events') { - let rowFilter = () => true; - return { - where(criteria) { - rowFilter = (row) => - Object.entries(criteria).every(([k, v]) => { - if (k === 'is_active') return Boolean(row.is_active) === Boolean(v); - if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v); - return row[k] === v; - }); - return this; - }, - async first() { - return fakeDb.events.find(rowFilter); - }, - }; - } - throw new Error(`Unexpected table: ${table}`); - } - return { db: dbFn, formatBoolean: () => 1 }; +const crypto = require('crypto'); +const { bootCrmDb, seedMinimal, assignAdminRole, buildRouteApp } = require('../integration/helpers/crmDb'); +process.env.JWT_SECRET = 'session-symmetry-test-secret-with-at-least-32-characters'; +let db, cleanup, app, adminId, customerId, eventId, cutoff; +const slug = 'session-symmetry'; +const sign = (claims = {}) => jwt.sign({ type: 'admin', id: adminId, username: 'tester', + iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims }, +process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '4h' }); +const gallery = (claims = {}) => sign({ type: 'gallery', eventId, eventSlug: slug, ...claims }); +const session = bearer => request(app).get(`/api/auth/session?slug=${slug}`).set('Authorization', `Bearer ${bearer}`); +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId, customerId } = await seedMinimal(db)); + await assignAdminRole(db, adminId); + const row = await require('../../src/services/eventCreationService').createEvent({ + event_type: 'wedding', event_name: 'Session symmetry', event_date: '2026-10-01', + slug, password: 'Session-Strong-Password-924!', expiration_days: 30, + customer_email: 'customer@example.test', admin_email: 'admin@example.test', + }, { actor: { id: adminId }, source: 'v1' }); + eventId = row.id; + await db('events').where({ id: eventId }).update({ slug }); + await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId }); + cutoff = require('../../src/utils/sessionCutoff'); + app = buildRouteApp('/api/auth', require('../../src/routes/auth')); +}, 120000); +beforeEach(async () => { + await db('admin_users').where({ id: adminId }).update({ is_active: 1, password_changed_at: null }); + await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null }); + await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0, + expires_at: new Date(Date.now() + 86400000).toISOString() }); + await cutoff.setSessionsValidAfter(0); }); - -jest.mock('../../src/utils/dbCompat', () => ({ - formatBoolean: (v) => (v ? 1 : 0), -})); - -jest.mock('../../src/utils/tokenRevocation', () => ({ - isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)), - revokeToken: jest.fn(), -})); - -jest.mock('../../src/utils/tokenUtils', () => ({ - getAdminTokenFromRequest: (req) => { - const auth = req.headers.authorization; - if (auth && auth.startsWith('Bearer ')) return auth.slice(7); - return null; - }, - getGalleryTokenFromRequest: () => null, - setAdminAuthCookie: jest.fn(), - setGalleryAuthCookies: jest.fn(), - clearAdminAuthCookie: jest.fn(), - clearGalleryAuthCookies: jest.fn(), - buildCookieOptionsWithExpiry: () => ({}), -})); - -jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) })); -// Mock sessionTimeout's isSessionExpired so each test controls the return. -// Default: not expired (so existing tests keep passing without setup). -jest.mock('../../src/middleware/sessionTimeout', () => ({ - endSession: jest.fn(), - isSessionExpired: jest.fn(() => Promise.resolve(false)), -})); -jest.mock('../../src/utils/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -const authRouter = require('../../src/routes/auth'); - -function makeApp() { - const app = express(); - app.use(express.json()); - app.use('/auth', authRouter); - return app; -} - -function signAdminToken({ id = 1, username = 'admin', iat, exp }) { - const issuedAt = iat ?? Math.floor(Date.now() / 1000); - // Note: do NOT pass noTimestamp:true here — that strips iat from the - // payload entirely, defeating the password-change comparison. Provide - // iat (and exp) via the payload directly instead. - return jwt.sign( - { id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 }, - process.env.JWT_SECRET, - { issuer: 'picpeak-auth' } - ); -} - -function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) { - return jwt.sign( - { eventId, eventSlug, type: 'gallery', ...extra }, - process.env.JWT_SECRET, - { expiresIn: '1h', issuer: 'picpeak-auth' } - ); -} - -describe('GET /auth/session — symmetry with protected middleware', () => { - beforeEach(() => { - fakeDb.adminUsers = []; - fakeDb.events = []; - fakeDb.revokedTokens = []; - }); - - it('returns valid:true for an active admin token', async () => { - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - email: 'a@b.com', - is_active: true, - password_changed_at: null, - }); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - expect(res.body.type).toBe('admin'); - }); - - it('returns valid:false when the admin user has been deactivated', async () => { - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - email: 'a@b.com', - is_active: false, - password_changed_at: null, - }); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - }); - - it('returns valid:false when the admin user no longer exists', async () => { - // adminUsers is empty - const token = signAdminToken({ id: 999 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - }); - - it('returns valid:false when password was changed after the token was issued', async () => { - // iat must be in the past, exp must be in the future so jwt.verify - // doesn't reject the token before /auth/session even gets to look - // at password_changed_at. - const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago - const tokenExp = tokenIssuedAt + 86400; - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - email: 'a@b.com', - is_active: true, - password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat - }); - const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - }); - - it('returns valid:true when password was changed BEFORE the token was issued', async () => { - const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; - const tokenExp = tokenIssuedAt + 86400; - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - email: 'a@b.com', - is_active: true, - password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat - }); - const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - }); - - it('returns valid:false for a gallery token whose event is archived', async () => { - fakeDb.events.push({ - id: 100, - slug: 'wedding', - is_active: true, - is_archived: true, - expires_at: null, - }); - const token = signGalleryToken(); - - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - }); - - it('returns valid:false for a gallery token whose event is expired', async () => { - fakeDb.events.push({ - id: 100, - slug: 'wedding', - is_active: true, - is_archived: false, - expires_at: new Date(Date.now() - 86400_000), - }); - const token = signGalleryToken(); - - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - }); - - it('returns valid:true for an active gallery token', async () => { - fakeDb.events.push({ - id: 100, - slug: 'wedding', - is_active: true, - is_archived: false, - expires_at: new Date(Date.now() + 86400_000), - }); - const token = signGalleryToken(); - - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - }); - - /** - * What KIND of gallery session this is (#1149). - * - * The frontend used to keep this in sessionStorage, which is per-TAB while - * the cookie is per-browser: a gallery reopened in a second tab lost - * 'client' even though the backend still served it as one, and the UI hid - * the only control that clears the privileged cookie. Reported from the - * token so a restored session knows what it actually is. - */ - describe('gallery session kind', () => { - beforeEach(() => { - fakeDb.events.push({ - id: 100, - slug: 'wedding', - is_active: true, - is_archived: false, - expires_at: new Date(Date.now() + 86400_000), - }); - }); - - it('reports a PIN-client session as client', async () => { - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${signGalleryToken({ accessLevel: 'client' })}`); - expect(res.body.valid).toBe(true); - expect(res.body.accessLevel).toBe('client'); - expect(res.body.viaCustomer).toBe(false); - }); - - it('reports a customer-portal session, which looks like a guest', async () => { - // via:'customer' runs at accessLevel 'guest' but bypasses reveal mode, - // so it is a credential that does not look like one. - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${signGalleryToken({ via: 'customer', customerId: 7 })}`); - expect(res.body.valid).toBe(true); - expect(res.body.accessLevel).toBe('guest'); - expect(res.body.viaCustomer).toBe(true); - }); - - it('reports a plain guest as neither', async () => { - // The flags have to discriminate, or they would just hand every visitor - // a Logout button back. - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${signGalleryToken()}`); - expect(res.body.valid).toBe(true); - expect(res.body.accessLevel).toBe('guest'); - expect(res.body.viaCustomer).toBe(false); - }); - }); - - it('returns valid:false when the token is revoked', async () => { - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - is_active: true, - password_changed_at: null, - }); - fakeDb.revokedTokens.push(1); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(401); - expect(res.body.valid).toBe(false); - }); - - // Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0. - // sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat - // tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't. - // The new isSessionExpired helper closes that asymmetry. - describe('session-timeout symmetry', () => { - const { isSessionExpired } = require('../../src/middleware/sessionTimeout'); - - beforeEach(() => { - isSessionExpired.mockReset(); - // Default to "active session" so the other admin checks above also - // pass when this branch runs. - isSessionExpired.mockResolvedValue(false); - }); - - it('returns valid:false when isSessionExpired reports the token has timed out', async () => { - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - is_active: true, - password_changed_at: null, - }); - isSessionExpired.mockResolvedValue(true); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - expect(res.body.error).toBe('Session expired'); - }); - - it('returns valid:true for an active admin token (helper says not expired)', async () => { - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - is_active: true, - password_changed_at: null, - }); - isSessionExpired.mockResolvedValue(false); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - expect(isSessionExpired).toHaveBeenCalledTimes(1); - }); - - it('does not call isSessionExpired for gallery tokens', async () => { - fakeDb.events.push({ - id: 100, - slug: 'wedding', - is_active: true, - is_archived: false, - expires_at: new Date(Date.now() + 86400_000), - }); - const token = signGalleryToken(); - - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - expect(isSessionExpired).not.toHaveBeenCalled(); - }); - - it('falls through (treats as valid) if the helper itself throws', async () => { - // Defensive: the require() in auth.js is wrapped in try/catch so a - // missing/broken helper doesn't fail-closed during early bootstrap. - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - is_active: true, - password_changed_at: null, - }); - isSessionExpired.mockRejectedValue(new Error('boom')); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - }); - }); +afterAll(async () => { + await require('../../src/services/serviceShutdown').stopServices(); + if (cleanup) await cleanup(); +}); +it('hydrates an active admin and its role', async () => { + const res = await session(sign()); + expect(res.body).toMatchObject({ valid: true, type: 'admin', adminUser: { id: adminId, role: { name: 'super_admin' } } }); +}); +it.each(['disabled', 'password', 'deleted', 'idle'])('rejects an admin after %s', async reason => { + let bearer = sign(); + if (reason === 'disabled') await db('admin_users').where({ id: adminId }).update({ is_active: 0 }); + if (reason === 'password') await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date().toISOString() }); + if (reason === 'deleted') bearer = sign({ id: 999999 }); + if (reason === 'idle') bearer = sign({ iat: Math.floor(Date.now() / 1000) - 7200 }); + expect((await session(bearer)).body.valid).toBe(false); +}); +it('accepts a session issued after a previous password change', async () => { + await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date(Date.now() - 120000).toISOString() }); + expect((await session(sign())).body.valid).toBe(true); +}); +it.each(['archived', 'expired', 'draft', 'inactive'])('rejects a gallery that is %s', async reason => { + await db('events').where({ id: eventId }).update({ + ...(reason === 'archived' && { is_archived: 1 }), ...(reason === 'draft' && { is_draft: 1 }), + ...(reason === 'inactive' && { is_active: 0 }), ...(reason === 'expired' && { expires_at: new Date(Date.now() - 1000).toISOString() }), + }); + expect((await session(gallery())).body.valid).toBe(false); +}); +it.each(['guest', 'client', 'customer'])('restores the %s gallery session kind', async kind => { + const res = await session(gallery(kind === 'customer' ? { via: 'customer', customerId } : { accessLevel: kind })); + expect(res.body).toMatchObject({ valid: true, accessLevel: kind === 'client' ? 'client' : 'guest', viaCustomer: kind === 'customer' }); +}); +it.each(['revoked', 'restore'])('invalidates both admin and gallery sessions after %s', async reason => { + const tokens = [sign(), gallery()]; + if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000)); + else for (const bearer of tokens) await require('../../src/utils/tokenRevocation').revokeToken(bearer, 'test'); + for (const bearer of tokens) expect((await session(bearer)).body.valid).toBe(false); +}); +it('refuses a deactivated customer gallery session', async () => { + const bearer = gallery({ via: 'customer', customerId }); + expect((await session(bearer)).body.valid).toBe(true); + await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 }); + expect((await session(bearer)).body.valid).toBe(false); +}); +it('refuses an unrelated JWT type', async () => { + const res = await session(sign({ type: 'password-reset' })); + expect(res.status).toBe(403); expect(res.body.valid).toBe(false); }); diff --git a/backend/__tests__/routes/galleryAccessPolicy.test.js b/backend/__tests__/routes/galleryAccessPolicy.test.js new file mode 100644 index 00000000..80dd960b --- /dev/null +++ b/backend/__tests__/routes/galleryAccessPolicy.test.js @@ -0,0 +1,203 @@ +/** Real routes + migrated SQLite: the same session policy protects lists and media. */ +const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb'); +const request = require('supertest'); +const express = require('express'); +const cookieParser = require('cookie-parser'); +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); +process.env.JWT_SECRET = 'gallery-policy-regression-secret-at-least-32-characters'; + +jest.mock('../../src/middleware/secureImageMiddleware', () => ({ + secureImageAccess: (req, _res, next) => { + req.clientInfo = { fingerprint: 'policy-test', ip: '127.0.0.1', userAgent: 'jest' }; + next(); + }, + getSecurityStatus: (_req, res) => res.json({}), +})); + +let db, cleanup, app, adminId, customerId, foreignId, event, secure, cutoff, revokeToken; +const eventId = 70001, photoId = 70002, slug = 'policy-test'; +const token = (claims = {}) => jwt.sign({ type: 'gallery', eventId, eventSlug: slug, + iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims }, +process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '1h' }); +const get = (url, bearer) => { + const req = request(app).get(url); + return bearer ? req.set('Authorization', `Bearer ${bearer}`) : req; +}; +const endpoints = [`/api/gallery/${slug}/photos`, `/api/gallery/${slug}/photo/${photoId}`, + `/api/gallery/${slug}/thumbnail/${photoId}`, `/api/gallery/${slug}/download/${photoId}`]; +const expectDirect = async (bearer, status, suffix = '') => { + for (const url of endpoints) expect((await get(url + suffix, bearer)).status).toBe(status); +}; + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId, customerId } = await seedMinimal(db)); + await assignAdminRole(db, adminId); + const [row] = await db('admin_users').insert({ username: 'foreign', email: 'foreign@example.test', password_hash: 'unused', is_active: 1 }).returning('id'); + foreignId = row.id ?? row; + await assignAdminRole(db, foreignId, 'viewer'); + await db('events').insert({ id: eventId, slug, event_type: 'wedding', event_name: 'Policy test', + event_date: '2026-01-01', host_email: 'h@example.test', admin_email: 'a@example.test', password_hash: 'unused', + share_link: '/gallery/policy-test', created_by: adminId }); + const file = path.join(process.env.STORAGE_PATH, `events/active/${slug}/individual/fixture.jpg`); + await fs.mkdir(path.dirname(file), { recursive: true }); + await require('sharp')({ create: { width: 8, height: 8, channels: 3, background: '#228844' } }).jpeg().toFile(file); + await db('photos').insert({ id: photoId, event_id: eventId, filename: 'fixture.jpg', path: `${slug}/individual/fixture.jpg`, + type: 'individual', mime_type: 'image/jpeg', processing_status: 'complete', size_bytes: (await fs.stat(file)).size }); + await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId }); + secure = require('../../src/services/secureImageService'); + jest.spyOn(secure, 'createClientFingerprint').mockReturnValue('policy-test'); + cutoff = require('../../src/utils/sessionCutoff'); + ({ revokeToken } = require('../../src/utils/tokenRevocation')); + app = express(); app.use(express.json()); app.use(cookieParser()); + app.use('/api', require('../../src/middleware/csrf')); + app.use('/api/admin/events', require('../../src/routes/adminEvents')); + app.use('/api/gallery', require('../../src/routes/gallery')); + app.use('/api/images', require('../../src/routes/protectedImages')); + app.use('/api/secure-images', require('../../src/routes/secureImages')); +}, 120000); + +beforeEach(async () => { + await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0, require_password: 1, + expires_at: new Date(Date.now() + 86400000).toISOString(), reveal_mode: 0 }); + await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null }); + if (!await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).first()) { + await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId }); + } + await cutoff.setSessionsValidAfter(0); + event = await db('events').where({ id: eventId }).first(); +}); +afterAll(async () => { secure?.dispose(); if (cleanup) await cleanup(); }); + +it('serves a valid session as a real list and JPEG', async () => { + const bearer = token(); + const list = await get(endpoints[0], bearer); + expect(list.status).toBe(200); + expect(list.body.photos).toEqual(expect.arrayContaining([expect.objectContaining({ id: photoId })])); + const image = await get(endpoints[1], bearer); + expect(image.status).toBe(200); expect(image.headers['content-type']).toMatch(/image\/jpeg/); + expect(image.body.length).toBeGreaterThan(100); +}); +it('scopes draft previews to the owner and current read permissions', async () => { + await db('events').where({ id: eventId }).update({ is_draft: 1 }); + await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1'); + await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1'); + // Ownership alone does not grant a user without a role read access. + const role = (await db('admin_users').where({ id: adminId }).first()).role_id; + await db('admin_users').where({ id: adminId }).update({ role_id: null }); + try { await expectDirect(mintAdminToken(adminId), 403, '?admin_preview=1'); } + finally { await db('admin_users').where({ id: adminId }).update({ role_id: role }); } +}); +it.each(['revocation', 'restore'])('rejects gallery sessions after %s', async (reason) => { + const bearer = token(); + if (reason === 'revocation') expect(await revokeToken(bearer, 'test')).toBe(true); + else await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000)); + await expectDirect(bearer, 401); +}); +it.each(['deactivated', 'password changed'])('rejects an assigned customer when %s', async (reason) => { + const bearer = token({ via: 'customer', customerId }); + await expectDirect(bearer, 200); + await db('customer_accounts').where({ id: customerId }).update(reason === 'deactivated' + ? { is_active: 0 } : { password_changed_at: new Date().toISOString() }); + await expectDirect(bearer, 401); +}); +it.each(['ISO', 'epoch'])('enforces expiry immediately for public and JWT access (%s)', async (format) => { + const expiry = Date.now() - 1000; + await db('events').where({ id: eventId }).update({ require_password: 0, expires_at: format === 'ISO' ? new Date(expiry).toISOString() : expiry }); + await expectDirect(undefined, 404); await expectDirect(token(), 404); + await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1'); +}); +it.each(['revocation', 'restore', 'expiry', 'customer'])('rechecks signed and secure image grants after %s', async (reason) => { + const bearer = token(reason === 'customer' ? { via: 'customer', customerId } : {}); + const signed = await request(app).post(`/api/images/${slug}/photo/${photoId}/generate-url`).set('Authorization', `Bearer ${bearer}`).send({}); + expect(signed.status).toBe(200); + const minted = await request(app).post(`/api/secure-images/${slug}/generate-token`).set('Authorization', `Bearer ${bearer}`).send({ photoId }); + expect(minted.status).toBe(200); + const secureUrl = `/api/secure-images/${slug}/secure/${photoId}/${minted.body.token}`; + expect((await get(signed.body.url)).status).toBe(200); + expect((await get(secureUrl)).status).toBe(200); + if (reason === 'revocation') await revokeToken(bearer, 'test'); + if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000)); + if (reason === 'expiry') await db('events').where({ id: eventId }).update({ expires_at: new Date(Date.now() - 1000).toISOString() }); + if (reason === 'customer') await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 }); + const status = reason === 'expiry' ? 404 : 401; + expect((await get(signed.body.url)).status).toBe(status); + expect((await get(secureUrl)).status).toBe(status); +}); + +it('blocks an empty cross-site cookie POST before the reveal state changes', async () => { + await db('events').where({ id: eventId }).update({ reveal_mode: 1, revealed_at: null }); + const cookie = `admin_token=${mintAdminToken(adminId)}`; + const url = `/api/admin/events/${eventId}/reveal`; + const blocked = await request(app).post(url).set('Cookie', cookie).set('Origin', 'https://attacker.example') + .set('Sec-Fetch-Site', 'cross-site').set('Content-Type', 'application/x-www-form-urlencoded').send(''); + expect(blocked.status).toBe(403); + expect((await db('events').where({ id: eventId }).first()).revealed_at).toBeNull(); + process.env.ADMIN_URL = 'https://admin.example.test'; + try { + const allowed = await request(app).post(url).set('Cookie', cookie).set('Origin', process.env.ADMIN_URL) + .set('Sec-Fetch-Site', 'cross-site').send({}); + expect(allowed.status).toBe(200); + expect((await db('events').where({ id: eventId }).first()).revealed_at).not.toBeNull(); + } finally { delete process.env.ADMIN_URL; } +}); + +it('toggles status on a fully migrated fresh database and records updated_at', async () => { + const response = await request(app).post(`/api/admin/events/${eventId}/toggle-status`) + .set('Authorization', `Bearer ${mintAdminToken(adminId)}`).send({}); + expect(response.status).toBe(200); + const row = await db('events').where({ id: eventId }).first(); + expect([false, 0]).toContain(row.is_active); + expect(Number.isFinite(require('../../src/utils/dateNormalize').toTimestamp(row.updated_at))).toBe(true); +}); +it('paginates after feedback filtering, with a total independent of page size', async () => { + const ids = [70003, 70004, 70005]; + await db('photos').insert(ids.map(id => ({ id, event_id: eventId, filename: `${id}.jpg`, path: 'unused', + type: 'individual', like_count: 1, processing_status: 'complete' }))); + await db('event_feedback_settings').insert({ event_id: eventId, feedback_enabled: 1, show_feedback_to_guests: 1 }); + try { + const bearer = token(); + const first = await get(`${endpoints[0]}?filter=liked&limit=2&page=1&sort=filename&order=asc`, bearer); + const second = await get(`${endpoints[0]}?filter=liked&limit=2&page=2&sort=filename&order=asc`, bearer); + expect(first.status).toBe(200); expect(second.status).toBe(200); + expect(first.body.pagination).toMatchObject({ total: 3, has_more: true }); + expect(second.body.pagination).toMatchObject({ total: 3, has_more: false }); + expect([...first.body.photos, ...second.body.photos].map(photo => photo.id)).toEqual(ids); + } finally { + await db('photos').whereIn('id', ids).del(); + await db('event_feedback_settings').where({ event_id: eventId }).del(); + } +}); + +it.each(['assignment removed', 'anonymized'])('rejects an existing customer grant after %s', async reason => { + const bearer = token({ via: 'customer', customerId }); + await expectDirect(bearer, 200); + if (reason === 'assignment removed') await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).del(); + else await require('../../src/services/customerAccountsService').eraseCustomer(customerId, adminId); + await expectDirect(bearer, reason === 'assignment removed' ? 403 : 401); +}); +it('denies foreign editors and allows the editor who owns the gallery', async () => { + await assignAdminRole(db, foreignId, 'editor'); + try { + await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1'); + await db('events').where({ id: eventId }).update({ created_by: foreignId }); + await expectDirect(mintAdminToken(foreignId), 200, '?admin_preview=1'); + } finally { + await db('events').where({ id: eventId }).update({ created_by: adminId }); + await assignAdminRole(db, foreignId, 'viewer'); + } +}); +it('bounds a large gallery response while retaining the complete count', async () => { + const rows = Array.from({ length: 5000 }, (_, index) => ({ id: 80000 + index, event_id: eventId, + filename: `large-${index}.jpg`, path: 'unused', type: 'individual', processing_status: 'complete' })); + try { + await db.batchInsert('photos', rows, 100); + const response = await get(`${endpoints[0]}?limit=999999&page=1`, token()); + expect(response.status).toBe(200); + expect(response.body.photos).toHaveLength(250); + expect(response.body.pagination).toMatchObject({ total: 5001, limit: 250, has_more: true }); + } finally { await db('photos').where('id', '>=', 80000).where({ event_id: eventId }).del(); } +}); diff --git a/backend/__tests__/routes/galleryTokenJti.test.js b/backend/__tests__/routes/galleryTokenJti.test.js new file mode 100644 index 00000000..4334c1d9 --- /dev/null +++ b/backend/__tests__/routes/galleryTokenJti.test.js @@ -0,0 +1,40 @@ +/** + * Gallery tokens must carry a per-token `jti`. tokenRevocation falls back to + * `${eventId}-${iat}-gallery` without one, so a guest logging out would revoke + * every other guest whose token was minted for the same event in the same + * second (QR-code share links at an event make that routine). + */ +const fs = require('fs'); +const path = require('path'); +const jwt = require('jsonwebtoken'); + +const mintSites = [ + 'src/routes/auth.js', + 'src/routes/customer.js', + 'src/routes/gallery/slideshow.js', +]; + +describe('gallery token mint sites', () => { + it.each(mintSites)('%s sets a unique jti on every gallery token', (file) => { + const source = fs.readFileSync(path.join(__dirname, '../../', file), 'utf8'); + const payloads = source.split('jwt.sign(').slice(1) + .map((chunk) => chunk.split('process.env.JWT_SECRET')[0]) + .filter((payload) => payload.includes("type: 'gallery'")); + expect(payloads.length).toBeGreaterThan(0); + for (const payload of payloads) expect(payload).toContain('jti: crypto.randomUUID()'); + }); +}); + +describe('revocation key', () => { + beforeAll(() => { process.env.JWT_SECRET = process.env.JWT_SECRET || 'jti-regression-secret-at-least-32-characters-long'; }); + it('is distinct for two same-second gallery logins of the same event', () => { + const { buildTokenId } = require('../../src/utils/tokenRevocation'); + const crypto = require('crypto'); + const iat = Math.floor(Date.now() / 1000); + const mint = () => jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat, jti: crypto.randomUUID() }, process.env.JWT_SECRET)); + expect(buildTokenId(mint())).not.toBe(buildTokenId(mint())); + // Without a jti the key collapses to eventId + login second. + const bare = jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat }, process.env.JWT_SECRET)); + expect(buildTokenId(bare)).toBe(buildTokenId({ ...bare })); + }); +}); diff --git a/backend/__tests__/routes/secureImageTokenBinding.test.js b/backend/__tests__/routes/secureImageTokenBinding.test.js index d11b818d..c14f93e7 100644 --- a/backend/__tests__/routes/secureImageTokenBinding.test.js +++ b/backend/__tests__/routes/secureImageTokenBinding.test.js @@ -89,7 +89,8 @@ describe('secure-image view route token binding (GHSA-g94x)', () => { const mint = (photoId, eventId) => secureImageService.generateSecureToken( photoId, `gallery_public_${eventId}_${Date.now()}`, - { clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 }, + { clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600, + galleryAccess: require('../../src/services/galleryAccessService').grant({ id: eventId }, 'public') }, ); const view = (slug, photoId, token) => request(app) @@ -114,7 +115,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => { const token = mint(photoA, galleryA); const res = await view('secimg-private-b', photoB, token); expect(res.status).toBe(403); - expect(res.body.error).toMatch(/not valid for this photo/i); + expect(res.body.code).toBe('INVALID_GALLERY_GRANT'); }); it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => { @@ -123,7 +124,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => { // check (sessionId gallery A != URL gallery B) must catch it. const res = await view('secimg-private-b', photoA, token); expect(res.status).toBe(403); - expect(res.body.error).toMatch(/not valid for this gallery/i); + expect(res.body.code).toBe('INVALID_GALLERY_GRANT'); }); it('lets a token read its own gallery + photo (binding passes)', async () => { diff --git a/backend/__tests__/services/emailWebhookTransport.test.js b/backend/__tests__/services/emailWebhookTransport.test.js index f13505a5..429c276e 100644 --- a/backend/__tests__/services/emailWebhookTransport.test.js +++ b/backend/__tests__/services/emailWebhookTransport.test.js @@ -15,7 +15,7 @@ jest.mock('axios', () => ({ post: jest.fn() })); jest.mock('../../src/utils/networkValidation', () => ({ - validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok' })), + validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] })), })); jest.mock('../../src/utils/logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), @@ -48,7 +48,7 @@ beforeEach(() => { process.env.EMAIL_WEBHOOK_SECRET = SECRET; transport.__testing.setAllowPrivateUrls(false); transport.__testing.resetSecretWarning(); - validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok' }); + validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] }); axios.post.mockResolvedValue({ status: 200, data: streamOf('') }); }); diff --git a/backend/__tests__/services/scheduledTask.test.js b/backend/__tests__/services/scheduledTask.test.js new file mode 100644 index 00000000..d9c600f6 --- /dev/null +++ b/backend/__tests__/services/scheduledTask.test.js @@ -0,0 +1,25 @@ +jest.mock('../../src/utils/logger', () => ({ error: jest.fn() })); +const { scheduledTask } = require('../../src/services/scheduledTask'); +beforeEach(() => jest.useFakeTimers()); +afterEach(() => jest.useRealTimers()); +it('starts once, skips overlap and drains the accepted run on stop', async () => { + let finish; + const work = jest.fn(() => new Promise(resolve => { finish = resolve; })); + const task = scheduledTask(work, { interval: 100 }); + task.start(); task.start(); + expect(jest.getTimerCount()).toBe(1); + await jest.advanceTimersByTimeAsync(300); + expect(work).toHaveBeenCalledTimes(1); + let stopped = false; + const stop = task.stop().then(() => { stopped = true; }); + await Promise.resolve(); expect(stopped).toBe(false); + finish(); await stop; expect(stopped).toBe(true); + expect(jest.getTimerCount()).toBe(0); + await jest.advanceTimersByTimeAsync(1000); expect(work).toHaveBeenCalledTimes(1); +}); +it('cancels a delayed first run and can restart cleanly', async () => { + const work = jest.fn(); const task = scheduledTask(work, { interval: 100, initialDelay: 10 }); + task.start(); await task.stop(); await jest.advanceTimersByTimeAsync(200); expect(work).not.toHaveBeenCalled(); + task.start(); await jest.advanceTimersByTimeAsync(10); expect(work).toHaveBeenCalledTimes(1); + await task.stop(); +}); diff --git a/backend/__tests__/services/secureImageService.lifecycle.test.js b/backend/__tests__/services/secureImageService.lifecycle.test.js new file mode 100644 index 00000000..108a488e --- /dev/null +++ b/backend/__tests__/services/secureImageService.lifecycle.test.js @@ -0,0 +1,15 @@ +jest.mock('../../src/database/db', () => ({ db: jest.fn() })); +const secure = require('../../src/services/secureImageService'); +beforeEach(() => { jest.useFakeTimers(); secure.dispose(); }); +afterEach(() => { secure.dispose(); jest.useRealTimers(); }); +it('owns one timer for many tokens and sweeps expired capabilities', () => { + for (let i = 0; i < 100; i++) secure.generateSecureToken(i, 'gallery_public_1_1', { expiresIn: 1 }); + expect(jest.getTimerCount()).toBe(1); expect(secure.tokenCache.size).toBe(100); + jest.advanceTimersByTime(60000); expect(secure.tokenCache.size).toBe(0); + secure.dispose(); expect(jest.getTimerCount()).toBe(0); +}); +it('disposes all session/rate caches and restarts on demand', () => { + secure.generateSecureToken(1, 'session'); secure.sessionTokens.set('a', 'b'); secure.rateLimitCache.set('a', 'b'); + secure.dispose(); expect(secure.sessionTokens.size + secure.rateLimitCache.size + secure.tokenCache.size).toBe(0); + secure.generateSecureToken(1, 'session'); expect(jest.getTimerCount()).toBe(1); +}); diff --git a/backend/__tests__/utils/photoContentType.test.js b/backend/__tests__/utils/photoContentType.test.js index 406cd196..4bf82cf6 100644 --- a/backend/__tests__/utils/photoContentType.test.js +++ b/backend/__tests__/utils/photoContentType.test.js @@ -31,7 +31,7 @@ describe('resolvePhotoContentType', () => { }); describe('serving routes use the resolver', () => { - const routes = ['gallery.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js']; + const routes = ['gallery/media.js', 'gallery/downloads.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js']; it.each(routes)('%s sets no Content-Type from photo.mime_type directly', (name) => { const src = fs.readFileSync(path.join(__dirname, '../../src/routes', name), 'utf8'); expect(src).not.toMatch(/'Content-Type':\s*photo\.mime_type/); diff --git a/backend/__tests__/utils/pinnedRequest.test.js b/backend/__tests__/utils/pinnedRequest.test.js new file mode 100644 index 00000000..70256db4 --- /dev/null +++ b/backend/__tests__/utils/pinnedRequest.test.js @@ -0,0 +1,79 @@ +const dns = require('dns'); +const http = require('http'); +const axios = require('axios'); +const { validateExternalUrlAsync } = require('../../src/utils/networkValidation'); +const { pinnedRequestOptions } = require('../../src/utils/pinnedRequest'); +afterEach(() => jest.restoreAllMocks()); +it('never performs a second DNS lookup that could reach a private listener', async () => { + const received = jest.fn(); + const server = http.createServer((req, res) => { received(); res.end('private'); }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const url = `http://rebind.example:${server.address().port}/hook`; + const preflight = jest.spyOn(dns.promises, 'lookup').mockResolvedValue([{ address: '192.0.2.1', family: 4 }]); + const unsafeLookup = jest.spyOn(dns, 'lookup').mockImplementation((_host, opts, cb) => { + if (typeof opts === 'function') { cb = opts; opts = {}; } + cb(null, ...(opts.all ? [[{ address: '127.0.0.1', family: 4 }]] : ['127.0.0.1', 4])); + }); + let options; + try { + const check = await validateExternalUrlAsync(url); + options = pinnedRequestOptions(check); + await expect(axios.post(url, 'private-data', { ...options, timeout: 200 })).rejects.toThrow(); + expect(preflight).toHaveBeenCalledTimes(1); + expect(unsafeLookup).not.toHaveBeenCalled(); expect(received).not.toHaveBeenCalled(); + } finally { + options?.httpAgent.destroy(); options?.httpsAgent.destroy(); + await new Promise(resolve => server.close(resolve)); + } +}); +it('preserves the original Host and refuses redirects while using the pinned address', async () => { + const hosts = []; + const server = http.createServer((req, res) => { + hosts.push(req.headers.host); res.writeHead(302, { Location: 'http://localhost/private' }); res.end(); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const host = `pinned.example:${server.address().port}`; + // Test the transport in isolation: production checks reject this private IP. + const options = pinnedRequestOptions({ valid: true, hostname: 'pinned.example', addresses: [{ address: '127.0.0.1', family: 4 }] }); + try { + const res = await axios.post(`http://${host}/hook`, 'body', { ...options, validateStatus: () => true }); + expect(res.status).toBe(302); expect(hosts).toEqual([host]); expect(options.proxy).toBe(false); + } finally { options.httpAgent.destroy(); options.httpsAgent.destroy(); await new Promise(resolve => server.close(resolve)); } +}); +it('fails closed for missing DNS results', () => { + expect(() => pinnedRequestOptions({ valid: true })).toThrow('validated destination'); +}); + +it('preserves TLS SNI and certificate hostname verification for a pinned connection', async () => { + const fs = require('fs/promises'); + const path = require('path'); + const dir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'picpeak-tls-pin-')); + const key = path.join(dir, 'key.pem'), cert = path.join(dir, 'cert.pem'); + require('child_process').execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=pinned.example', + '-addext', 'subjectAltName=DNS:pinned.example'], { stdio: 'ignore' }); + const certificate = await fs.readFile(cert); + const seen = []; + const server = require('https').createServer({ key: await fs.readFile(key), cert: certificate }, (req, res) => { + seen.push({ host: req.headers.host, servername: req.socket.servername }); res.end('ok'); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const makeOptions = hostname => { + const options = pinnedRequestOptions({ valid: true, hostname, addresses: [{ address: '127.0.0.1', family: 4 }] }); + options.httpsAgent.options.ca = certificate; + return options; + }; + const allowed = makeOptions('pinned.example'), wrong = makeOptions('wrong.example'); + try { + const host = `pinned.example:${server.address().port}`; + expect((await axios.post(`https://${host}/hook`, 'data', { ...allowed, timeout: 2000 })).status).toBe(200); + expect(seen).toEqual([{ host, servername: 'pinned.example' }]); + await expect(axios.post(`https://wrong.example:${server.address().port}/hook`, 'data', { ...wrong, timeout: 2000 })) + .rejects.toMatchObject({ code: 'ERR_TLS_CERT_ALTNAME_INVALID' }); + expect(seen).toHaveLength(1); + } finally { + for (const options of [allowed, wrong]) { options.httpAgent.destroy(); options.httpsAgent.destroy(); } + await new Promise(resolve => server.close(resolve)); + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/backend/__tests__/utils/requestLogPath.test.js b/backend/__tests__/utils/requestLogPath.test.js new file mode 100644 index 00000000..a54ce2f1 --- /dev/null +++ b/backend/__tests__/utils/requestLogPath.test.js @@ -0,0 +1,28 @@ +const EventEmitter = require('events'); +jest.mock('../../src/utils/logger', () => ({ info: jest.fn() })); +const logger = require('../../src/utils/logger'); +const middleware = require('../../src/middleware/apiRequestLogger'); +const { requestLogPath } = require('../../src/utils/requestLogPath'); +const marker = 'SECRET_TEST_CAPABILITY'; +it.each([ + `/api/gallery/g/photos?token=${marker}&password=${marker}`, + `/api/gallery/g/verify-token/${marker}`, + `/api/gallery/g/show/${marker}/state`, + `/api/images/g/photo/1/signed/${marker}`, + `/api/secure-images/g/secure/1/${marker}`, + `/api/secure-images/g/secure-download/1/${marker}`, + `/api/public/contracts/${marker}/sign`, + `/api/customer/auth/password-reset/${marker}`, + `/api/public/newsletter/unsubscribe/${marker}`, +])('does not log capabilities on request or response: %s', (originalUrl) => { + logger.info.mockClear(); + const res = new EventEmitter(); res.statusCode = 200; + middleware({ originalUrl, method: 'GET' }, res, jest.fn()); + res.emit('finish'); + expect(logger.info).toHaveBeenCalledTimes(2); + expect(JSON.stringify(logger.info.mock.calls)).not.toContain(marker); +}); +it('retains useful non-secret routes and removes control characters', () => { + expect(requestLogPath('/api/admin/events/12?search=private')).toBe('/api/admin/events/12'); + expect(requestLogPath('/api/admin/events\nforged')).not.toContain('\n'); +}); diff --git a/backend/__tests__/utils/testListenerAddress.test.js b/backend/__tests__/utils/testListenerAddress.test.js new file mode 100644 index 00000000..93ee2605 --- /dev/null +++ b/backend/__tests__/utils/testListenerAddress.test.js @@ -0,0 +1,13 @@ +const http = require('http'); +it('directs Supertest to the actual IPv6 listener instead of an unrelated IPv4 port', async () => { + jest.resetModules(); + const request = require('supertest'); + const server = http.createServer((_req, res) => { res.end('actual test listener'); }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '::1', resolve); }); + try { + const response = await request(server).get('/'); + expect(response.status).toBe(200); + expect(response.text).toBe('actual test listener'); + expect(response.request.url).toContain('://[::1]:'); + } finally { await new Promise(resolve => server.close(resolve)); } +}); diff --git a/backend/jest.setup.js b/backend/jest.setup.js index e20fdec2..a9f09ac0 100644 --- a/backend/jest.setup.js +++ b/backend/jest.setup.js @@ -1,3 +1,18 @@ +// Supertest 6 binds an IPv6 wildcard listener but hardcodes an IPv4 URL. +// macOS can allocate that IPv6 port while a different IPv4 service owns it. +// Address the listener's actual family so a test cannot reach that service. +jest.mock('supertest/lib/test', () => { + const Test = jest.requireActual('supertest/lib/test'); + const serverAddress = Test.prototype.serverAddress; + Test.prototype.serverAddress = function(app, path) { + const url = serverAddress.call(this, app, path); + return app.address()?.family === 'IPv6' + ? url.replace('://127.0.0.1:', '://[::1]:') + : url; + }; + return Test; +}); + beforeAll(() => { process.env.NODE_ENV = 'test'; process.env.JWT_SECRET = 'test-secret'; @@ -8,3 +23,11 @@ beforeAll(() => { process.env.STORAGE_PATH = '/storage'; } }); + +// Dispose resources loaded by this suite using the application's draining +// shutdown. Individual fixtures still own temporary files and other DB pools. +afterAll(async () => { + await require('./src/services/serviceShutdown').stopServices(); + const loadedDb = require.cache[require.resolve('./src/database/db')]; + if (typeof loadedDb?.exports.db?.destroy === 'function') await loadedDb.exports.db.destroy(); +}); diff --git a/backend/migrations/core/210_events_updated_at.js b/backend/migrations/core/210_events_updated_at.js new file mode 100644 index 00000000..3a9099b0 --- /dev/null +++ b/backend/migrations/core/210_events_updated_at.js @@ -0,0 +1,16 @@ +/** Fresh installs and upgraded databases expose the same event timestamp. */ +exports.up = async function (knex) { + if (!await knex.schema.hasTable('events')) return; + if (!await knex.schema.hasColumn('events', 'updated_at')) { + await knex.schema.alterTable('events', table => { + table.timestamp('updated_at'); + }); + } + // Do not replace existing modification times on a repeated migration. + await knex('events').whereNull('updated_at').update({ updated_at: knex.ref('created_at') }); +}; +exports.down = async function (knex) { + if (await knex.schema.hasTable('events') && await knex.schema.hasColumn('events', 'updated_at')) { + await knex.schema.alterTable('events', table => table.dropColumn('updated_at')); + } +}; diff --git a/backend/server.js b/backend/server.js index d2e9d321..dbdcbaed 100644 --- a/backend/server.js +++ b/backend/server.js @@ -218,7 +218,7 @@ app.use((req, res, next) => { }); // CORS configuration (apply only to API routes) -const { isAllowedOrigin, multipartOriginAllowed } = require('./src/utils/requestOrigin'); +const { isAllowedOrigin } = require('./src/utils/requestOrigin'); const corsOptions = { origin: function (origin, callback) { @@ -528,43 +528,10 @@ app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' })); app.use(express.json({ limit: '2mb' })); app.use(express.urlencoded({ extended: true, limit: '2mb' })); -// CSRF protection: require JSON Content-Type on mutating API requests -// This blocks cross-origin form submissions which cannot set Content-Type: application/json -app.use('/api', (req, res, next) => { - if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { - const contentType = req.headers['content-type'] || ''; - const contentLength = parseInt(req.headers['content-length'] || '0', 10); - // Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls - if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) { - return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' }); - } - // multipart is exactly what a cross-site