diff --git a/backend/__tests__/integration/galleryShortUrlRoute.test.js b/backend/__tests__/integration/galleryShortUrlRoute.test.js new file mode 100644 index 00000000..fd157c84 --- /dev/null +++ b/backend/__tests__/integration/galleryShortUrlRoute.test.js @@ -0,0 +1,231 @@ +/** + * HTTP-level tests for the `/s/:shortSlug` public resolver (#699). + * + * Verifies the contract the public route is expected to honour: + * - Browser UA → 302 to target_path + * - Social crawler UA → 200 with OG , canonical = /s/ + * - Soft-deleted slug → 410 Gone (intentional-delete signal) + * - Unknown slug → 404 Not Found + * - Hit count increments after successful resolutions (both shapes) + * + * Mirrors the production server.js wiring but doesn't load the whole + * server — the surrounding middleware (CORS, helmet, rate limiters) + * isn't part of this route's contract. + */ +const express = require('express'); +const request = require('supertest'); + +const { bootCrmDb } = require('./helpers/crmDb'); + +jest.setTimeout(60000); + +let db; let cleanup; let service; let app; + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + + // Persist a business_profile + business_name so buildOgMetadata's + // settings-based fields populate consistently. + const { upsertAppSetting } = require('../../src/utils/appSettings'); + await upsertAppSetting('branding_company_name', JSON.stringify('Test Studio'), 'string'); + + service = require('../../src/services/galleryShortUrlService'); + const { + isSocialCrawler, buildOgMetadata, renderOgHtml, + } = require('../../src/services/galleryOgService'); + + app = express(); + app.get('/s/:shortSlug', async (req, res) => { + try { + const row = await service.findByShortSlug(req.params.shortSlug); + if (!row) return res.status(404).type('text/plain').send('Short URL not found'); + if (row.deleted_at) return res.status(410).type('text/plain').send('Short URL has been removed'); + + if (isSocialCrawler(req.get('user-agent'))) { + const event = await db('events').where({ id: row.event_id }).first('slug'); + if (event?.slug) { + const meta = await buildOgMetadata(event.slug, req.originalUrl); + const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, ''); + meta.url = `${base}/s/${row.short_slug}`; + res.set('Cache-Control', 'public, max-age=300'); + res.set('Content-Type', 'text/html; charset=utf-8'); + res.send(renderOgHtml(meta)); + service.recordHit(row.id).catch(() => {}); + return; + } + return res.status(410).type('text/plain').send('Short URL points at a deleted event'); + } + + service.recordHit(row.id).catch(() => {}); + return res.redirect(302, row.target_path); + } catch (err) { + return res.status(500).type('text/plain').send(err.message); + } + }); +}, 120000); + +afterAll(async () => { if (cleanup) await cleanup(); }); + +async function seedEventAndShortUrl({ slug = `evt-${Date.now()}`, shortSlug }) { + const farFuture = new Date(Date.now() + 365 * 86400000).toISOString(); + const [eventId] = await db('events').insert({ + slug, + event_type: 'wedding', + event_name: 'Test Event', + event_date: '2026-06-05', + password_hash: 'x', + expires_at: farFuture, + is_active: true, + is_archived: false, + share_link: slug, + share_token: `tok${Math.random().toString(36).slice(2, 12)}`, + welcome_message: null, + }); + const row = await service.createShortUrl({ + eventId, customSlug: shortSlug, + }); + return { eventId, shortUrl: row }; +} + +// User-agent strings the production `isSocialCrawler` helper matches. +// Snapshot known-true samples here so the test stays in sync if the +// helper's allowlist evolves. +const BOT_UA_WHATSAPP = 'WhatsApp/2.23.20.0'; +const BOT_UA_FACEBOOK = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)'; +const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15'; + +describe('GET /s/:shortSlug — browser (302 redirect)', () => { + it('redirects to the snapshotted target_path with a 302', async () => { + const { shortUrl } = await seedEventAndShortUrl({ + slug: 'browser-redirect', shortSlug: 'go-here', + }); + const res = await request(app) + .get('/s/go-here') + .set('User-Agent', BROWSER_UA); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(shortUrl.target_path); + expect(res.headers.location).toMatch(/^\/gallery\//); + }); + + it('increments hit_count on a browser hit (fire-and-forget — wait briefly)', async () => { + await seedEventAndShortUrl({ + slug: 'hit-browser', shortSlug: 'hit-from-browser', + }); + await request(app).get('/s/hit-from-browser').set('User-Agent', BROWSER_UA); + await new Promise((r) => setTimeout(r, 50)); + const row = await service.findByShortSlug('hit-from-browser'); + expect(row.hit_count).toBe(1); + expect(row.last_hit_at).toBeTruthy(); + }); +}); + +describe('GET /s/:shortSlug — social crawler (OG metadata)', () => { + it('returns 200 with OG HTML for WhatsApp UA', async () => { + await seedEventAndShortUrl({ + slug: 'whatsapp-og', shortSlug: 'wa-preview', + }); + const res = await request(app) + .get('/s/wa-preview') + .set('User-Agent', BOT_UA_WHATSAPP); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/html/); + expect(res.text).toContain(', not the underlying gallery URL', async () => { + await seedEventAndShortUrl({ + slug: 'canonical-test', shortSlug: 'canonical-short', + }); + const res = await request(app) + .get('/s/canonical-short') + .set('User-Agent', BOT_UA_FACEBOOK); + expect(res.status).toBe(200); + // The og:url meta tag must contain the short-URL path, not the + // /gallery/ path — this is the cache-key invariant from #699. + expect(res.text).toMatch(/property="og:url"\s+content="[^"]*\/s\/canonical-short"/); + expect(res.text).not.toMatch( + /property="og:url"\s+content="[^"]*\/gallery\/canonical-test"/ + ); + }); + + it('sets a short cache header so scrapers can re-fetch when admin rotates the preview', async () => { + await seedEventAndShortUrl({ + slug: 'cache-header', shortSlug: 'cache-test', + }); + const res = await request(app) + .get('/s/cache-test') + .set('User-Agent', BOT_UA_WHATSAPP); + expect(res.headers['cache-control']).toMatch(/public/); + expect(res.headers['cache-control']).toMatch(/max-age=300/); + }); + + it('increments hit_count on a crawler hit as well', async () => { + await seedEventAndShortUrl({ + slug: 'hit-bot', shortSlug: 'hit-from-bot', + }); + await request(app).get('/s/hit-from-bot').set('User-Agent', BOT_UA_WHATSAPP); + await new Promise((r) => setTimeout(r, 50)); + const row = await service.findByShortSlug('hit-from-bot'); + expect(row.hit_count).toBe(1); + }); +}); + +describe('GET /s/:shortSlug — error states', () => { + it('404 for an unknown slug', async () => { + const res = await request(app) + .get('/s/never-existed') + .set('User-Agent', BROWSER_UA); + expect(res.status).toBe(404); + }); + + it('410 for a soft-deleted slug (intentional-delete signal)', async () => { + const { shortUrl } = await seedEventAndShortUrl({ + slug: 'gone-test', shortSlug: 'gone-slug', + }); + await service.softDelete(shortUrl.id, null); + const res = await request(app) + .get('/s/gone-slug') + .set('User-Agent', BROWSER_UA); + expect(res.status).toBe(410); + }); + + it('410 if the event was hard-deleted but the short URL row somehow survives', async () => { + const { eventId } = await seedEventAndShortUrl({ + slug: 'orphan-test', shortSlug: 'orphan-slug', + }); + // Hard-delete the event row (FK CASCADE would normally clean up the + // short URL too — but if CASCADE didn't fire for whatever reason + // (e.g. SQLite foreign_keys pragma off in a particular runtime), the + // resolver should still degrade safely). + // SQLite's foreign_keys pragma is OFF by default; the migration + // doesn't toggle it, so this delete leaves the short URL row. + await db('events').where({ id: eventId }).delete(); + const res = await request(app) + .get('/s/orphan-slug') + .set('User-Agent', BOT_UA_WHATSAPP); + expect(res.status).toBe(410); + }); + + it('404 for a malformed slug (rejected at validation, no DB hit)', async () => { + const res = await request(app) + .get('/s/UPPER_CASE') + .set('User-Agent', BROWSER_UA); + expect(res.status).toBe(404); + }); +}); + +describe('Regression — existing URL paths must still respond the same', () => { + // The /s/* namespace is additive: it must NOT shadow /gallery/* + // or any of the OG routes. We don't load the whole app here, but we + // can at least pin that the route param doesn't accept slashes — + // i.e. /s/foo/bar must NOT be matched by our handler. + it('the /s/:shortSlug route does not match nested paths', async () => { + const res = await request(app) + .get('/s/foo/bar') + .set('User-Agent', BROWSER_UA); + // Express returns its default 404 when no route matches the path. + expect(res.status).toBe(404); + }); +}); diff --git a/backend/__tests__/integration/galleryShortUrls.test.js b/backend/__tests__/integration/galleryShortUrls.test.js new file mode 100644 index 00000000..cc3b40ec --- /dev/null +++ b/backend/__tests__/integration/galleryShortUrls.test.js @@ -0,0 +1,282 @@ +/** + * Integration tests for the branded short-URL service (#699). + * + * Exercises createShortUrl + findByShortSlug + listForEvent + softDelete + * + recordHit against a real SQLite DB, including the contracts that + * matter for production correctness: + * + * - Custom slug + collision detection (409 with `suggested`) + * - Auto-generated slug from event slug + year + * - Soft-delete preserves the row (admin can audit) + * - target_path snapshots at create time (toggling the global + * "Use short gallery URLs" setting later doesn't change existing + * short URLs — backward-compat invariant from #699) + * - hit_count increments idempotently + * - findByShortSlug returns soft-deleted rows (caller decides 410 vs 404) + * + * Boots one DB for the whole file (cheap on SQLite); each test seeds + * its own event row to keep scope clean. + */ +const { bootCrmDb } = require('./helpers/crmDb'); + +jest.setTimeout(60000); + +let db; let cleanup; let service; let adminId; + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + + // Minimal admin for created_by audit. + const adminInsert = await db('admin_users').insert({ + username: 'shorturl-test', + email: 'shorturl@example.com', + password_hash: 'x', + must_change_password: false, + created_at: new Date(), + }).returning('id'); + adminId = adminInsert[0]?.id ?? adminInsert[0]; + + service = require('../../src/services/galleryShortUrlService'); +}, 120000); + +afterAll(async () => { if (cleanup) await cleanup(); }); + +// Each test seeds a fresh event so collisions / counter state don't leak. +async function seedEvent(overrides = {}) { + const slug = overrides.slug || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const farFuture = new Date(Date.now() + 365 * 86400000).toISOString(); + const [id] = await db('events').insert({ + slug, + event_type: 'wedding', + event_name: overrides.event_name || 'Test Wedding', + event_date: overrides.event_date || '2026-06-05', + password_hash: 'x', + expires_at: farFuture, + is_active: true, + is_archived: false, + share_link: slug, + share_token: overrides.share_token || `tok${Math.random().toString(36).slice(2, 12)}`, + welcome_message: null, + }); + const event = await db('events').where({ id }).first(); + return event; +} + +describe('createShortUrl — custom slug', () => { + it('creates with a custom slug', async () => { + const event = await seedEvent({ slug: 'sofia-grad-1' }); + const row = await service.createShortUrl({ + eventId: event.id, + customSlug: 'sofia-graduation-1', + createdBy: adminId, + }); + expect(row.short_slug).toBe('sofia-graduation-1'); + expect(row.target_path).toBe(`/gallery/${event.slug}`); + expect(row.event_id).toBe(event.id); + expect(row.hit_count).toBe(0); + }); + + it('lowercases the input — operators pasting mixed-case still get a clean slug', async () => { + const event = await seedEvent({ slug: 'sofia-grad-2' }); + const row = await service.createShortUrl({ + eventId: event.id, + customSlug: 'Sofia-GraduAtion-2', // mixed case + createdBy: adminId, + }); + expect(row.short_slug).toBe('sofia-graduation-2'); + }); + + it('rejects an invalid slug with INVALID_SLUG code', async () => { + const event = await seedEvent({ slug: 'invalid-test' }); + await expect(service.createShortUrl({ + eventId: event.id, + customSlug: 'invalid slug with spaces', + createdBy: adminId, + })).rejects.toMatchObject({ code: 'INVALID_SLUG' }); + }); + + it('rejects a reserved slug with INVALID_SLUG code', async () => { + const event = await seedEvent({ slug: 'reserved-test' }); + await expect(service.createShortUrl({ + eventId: event.id, + customSlug: 'admin', + createdBy: adminId, + })).rejects.toMatchObject({ code: 'INVALID_SLUG' }); + }); + + it('rejects a duplicate slug with SLUG_TAKEN + suggested fallback', async () => { + const event1 = await seedEvent({ slug: 'dup-test-1' }); + const event2 = await seedEvent({ slug: 'dup-test-2' }); + await service.createShortUrl({ eventId: event1.id, customSlug: 'collide-me' }); + await expect(service.createShortUrl({ + eventId: event2.id, customSlug: 'collide-me', + })).rejects.toMatchObject({ + code: 'SLUG_TAKEN', + suggested: expect.any(String), + }); + }); + + it('throws EVENT_NOT_FOUND when the event id does not exist', async () => { + await expect(service.createShortUrl({ + eventId: 9999999, customSlug: 'no-event', + })).rejects.toMatchObject({ code: 'EVENT_NOT_FOUND' }); + }); +}); + +describe('createShortUrl — auto-generated slug', () => { + it('uses event slug + year when no custom slug provided', async () => { + const event = await seedEvent({ + slug: 'autogen-wedding', event_date: '2026-06-05', + }); + const row = await service.createShortUrl({ + eventId: event.id, + createdBy: adminId, + }); + // First-choice candidate is just the slug; takes that. + expect(row.short_slug).toBe('autogen-wedding'); + }); + + it('falls back to slug-year when the bare slug is already taken', async () => { + // Both events SHARE the same canonical slug so the first-choice + // bare-slug candidate is burned, forcing autoGen to try the + // year-suffixed variant. + const event1 = await seedEvent({ + slug: 'collide-base', event_date: '2026-07-01', + }); + await service.createShortUrl({ + eventId: event1.id, customSlug: 'collide-base', + }); + const event2 = await seedEvent({ + slug: 'collide-base-2', event_date: '2026-07-01', + }); + // Force the bare candidate of event2 to also collide by burning it. + await service.createShortUrl({ + eventId: event1.id, customSlug: 'collide-base-2', + }); + const row = await service.createShortUrl({ + eventId: event2.id, // No custom — auto-gen from event2.slug + }); + // Bare candidate `collide-base-2` is taken → year-suffixed picks. + expect(row.short_slug).toBe('collide-base-2-2026'); + }); +}); + +describe('createShortUrl — target_path snapshotting (#699 backward-compat)', () => { + it('uses /gallery/ when the global short-URLs setting is OFF (default)', async () => { + const event = await seedEvent({ slug: 'snapshot-off' }); + const row = await service.createShortUrl({ + eventId: event.id, customSlug: 'snap-off', + }); + expect(row.target_path).toBe(`/gallery/${event.slug}`); + }); + + it('uses /gallery/ when the global setting is ON at create time', async () => { + // Persist the setting. + const { upsertAppSetting } = require('../../src/utils/appSettings'); + await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(true), 'system'); + try { + const event = await seedEvent({ slug: 'snapshot-on', share_token: 'tokenAbc123' }); + const row = await service.createShortUrl({ + eventId: event.id, customSlug: 'snap-on', + }); + expect(row.target_path).toBe(`/gallery/${event.share_token}`); + + // CRITICAL backward-compat invariant: now flip the setting OFF. + // Existing short URLs must still resolve to the same target_path + // they were created with — operator's existing share links don't + // silently change behaviour. + await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system'); + const refetched = await service.findByShortSlug('snap-on'); + expect(refetched.target_path).toBe(`/gallery/${event.share_token}`); + } finally { + await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system'); + } + }); +}); + +describe('findByShortSlug + listForEvent', () => { + it('returns null for an unknown slug', async () => { + expect(await service.findByShortSlug('does-not-exist-xyz')).toBeNull(); + }); + + it('returns null for a malformed slug (no DB hit)', async () => { + expect(await service.findByShortSlug('UPPER_CASE')).toBeNull(); + expect(await service.findByShortSlug('with spaces')).toBeNull(); + expect(await service.findByShortSlug('')).toBeNull(); + }); + + it('returns soft-deleted rows (caller decides 410 vs 404)', async () => { + const event = await seedEvent({ slug: 'softdel-find' }); + const created = await service.createShortUrl({ + eventId: event.id, customSlug: 'find-deleted', + }); + await service.softDelete(created.id, adminId); + const fetched = await service.findByShortSlug('find-deleted'); + expect(fetched).not.toBeNull(); + expect(fetched.deleted_at).toBeTruthy(); + }); + + it('listForEvent excludes soft-deleted rows', async () => { + const event = await seedEvent({ slug: 'list-test' }); + const live = await service.createShortUrl({ + eventId: event.id, customSlug: 'list-live', + }); + const deleted = await service.createShortUrl({ + eventId: event.id, customSlug: 'list-deleted', + }); + await service.softDelete(deleted.id, adminId); + const list = await service.listForEvent(event.id); + const ids = list.map((r) => r.id); + expect(ids).toContain(live.id); + expect(ids).not.toContain(deleted.id); + }); +}); + +describe('softDelete', () => { + it('returns true on first call, false on second (idempotent admin clicks)', async () => { + const event = await seedEvent({ slug: 'softdel-idem' }); + const created = await service.createShortUrl({ + eventId: event.id, customSlug: 'idem-delete', + }); + expect(await service.softDelete(created.id, adminId)).toBe(true); + expect(await service.softDelete(created.id, adminId)).toBe(false); + }); + + it('returns false for an unknown id (caller maps to 404)', async () => { + expect(await service.softDelete(9999999, adminId)).toBe(false); + }); +}); + +describe('createShortUrl after soft-delete — slug rotation', () => { + it('re-creating a soft-deleted slug succeeds (purges the deleted row)', async () => { + const event = await seedEvent({ slug: 'rotate' }); + const first = await service.createShortUrl({ + eventId: event.id, customSlug: 'rotate-me', + }); + await service.softDelete(first.id, adminId); + // The slug is now reclaimable for a fresh row. + const second = await service.createShortUrl({ + eventId: event.id, customSlug: 'rotate-me', + }); + expect(second.id).not.toBe(first.id); + expect(second.short_slug).toBe('rotate-me'); + }); +}); + +describe('recordHit', () => { + it('increments hit_count + stamps last_hit_at', async () => { + const event = await seedEvent({ slug: 'hit-counter' }); + const row = await service.createShortUrl({ + eventId: event.id, customSlug: 'count-me', + }); + await service.recordHit(row.id); + await service.recordHit(row.id); + const fetched = await service.findByShortSlug('count-me'); + expect(fetched.hit_count).toBe(2); + expect(fetched.last_hit_at).toBeTruthy(); + }); + + it('is fire-and-forget — invalid id does not throw', async () => { + await expect(service.recordHit(9999999)).resolves.not.toThrow(); + }); +}); diff --git a/backend/__tests__/utils/galleryShortUrlValidation.test.js b/backend/__tests__/utils/galleryShortUrlValidation.test.js new file mode 100644 index 00000000..cc66d1e2 --- /dev/null +++ b/backend/__tests__/utils/galleryShortUrlValidation.test.js @@ -0,0 +1,120 @@ +/** + * Pure-function tests for the slug validator in galleryShortUrlService. + * The validator is the security boundary for the `/s/` public + * route — bad shapes leak into a UNIQUE column that's used in URLs + * without further escaping, so the rules need to be tight. + */ + +// Provide a minimal db stub so requiring the service doesn't crash — +// the validator path doesn't touch the DB. +jest.mock('../../src/database/db', () => ({ db: jest.fn() })); +jest.mock('../../src/utils/logger', () => ({ + info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), +})); +jest.mock('../../src/utils/appSettings', () => ({ + getAppSetting: jest.fn().mockResolvedValue(false), +})); + +const { + validateSlug, + _RESERVED_SLUGS, +} = require('../../src/services/galleryShortUrlService'); + +describe('validateSlug', () => { + describe('accepts', () => { + test.each([ + 'sofia-graduation', + 'sofia', + 'a', // single char (alphanumeric) + '1', // single digit + 'abc123', + '123-abc', + 'sofia-2026-06-05', + 'sofia-2026', + 'a-b-c-d', + 'wedding-2026', + 'xK7p2'.toLowerCase(), // lowercase 5-char + 'a'.repeat(64), // exactly at the limit + ])('%j', (slug) => { + expect(validateSlug(slug)).toBeNull(); + }); + }); + + describe('rejects', () => { + test.each([ + ['', 'cannot be empty'], + [' ', 'cannot be empty'], // trimmed → empty + ['-sofia', 'lowercase letters'], // leading hyphen + ['sofia-', 'lowercase letters'], // trailing hyphen + ['Sofia', 'lowercase letters'], // uppercase + ['sofia_graduation', 'lowercase letters'], // underscore + ['sofia.graduation', 'lowercase letters'], // dot + ['sofia graduation', 'lowercase letters'], // space + ['sofia/graduation', 'lowercase letters'], // slash (path traversal vector) + ['sofia%20graduation', 'lowercase letters'], + ['a'.repeat(65), 'at most 64'], // one over limit + ])('%j → %s', (slug, expectedReason) => { + const result = validateSlug(slug); + expect(result).not.toBeNull(); + expect(result.toLowerCase()).toContain(expectedReason); + }); + + test('null', () => { + expect(validateSlug(null)).toContain('must be a string'); + }); + + test('undefined', () => { + expect(validateSlug(undefined)).toContain('must be a string'); + }); + + test('number', () => { + expect(validateSlug(42)).toContain('must be a string'); + }); + + test('object', () => { + expect(validateSlug({})).toContain('must be a string'); + }); + }); + + describe('reserved slugs', () => { + test.each([ + 'admin', + 'api', + 'auth', + 'gallery', + 'og', + 'health', + 's', // can't shadow the shortener itself + 'login', + 'favicon.ico', // even with the dot — covered by SLUG_REGEX fail too + ])('reserves %j', (slug) => { + expect(_RESERVED_SLUGS.has(slug)).toBe(true); + }); + + test('"admin" → rejected with "reserved" reason', () => { + // validateSlug short-circuits at the regex for slugs containing + // dots (favicon.ico fails the regex first). Test a clean + // alphanumeric reserved word. + const result = validateSlug('admin'); + expect(result).toBe('short_slug is reserved'); + }); + }); + + describe('path-traversal + URL-injection vectors are rejected at the regex', () => { + test.each([ + '../etc/passwd', + 'foo/../bar', + 'foo?query=1', + 'foo#fragment', + 'foo&bar', + 'foo bar', + 'foo