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 = `
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 = '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 = `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 ?? '