diff --git a/backend/__tests__/adminSettings.logo.test.js b/backend/__tests__/adminSettings.logo.test.js index 16eb8911..0ff1c17d 100644 --- a/backend/__tests__/adminSettings.logo.test.js +++ b/backend/__tests__/adminSettings.logo.test.js @@ -110,6 +110,11 @@ describe('Admin settings logo upload flow', () => { } })); + 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(), diff --git a/backend/__tests__/integration/adminPhotos.reference.test.js b/backend/__tests__/integration/adminPhotos.reference.test.js index 76248264..67424970 100644 --- a/backend/__tests__/integration/adminPhotos.reference.test.js +++ b/backend/__tests__/integration/adminPhotos.reference.test.js @@ -40,6 +40,15 @@ describe('Admin photos in reference mode', () => { } })); + // 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() @@ -98,8 +107,21 @@ describe('Admin photos in reference mode', () => { table.string('type').notNullable(); table.integer('size_bytes'); table.integer('category_id'); - table.string('source_origin'); + // 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); @@ -153,7 +175,10 @@ describe('Admin photos in reference mode', () => { .field('category_id', String(categoryId)) .attach('photos', Buffer.from('fake image data'), 'photo.jpg'); - expect(uploadResponse.status).toBe(200); + // 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); @@ -203,5 +228,24 @@ describe('Admin photos in reference mode', () => { 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 }); + } }); }); diff --git a/backend/__tests__/integration/crmMintPaths.test.js b/backend/__tests__/integration/crmMintPaths.test.js index 74536a9c..92e19d6a 100644 --- a/backend/__tests__/integration/crmMintPaths.test.js +++ b/backend/__tests__/integration/crmMintPaths.test.js @@ -148,10 +148,15 @@ async function seedCustomerSignedContract() { beforeAll(async () => { ({ db, cleanup, tmpDir } = await bootCrmDb()); // Business-doc PDFs (quotes/invoices/contracts) persist under - // `process.cwd()/storage/business-docs/...` — chdir into the temp dir - // so every test artifact lands isolated and gets cleaned up. + // `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); - storageRoot = path.join(fs.realpathSync(tmpDir), 'storage', 'business-docs'); + // 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 diff --git a/backend/__tests__/integration/webhookDelivery.test.js b/backend/__tests__/integration/webhookDelivery.test.js index 14ed1ab5..35de5a18 100644 --- a/backend/__tests__/integration/webhookDelivery.test.js +++ b/backend/__tests__/integration/webhookDelivery.test.js @@ -146,8 +146,8 @@ describe('webhook delivery worker (#327)', () => { payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), attempt_count: 4, status: 'pending', - next_retry_at: new Date(), - created_at: new Date(), + next_retry_at: new Date().toISOString(), + created_at: new Date().toISOString(), }); await __test.tick(); @@ -190,8 +190,8 @@ describe('webhook delivery worker (#327)', () => { payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), attempt_count: 0, status: 'pending', - next_retry_at: new Date(), - created_at: new Date(), + next_retry_at: new Date().toISOString(), + created_at: new Date().toISOString(), }); await __test.tick(); @@ -214,8 +214,8 @@ describe('webhook delivery worker (#327)', () => { payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), attempt_count: 0, status: 'pending', - next_retry_at: new Date(), - created_at: new Date(), + next_retry_at: new Date().toISOString(), + created_at: new Date().toISOString(), }); await __test.tick(); diff --git a/backend/__tests__/migrations/194_german_gallery_created_translation.test.js b/backend/__tests__/migrations/194_german_gallery_created_translation.test.js new file mode 100644 index 00000000..3fb75d46 --- /dev/null +++ b/backend/__tests__/migrations/194_german_gallery_created_translation.test.js @@ -0,0 +1,234 @@ +/** + * The `gallery_created` German translation shipped as the English text + * verbatim on every fresh install (QA J.04), because 059 seeds `_de` from + * `_en` and 075 turns those columns into the `de` translation row. + * + * What is pinned here is as much about restraint as repair: the migration may + * only overwrite a German row that is still the English one, so a legacy + * install (whose German came from legacy migration 026) and any template an + * admin has edited themselves survive untouched. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const migration = require('../../migrations/core/194_german_gallery_created_translation'); + +// The English copy seeded by migration 001, verbatim. +const EN_SUBJECT = 'Your Photo Gallery is Ready!'; +const EN_HTML = `

Gallery Created Successfully

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been created successfully!

+

Gallery Details:

+ +

Share this link and password with your guests to allow them to view and download photos.

`; +const EN_TEXT = 'Gallery Created Successfully\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has been created successfully!'; + +const placeholdersOf = (...parts) => { + const found = new Set(); + for (const part of parts) { + for (const match of String(part || '').matchAll(/\{\{\s*([#/]?[\w.]+)\s*\}\}/g)) { + found.add(match[1]); + } + } + return [...found].sort(); +}; + +describe('migration 194 — German gallery_created translation (QA J.04)', () => { + let knex; let tmpDir; + + beforeAll(async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig194-')); + knex = require('knex')({ + client: 'sqlite3', + connection: { filename: path.join(tmpDir, 'db.sqlite') }, + useNullAsDefault: true, + }); + }); + + afterAll(async () => { + if (knex) await knex.destroy(); + await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + }); + + const createTables = async () => { + await knex.schema.createTable('email_templates', (t) => { + t.increments('id').primary(); + t.string('template_key').unique().notNullable(); + t.string('subject_en'); + t.string('subject_de'); + t.text('body_html_en'); + t.text('body_html_de'); + t.text('body_text_en'); + t.text('body_text_de'); + t.json('variables'); + t.string('updated_at'); + }); + await knex.schema.createTable('email_template_translations', (t) => { + t.increments('id').primary(); + t.integer('template_id'); + t.string('language', 10); + t.text('subject'); + t.text('body_html'); + t.text('body_text'); + t.string('created_at'); + t.string('updated_at'); + }); + }; + + const dropTables = async () => { + if (await knex.schema.hasTable('email_template_translations')) { + await knex.schema.dropTable('email_template_translations'); + } + if (await knex.schema.hasTable('email_templates')) { + await knex.schema.dropTable('email_templates'); + } + }; + + /** The state a fresh install lands in: 059 copied EN into the DE columns. */ + const seedFreshInstall = async ({ deHtml = EN_HTML, deSubject = EN_SUBJECT, deText = EN_TEXT } = {}) => { + const [id] = await knex('email_templates').insert({ + template_key: 'gallery_created', + subject_en: EN_SUBJECT, + subject_de: deSubject, + body_html_en: EN_HTML, + body_html_de: deHtml, + body_text_en: EN_TEXT, + body_text_de: deText, + variables: JSON.stringify(['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date']), + }); + await knex('email_template_translations').insert([ + { template_id: id, language: 'en', subject: EN_SUBJECT, body_html: EN_HTML, body_text: EN_TEXT }, + { template_id: id, language: 'de', subject: deSubject, body_html: deHtml, body_text: deText }, + ]); + return id; + }; + + const rowFor = async (templateId, language) => + knex('email_template_translations').where({ template_id: templateId, language }).first(); + + beforeEach(async () => { + await dropTables(); + await createTables(); + }); + + it('replaces the English-as-German row with actual German', async () => { + const id = await seedFreshInstall(); + + await migration.up(knex); + + const de = await rowFor(id, 'de'); + expect(de.subject).not.toBe(EN_SUBJECT); + expect(de.body_html).not.toBe(EN_HTML); + expect(de.body_text).not.toBe(EN_TEXT); + expect(de.subject).toContain('Fotogalerie'); + expect(de.body_html).toContain('Galerie erfolgreich erstellt'); + expect(de.body_text).toContain('Passwort'); + // The English row is not collateral damage. + const en = await rowFor(id, 'en'); + expect(en.body_html).toBe(EN_HTML); + }); + + it('uses exactly the placeholder set of the English original', async () => { + const id = await seedFreshInstall(); + + await migration.up(knex); + + const en = await rowFor(id, 'en'); + const de = await rowFor(id, 'de'); + const expected = placeholdersOf(en.subject, en.body_html, en.body_text); + expect(expected).toEqual(['event_date', 'event_name', 'expiry_date', 'gallery_link', 'gallery_password', 'host_name']); + expect(placeholdersOf(de.subject, de.body_html, de.body_text)).toEqual(expected); + }); + + it('repairs the legacy _de columns too', async () => { + const id = await seedFreshInstall(); + + await migration.up(knex); + + const master = await knex('email_templates').where({ id }).first(); + expect(master.body_html_de).not.toBe(EN_HTML); + expect(master.body_html_de).toContain('Galerie erfolgreich erstellt'); + expect(master.subject_de).not.toBe(EN_SUBJECT); + expect(master.body_html_en).toBe(EN_HTML); + }); + + it('leaves an already-translated German row (and columns) alone', async () => { + // What a legacy install carries after legacy migration 026. + const legacyDe = '

Galerie erfolgreich erstellt

Liebe(r) {{host_name}},

'; + const id = await seedFreshInstall({ + deSubject: 'Ihre Fotogalerie ist bereit!', + deHtml: legacyDe, + deText: 'Galerie erfolgreich erstellt', + }); + + await migration.up(knex); + + expect((await rowFor(id, 'de')).body_html).toBe(legacyDe); + expect((await knex('email_templates').where({ id }).first()).body_html_de).toBe(legacyDe); + }); + + it('fills in a missing German row', async () => { + const id = await seedFreshInstall(); + await knex('email_template_translations').where({ template_id: id, language: 'de' }).del(); + + await migration.up(knex); + + expect((await rowFor(id, 'de')).body_html).toContain('Galerie erfolgreich erstellt'); + }); + + it('keeps a subject the admin translated while repairing the still-English body', async () => { + // Each field is judged on its own. Gating on body_html alone would have + // thrown this subject away — and down() is a no-op, so for good. + const adminSubject = 'Ihre Galerie steht bereit'; + const id = await seedFreshInstall({ deSubject: adminSubject }); + + await migration.up(knex); + + const de = await rowFor(id, 'de'); + expect(de.subject).toBe(adminSubject); + expect(de.body_html).toContain('Galerie erfolgreich erstellt'); + expect(de.body_text).toContain('Passwort'); + const master = await knex('email_templates').where({ id }).first(); + expect(master.subject_de).toBe(adminSubject); + expect(master.body_html_de).toContain('Galerie erfolgreich erstellt'); + }); + + it('keeps an admin-translated body while repairing a still-English subject', async () => { + const adminHtml = '

Hallo {{host_name}}, „{{event_name}}“ ist online: {{gallery_link}} / {{gallery_password}} bis {{expiry_date}} ({{event_date}})

'; + const id = await seedFreshInstall({ deHtml: adminHtml }); + + await migration.up(knex); + + const de = await rowFor(id, 'de'); + expect(de.body_html).toBe(adminHtml); + expect(de.subject).toBe('Ihre Fotogalerie ist bereit!'); + expect((await knex('email_templates').where({ id }).first()).body_html_de).toBe(adminHtml); + }); + + it('is idempotent', async () => { + const id = await seedFreshInstall(); + + await migration.up(knex); + const once = await rowFor(id, 'de'); + await migration.up(knex); + const twice = await rowFor(id, 'de'); + + expect(twice.body_html).toBe(once.body_html); + expect(await knex('email_template_translations').where({ template_id: id, language: 'de' }).count()) + .toEqual([{ 'count(*)': 1 }]); + }); + + it('no-ops when the template or the tables are absent', async () => { + await expect(migration.up(knex)).resolves.toBeUndefined(); + await dropTables(); + await expect(migration.up(knex)).resolves.toBeUndefined(); + await createTables(); + }); +}); diff --git a/backend/__tests__/routes/adminArchivesQuery.test.js b/backend/__tests__/routes/adminArchivesQuery.test.js new file mode 100644 index 00000000..1904f53f --- /dev/null +++ b/backend/__tests__/routes/adminArchivesQuery.test.js @@ -0,0 +1,209 @@ +/** + * The archives list must resolve search / type filter / sort in SQL. + * + * Before this, GET /admin/archives ignored every query param except page and + * limit: the UI fetched one 20-row page and filtered it in JavaScript while + * the pagination footer kept reporting the unfiltered server-side total. An + * archive that matched the search but lived on another page came back as a + * false "0 results". These tests pin the params the route now honours, and + * — the part that actually made the bug visible — that `pagination.total` + * describes the *filtered* set. + */ + +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-archquery-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'archquery-test-secret'; + +const request = require('supertest'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); + +const { bootCrmDb, seedMinimal, buildRouteApp } = require('../integration/helpers/crmDb'); + +describe('GET /admin/archives query params (#I.01)', () => { + let db; let cleanup; let app; let token; + + // name, type, archived_at, photo sizes + const fixtures = [ + ['Alpha Wedding', 'wedding', '2026-01-05T10:00:00.000Z', [300]], + ['Bravo Birthday', 'birthday', '2026-02-05T10:00:00.000Z', [100]], + ['Charlie Wedding', 'wedding', '2026-03-05T10:00:00.000Z', [500, 400]], + ['Delta Corporate', 'corporate', '2026-04-05T10:00:00.000Z', [200]], + ['Echo WEDDING Gala', 'wedding', '2026-05-05T10:00:00.000Z', [50]], + ]; + + const list = async (query) => { + const res = await request(app) + .get('/admin/archives') + .query(query) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + return res.body; + }; + + const names = (body) => body.archives.map((a) => a.eventName); + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + + const role = await db('roles').where({ name: 'super_admin' }).first(); + const inserted = await db('admin_users').insert({ + username: 'arch-admin', + email: 'arch-admin@example.com', + password_hash: await bcrypt.hash('Passw0rd!', 4), + role_id: role.id, + is_active: 1, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }).returning('id'); + const adminId = inserted[0]?.id ?? inserted[0]; + token = jwt.sign( + { id: adminId, username: 'arch-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() }, + process.env.JWT_SECRET, + { expiresIn: '1h', issuer: 'picpeak-auth' }, + ); + + let i = 0; + for (const [eventName, eventType, archivedAt, sizes] of fixtures) { + const slug = `arch-${i++}`; + const ev = await db('events').insert({ + slug, + event_type: eventType, + event_name: eventName, + event_date: '2026-08-01', + host_email: 'h@example.com', + admin_email: 'a@example.com', + password_hash: 'x', + share_token: `tok-${slug}`, + share_link: `/gallery/${slug}/tok-${slug}`, + expires_at: new Date(Date.now() + 7 * 864e5).toISOString(), + is_active: 0, + is_archived: 1, + is_draft: 0, + archived_at: archivedAt, + created_at: new Date().toISOString(), + }).returning('id'); + const eventId = ev[0]?.id ?? ev[0]; + + let p = 0; + for (const size of sizes) { + await db('photos').insert({ + event_id: eventId, + filename: `${slug}-${p++}.jpg`, + path: `events/archived/${slug}.jpg`, + type: 'individual', + size_bytes: size, + uploaded_at: new Date().toISOString(), + }); + } + } + + // A live event that must never surface in the archives list. + await db('events').insert({ + slug: 'not-archived', + event_type: 'wedding', + event_name: 'Alpha Live Wedding', + event_date: '2026-08-01', + host_email: 'h@example.com', + admin_email: 'a@example.com', + password_hash: 'x', + share_token: 'tok-not-archived', + share_link: '/gallery/not-archived/tok-not-archived', + expires_at: new Date(Date.now() + 7 * 864e5).toISOString(), + is_active: 1, + is_archived: 0, + is_draft: 0, + created_at: new Date().toISOString(), + }); + + app = buildRouteApp('/admin/archives', require('../../src/routes/adminArchives')); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + test('no params: every archive, newest first, unfiltered total', async () => { + const body = await list({}); + expect(names(body)).toEqual([ + 'Echo WEDDING Gala', 'Delta Corporate', 'Charlie Wedding', 'Bravo Birthday', 'Alpha Wedding', + ]); + expect(body.pagination.total).toBe(5); + }); + + test('search filters in SQL and the total describes the filtered set', async () => { + const body = await list({ search: 'wedding' }); + // Case-insensitive, matches "Echo WEDDING Gala" too, and never the + // non-archived "Alpha Live Wedding". + expect(names(body).sort()).toEqual(['Alpha Wedding', 'Charlie Wedding', 'Echo WEDDING Gala']); + expect(body.pagination.total).toBe(3); + expect(body.pagination.totalPages).toBe(1); + }); + + test('search reaches rows that are not on page 1 — the actual bug', async () => { + // limit=2 puts "Alpha Wedding" (oldest) on page 3 of the unfiltered list. + // Client-side filtering of page 1 returned nothing for this query. + const body = await list({ search: 'alpha', limit: 2, page: 1 }); + expect(names(body)).toEqual(['Alpha Wedding']); + expect(body.pagination.total).toBe(1); + }); + + test('search with no match returns an empty page and a zero total', async () => { + const body = await list({ search: 'zzz-nothing' }); + expect(body.archives).toEqual([]); + expect(body.pagination.total).toBe(0); + expect(body.pagination.totalPages).toBe(0); + }); + + test('type filter narrows the rows and the total; "all" is a no-op', async () => { + const filtered = await list({ type: 'wedding' }); + expect(names(filtered).sort()).toEqual(['Alpha Wedding', 'Charlie Wedding', 'Echo WEDDING Gala']); + expect(filtered.pagination.total).toBe(3); + + const all = await list({ type: 'all' }); + expect(all.pagination.total).toBe(5); + }); + + test('search and type filter combine', async () => { + const body = await list({ search: 'wedding', type: 'birthday' }); + expect(body.archives).toEqual([]); + expect(body.pagination.total).toBe(0); + }); + + test('sortBy=name orders across the whole set, not just the page', async () => { + const page1 = await list({ sortBy: 'name', limit: 2, page: 1 }); + expect(names(page1)).toEqual(['Alpha Wedding', 'Bravo Birthday']); + + const page3 = await list({ sortBy: 'name', limit: 2, page: 3 }); + expect(names(page3)).toEqual(['Echo WEDDING Gala']); + }); + + test('sortBy=size orders by archived content size, largest first', async () => { + const body = await list({ sortBy: 'size' }); + expect(names(body)).toEqual([ + 'Charlie Wedding', // 900 + 'Alpha Wedding', // 300 + 'Delta Corporate', // 200 + 'Bravo Birthday', // 100 + 'Echo WEDDING Gala' // 50 + ]); + }); + + test('an unknown sortBy falls back to the date ordering', async () => { + const body = await list({ sortBy: 'events.id; drop table events' }); + expect(names(body)[0]).toBe('Echo WEDDING Gala'); + expect(body.pagination.total).toBe(5); + }); + + test('quotes in the search are bound as a value, not injected as SQL', async () => { + const body = await list({ search: '\'; DROP TABLE events; --' }); + expect(body.archives).toEqual([]); + // The table is still there. + expect((await list({})).pagination.total).toBe(5); + }); +}); diff --git a/backend/__tests__/routes/adminCategoriesNameLength.test.js b/backend/__tests__/routes/adminCategoriesNameLength.test.js new file mode 100644 index 00000000..6d0a7915 --- /dev/null +++ b/backend/__tests__/routes/adminCategoriesNameLength.test.js @@ -0,0 +1,76 @@ +/** + * photo_categories.name is varchar(100). Without a length check the insert + * hit Postgres' "value too long" and the route's catch turned it into a raw + * 500 with no message the form could surface — a >100-char name must come + * back as a normal 400 validation error instead. + */ +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-catlen-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'catlen-test-secret'; +process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-catlen-storage-')); + +const request = require('supertest'); +const express = require('express'); +const cookieParser = require('cookie-parser'); +const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb'); + +const TOO_LONG = 'z'.repeat(101); + +describe('category name length validation', () => { + let db; let cleanup; let app; let superTok; + + const auth = (req) => req.set('Authorization', `Bearer ${superTok}`); + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + const { adminId: superId } = await seedMinimal(db); + await assignAdminRole(db, superId, 'super_admin'); + superTok = mintAdminToken(superId); + + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use('/api/admin/categories', require('../../src/routes/adminCategories')); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + it('rejects a >100-char name on create with a 400, not a 500', async () => { + const res = await auth(request(app).post('/api/admin/categories')) + .send({ name: TOO_LONG, is_global: true }); + + expect(res.status).toBe(400); + expect(res.body.errors.some((e) => e.path === 'name')).toBe(true); + const rows = await db('photo_categories').where('name', TOO_LONG); + expect(rows).toHaveLength(0); + }); + + it('rejects a >100-char name on update with a 400, not a 500', async () => { + const created = await auth(request(app).post('/api/admin/categories')) + .send({ name: 'zzcatlen-ok', is_global: true }); + expect(created.status).toBe(200); + + const res = await auth(request(app).put(`/api/admin/categories/${created.body.id}`)) + .send({ name: TOO_LONG }); + + expect(res.status).toBe(400); + expect(res.body.errors.some((e) => e.path === 'name')).toBe(true); + const row = await db('photo_categories').where('id', created.body.id).first(); + expect(row.name).toBe('zzcatlen-ok'); + }); + + it('still accepts a name at exactly the 100-char limit', async () => { + const name = 'y'.repeat(100); + const res = await auth(request(app).post('/api/admin/categories')) + .send({ name, is_global: true }); + + expect(res.status).toBe(200); + expect(res.body.name).toBe(name); + }); +}); diff --git a/backend/__tests__/routes/adminFeedbackWordFilterSeverity.test.js b/backend/__tests__/routes/adminFeedbackWordFilterSeverity.test.js new file mode 100644 index 00000000..a7329ce7 --- /dev/null +++ b/backend/__tests__/routes/adminFeedbackWordFilterSeverity.test.js @@ -0,0 +1,72 @@ +/** + * Word-filter severity vocabulary. The Settings → Moderation UI offers + * low / moderate / high / block, but the validator only accepted the + * unrelated mild / moderate / severe set, so 3 of the 4 levels — including + * "block", the strongest tier — 400'd on every add. + */ +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-wfsev-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'wfsev-test-secret'; +process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-wfsev-storage-')); + +const request = require('supertest'); +const express = require('express'); +const cookieParser = require('cookie-parser'); +const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb'); + +describe('word filter severity levels', () => { + let db; let cleanup; let app; let superTok; + + const auth = (req) => req.set('Authorization', `Bearer ${superTok}`); + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + const { adminId: superId } = await seedMinimal(db); + await assignAdminRole(db, superId, 'super_admin'); + superTok = mintAdminToken(superId); + + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use('/api/admin/feedback', require('../../src/routes/adminFeedback')); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + it.each(['low', 'moderate', 'high', 'block'])('accepts severity "%s"', async (severity) => { + const word = `zzsev${severity}`; + const res = await auth(request(app).post('/api/admin/feedback/word-filters')) + .send({ word, severity }); + + expect(res.status).toBe(200); + const row = await db('feedback_word_filters').where({ word }).first(); + expect(row.severity).toBe(severity); + }); + + it('still rejects a severity outside the vocabulary', async () => { + const res = await auth(request(app).post('/api/admin/feedback/word-filters')) + .send({ word: 'zzsevbogus', severity: 'catastrophic' }); + + expect(res.status).toBe(400); + expect(res.body.errors.some((e) => e.path === 'severity')).toBe(true); + }); + + it('blocks a comment matching a "block" filter and only flags a "low" one', async () => { + const moderation = require('../../src/services/feedbackModeration'); + moderation.clearCache(); + + const blocked = await moderation.moderateText('this is zzsevblock speech'); + expect(blocked.approved).toBe(false); + expect(blocked.violations.map((v) => v.word)).toEqual(['zzsevblock']); + + const flagged = await moderation.moderateText('this is zzsevlow speech'); + expect(flagged.approved).toBe(true); + expect(flagged.flagged).toBe(true); + }); +}); diff --git a/backend/__tests__/routes/adminPhotoUploadSizeLimit.test.js b/backend/__tests__/routes/adminPhotoUploadSizeLimit.test.js new file mode 100644 index 00000000..212d1b7e --- /dev/null +++ b/backend/__tests__/routes/adminPhotoUploadSizeLimit.test.js @@ -0,0 +1,164 @@ +/** + * Per-file upload size limit on the admin photo routes. + * + * `general_max_file_size_mb` (Settings → General, default 50MB) is what the + * dropzone advertises ("max. 50MB per file"), but the admin upload route + * hardcoded multer's cap at 10GB and the chunked-upload init route at 10GB + * too — so the advertised limit was never enforced anywhere server-side and a + * 50.74MB JPEG uploaded cleanly. + * + * Pins: + * - a file over the configured cap is rejected with a 400 naming the limit + * - the chunked-upload init route honours the same cap (it would otherwise + * be a trivial bypass of the multipart route's cap) + * - the chunk route enforces the cap on the bytes actually received, so a + * client can't declare `fileSize: 1` at init and stream past the limit + * - a file under the cap still gets past the size gate + * - the limit is read per request, so an admin raising it takes effect + */ + +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-upload-size-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'upload-size-test-secret'; +process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-upload-size-storage-')); + +const request = require('supertest'); +const express = require('express'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); + +const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb'); + +const SLUG = 'upload-size-test-event'; + +describe('admin upload per-file size limit (general_max_file_size_mb)', () => { + let db; + let cleanup; + let app; + let eventId; + let adminToken; + let uploadSettings; + + const setLimitMb = async (mb) => { + await db('app_settings') + .insert({ + setting_key: 'general_max_file_size_mb', + setting_value: JSON.stringify(mb), + setting_type: 'general', + updated_at: new Date().toISOString(), + }) + .onConflict('setting_key') + .merge({ setting_value: JSON.stringify(mb) }); + uploadSettings.clearMaxFileSizeCache(); + }; + + const postUpload = (bytes, filename = 'shot.jpg') => request(app) + .post(`/api/admin/photos/${eventId}/upload`) + .set('Authorization', `Bearer ${adminToken}`) + .attach('photos', Buffer.alloc(bytes, 0x41), { filename, contentType: 'image/jpeg' }); + + const postChunkedInit = (fileSize) => request(app) + .post(`/api/admin/photos/${eventId}/chunked-upload/init`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ filename: 'clip.mp4', fileSize, mimeType: 'video/mp4', totalChunks: 1 }); + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + + const inserted = await db('events').insert({ + slug: SLUG, + event_type: 'wedding', + event_name: 'Upload Size Test', + event_date: '2026-09-01', + host_email: 'host@example.com', + admin_email: 'admin@example.com', + password_hash: 'x', + share_link: `/gallery/${SLUG}/share`, + share_token: 'upload-size-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 superRole = await db('roles').where({ name: 'super_admin' }).first(); + const [rootId] = await db('admin_users').insert({ + username: 'upload-size-admin', + email: 'upload-size-admin@example.com', + password_hash: await bcrypt.hash('UploadSize123', 4), + role_id: superRole.id, + is_active: 1, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }).returning('id').then((r) => [r[0]?.id || r[0]]); + adminToken = jwt.sign( + { id: rootId, username: 'upload-size-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() }, + process.env.JWT_SECRET, + { expiresIn: '1h', issuer: 'picpeak-auth' } + ); + + uploadSettings = require('../../src/services/uploadSettings'); + + app = express(); + app.use(express.json()); + app.use('/api/admin/photos', require('../../src/routes/adminPhotos')); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + it('rejects a file over the configured limit with a 400 naming the limit', async () => { + await setLimitMb(1); + const res = await postUpload(2 * 1024 * 1024); + expect(res.status).toBe(400); + expect(res.body.error).toBe('File too large. Maximum size is 1 MB per file.'); + }); + + it('rejects an over-limit chunked upload at init instead of allowing 10GB', async () => { + await setLimitMb(1); + const res = await postChunkedInit(200 * 1024 * 1024); + expect(res.status).toBe(400); + expect(res.body.error).toBe('File too large. Maximum size is 1 MB per file.'); + }); + + it('rejects chunk bytes over the limit regardless of the declared fileSize', async () => { + await setLimitMb(1); + const initRes = await postChunkedInit(1); + expect(initRes.status).toBe(200); + + const res = await request(app) + .post(`/api/admin/photos/${eventId}/chunked-upload/${initRes.body.uploadId}/chunk/0`) + .set('Authorization', `Bearer ${adminToken}`) + .set('Content-Type', 'application/octet-stream') + .send(Buffer.alloc(2 * 1024 * 1024, 0x41)); + expect(res.status).toBe(413); + expect(res.body.error).toBe('File too large. Maximum size is 1 MB per file.'); + }); + + it('lets a file under the limit past the size gate', async () => { + await setLimitMb(1); + // Junk bytes, so it still fails downstream on the content check — that is + // the point: the failure is no longer about size. + const res = await postUpload(64 * 1024); + expect(res.status).toBe(400); + expect(res.body.error).toBe('File content does not match declared type: shot.jpg'); + }); + + it('reads the limit per request, so raising it takes effect immediately', async () => { + await setLimitMb(1); + expect((await postUpload(2 * 1024 * 1024)).status).toBe(400); + + await setLimitMb(10); + const res = await postUpload(2 * 1024 * 1024); + expect(res.status).toBe(400); + expect(res.body.error).toBe('File content does not match declared type: shot.jpg'); + }); +}); diff --git a/backend/__tests__/routes/feedbackBlockSeverity.test.js b/backend/__tests__/routes/feedbackBlockSeverity.test.js new file mode 100644 index 00000000..281a4c2a --- /dev/null +++ b/backend/__tests__/routes/feedbackBlockSeverity.test.js @@ -0,0 +1,119 @@ +/** + * Word-filter severity tiers, at the submission route. + * + * The Settings → Moderation UI advertises `block` as "comment is rejected + * immediately", but the submit route saved every non-approved comment with + * is_approved = false — identical handling to `moderate`/`high`. So the + * strongest tier stored the prohibited text anyway and only hid it from the + * public list. + * + * These three cases pin the tiers apart: + * block → 4xx, nothing written + * moderate / high → 201, stored held-for-moderation (is_approved = false) + * low → 201, stored approved (flag-only) + */ + +const request = require('supertest'); +const express = require('express'); +const cookieParser = require('cookie-parser'); +const jwt = require('jsonwebtoken'); + +const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb'); + +process.env.JWT_SECRET = process.env.JWT_SECRET || 'block-severity-secret'; + +const SLUG = 'block-severity'; + +describe('word-filter severity tiers at submission (#B11)', () => { + let db; let cleanup; let app; + let eventId; let photoId; + + const galleryToken = () => jwt.sign( + { eventId, eventSlug: SLUG, type: 'gallery' }, + process.env.JWT_SECRET, + { expiresIn: '1h', issuer: 'picpeak-auth' } + ); + + const comment = (text) => request(app) + .post(`/api/gallery/${SLUG}/photos/${photoId}/feedback`) + .set('Authorization', `Bearer ${galleryToken()}`) + .send({ feedback_type: 'comment', comment_text: text }); + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + + const [ev] = await db('events').insert({ + slug: SLUG, + event_type: 'wedding', + event_name: 'Block Severity', + 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: 'block-severity-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/${SLUG}/shot.jpg`, + type: 'individual', uploaded_at: new Date().toISOString(), + }).returning('id'); + photoId = typeof p === 'object' ? p.id : p; + + await db('event_feedback_settings').insert({ + event_id: eventId, feedback_enabled: true, allow_comments: true, + moderate_comments: false, require_name_email: false, + show_feedback_to_guests: true, + }); + + await db('feedback_word_filters').insert([ + { word: 'zzblocked', severity: 'block', is_active: true, created_at: new Date().toISOString() }, + { word: 'zzmoderated', severity: 'moderate', is_active: true, created_at: new Date().toISOString() }, + { word: 'zzmild', severity: 'low', is_active: true, created_at: new Date().toISOString() }, + ]); + require('../../src/services/feedbackModeration').clearCache(); + + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use('/api/gallery', require('../../src/routes/galleryFeedback')); + }, 180000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + beforeEach(async () => { + await db('photo_feedback').where({ photo_id: photoId }).del(); + }); + + it('rejects a "block" match outright and stores nothing', async () => { + const res = await comment('this is zzblocked content'); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('COMMENT_BLOCKED'); + expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(0); + }); + + it('still holds a "moderate" match for moderation', async () => { + const res = await comment('this is zzmoderated content'); + + expect(res.status).toBeLessThan(400); + const rows = await db('photo_feedback').where({ photo_id: photoId }); + expect(rows).toHaveLength(1); + expect([false, 0]).toContain(rows[0].is_approved); + expect(rows[0].comment_text).toContain('zzmoderated'); + }); + + it('lets a "low" match through approved (flag only)', async () => { + const res = await comment('this is zzmild content'); + + expect(res.status).toBeLessThan(400); + const rows = await db('photo_feedback').where({ photo_id: photoId }); + expect(rows).toHaveLength(1); + expect([true, 1]).toContain(rows[0].is_approved); + }); +}); diff --git a/backend/__tests__/services/backupService.enhanced.test.js b/backend/__tests__/services/backupService.enhanced.test.js index e2349727..a300b9d2 100644 --- a/backend/__tests__/services/backupService.enhanced.test.js +++ b/backend/__tests__/services/backupService.enhanced.test.js @@ -11,8 +11,27 @@ jest.mock('../../src/services/emailProcessor'); jest.mock('node-cron'); jest.mock('../../src/services/backupManifest'); jest.mock('../../src/services/storage/s3Storage'); +// runBackup lazily requires this from inside the run — resolve+register it +// here so the require doesn't hit the (mock-fs'd) filesystem mid-backup. +jest.mock('../../src/services/databaseBackup', () => ({ + databaseBackupService: { + backup: jest.fn() + } +})); +// Same deal for the rsync path's lazy requires. +jest.mock('../../src/utils/safeExec', () => ({ + spawnAsync: jest.fn(), + spawnToFile: jest.fn(), + spawnFromFile: jest.fn() +})); +jest.mock('../../src/utils/networkValidation', () => ({ + isHostAllowed: jest.fn().mockResolvedValue(true) +})); const backupService = require('../../src/services/backupService'); +const { databaseBackupService } = require('../../src/services/databaseBackup'); +const { spawnAsync } = require('../../src/utils/safeExec'); +const { isHostAllowed } = require('../../src/utils/networkValidation'); const { db } = require('../../src/database/db'); const logger = require('../../src/utils/logger'); const { queueEmail } = require('../../src/services/emailProcessor'); @@ -20,6 +39,20 @@ const cron = require('node-cron'); const backupManifest = require('../../src/services/backupManifest'); const S3StorageAdapter = require('../../src/services/storage/s3Storage'); +// `runBackup` opens the run row with `db('backup_runs').insert(...).returning('id')`, +// so the insert mock has to be awaitable AND carry a `.returning()`. +const insertResult = (value) => { + const thenable = Promise.resolve(value); + thenable.returning = jest.fn().mockResolvedValue(value); + return thenable; +}; + +// Every runBackup goes through ensureDatabaseDumpForBackup, which stats the +// DB dump on disk and refuses to continue without it — seed it into every +// mock-fs tree. +const DB_DUMP_PATH = '/backup/db-dump.sql'; +const mockStorage = (tree) => mockFs({ [DB_DUMP_PATH]: Buffer.from('database dump'), ...tree }); + describe('Enhanced Backup Service Tests', () => { let mockDb; let mockS3Client; @@ -36,7 +69,7 @@ describe('Enhanced Backup Service Tests', () => { orderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), first: jest.fn(), - insert: jest.fn(), + insert: jest.fn(() => insertResult([1])), update: jest.fn(), delete: jest.fn() }; @@ -75,6 +108,19 @@ describe('Enhanced Backup Service Tests', () => { logger.error = jest.fn(); logger.warn = jest.fn(); logger.debug = jest.fn(); + + // The inline DB dump and its on-disk verification run on every backup and + // throw when no dump is available — give both a passing default so each + // test can focus on the destination path it actually covers. + databaseBackupService.backup.mockResolvedValue({ path: DB_DUMP_PATH, size: 13 }); + isHostAllowed.mockResolvedValue(true); + jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({ + type: 'sqlite', + backupFile: DB_DUMP_PATH, + size: 13, + checksum: 'abc123', + hasChanged: false + }); }); afterEach(() => { @@ -132,7 +178,7 @@ describe('Enhanced Backup Service Tests', () => { describe('S3 Backup Functionality', () => { beforeEach(() => { // Mock file system - mockFs({ + mockStorage({ '/storage/events/active/event1': { 'photo1.jpg': Buffer.from('photo1 content'), 'photo2.jpg': Buffer.from('photo2 content') @@ -165,12 +211,12 @@ describe('Enhanced Backup Service Tests', () => { mockDb.select.mockResolvedValue([]); mockDb.where.mockReturnThis(); mockDb.first.mockResolvedValue(null); - mockDb.insert.mockResolvedValue([1]); + mockDb.insert.mockReturnValue(insertResult([1])); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({ type: 'sqlite', - backupFile: null, + backupFile: DB_DUMP_PATH, hasChanged: true }); @@ -202,7 +248,7 @@ describe('Enhanced Backup Service Tests', () => { }; mockDb.select.mockResolvedValue([]); - mockDb.insert.mockResolvedValue([1]); + mockDb.insert.mockReturnValue(insertResult([1])); mockDb.first.mockResolvedValue(null); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); @@ -240,7 +286,7 @@ describe('Enhanced Backup Service Tests', () => { }); mockDb.select.mockResolvedValue([]); - mockDb.insert.mockResolvedValue([1]); + mockDb.insert.mockReturnValue(insertResult([1])); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); @@ -265,7 +311,7 @@ describe('Enhanced Backup Service Tests', () => { }; mockDb.select.mockResolvedValue([]); - mockDb.insert.mockResolvedValue([1]); + mockDb.insert.mockReturnValue(insertResult([1])); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({ @@ -277,7 +323,7 @@ describe('Enhanced Backup Service Tests', () => { }); // Mock database backup file - mockFs({ + mockStorage({ '/storage/events/active': {}, '/backup/db-backup.sql': Buffer.from('database backup content') }); @@ -301,7 +347,7 @@ describe('Enhanced Backup Service Tests', () => { }; mockDb.select.mockResolvedValue([]); - mockDb.insert.mockResolvedValue([1]); + mockDb.insert.mockReturnValue(insertResult([1])); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); @@ -326,12 +372,12 @@ describe('Enhanced Backup Service Tests', () => { }; mockDb.select.mockResolvedValue([]); - mockDb.insert.mockResolvedValue([1]); + mockDb.insert.mockReturnValue(insertResult([1])); mockDb.first.mockResolvedValue(null); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); - mockFs({ + mockStorage({ '/storage/events/active/event1': { 'photo1.jpg': Buffer.from('photo1 content') }, @@ -365,7 +411,7 @@ describe('Enhanced Backup Service Tests', () => { }; mockDb.select.mockResolvedValue([]); - mockDb.insert.mockResolvedValue([2]); + mockDb.insert.mockReturnValue(insertResult([2])); mockDb.first.mockImplementation(() => Promise.resolve(lastBackup)); mockDb.orderBy.mockReturnThis(); mockDb.where.mockReturnThis(); @@ -373,7 +419,7 @@ describe('Enhanced Backup Service Tests', () => { jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); - mockFs({ + mockStorage({ '/storage/events/active': {}, '/backup': {} }); @@ -395,7 +441,7 @@ describe('Enhanced Backup Service Tests', () => { }; mockDb.select.mockResolvedValue([]); - mockDb.insert.mockResolvedValue([1]); + mockDb.insert.mockReturnValue(insertResult([1])); mockDb.first.mockResolvedValue(null); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); @@ -406,7 +452,7 @@ describe('Enhanced Backup Service Tests', () => { }; backupManifest.generateManifest.mockResolvedValue(manifest); - mockFs({ + mockStorage({ '/storage/events/active': {}, '/storage/temp': {} }); @@ -431,12 +477,12 @@ describe('Enhanced Backup Service Tests', () => { }; mockDb.select.mockResolvedValue([]); - mockDb.insert.mockResolvedValue([1]); + mockDb.insert.mockReturnValue(insertResult([1])); mockDb.first.mockResolvedValue(null); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); - mockFs({ + mockStorage({ '/storage/events/active/event1': { 'photo1.jpg': Buffer.from('photo1 content') }, @@ -461,28 +507,27 @@ describe('Enhanced Backup Service Tests', () => { }; mockDb.select.mockResolvedValue([]); - mockDb.insert.mockResolvedValue([1]); + mockDb.insert.mockReturnValue(insertResult([1])); mockDb.first.mockResolvedValue(null); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); - - // Mock exec for rsync - const { exec } = require('child_process'); - const mockExec = jest.fn((cmd, callback) => { - callback(null, { stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes' }); + + // rsync is spawned argv-style (no shell) — assert that shape, not the + // legacy `exec('rsync ...')` string. + spawnAsync.mockResolvedValue({ + stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes' }); - exec.mockImplementation(mockExec); - - mockFs({ + + mockStorage({ '/storage/events/active': {} }); - + await backupService.runBackup(); - - expect(mockExec).toHaveBeenCalledWith( - expect.stringContaining('rsync'), - expect.any(Function) - ); + + expect(spawnAsync).toHaveBeenCalledWith('rsync', expect.any(Array)); + const [, rsyncArgs] = spawnAsync.mock.calls[0]; + expect(rsyncArgs).toContain('-avz'); + expect(rsyncArgs[rsyncArgs.length - 1]).toBe('backup@backup.example.com:/remote/backup'); }); }); @@ -497,7 +542,7 @@ describe('Enhanced Backup Service Tests', () => { }; mockDb.select.mockResolvedValue([]); - mockDb.insert.mockResolvedValue([1]); + mockDb.insert.mockReturnValue(insertResult([1])); mockDb.first.mockResolvedValue(null); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); @@ -514,7 +559,7 @@ describe('Enhanced Backup Service Tests', () => { return originalCreateReadStream(path); }); - mockFs({ + mockStorage({ '/storage/events/active': { 'error.jpg': Buffer.from('content'), 'good.jpg': Buffer.from('content') @@ -546,7 +591,7 @@ describe('Enhanced Backup Service Tests', () => { ]; mockDb.select.mockResolvedValue([]); - mockDb.insert.mockResolvedValue([1]); + mockDb.insert.mockReturnValue(insertResult([1])); mockDb.where.mockReturnThis(); jest.spyOn(backupService, 'getBackupConfig') @@ -555,7 +600,13 @@ describe('Enhanced Backup Service Tests', () => { // Force an error jest.spyOn(backupService, 'getFilesToBackup').mockRejectedValue(new Error('Storage error')); - + + // The DB-dump verification runs first and would throw its own error — + // give it a tree so 'Storage error' is what actually surfaces. + mockStorage({ + '/storage/events/active': {} + }); + // Mock admin users query db.mockImplementation((table) => { if (table === 'admin_users') { @@ -588,7 +639,7 @@ describe('Enhanced Backup Service Tests', () => { jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); - mockFs({ + mockStorage({ '/storage/events/active': {}, '/backup': {} }); @@ -675,20 +726,32 @@ describe('Enhanced Backup Service Tests', () => { ]; mockDb.limit.mockResolvedValue(recentRuns); - + // getBackupStatus also reads the backup config to compute the next run; + // an unscheduled/disabled backup legitimately yields null (#871). + mockDb.select.mockResolvedValue([ + { setting_key: 'backup_enabled', setting_value: 'true' }, + { setting_key: 'backup_schedule', setting_value: '"daily"' } + ]); + backupManifest.validateManifest.mockImplementation(() => true); - + const status = await backupService.getBackupStatus(); - + + // Runs are returned with a `created_at` alias for the frontend. + const run = { ...recentRuns[0], created_at: recentRuns[0].started_at }; + expect(status).toEqual({ isRunning: false, isHealthy: true, - lastRun: expect.objectContaining({ - ...recentRuns[0], - manifestValid: true - }), - recentRuns: recentRuns, - nextScheduledRun: expect.any(String) + lastRun: { ...run, manifestValid: true }, + lastBackup: { ...run, manifestValid: true }, + lastSuccessfulBackup: run, + zombieRuns: [], + recentRuns: [run], + recentBackups: [run], + totalBackups: 1, + nextScheduledRun: expect.any(String), + nextBackup: expect.any(String) }); }); diff --git a/backend/__tests__/services/chunkedUploadSizeCap.test.js b/backend/__tests__/services/chunkedUploadSizeCap.test.js new file mode 100644 index 00000000..7b8a32b2 --- /dev/null +++ b/backend/__tests__/services/chunkedUploadSizeCap.test.js @@ -0,0 +1,80 @@ +/** + * The chunked-upload per-file cap has to hold on the bytes actually received, + * not on the client-declared `fileSize` the init route validates. Declaring + * `fileSize: 1` and then streaming 10 GB through the chunk route was a + * complete bypass of general_max_file_size_mb; the merge step only logged a + * size mismatch and processed the file anyway. + */ +const path = require('path'); +const os = require('os'); +const fs = require('fs').promises; + +process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-cap-test-${process.pid}`); + +const chunkedUpload = require('../../src/services/chunkedUploadService'); + +const MB = 1024 * 1024; + +const init = (overrides = {}) => chunkedUpload.initializeUpload({ + filename: 'clip.mp4', + fileSize: 1, + mimeType: 'video/mp4', + eventId: 1, + totalChunks: 2, + maxFileSizeBytes: 1 * MB, + ...overrides, +}); + +describe('chunkedUploadService per-file size cap', () => { + afterAll(async () => { + await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {}); + }); + + it('rejects a single chunk over the cap even when the declared fileSize is tiny', async () => { + const { uploadId } = await init(); + await expect(chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(2 * MB))) + .rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 }); + // Aborted, not merely rejected: the upload can no longer be completed. + expect(chunkedUpload.getUploadStatus(uploadId)).toBeNull(); + }); + + it('rejects when the running total across chunks crosses the cap', async () => { + const { uploadId } = await init(); + await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.75 * MB)); + await expect(chunkedUpload.uploadChunk(uploadId, 1, Buffer.alloc(0.5 * MB))) + .rejects.toMatchObject({ code: 'FILE_TOO_LARGE' }); + }); + + it('counts a re-sent chunk once, not twice', async () => { + const { uploadId } = await init(); + await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.6 * MB)); + // Same index again — replaces the earlier bytes, so the total stays 0.6 MB. + await expect(chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.6 * MB))).resolves.toBeTruthy(); + await expect(chunkedUpload.uploadChunk(uploadId, 1, Buffer.alloc(0.3 * MB))).resolves.toBeTruthy(); + }); + + it('rejects chunk indices outside the announced range', async () => { + const { uploadId } = await init(); + await expect(chunkedUpload.uploadChunk(uploadId, 2, Buffer.alloc(10))) + .rejects.toMatchObject({ code: 'INVALID_CHUNK', statusCode: 400 }); + await expect(chunkedUpload.uploadChunk(uploadId, -1, Buffer.alloc(10))) + .rejects.toMatchObject({ code: 'INVALID_CHUNK' }); + await expect(chunkedUpload.uploadChunk(uploadId, NaN, Buffer.alloc(10))) + .rejects.toMatchObject({ code: 'INVALID_CHUNK' }); + }); + + it('merges an upload under the cap and reports the real size', async () => { + const { uploadId } = await init(); + await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(400 * 1024, 0x41)); + await chunkedUpload.uploadChunk(uploadId, 1, Buffer.alloc(400 * 1024, 0x42)); + const merged = await chunkedUpload.completeUpload(uploadId); + expect(merged.size).toBe(800 * 1024); + await fs.rm(merged.tempDir, { recursive: true, force: true }); + }); + + it('applies no cap when none is given', async () => { + const { uploadId } = await init({ maxFileSizeBytes: undefined, totalChunks: 1 }); + await expect(chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(3 * MB))).resolves.toBeTruthy(); + await chunkedUpload.abortUpload(uploadId); + }); +}); diff --git a/backend/migrations/core/194_german_gallery_created_translation.js b/backend/migrations/core/194_german_gallery_created_translation.js new file mode 100644 index 00000000..15b97421 --- /dev/null +++ b/backend/migrations/core/194_german_gallery_created_translation.js @@ -0,0 +1,126 @@ +/** + * Migration 194: give `gallery_created` a real German translation. + * + * On a fresh install the German copy of this template is the ENGLISH copy, + * verbatim. Migration 059 introduces the multilingual columns by seeding + * `subject_de`/`body_html_de`/`body_text_de` from their `_en` counterparts + * ("Copy to German as default"), and migration 075 then materialises exactly + * those columns as the `de` row in `email_template_translations`. The proper + * German lived only in the LEGACY migrations (009/026), which never run on a + * fresh install — so every install created since then mails English to + * German-locale recipients, while nl/pt/ru/fr/es/sl are all localised. + * + * This is the most customer-visible transactional mail we send (one per + * published gallery), so it is repaired as a content UPDATE: a code-only fix + * would leave every existing install on the English-as-German row forever, + * because Knex will not re-run 059/075. + * + * Idempotent, and deliberately conservative about WHICH rows it touches: the + * German row is rewritten only while it is still byte-identical to the English + * one (or empty), which is precisely the broken state. A legacy install whose + * German came from migration 026, or any install where an admin has edited the + * template themselves, is left alone. + * + * The placeholder set matches the English original exactly — host_name, + * event_name, event_date, gallery_link, gallery_password, expiry_date — which + * is also the template's declared `variables` array. + */ + +const SUBJECT_DE = 'Ihre Fotogalerie ist bereit!'; + +const HTML_DE = `

Galerie erfolgreich erstellt

+

Guten Tag {{host_name}},

+

Ihre Fotogalerie „{{event_name}}“ wurde erfolgreich erstellt!

+

Details zur Galerie:

+ +

Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.

`; + +const TEXT_DE = 'Galerie erfolgreich erstellt\n\nGuten Tag {{host_name}},\n\nIhre Fotogalerie „{{event_name}}“ wurde erfolgreich erstellt!\n\nVeranstaltungsdatum: {{event_date}}\nLink zur Galerie: {{gallery_link}}\nPasswort: {{gallery_password}}\nVerfügbar bis: {{expiry_date}}\n\nTeilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.'; + +// "Not translated yet" = empty, or still the English text. +const isUntranslated = (german, english) => { + const de = (german || '').trim(); + if (!de) return true; + return de === (english || '').trim(); +}; + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('email_templates'))) return; + + const master = await knex('email_templates') + .where('template_key', 'gallery_created') + .first(); + if (!master) return; // Template not seeded on this install — nothing to fix. + + const now = new Date().toISOString(); + + if (await knex.schema.hasTable('email_template_translations')) { + const enRow = await knex('email_template_translations') + .where({ template_id: master.id, language: 'en' }) + .first(); + const deRow = await knex('email_template_translations') + .where({ template_id: master.id, language: 'de' }) + .first(); + + const englishHtml = (enRow && enRow.body_html) || master.body_html_en || master.body_html || ''; + + if (!deRow) { + await knex('email_template_translations').insert({ + template_id: master.id, + language: 'de', + subject: SUBJECT_DE, + body_html: HTML_DE, + body_text: TEXT_DE, + created_at: now, + updated_at: now, + }); + } else { + // Each field is judged on its own. Gating all three on body_html would + // overwrite a subject the admin had already translated whenever the HTML + // still matched English — and down() is a deliberate no-op, so that loss + // would be unrecoverable. + const englishSubject = (enRow && enRow.subject) || master.subject_en || master.subject || ''; + const englishText = (enRow && enRow.body_text) || master.body_text_en || master.body_text || ''; + const patch = {}; + if (isUntranslated(deRow.subject, englishSubject)) patch.subject = SUBJECT_DE; + if (isUntranslated(deRow.body_html, englishHtml)) patch.body_html = HTML_DE; + if (isUntranslated(deRow.body_text, englishText)) patch.body_text = TEXT_DE; + + if (Object.keys(patch).length > 0) { + patch.updated_at = now; + await knex('email_template_translations').where({ id: deRow.id }).update(patch); + } + } + } + + // Legacy per-language columns on the master row — still the fallback path in + // emailProcessor.processTemplate when the translations table is unavailable. + const cols = await knex('email_templates').columnInfo(); + if (cols.body_html_de) { + // Same per-field rule as the translations table above. + const legacyPatch = {}; + if (cols.subject_de && isUntranslated(master.subject_de, master.subject_en)) { + legacyPatch.subject_de = SUBJECT_DE; + } + if (isUntranslated(master.body_html_de, master.body_html_en)) { + legacyPatch.body_html_de = HTML_DE; + } + if (cols.body_text_de && isUntranslated(master.body_text_de, master.body_text_en)) { + legacyPatch.body_text_de = TEXT_DE; + } + if (Object.keys(legacyPatch).length > 0) { + legacyPatch.updated_at = now; + await knex('email_templates').where({ id: master.id }).update(legacyPatch); + } + } +}; + +exports.down = async function() { + // No-op: reverting would restore English-as-German. Admins who want + // different copy can edit it under Settings → Email → Templates. +}; diff --git a/backend/src/__tests__/customerAuth.middleware.test.js b/backend/src/__tests__/customerAuth.middleware.test.js index 649a2cc4..9731d44a 100644 --- a/backend/src/__tests__/customerAuth.middleware.test.js +++ b/backend/src/__tests__/customerAuth.middleware.test.js @@ -65,7 +65,7 @@ function makeRes() { res.json = jest.fn().mockReturnValue(res); return res; } -function makeReq({ token = 'tkn', cookies = {}, headers = {}, originalUrl = '/api/customer/foo', ip = '1.2.3.4' } = {}) { +function makeReq({ cookies = {}, headers = {}, originalUrl = '/api/customer/foo', ip = '1.2.3.4' } = {}) { return { headers: { authorization: undefined, ...headers }, cookies, originalUrl, ip, connection: { remoteAddress: ip } }; } diff --git a/backend/src/__tests__/publicSiteService.test.js b/backend/src/__tests__/publicSiteService.test.js index 17c97b30..a4a2f01b 100644 --- a/backend/src/__tests__/publicSiteService.test.js +++ b/backend/src/__tests__/publicSiteService.test.js @@ -19,7 +19,7 @@ const { sanitizeCss } = require('../utils/cssSanitizer'); const buildPublicSiteRows = (overrides = {}) => ([ { setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) }, { setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '

{{company_name}}

') }, - { setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? "body { color: red; }") } + { setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? 'body { color: red; }') } ]); const buildBrandingRows = (overrides = {}) => ([ @@ -64,7 +64,7 @@ describe('publicSiteService', () => { it('sanitizes custom CSS and removes dangerous patterns', async () => { const publicSiteRows = buildPublicSiteRows({ - css: "body { color: blue; } @import url('https://malicious.example/style.css'); div { background: url(\"javascript:alert(1)\"); }" + css: 'body { color: blue; } @import url(\'https://malicious.example/style.css\'); div { background: url("javascript:alert(1)"); }' }); const brandingRows = buildBrandingRows(); diff --git a/backend/src/database/db.js b/backend/src/database/db.js index 982ea4eb..11d0257c 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -21,7 +21,7 @@ try { } } catch (e) { // Non-fatal: log and continue; SQLite will fail later if still missing - try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) {} + try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) { /* non-fatal */ } } // Create database connection with built-in retry logic. @@ -205,28 +205,28 @@ async function initializeDatabase() { ) `); - const pragmaRows = await db.raw("PRAGMA table_info('events')"); + const pragmaRows = await db.raw('PRAGMA table_info(\'events\')'); const existingColumns = pragmaRows.map(row => row.name); const selectColumns = existingColumns.map((col) => { switch (col) { - case 'allow_user_uploads': - return "COALESCE(allow_user_uploads, 0) as allow_user_uploads"; - case 'upload_category_id': - return "upload_category_id"; - case 'allow_downloads': - return "COALESCE(allow_downloads, 1) as allow_downloads"; - case 'disable_right_click': - return "COALESCE(disable_right_click, 0) as disable_right_click"; - case 'watermark_downloads': - return "COALESCE(watermark_downloads, 0) as watermark_downloads"; - case 'watermark_text': - return 'watermark_text'; - case 'hero_photo_id': - return 'hero_photo_id'; - case 'require_password': - return 'COALESCE(require_password, 1) as require_password'; - default: - return col; + case 'allow_user_uploads': + return 'COALESCE(allow_user_uploads, 0) as allow_user_uploads'; + case 'upload_category_id': + return 'upload_category_id'; + case 'allow_downloads': + return 'COALESCE(allow_downloads, 1) as allow_downloads'; + case 'disable_right_click': + return 'COALESCE(disable_right_click, 0) as disable_right_click'; + case 'watermark_downloads': + return 'COALESCE(watermark_downloads, 0) as watermark_downloads'; + case 'watermark_text': + return 'watermark_text'; + case 'hero_photo_id': + return 'hero_photo_id'; + case 'require_password': + return 'COALESCE(require_password, 1) as require_password'; + default: + return col; } }); diff --git a/backend/src/middleware/feedbackRateLimit.js b/backend/src/middleware/feedbackRateLimit.js index 51692929..192e472b 100644 --- a/backend/src/middleware/feedbackRateLimit.js +++ b/backend/src/middleware/feedbackRateLimit.js @@ -176,7 +176,7 @@ function feedbackRateLimit(actionType) { return res.status(429).json({ error: 'Too many requests', - message: `Rate limit exceeded. Please try again later.`, + message: 'Rate limit exceeded. Please try again later.', retryAfter: rateLimitStatus.window }); } diff --git a/backend/src/middleware/ownership.js b/backend/src/middleware/ownership.js index 1a04e0b3..cb6914d5 100644 --- a/backend/src/middleware/ownership.js +++ b/backend/src/middleware/ownership.js @@ -27,7 +27,7 @@ function requireEventOwnership(req, res, next) { } next(); }) - .catch((err) => { + .catch((_err) => { res.status(500).json({ error: 'Failed to verify ownership' }); }); } diff --git a/backend/src/middleware/photoAuth.js b/backend/src/middleware/photoAuth.js index ae350f2d..faed44fc 100644 --- a/backend/src/middleware/photoAuth.js +++ b/backend/src/middleware/photoAuth.js @@ -120,9 +120,9 @@ async function photoAuth(req, res, next) { } return next(); } - } catch (err) { + } catch (err) { // Token invalid, fall through to password check - logger.warn('JWT verification failed in photoAuth', { error: err.message }); + logger.warn('JWT verification failed in photoAuth', { error: err.message }); } } diff --git a/backend/src/middleware/secureImageMiddleware.js b/backend/src/middleware/secureImageMiddleware.js index 7d86516e..5d963a53 100644 --- a/backend/src/middleware/secureImageMiddleware.js +++ b/backend/src/middleware/secureImageMiddleware.js @@ -1,7 +1,6 @@ const { db } = require('../database/db'); const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); -const { formatBoolean } = require('../utils/dbCompat'); /** * Enhanced secure image middleware with comprehensive protection @@ -69,7 +68,7 @@ class SecureImageMiddleware { /** * Perform comprehensive security checks */ - async performSecurityChecks(req, res) { + async performSecurityChecks(req, _res) { const { clientInfo } = req; const { photoId } = req.params; @@ -132,7 +131,6 @@ class SecureImageMiddleware { */ async checkRateLimit(req) { const { clientInfo } = req; - const now = Date.now(); // Get rate limit settings from database const settings = await this.getRateLimitSettings(); diff --git a/backend/src/middleware/secureStatic.js b/backend/src/middleware/secureStatic.js index f562eed5..5006f466 100644 --- a/backend/src/middleware/secureStatic.js +++ b/backend/src/middleware/secureStatic.js @@ -24,7 +24,8 @@ function secureStatic(basePath, options = {}) { try { // Validate the full path is within the base directory - const fullPath = safePathJoin(normalizedBase, requestedPath); + // Validates the path stays inside the base dir; throws on traversal. + safePathJoin(normalizedBase, requestedPath); // If validation passes, use express.static const staticMiddleware = express.static(normalizedBase, { diff --git a/backend/src/middleware/sessionTimeout.js b/backend/src/middleware/sessionTimeout.js index 1a37b96f..aecd6750 100644 --- a/backend/src/middleware/sessionTimeout.js +++ b/backend/src/middleware/sessionTimeout.js @@ -135,7 +135,7 @@ async function sessionTimeoutMiddleware(req, res, next) { // Clean up old token if user has a new one // This prevents memory leaks from token renewals const userId = decoded.id; - for (const [oldToken, _] of sessions.entries()) { + for (const oldToken of sessions.keys()) { if (oldToken !== token) { try { const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] }); @@ -198,7 +198,7 @@ function getActiveSessions() { const now = Date.now(); let active = 0; - for (const [_, lastActivity] of sessions.entries()) { + for (const lastActivity of sessions.values()) { if (now - lastActivity <= DEFAULT_SESSION_TIMEOUT) { active++; } diff --git a/backend/src/middleware/uploadValidation.js b/backend/src/middleware/uploadValidation.js index 6b829a5f..afc618c5 100644 --- a/backend/src/middleware/uploadValidation.js +++ b/backend/src/middleware/uploadValidation.js @@ -46,8 +46,8 @@ async function validateUploadedFile(filePath) { failOn: 'none', limitInputPixels: 268402689 }) - .resize(10, 10) // Try to resize to very small size - .toBuffer(); + .resize(10, 10) // Try to resize to very small size + .toBuffer(); } catch (decodeError) { throw new Error(`Image decode failed - file may be corrupted: ${decodeError.message}`); } diff --git a/backend/src/routes/__tests__/adminAuth.test.js b/backend/src/routes/__tests__/adminAuth.test.js index 934fd464..8ce68347 100644 --- a/backend/src/routes/__tests__/adminAuth.test.js +++ b/backend/src/routes/__tests__/adminAuth.test.js @@ -36,11 +36,13 @@ jest.mock('../../middleware/auth', () => ({ const { db, logActivity } = require('../../database/db'); const adminAuthRouter = require('../adminAuth'); +const { errorHandler } = require('../../middleware/errorHandler'); describe('adminAuth profile updates', () => { const app = express(); app.use(express.json()); app.use('/auth/admin', adminAuthRouter); + app.use(errorHandler); beforeEach(() => { jest.clearAllMocks(); @@ -55,8 +57,8 @@ describe('adminAuth profile updates', () => { }; db.__setImplementations( - buildChain({ firstResult: null }), // email check buildChain({ firstResult: null }), // username check + buildChain({ firstResult: null }), // email check buildChain({ updateResult: 1 }), // update buildChain({ firstResult: updatedUser }), // fetch updated user ); @@ -66,18 +68,22 @@ describe('adminAuth profile updates', () => { .send({ username: updatedUser.username, email: updatedUser.email }) .expect(200); - expect(response.body).toEqual({ user: updatedUser }); + expect(response.body).toEqual({ + message: 'Admin profile updated successfully', + user: updatedUser + }); expect(logActivity).toHaveBeenCalledWith( 'admin_profile_updated', - { admin_id: 1, updated_fields: ['username', 'email'] }, + { username: updatedUser.username, email: updatedUser.email }, null, - { type: 'admin', id: 1, name: updatedUser.username } + { type: 'admin', id: 1, name: 'admin' } ); }); it('rejects email conflicts', async () => { db.__setImplementations( - buildChain({ firstResult: { id: 2 } }) + buildChain({ firstResult: null }), // username check + buildChain({ firstResult: { id: 2 } }), // email check ); const response = await request(app) @@ -85,7 +91,11 @@ describe('adminAuth profile updates', () => { .send({ username: 'newadmin', email: 'taken@example.com' }) .expect(409); - expect(response.body).toEqual({ error: 'Email is already in use by another admin' }); + expect(response.body).toEqual({ + error: 'Email address is already in use', + code: 'CONFLICT', + field: 'email' + }); }); it('validates input', async () => { @@ -94,6 +104,6 @@ describe('adminAuth profile updates', () => { .send({ username: '', email: 'not-an-email' }) .expect(400); - expect(response.body.errors).toBeDefined(); + expect(response.body.details).toBeDefined(); }); }); diff --git a/backend/src/routes/adminArchives.js b/backend/src/routes/adminArchives.js index 4a25213a..8fefff4c 100644 --- a/backend/src/routes/adminArchives.js +++ b/backend/src/routes/adminArchives.js @@ -6,7 +6,6 @@ const { formatBoolean } = require('../utils/dbCompat'); const { slugify } = require('../utils/slug'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); -const archiver = require('archiver'); const StreamZip = require('node-stream-zip'); const { requireEventOwnership } = require('../middleware/ownership'); const { assertZipEntriesWithin } = require('../utils/safePath'); @@ -19,26 +18,63 @@ const router = express.Router(); router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => { try { const { page, limit, offset } = getPagination(req); + const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; + const type = typeof req.query.type === 'string' ? req.query.type.trim() : ''; + const sortBy = ['date', 'name', 'size'].includes(req.query.sortBy) ? req.query.sortBy : 'date'; - // Get total count - const totalCount = await db('events') - .where('is_archived', formatBoolean(true)) - .count('id as count') + // Search and type filtering run in SQL so both the returned rows and + // the total count cover the whole archive table, not just the page the + // client happens to be on. Values are bound, never interpolated. + // % and _ are wildcards to LIKE but literal characters to the client-side + // `includes()` this replaced, so searching for "100%" would otherwise match + // every archive and report a nonsense total. The ESCAPE clause is + // load-bearing rather than decorative: SQLite has no default LIKE escape + // character, so without it the escaped pattern matches literal backslashes + // there while working on Postgres. + const escapeLike = (value) => value.replace(/[\\%_]/g, '\\$&'); + const applyFilters = (query) => { + if (search) { + query.whereRaw( + 'LOWER(events.event_name) LIKE ? ESCAPE \'\\\'', + [`%${escapeLike(search.toLowerCase())}%`] + ); + } + if (type && type !== 'all') { + query.where('events.event_type', type); + } + return query; + }; + + // Get total count (of the filtered set, so pagination stays truthful) + const totalCount = await applyFilters( + db('events').where('events.is_archived', formatBoolean(true)) + ) + .count('events.id as count') .first(); // Get archived events - const archives = await db('events') - .select( - 'events.*', - db.raw('COUNT(DISTINCT photos.id) as photo_count'), - db.raw('SUM(photos.size_bytes) as total_size') - ) - .leftJoin('photos', 'events.id', 'photos.event_id') - .where('events.is_archived', formatBoolean(true)) - .groupBy('events.id') - .orderBy('events.archived_at', 'desc') - .limit(limit) - .offset(offset); + const archivesQuery = applyFilters( + db('events') + .select( + 'events.*', + db.raw('COUNT(DISTINCT photos.id) as photo_count'), + db.raw('SUM(photos.size_bytes) as total_size') + ) + .leftJoin('photos', 'events.id', 'photos.event_id') + .where('events.is_archived', formatBoolean(true)) + ).groupBy('events.id'); + + if (sortBy === 'name') { + archivesQuery.orderBy('events.event_name', 'asc'); + } else if (sortBy === 'size') { + // The zip's on-disk size is only known after the per-row fs.stat below, + // so a global size sort has to use the archived content size instead. + archivesQuery.orderByRaw('COALESCE(SUM(photos.size_bytes), 0) desc'); + } else { + archivesQuery.orderBy('events.archived_at', 'desc'); + } + + const archives = await archivesQuery.limit(limit).offset(offset); // Check if archive files exist and get their sizes const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index e8ac7e42..4590b7d3 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -346,7 +346,7 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a const { destination_type, ...config } = req.body; switch (destination_type) { - case 'local': + case 'local': { // Test local path access const fs = require('fs').promises; try { @@ -360,8 +360,9 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' }); } break; - - case 'rsync': + } + + case 'rsync': { // Test rsync connection using spawn with argument arrays to prevent command injection const { spawn } = require('child_process'); @@ -425,7 +426,7 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a sshArgs.push('echo', 'Connection successful'); try { - const result = await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const sshProcess = spawn('ssh', sshArgs, { timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] @@ -459,7 +460,8 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' }); } break; - + } + case 's3': // Test S3 connection (would need AWS SDK) res.json({ success: false, message: 'S3 testing not implemented yet' }); @@ -829,7 +831,7 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a // Handle different backup types switch (config.backup_destination_type) { - case 'local': + case 'local': { // Stream local backup as zip const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`); const archive = archiver('zip', { zlib: { level: 9 } }); @@ -847,8 +849,9 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a await archive.finalize(); break; - - case 's3': + } + + case 's3': { // For S3, provide pre-signed URLs or stream files const s3Adapter = new S3StorageAdapter({ endpoint: config.backup_s3_endpoint, @@ -882,7 +885,8 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a message: 'Use the provided URLs to download individual files' }); break; - + } + case 'rsync': return res.status(400).json({ error: 'Direct download not available for rsync backups' }); @@ -909,7 +913,7 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req } // Calculate checksums for files - async function calculateDirChecksums(dirPath, relative = '') { + const calculateDirChecksums = async (dirPath, relative = '') => { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); @@ -940,7 +944,7 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req } catch (error) { logger.error(`Failed to calculate checksums for ${dirPath}:`, error); } - } + }; await calculateDirChecksums(basePath); @@ -978,7 +982,7 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req const breakdown = {}; // Estimate size for each directory - async function estimateDir(dirPath, category) { + const estimateDir = async (dirPath, category) => { let dirSize = 0; let dirCount = 0; @@ -1005,7 +1009,7 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req } return { size: dirSize, count: dirCount }; - } + }; // Estimate each category const categories = [ diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js index d5ae2404..e7bddada 100644 --- a/backend/src/routes/adminCategories.js +++ b/backend/src/routes/adminCategories.js @@ -40,7 +40,11 @@ router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), req // Create a new category router.post('/', adminAuth, requirePermission('settings.edit'), [ - body('name').notEmpty().withMessage('Category name is required'), + // photo_categories.name is varchar(100) — without the length check Postgres + // raises "value too long" and the catch below turns it into a raw 500 with + // no usable message for the form. + body('name').notEmpty().withMessage('Category name is required') + .isLength({ max: 100 }).withMessage('Category name must be at most 100 characters'), body('slug').optional(), body('is_global').optional().isBoolean(), body('event_id').optional().isInt(), @@ -127,7 +131,8 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [ // Update a category router.put('/:id', adminAuth, requirePermission('settings.edit'), [ - body('name').notEmpty().withMessage('Category name is required'), + body('name').notEmpty().withMessage('Category name is required') + .isLength({ max: 100 }).withMessage('Category name must be at most 100 characters'), body('hero_photo_id').optional({ nullable: true }).custom((value) => { if (value === null || value === undefined) return true; return Number.isInteger(Number(value)); diff --git a/backend/src/routes/adminContracts.js b/backend/src/routes/adminContracts.js index d6c3564e..94579212 100644 --- a/backend/src/routes/adminContracts.js +++ b/backend/src/routes/adminContracts.js @@ -155,22 +155,22 @@ function transformContract(c, inclusions) { updatedAt: c.updated_at, inclusions: Array.isArray(inclusions) ? inclusions.map((inc) => ({ - id: inc.id, - blockId: inc.block_id, - section: inc.section, - position: inc.position, - included: inc.included === true || inc.included === 1 || inc.included === '1', - block: { - slug: inc.block_slug, - name: inc.block_name, - description: inc.block_description, - bodyText: inc.block_body_text, - bodyTextDe: inc.block_body_text_de, - isSystem: inc.block_is_system === true || inc.block_is_system === 1 || inc.block_is_system === '1', - }, - bodyTextSnapshot: inc.body_text_snapshot, - bodyTextDeSnapshot: inc.body_text_de_snapshot, - })) + id: inc.id, + blockId: inc.block_id, + section: inc.section, + position: inc.position, + included: inc.included === true || inc.included === 1 || inc.included === '1', + block: { + slug: inc.block_slug, + name: inc.block_name, + description: inc.block_description, + bodyText: inc.block_body_text, + bodyTextDe: inc.block_body_text_de, + isSystem: inc.block_is_system === true || inc.block_is_system === 1 || inc.block_is_system === '1', + }, + bodyTextSnapshot: inc.body_text_snapshot, + bodyTextDeSnapshot: inc.body_text_de_snapshot, + })) : undefined, }; } diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js index 9202134b..8ebe6f89 100644 --- a/backend/src/routes/adminDashboard.js +++ b/backend/src/routes/adminDashboard.js @@ -2,7 +2,7 @@ const express = require('express'); const { db } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); -const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity'); +const { sanitizeDays } = require('../utils/sqlSecurity'); const { formatBoolean } = require('../utils/dbCompat'); const { resolveAdapter } = require('../services/trackers'); const logger = require('../utils/logger'); diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 6186ea8a..bf1c818f 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -318,8 +318,6 @@ module.exports = (router) => { css_template_id = null, // Hero logo settings hero_logo_visible = true, - hero_logo_size = 'medium', - hero_logo_position = 'top', // Header style settings header_style = 'standard', hero_divider_style = 'wave', diff --git a/backend/src/routes/adminEvents/helpers.js b/backend/src/routes/adminEvents/helpers.js index b84d8d01..83d2df14 100644 --- a/backend/src/routes/adminEvents/helpers.js +++ b/backend/src/routes/adminEvents/helpers.js @@ -176,8 +176,9 @@ const mapEventForApi = (event) => { customer_name, customer_email, customer_phone, - password_hash: _ph, - client_password_hash: _cph, + // Bound only to exclude the secrets from `...rest` — never read. + // eslint-disable-next-line no-unused-vars -- rest-sibling omission + password_hash: _ph, client_password_hash: _cph, ...rest } = event; diff --git a/backend/src/routes/adminExpenses.js b/backend/src/routes/adminExpenses.js index 8fbb51af..6b89a48a 100644 --- a/backend/src/routes/adminExpenses.js +++ b/backend/src/routes/adminExpenses.js @@ -130,7 +130,7 @@ router.get('/inbound/:id/file', requireIncoming, requirePermission('accounting.v res.setHeader('Content-Type', row.mime_type || 'application/octet-stream'); res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline'); res.setHeader('X-Content-Type-Options', 'nosniff'); - if (!isPdf) res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + if (!isPdf) res.setHeader('Content-Security-Policy', 'default-src \'none\'; img-src \'self\' data:; style-src \'unsafe-inline\''); createReadStream(safe).pipe(res); })); @@ -149,7 +149,7 @@ router.get('/inbound/:id/page/:n', requireIncoming, requirePermission('accountin res.setHeader('Content-Type', 'image/png'); res.setHeader('Content-Disposition', 'inline'); res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + res.setHeader('Content-Security-Policy', 'default-src \'none\'; img-src \'self\' data:; style-src \'unsafe-inline\''); createReadStream(safePng).pipe(res); })); @@ -217,7 +217,7 @@ router.get('/:id/proof', requireExpenses, requirePermission('accounting.view'), res.setHeader('Content-Type', isPdf ? 'application/pdf' : 'application/octet-stream'); res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline'); res.setHeader('X-Content-Type-Options', 'nosniff'); - if (!isPdf) res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + if (!isPdf) res.setHeader('Content-Security-Policy', 'default-src \'none\'; img-src \'self\' data:; style-src \'unsafe-inline\''); createReadStream(safe).pipe(res); })); diff --git a/backend/src/routes/adminExternalMedia.js b/backend/src/routes/adminExternalMedia.js index bb63f745..4bce828c 100644 --- a/backend/src/routes/adminExternalMedia.js +++ b/backend/src/routes/adminExternalMedia.js @@ -4,7 +4,7 @@ const fs = require('fs').promises; const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { requireEventOwnership } = require('../middleware/ownership'); -const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService'); +const { list, resolveExternalPath } = require('../services/externalMediaService'); const { db, logActivity } = require('../database/db'); const sharp = require('sharp'); const logger = require('../utils/logger'); diff --git a/backend/src/routes/adminFeedback.js b/backend/src/routes/adminFeedback.js index a1b76d59..ded0c1e7 100644 --- a/backend/src/routes/adminFeedback.js +++ b/backend/src/routes/adminFeedback.js @@ -107,7 +107,7 @@ router.get('/events/:eventId/feedback', if (status === 'pending') { query = query.where('photo_feedback.is_approved', false) - .where('photo_feedback.is_hidden', false); + .where('photo_feedback.is_hidden', false); } else if (status === 'approved') { query = query.where('photo_feedback.is_approved', true); } else if (status === 'hidden') { @@ -131,7 +131,7 @@ router.get('/events/:eventId/feedback', if (status === 'pending') { countQuery = countQuery.where('photo_feedback.is_approved', false) - .where('photo_feedback.is_hidden', false); + .where('photo_feedback.is_hidden', false); } else if (status === 'approved') { countQuery = countQuery.where('photo_feedback.is_approved', true); } else if (status === 'hidden') { diff --git a/backend/src/routes/adminImageSecurity.js b/backend/src/routes/adminImageSecurity.js index 35699dd6..877a5421 100644 --- a/backend/src/routes/adminImageSecurity.js +++ b/backend/src/routes/adminImageSecurity.js @@ -104,17 +104,17 @@ router.get('/dashboard', adminAuth, requirePermission(['settings.view', 'image_s let timeFilter; switch (timeframe) { - case '1h': - timeFilter = new Date(Date.now() - 3600000); - break; - case '24h': - timeFilter = new Date(Date.now() - 86400000); - break; - case '7d': - timeFilter = new Date(Date.now() - 604800000); - break; - default: - timeFilter = new Date(Date.now() - 86400000); + case '1h': + timeFilter = new Date(Date.now() - 3600000); + break; + case '24h': + timeFilter = new Date(Date.now() - 86400000); + break; + case '7d': + timeFilter = new Date(Date.now() - 604800000); + break; + default: + timeFilter = new Date(Date.now() - 86400000); } // Get image access statistics @@ -214,17 +214,17 @@ router.get('/logs', adminAuth, requirePermission(['settings.view', 'image_securi let timeFilter; switch (timeframe) { - case '1h': - timeFilter = new Date(Date.now() - 3600000); - break; - case '24h': - timeFilter = new Date(Date.now() - 86400000); - break; - case '7d': - timeFilter = new Date(Date.now() - 604800000); - break; - default: - timeFilter = new Date(Date.now() - 86400000); + case '1h': + timeFilter = new Date(Date.now() - 3600000); + break; + case '24h': + timeFilter = new Date(Date.now() - 86400000); + break; + case '7d': + timeFilter = new Date(Date.now() - 604800000); + break; + default: + timeFilter = new Date(Date.now() - 86400000); } let query = db('security_logs') @@ -374,17 +374,17 @@ router.delete('/logs/cleanup', adminAuth, requirePermission('image_security.mana let cutoffDate; switch (olderThan) { - case '7d': - cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - break; - case '30d': - cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - break; - case '90d': - cutoffDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); - break; - default: - cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + case '7d': + cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + break; + case '30d': + cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + break; + case '90d': + cutoffDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + break; + default: + cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); } // Delete old security logs @@ -431,17 +431,17 @@ router.get('/export', adminAuth, requirePermission(['settings.view', 'image_secu let timeFilter; switch (timeframe) { - case '24h': - timeFilter = new Date(Date.now() - 86400000); - break; - case '7d': - timeFilter = new Date(Date.now() - 604800000); - break; - case '30d': - timeFilter = new Date(Date.now() - 2592000000); - break; - default: - timeFilter = new Date(Date.now() - 604800000); + case '24h': + timeFilter = new Date(Date.now() - 86400000); + break; + case '7d': + timeFilter = new Date(Date.now() - 604800000); + break; + case '30d': + timeFilter = new Date(Date.now() - 2592000000); + break; + default: + timeFilter = new Date(Date.now() - 604800000); } // Get security logs diff --git a/backend/src/routes/adminLedger.js b/backend/src/routes/adminLedger.js index 3a948edb..9d720972 100644 --- a/backend/src/routes/adminLedger.js +++ b/backend/src/routes/adminLedger.js @@ -15,7 +15,6 @@ const { body, param, query } = require('express-validator'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); -const { db } = require('../database/db'); const ledgerService = require('../services/ledgerService'); const router = express.Router(); diff --git a/backend/src/routes/adminNotifications.js b/backend/src/routes/adminNotifications.js index 42d6c9d2..ff6144d6 100644 --- a/backend/src/routes/adminNotifications.js +++ b/backend/src/routes/adminNotifications.js @@ -1,5 +1,5 @@ const express = require('express'); -const { db, logActivity } = require('../database/db'); +const { db } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const logger = require('../utils/logger'); diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 4c31ef1c..e27451b4 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -17,7 +17,7 @@ const { COLOR_LABELS, dominantColorLabel, SHARED_COLOR_LABEL_IDENTITY } = requir const feedbackService = require('../services/feedbackService'); const photoAdminMarksService = require('../services/photoAdminMarksService'); const { validateUploadedFiles } = require('../middleware/uploadValidation'); -const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings'); +const { getMaxFilesPerUpload, getAllowedMimeTypes, getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../services/uploadSettings'); const { processUploadedPhotos } = require('../services/photoProcessor'); const chunkedUpload = require('../services/chunkedUploadService'); const watermarkGeneratorService = require('../services/watermarkGeneratorService'); @@ -37,7 +37,6 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '. const storage = multer.diskStorage({ destination: (req, file, cb) => { logger.info('Multer destination called for file:', file.originalname); - const { eventId } = req.params; // We'll validate the event exists in the route handler // For now, just create a temp destination @@ -67,10 +66,16 @@ const { validateFileType, createFileUploadValidator } = require('../utils/fileSe // The allowed types are fetched from the database once per request (before multer // processes files) and attached to req.allowedMimeTypes so that the fileFilter // callback can read them synchronously. -const upload = multer({ +// +// The per-file size cap is resolved per request too (general_max_file_size_mb), +// so the uploader has to be built per request like the transfer routes do. It +// was hardcoded to 10GB here, which meant the advertised "max. 50MB per file" +// in the dropzone was never enforced anywhere server-side. getMaxFileSizeBytes() +// clamps to MAX_ALLOWED_FILE_SIZE_MB (10GB), so that hard ceiling still applies. +const createUpload = (maxFileSizeBytes) => multer({ storage: storage, limits: { - fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos + fileSize: maxFileSizeBytes, files: 2000, // Hard safety ceiling; actual limit enforced dynamically fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields parts: 10000, @@ -105,7 +110,9 @@ const validateUploadContent = async (req, res, next) => { const allowedTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp']; const validator = createFileUploadValidator({ allowedTypes, - maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos + // Same per-request cap multer streamed against, so the two layers can't + // disagree; this one names the offending file in the 400. + maxFileSize: req.maxFileSizeBytes || DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024, validateContent: true }); return validator(req, res, next); @@ -132,21 +139,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default }; // Upload photos for an event -// Max file count is configurable via general settings +// Max file count and max file size are configurable via general settings router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), requireEventOwnership, uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout let maxFilesPerUpload; + let maxFileSizeBytes; try { maxFilesPerUpload = await getMaxFilesPerUpload(); + maxFileSizeBytes = await getMaxFileSizeBytes(); } catch (error) { return errorResponse(res, error, 500, 'Unable to determine upload limits'); } + req.maxFileSizeBytes = maxFileSizeBytes; + const maxFileSizeMb = Math.floor(maxFileSizeBytes / (1024 * 1024)); - upload.array('photos', maxFilesPerUpload)(req, res, (err) => { + createUpload(maxFileSizeBytes).array('photos', maxFilesPerUpload)(req, res, (err) => { if (err) { logger.error('Multer error:', err); if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { - return res.status(400).json({ error: 'File too large. Maximum size is 10GB per file.' }); + return res.status(400).json({ error: `File too large. Maximum size is ${maxFileSizeMb} MB per file.` }); } if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') { return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` }); @@ -231,8 +242,11 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r } // Parse category_id to number if provided (handle string values like 'individual', 'collage') + // Same 0-is-not-a-category rule as the PATCH route below: '0' is truthy, so + // it parsed to 0 and the scope-validation guard (`if (parsedCategoryId && ...)`) + // then skipped on the falsy 0 and let it into the insert unvalidated. const rawParsed = category_id ? parseInt(category_id, 10) : NaN; - const parsedCategoryId = !isNaN(rawParsed) ? rawParsed : null; + const parsedCategoryId = rawParsed > 0 ? rawParsed : null; // Determine photo type and category name let photoType = 'individual'; // default @@ -834,9 +848,16 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e // Explicitly clear category updateData.category_id = null; } else { - // Handle numeric category IDs from photo_categories table + // Handle numeric category IDs from photo_categories table. + // 0 and negatives mean "no category", not category zero: photo_categories.id + // is an increments() column so it starts at 1, and a setTestEventType(e.target.value)} @@ -351,9 +381,9 @@ export const WebhookDeliveriesPage: React.FC = () => { {WEBHOOK_EVENT_TYPES.map((e) => )}
- +
diff --git a/frontend/src/pages/admin/__tests__/activityInterpolation.test.ts b/frontend/src/pages/admin/__tests__/activityInterpolation.test.ts new file mode 100644 index 00000000..90dde797 --- /dev/null +++ b/frontend/src/pages/admin/__tests__/activityInterpolation.test.ts @@ -0,0 +1,121 @@ +/** + * Raw `{{placeholder}}` tokens leaking into the admin UI (QA bug #15b). + * + * Three confirmed sightings, two distinct call sites: + * + * - Dashboard "recent activity" feed — `buildActivityParams` used to pass a + * fixed five-value allowlist (eventName / email / count / template / + * categoryName), so every `admin.activities.*` string interpolating + * anything else rendered its literal token: "Quote created: {{quoteNumber}}", + * "Webhook erstellt: {{name}}". The backend does record those values, they + * just never reached i18next. + * + * - Notification bell — `bulk_archive_completed` fell through to the generic + * default branch, which spreads `metadata`. The bulk routes log + * `successfulCount`, never `count`, so the bell showed + * "Bulk archive completed: {{count}} events archived". + * + * These assertions are deliberately written against the *rendered output*: any + * future regression that drops an interpolation value shows up as a surviving + * "{{" in the string, whatever the mechanism. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import i18n from '../../../i18n/config'; +import { notificationsService, type Notification } from '../../../services/notifications.service'; +import { buildActivityParams } from '../AdminDashboard'; +import type { Activity } from '../../../services/admin.service'; + +const activity = (type: string, metadata: Record): Activity => + ({ + id: 1, + type, + actorType: 'admin', + actorName: 'admin', + eventName: undefined, + metadata, + createdAt: '2026-09-01T12:00:00Z', + }) as Activity; + +const notification = (type: string, metadata: Record): Notification => + ({ + id: 1, + type, + actorType: 'admin', + actorName: 'admin', + eventName: undefined, + metadata, + createdAt: '2026-09-01T12:00:00Z', + isRead: false, + }) as Notification; + +const render = (a: Activity) => + i18n.t(`admin.activities.${a.type}`, buildActivityParams(a)) as string; + +describe('dashboard activity feed interpolation', () => { + beforeAll(async () => { + await i18n.changeLanguage('en'); + }); + + it('interpolates {{name}} for webhook_created', () => { + const msg = render(activity('webhook_created', { name: 'n8n WhatsApp', events: ['event.published'] })); + expect(msg).toContain('n8n WhatsApp'); + expect(msg).not.toContain('{{'); + }); + + it('interpolates {{quoteNumber}} for quote_created', () => { + const msg = render(activity('quote_created', { quoteId: 7, quoteNumber: 'Q-2026-0007' })); + expect(msg).toContain('Q-2026-0007'); + expect(msg).not.toContain('{{'); + }); + + it('still honours the derived overrides that are not plain metadata', () => { + // `template` is read from metadata.template_key, `categoryName` from + // metadata.category_name — the spread must not shadow those mappings. + const params = buildActivityParams( + activity('email_template_created', { template_key: 'gallery_created', category_name: 'Ceremony' }) + ); + expect(params.template).toBe('gallery_created'); + expect(params.categoryName).toBe('Ceremony'); + }); + + it('leaves no raw placeholder on any German activity string either', async () => { + await i18n.changeLanguage('de'); + const msg = render(activity('webhook_created', { name: 'ZZTEST-hook' })); + expect(msg).toContain('ZZTEST-hook'); + expect(msg).not.toContain('{{'); + await i18n.changeLanguage('en'); + }); +}); + +describe('notification bell interpolation', () => { + beforeAll(async () => { + await i18n.changeLanguage('en'); + }); + + it('maps the bulk-archive metadata onto {{count}}', () => { + // Exactly what adminEvents/archiveBulk.js writes — no `count` key. + const msg = notificationsService.formatNotificationMessage( + notification('bulk_archive_completed', { totalEvents: 5, successfulCount: 4, failedCount: 1 }) + ); + expect(msg).toContain('4'); + expect(msg).not.toContain('{{'); + }); + + it('falls back to 0 rather than a placeholder when metadata is empty', () => { + const msg = notificationsService.formatNotificationMessage( + notification('bulk_archive_completed', {}) + ); + expect(msg).not.toContain('{{'); + }); + + it('keeps interpolating the webhook/quote bell rows', () => { + expect( + notificationsService.formatNotificationMessage(notification('webhook_created', { name: 'n8n' })) + ).not.toContain('{{'); + expect( + notificationsService.formatNotificationMessage( + notification('quote_created', { quoteNumber: 'Q-2026-0007' }) + ) + ).toContain('Q-2026-0007'); + }); +}); diff --git a/frontend/src/pages/admin/__tests__/archivesServerSideQuery.test.tsx b/frontend/src/pages/admin/__tests__/archivesServerSideQuery.test.tsx new file mode 100644 index 00000000..afd66dd5 --- /dev/null +++ b/frontend/src/pages/admin/__tests__/archivesServerSideQuery.test.tsx @@ -0,0 +1,136 @@ +/** + * /admin/archives search, type filter and sort were applied client-side to + * whatever 20-row page happened to be loaded (QA I.01), while the + * pagination footer kept reporting the full server-side total. An archive on + * page 7 was invisible to a search, with no hint the search was page-scoped. + * + * These pin the contract that fixes it: every control is a server query param, + * and changing any of them goes back to page 1. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k), + i18n: { language: 'en' }, + }), + }; +}); + +vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); + +vi.mock('../../../hooks/usePublicSettings', () => ({ + PUBLIC_SETTINGS_QUERY_KEY: ['public-settings'], + usePublicSettings: () => ({ data: {} }), +})); + +vi.mock('../../../contexts/PermissionsContext', () => ({ + usePermissions: () => ({ + hasPermission: () => true, + hasAnyPermission: () => true, + hasAllPermissions: () => true, + isSuperAdmin: true, + isLoading: false, + }), +})); + +const getArchives = vi.fn(); +vi.mock('../../../services/archive.service', () => ({ + archiveService: { + getArchives: (...args: unknown[]) => getArchives(...args), + restoreArchive: vi.fn(), + deleteArchive: vi.fn(), + downloadArchive: vi.fn(), + formatBytes: (b: number) => `${b} B`, + }, +})); + +import { ArchivesPage } from '../ArchivesPage'; + +const page = (archives: unknown[], total: number) => ({ + archives, + pagination: { page: 1, limit: 20, total, totalPages: Math.ceil(total / 20) }, +}); + +const archive = (id: number, eventName: string, eventType = 'wedding') => ({ + id, + slug: `slug-${id}`, + eventName, + eventDate: '2026-08-01', + eventType, + hostEmail: 'h@example.com', + archivedAt: '2026-08-02T10:00:00.000Z', + expiresAt: '2026-09-01T10:00:00.000Z', + photoCount: 3, + originalSize: 100, + archiveSize: 100, + archivePath: 'events/archived/x.zip', +}); + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + ); +} + +describe('ArchivesPage server-side query (QA I.01)', () => { + beforeEach(() => { + getArchives.mockReset(); + getArchives.mockResolvedValue(page([archive(1, 'Alpha Wedding')], 802)); + }); + + it('sends the search term to the server instead of filtering the loaded page', async () => { + renderPage(); + const input = await screen.findByPlaceholderText('archives.searchPlaceholder'); + expect(getArchives).toHaveBeenLastCalledWith(1, 20, undefined, 'all', 'date'); + + await userEvent.type(input, 'bravo'); + + // Debounced — one request for the settled term, not one per keystroke. + await waitFor( + () => expect(getArchives).toHaveBeenLastCalledWith(1, 20, 'bravo', 'all', 'date'), + { timeout: 2000 } + ); + }); + + it('sends the type filter and the sort key to the server', async () => { + renderPage(); + await screen.findByDisplayValue('archives.allTypes'); + + await userEvent.selectOptions(screen.getByDisplayValue('archives.allTypes'), 'birthday'); + await waitFor(() => expect(getArchives).toHaveBeenLastCalledWith(1, 20, undefined, 'birthday', 'date')); + + await userEvent.selectOptions(screen.getByDisplayValue('archives.sortByDate'), 'size'); + await waitFor(() => expect(getArchives).toHaveBeenLastCalledWith(1, 20, undefined, 'birthday', 'size')); + }); + + it('renders exactly the rows the server returned, unfiltered by the client', async () => { + // A row the old client-side filter would have dropped: the server decided + // it matches, so the page must show it. + getArchives.mockResolvedValue(page([archive(2, 'Bravo Birthday', 'birthday')], 1)); + renderPage(); + expect(await screen.findByText('Bravo Birthday')).toBeInTheDocument(); + }); + + it('goes back to page 1 when the query changes', async () => { + renderPage(); + await userEvent.click(await screen.findByText('common.next')); + await waitFor(() => expect(getArchives).toHaveBeenLastCalledWith(2, 20, undefined, 'all', 'date')); + + await userEvent.selectOptions(screen.getByDisplayValue('archives.allTypes'), 'corporate'); + await waitFor(() => expect(getArchives).toHaveBeenLastCalledWith(1, 20, undefined, 'corporate', 'date')); + }); +}); diff --git a/frontend/src/pages/admin/__tests__/brandingThemeTextLeak.test.ts b/frontend/src/pages/admin/__tests__/brandingThemeTextLeak.test.ts new file mode 100644 index 00000000..b022f196 --- /dev/null +++ b/frontend/src/pages/admin/__tests__/brandingThemeTextLeak.test.ts @@ -0,0 +1,59 @@ +/** + * ThemeContext.applyTheme() writes the branding theme's `--color-text` as an + * inline style on , so `body { color: var(--color-text) }` applies + * everywhere — including the light admin chrome and the light-chromed public + * legal pages. Any heading that ships without an explicit text-color class + * therefore renders near-white on white as soon as the install picks a + * dark-toned branding theme (QA S3 / S4 / S13). + * + * Source-inspection guard: every heading on the surfaces that were fixed must + * declare its own colour rather than inheriting the themed body colour. + */ +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + +const SRC = path.resolve(__dirname, '../../..'); + +const read = (rel: string) => fs.readFileSync(path.join(SRC, rel), 'utf8'); + +// Headings must set a colour explicitly. `text-theme` / `text-muted-theme` are +// deliberately NOT accepted — they resolve to the same leaking variables. +const EXPLICIT_COLOR = /\btext-(neutral|white|amber|blue|red|green|primary|accent)\b|\btext-(neutral|amber|blue|red|green|primary)-\d/; + +const HEADING_TAG = /<(h[1-4])(\s[^>]*?)?>/gs; + +const HEADING_FILES = [ + 'pages/admin/settings/SettingsBusinessProfilePage.tsx', + 'pages/admin/settings/CrmSettingsPage.tsx', + 'pages/admin/settings/ReminderTemplatesPage.tsx', + 'pages/public/LegalPage.tsx', +]; + +describe('branding-theme text colour leak (QA S3 / S4 / S13)', () => { + it.each(HEADING_FILES)('every heading in %s declares an explicit text colour', (rel) => { + const source = read(rel); + const offenders: string[] = []; + + for (const match of source.matchAll(HEADING_TAG)) { + const attrs = match[2] || ''; + const className = /className="([^"]*)"/.exec(attrs)?.[1] ?? ''; + if (!EXPLICIT_COLOR.test(className)) offenders.push(match[0]); + } + + expect(offenders).toEqual([]); + }); + + it('gives the LegalPage CMS body an explicit colour instead of the themed body colour', () => { + const source = read('pages/public/LegalPage.tsx'); + expect(source).toMatch(/className="prose prose-neutral max-w-none text-neutral-\d00"/); + }); + + it('keeps the CMS 404 card surface on the same theme tokens as its text', () => { + // CMSContentBlock intentionally renders themed text (var(--color-text)); + // the card surface has to follow, because `.card` hardcodes bg-white. + const source = read('components/common/CMSContentBlock.tsx'); + expect(source).toContain("backgroundColor: 'var(--color-surface)'"); + expect(source).toContain("color: 'var(--color-text)'"); + }); +}); diff --git a/frontend/src/pages/admin/__tests__/createEventDoubleSubmit.test.tsx b/frontend/src/pages/admin/__tests__/createEventDoubleSubmit.test.tsx new file mode 100644 index 00000000..6043fd36 --- /dev/null +++ b/frontend/src/pages/admin/__tests__/createEventDoubleSubmit.test.tsx @@ -0,0 +1,138 @@ +/** + * "Create event" could fire two real POSTs racing the same slug — one 500'd on + * `events_slug_unique` (QA 7.03). + * + * The Button already carried `disabled={createMutation.isPending}`, which + * covers the ordinary double-click. `handleSubmit` itself had no re-entrancy + * guard though, so any submit that does not go through the button (implicit + * form submission, a programmatic `requestSubmit`) still raced a second POST. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k), + i18n: { language: 'en' }, + }), + }; +}); + +vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); + +const createEvent = vi.fn(); +vi.mock('../../../services/events.service', () => ({ + eventsService: { createEvent: (...args: unknown[]) => createEvent(...args) }, +})); + +vi.mock('../../../services/categories.service', () => ({ + categoriesService: { getCategories: vi.fn(async () => []) }, +})); +vi.mock('../../../services/settings.service', () => ({ + settingsService: { getAllSettings: vi.fn(async () => ({})) }, +})); +vi.mock('../../../services/cssTemplates.service', () => ({ + cssTemplatesService: { getEnabledTemplates: vi.fn(async () => []) }, +})); +vi.mock('../../../services/eventTypes.service', () => ({ + eventTypesService: { getEventTypes: vi.fn(async () => []) }, +})); +vi.mock('../../../services/userManagement.service', () => ({ + userManagementService: { getUsers: vi.fn(async () => []) }, +})); + +// Every "is this field required" flag off, so the only thing validateForm +// needs is the event name. +vi.mock('../../../hooks/usePublicSettings', () => ({ + PUBLIC_SETTINGS_QUERY_KEY: ['public-settings'], + usePublicSettings: () => ({ + data: { + event_require_customer_name: false, + event_require_customer_email: false, + event_require_admin_email: false, + event_require_event_date: false, + event_require_expiration: false, + event_default_require_password: false, + }, + }), +})); + +vi.mock('../../../contexts/AdminAuthContext', () => ({ + useAdminAuth: () => ({ user: null }), +})); + +vi.mock('../../../contexts/FeatureFlagsContext', () => ({ + useFeatureFlags: () => ({ flags: {}, isLoading: false }), + useFeatureEnabled: () => false, +})); + +// Heavy children not involved in the submit path. +vi.mock('../../../components/admin', async () => { + const actual = await vi.importActual('../../../components/admin'); + return { + ...actual, + ThemeCustomizerEnhanced: () => null, + GalleryPreview: () => null, + WelcomeMessageEditor: () => null, + FeedbackSettings: () => null, + }; +}); +vi.mock('../../../components/admin/CustomerAccountPicker', () => ({ + CustomerAccountPicker: () => null, +})); + +import { CreateEventPage } from '../CreateEventPage'; + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + ); +} + +describe('CreateEventPage double-submit guard (QA 7.03)', () => { + beforeEach(() => { + createEvent.mockReset(); + // Never settles — keeps the mutation in flight for the whole test. + createEvent.mockImplementation(() => new Promise(() => {})); + }); + + it('fires exactly one POST when the form is submitted twice in a row', async () => { + renderPage(); + + fireEvent.change(screen.getByPlaceholderText('events.eventNamePlaceholder'), { + target: { value: 'ZZTEST double submit' }, + }); + + const form = screen.getByRole('button', { name: 'events.createEvent' }).closest('form')!; + fireEvent.submit(form); + fireEvent.submit(form); + + await waitFor(() => expect(createEvent).toHaveBeenCalled()); + expect(createEvent).toHaveBeenCalledTimes(1); + }); + + it('disables the submit button while the request is in flight', async () => { + renderPage(); + + fireEvent.change(screen.getByPlaceholderText('events.eventNamePlaceholder'), { + target: { value: 'ZZTEST in flight' }, + }); + + const submit = screen.getByRole('button', { name: 'events.createEvent' }) as HTMLButtonElement; + fireEvent.click(submit); + + expect(submit).toBeDisabled(); + await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/frontend/src/pages/admin/__tests__/emailPreviewSampleData.test.ts b/frontend/src/pages/admin/__tests__/emailPreviewSampleData.test.ts new file mode 100644 index 00000000..e3361f1a --- /dev/null +++ b/frontend/src/pages/admin/__tests__/emailPreviewSampleData.test.ts @@ -0,0 +1,56 @@ +/** + * The template Preview modal substituted a hand-maintained sample-data object + * whose keys had drifted from the templates' declared `variables` (QA J.04): + * {{host_name}}, {{gallery_password}} and {{expiry_date}} rendered as literal + * tokens because the object still carried `password` / `expiration_date` and + * no `host_name` at all. The payload is now derived from `variables`, so the + * property that matters is coverage, not the contents of any one value. + */ +import { describe, it, expect } from 'vitest'; + +import { buildPreviewSampleData } from '../EmailConfigPage'; + +describe('buildPreviewSampleData', () => { + const galleryCreatedVariables = [ + 'host_name', + 'event_name', + 'event_date', + 'gallery_link', + 'gallery_password', + 'expiry_date', + ]; + + it('supplies a value for every variable the template declares', () => { + const sample = buildPreviewSampleData(galleryCreatedVariables); + + expect(Object.keys(sample).sort()).toEqual([...galleryCreatedVariables].sort()); + for (const name of galleryCreatedVariables) { + expect(sample[name]).toBeTruthy(); + } + }); + + it('never leaves a variable to render as a raw {{token}}', () => { + const sample = buildPreviewSampleData(['customer_name', 'invoice_number', 'total_amount']); + + for (const value of Object.values(sample)) { + expect(value).not.toMatch(/\{\{|\}\}/); + } + }); + + it('keeps date- and link-shaped variables looking like dates and links', () => { + const sample = buildPreviewSampleData(galleryCreatedVariables); + + expect(sample.event_date).toMatch(/\d{4}/); + expect(sample.expiry_date).toMatch(/\d{4}/); + expect(sample.gallery_link).toMatch(/^https?:\/\//); + }); + + it('falls back to a readable stand-in for variables it does not know', () => { + expect(buildPreviewSampleData(['storno_number'])).toEqual({ storno_number: '[storno_number]' }); + }); + + it('handles a template with no declared variables', () => { + expect(buildPreviewSampleData()).toEqual({}); + expect(buildPreviewSampleData([])).toEqual({}); + }); +}); diff --git a/frontend/src/pages/admin/__tests__/eventDetailsNotFound.test.tsx b/frontend/src/pages/admin/__tests__/eventDetailsNotFound.test.tsx new file mode 100644 index 00000000..621f7cdb --- /dev/null +++ b/frontend/src/pages/admin/__tests__/eventDetailsNotFound.test.tsx @@ -0,0 +1,84 @@ +/** + * /admin/events/:id hung on the spinner forever for a nonexistent id + * (QA 7.02). The backend returns a clean 404, but the page gated on + * `eventLoading || !event`, so once the query settled `event` stayed + * undefined and the condition never went false. + */ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k), + i18n: { language: 'en' }, + }), + }; +}); + +vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); + +const getEvent = vi.fn(); +vi.mock('../../../services/events.service', () => ({ + eventsService: { + getEvent: (...args: unknown[]) => getEvent(...args), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + extendExpiration: vi.fn(), + duplicateEvent: vi.fn(), + resetPassword: vi.fn(), + publishEvent: vi.fn(), + renameEvent: vi.fn(), + }, +})); + +vi.mock('../../../hooks/usePublicSettings', () => ({ + PUBLIC_SETTINGS_QUERY_KEY: ['public-settings'], + usePublicSettings: () => ({ data: {} }), +})); + +vi.mock('../../../contexts/FeatureFlagsContext', () => ({ + useFeatureFlags: () => ({ flags: {}, isLoading: false }), + useFeatureEnabled: () => false, +})); + +vi.mock('../../../contexts/PermissionsContext', () => ({ + usePermissions: () => ({ hasAnyPermission: () => true, hasPermission: () => true, isLoading: false }), +})); + +import { EventDetailsPage } from '../EventDetailsPage'; + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + } /> + events list} /> + + + + ); +} + +describe('EventDetailsPage 404 handling (QA 7.02)', () => { + it('renders a not-found state instead of spinning forever when the event 404s', async () => { + getEvent.mockRejectedValue({ response: { status: 404, data: { error: 'Event not found' } } }); + + renderPage(); + + expect(screen.getByText('events.loadingEventDetails')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('Event not found')).toBeInTheDocument(); + }); + expect(screen.queryByText('events.loadingEventDetails')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'events.backToEvents' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx b/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx new file mode 100644 index 00000000..934538de --- /dev/null +++ b/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx @@ -0,0 +1,110 @@ +/** + * Settings crashed on a fresh/hard load of any non-default tab (QA J.08). + * + * The nav groups are permission-filtered, so before PermissionsContext has + * resolved every group filters to empty, `allItems[0]` is undefined, and the + * section heading's `` throws. In-app SPA navigation never + * hit it because the context was already warm. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k) }), + }; +}); + +const flagsState = { flags: {} as Record, isLoading: false }; +vi.mock('../../../contexts/FeatureFlagsContext', () => ({ + useFeatureFlags: () => flagsState, + useFeatureEnabled: () => false, +})); + +const permissionsState = { hasAnyPermission: (_: string[]) => true, isLoading: false }; +vi.mock('../../../contexts/PermissionsContext', () => ({ + usePermissions: () => permissionsState, +})); + +// The settings barrel pulls in every tab; stub it down to the shell's needs. +vi.mock('../../../features/settings', () => { + const Stub = () => null; + return { + useSettingsState: () => ({ isLoading: false }), + FeaturesTab: Stub, + GeneralTab: Stub, + EventsTab: Stub, + StatusTab: Stub, + SecurityTab: Stub, + ImageSecurityTab: Stub, + CategoriesTab: Stub, + AnalyticsTab: Stub, + ModerationTab: Stub, + StylingTab: Stub, + SEOTab: Stub, + ThumbnailsTab: Stub, + DownloadsTab: Stub, + ApiTokensTab: Stub, + WebhooksTab: Stub, + AccountingTab: Stub, + WhatsAppTab: Stub, + SsoTab: Stub, + }; +}); + +vi.mock('../EmailConfigPage', () => ({ EmailConfigPage: () => null })); +vi.mock('../BrandingPage', () => ({ BrandingPage: () => null })); +vi.mock('../EventTypesPage', () => ({ EventTypesPage: () => null })); +vi.mock('../SlideshowSettingsPage', () => ({ SlideshowSettingsPage: () => null })); +vi.mock('../BackupManagement', () => ({ BackupManagement: () => null })); +vi.mock('../CMSPage', () => ({ CMSPage: () => null })); +vi.mock('../settings/SettingsBusinessProfilePage', () => ({ SettingsBusinessProfilePage: () => null })); +vi.mock('../settings/CrmSettingsPage', () => ({ CrmSettingsPage: () => null })); +vi.mock('../settings/ReminderTemplatesPage', () => ({ ReminderTemplatesPage: () => null })); +vi.mock('../contracts/BlockLibraryPage', () => ({ BlockLibraryPage: () => null })); + +import { SettingsPage } from '../SettingsPage'; + +function renderAt(tab: string) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + ); +} + +describe('SettingsPage fresh-mount permission race (QA J.08)', () => { + beforeEach(() => { + permissionsState.hasAnyPermission = () => true; + permissionsState.isLoading = false; + }); + + it('does not crash on a deep-linked tab while permissions are still loading', () => { + permissionsState.isLoading = true; + permissionsState.hasAnyPermission = () => false; + + expect(() => renderAt('webhooks')).not.toThrow(); + expect(screen.getByText('settings.loadingSettings')).toBeInTheDocument(); + }); + + it('still lands on the deep-linked tab once permissions arrive', () => { + renderAt('webhooks'); + + expect(screen.getByRole('heading', { level: 2, name: 'Webhooks' })).toBeInTheDocument(); + }); + + it('does not crash when the role has no settings tab permissions at all', () => { + permissionsState.hasAnyPermission = () => false; + + expect(() => renderAt('webhooks')).not.toThrow(); + expect(screen.getByText('settings.title')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx index 3b8f1b8c..0db463c2 100644 --- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -295,7 +295,11 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[ {BOOKING_DISPOSITIONS.includes(disposition) && (
- setCustomer(next.slice(-1))} /> + {/* portalAssignment={false} — same reason as the expenses + ledger: this is an `incomingInvoices` flow, not a + customer-portal one, and the rebill disposition's + required field would otherwise render label-only. */} + setCustomer(next.slice(-1))} /> {disposition === 'durchlaufend' &&

{t('accounting.inbox.field.passthroughCustomerHint', 'Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.')}

}
{/* Markup is a re-bill concept only. A pass-through is invoiced diff --git a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx index 67e6021d..660811f1 100644 --- a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx +++ b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx @@ -209,7 +209,11 @@ const InvoiceExpenseModal: React.FC<{ expense: Expense; onClose: () => void; onD

{t('accounting.ledger.invoiceHint', 'This creates a billable line on the client’s next scheduled invoice and locks the expense from further edits.')}

- setCustomer(next.slice(-1))} /> + {/* portalAssignment={false}: re-billing an expense is an Accounting + flow gated by `expenses`, not by the customer portal — without + this the required field renders a bare label and the submit + button can never enable (QA S10). */} + setCustomer(next.slice(-1))} />
setVal(k, e.target.checked)} /> {t(`crmSettings.${k}.label`, label)} @@ -158,7 +158,7 @@ export const CrmSettingsPage: React.FC = () => { const stored = values[k]; const effective = stored === undefined || stored === null ? true : !!stored; return ( -