// Point the DB at a closed port BEFORE the pool is built, and the upload // directory at a throwaway one BEFORE imageUpload.js resolves it — both are read // at require time. Every model call is monkeypatched, so no query runs. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const os = require('os') const path = require('path') const fs = require('fs') const UPLOAD_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-brand-assets-')) process.env.UPLOAD_DIR = UPLOAD_DIR const { test, after, afterEach } = require('node:test') const assert = require('node:assert/strict') // Phase 5 of docs/website/THEMING_AND_NAV.md: the brand-asset overrides. Two // halves are worth locking — what a stored value is allowed to be (these values // are written straight into HTML as URLs) and the upload route's per-slot rules, // which tighten the shared allowlist without ever widening it (§9). const { startApp } = require('./_helper') const { isSafeAssetPath, validateBrandAssets, resolveBrandAssets, SLOTS } = require('../src/utils/brandAssets') const settingsRouter = require('../src/router/v1/admin/settings.router') const settingsDb = require('../src/model/settings/settings.db') const sessionService = require('../src/auth/session.service') const { requireAuth } = require('../src/auth/session.middleware') const users = require('../src/model/users/users.model') const activity = require('../src/model/activity/activity.model') const htmlShell = require('../src/utils/htmlShell') const db = require('../src/utils/db') after(() => { db.close() fs.rmSync(UPLOAD_DIR, { recursive: true, force: true }) }) const originals = { validateSession: sessionService.validateSession, isSessionRevoked: sessionService.isSessionRevoked, sessionMeta: sessionService.sessionMeta, getById: users.getById, get: settingsDb.get, set: settingsDb.set, log: activity.log, } afterEach(() => { Object.assign(sessionService, { validateSession: originals.validateSession, isSessionRevoked: originals.isSessionRevoked, sessionMeta: originals.sessionMeta, }) users.getById = originals.getById settingsDb.get = originals.get settingsDb.set = originals.set activity.log = originals.log }) function signInAs(user) { sessionService.validateSession = () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' }) sessionService.isSessionRevoked = async () => false sessionService.sessionMeta = () => ({}) users.getById = async () => user activity.log = async () => {} } // ── What a stored asset path may be ─────────────────────────────────── test('only same-origin paths under the directories this server serves are accepted', () => { for (const ok of ['/uploads/1-a.png', '/brand/logo.svg', '/assets/img/runic-emblem.png']) { assert.equal(isSafeAssetPath(ok), true, `${ok} should be accepted`) } const rejected = [ 'https://evil.example/x.png', // off-origin: an the operator did not choose '//evil.example/x.png', // protocol-relative — looks like a path, loads off-origin 'javascript:alert(1)', // no scheme survives the prefix check, but be explicit '/uploads/../../etc/passwd', // climbing out of the served directory '/uploads/a b.png', // whitespace is the raw material for smuggling '/uploads/"onerror="alert(1)', // quote would break out of the attribute '/etc/passwd', // a path, but not one we serve 'uploads/1-a.png', // relative to the current route, not to the origin '', null, 42, ] for (const bad of rejected) { assert.equal(isSafeAssetPath(bad), false, `${String(bad)} should be rejected`) } }) // Strict on write: the admin gets told which field is wrong, rather than saving // something that silently never renders. test('a write naming an unknown slot or an unusable path is rejected by field', () => { assert.equal(validateBrandAssets({ logo: '/uploads/a.png', hero: null }).ok, true) assert.equal(validateBrandAssets(null).ok, true) // clearing every slot const unknown = validateBrandAssets({ banner: '/uploads/a.png' }) assert.equal(unknown.ok, false) assert.match(unknown.message, /banner/) const offsite = validateBrandAssets({ favicon: 'https://evil.example/f.png' }) assert.equal(offsite.ok, false) assert.match(offsite.message, /favicon/) assert.equal(validateBrandAssets(['/uploads/a.png']).ok, false) }) // Forgiving on read: one hand-edited slot must not cost the admin the other two. test('a bad stored slot is dropped and its neighbours are kept', () => { const resolved = resolveBrandAssets({ logo: '/uploads/a.png', hero: 'https://evil.example/h.png', favicon: null }) assert.deepEqual(resolved, { logo: '/uploads/a.png' }) }) test('resolve is also how a cleared slot stops being stored', () => { // '' and null are how the UI clears a slot; neither may survive into the row, // or "the field is absent" would stop being the single meaning of "use env". assert.deepEqual(resolveBrandAssets({ logo: '', hero: null }), {}) assert.deepEqual(resolveBrandAssets(null), {}) assert.deepEqual(SLOTS, ['logo', 'hero', 'favicon']) }) // ── POST /admin/settings/brand-asset/:slot ──────────────────────────── // A 1x1 PNG and a 1x1 GIF, small enough to inline and real enough for multer to // accept by mimetype (which is what the shared allowlist keys off). const PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ) const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64') function form(buffer, { filename = 'x.png', type = 'image/png' } = {}) { const fd = new FormData() fd.append('image', new Blob([buffer], { type }), filename) return fd } const startSettingsApp = () => startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) const filesInUploadDir = () => fs.readdirSync(UPLOAD_DIR) test('uploading a slot stores the file and points brand_assets at it', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.get = async () => null // never set before let stored = null settingsDb.set = async (key, value) => { stored = { key, value } } const before = filesInUploadDir().length const app = await startSettingsApp() try { const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, { method: 'POST', body: form(PNG), }) assert.equal(res.status, 201) const body = await res.json() assert.match(body.url, /^\/uploads\/\d+-[0-9a-f]{16}\.png$/) assert.deepEqual(body.brand_assets, { logo: body.url }) assert.equal(stored.key, 'brand_assets') assert.deepEqual(JSON.parse(stored.value), { logo: body.url }) assert.equal(filesInUploadDir().length, before + 1, 'the file is kept') } finally { await app.close() } }) // §6.3: uploading a logo does not force the admin to also pick a hero — and must // not silently discard the hero they picked last week. test('an upload merges into the existing overrides rather than replacing them', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.get = async () => JSON.stringify({ hero: '/uploads/existing-hero.png' }) let stored = null settingsDb.set = async (key, value) => { stored = value } const app = await startSettingsApp() try { const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, { method: 'POST', body: form(PNG), }) assert.equal(res.status, 201) const saved = JSON.parse(stored) assert.equal(saved.hero, '/uploads/existing-hero.png', 'the hero survives') assert.match(saved.favicon, /^\/uploads\//) } finally { await app.close() } }) // §4.10: .ico would mean adding a type to MIME_EXT, and the stored extension // coming from that map is what makes the upload path safe. PNG only, and the // rejected file does not stay on disk. test('a favicon that is not a PNG is refused and the file is discarded', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.set = async () => assert.fail('a refused upload must not write the row') const before = filesInUploadDir().length const app = await startSettingsApp() try { const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, { method: 'POST', body: form(GIF, { filename: 'x.gif', type: 'image/gif' }), }) assert.equal(res.status, 400) assert.match((await res.json()).message, /PNG/) assert.equal(filesInUploadDir().length, before, 'no orphan file left behind') } finally { await app.close() } }) test('a file over the slot cap is refused and discarded', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.set = async () => assert.fail('a refused upload must not write the row') // Valid PNG header, then padding past the favicon's 512 KB cap — the shared // multer limit is 8 MB, so only the per-slot rule can reject this. const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)]) const before = filesInUploadDir().length const app = await startSettingsApp() try { const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, { method: 'POST', body: form(big), }) assert.equal(res.status, 400) assert.match((await res.json()).message, /512 KB or smaller/) assert.equal(filesInUploadDir().length, before) } finally { await app.close() } }) test('the same file is accepted for a slot with a bigger cap', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.get = async () => null settingsDb.set = async () => {} const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)]) const app = await startSettingsApp() try { const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/hero`, { method: 'POST', body: form(big), }) assert.equal(res.status, 201) } finally { await app.close() } }) test('an unknown slot is refused and the file is discarded', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.set = async () => assert.fail('an unknown slot must not write the row') const before = filesInUploadDir().length const app = await startSettingsApp() try { const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/banner`, { method: 'POST', body: form(PNG), }) assert.equal(res.status, 400) assert.equal(filesInUploadDir().length, before) } finally { await app.close() } }) // The generic POST /admin/uploads is reachable by editors. The site's identity // is not theirs to change, so this route carries the same admin gate as the // settings row it writes. test('an editor cannot upload a brand asset', async () => { signInAs({ id: 2, username: 'e', role: 'editor', status: 'active' }) settingsDb.set = async () => assert.fail('an editor must not write brand_assets') const before = filesInUploadDir().length const app = await startSettingsApp() try { const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, { method: 'POST', body: form(PNG), }) assert.equal(res.status, 403) assert.equal(filesInUploadDir().length, before, 'the gate runs before multer writes') } finally { await app.close() } }) // ── PUT /admin/settings { brand_assets } — how a slot is CLEARED ────── // // There is no per-slot delete route: clearing the logo is a write of the // remaining slots, and clearing the last one is the existing reset-by-delete. test('clearing a slot through the settings write drops it from the row', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) let stored = null settingsDb.set = async (key, value) => { stored = value } settingsDb.getAll = async () => [] const app = await startSettingsApp() try { const res = await fetch(`${app.url}/api/v1/admin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ brand_assets: { logo: '/uploads/a.png', hero: null, favicon: '' } }), }) assert.equal(res.status, 200) assert.deepEqual(JSON.parse(stored), { logo: '/uploads/a.png' }, 'no null fields survive into the row') } finally { await app.close() } }) test('a settings write carrying an off-origin asset URL is rejected by field', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.set = async () => assert.fail('an invalid brand_assets must not be stored') const app = await startSettingsApp() try { const res = await fetch(`${app.url}/api/v1/admin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ brand_assets: { logo: 'https://tracker.example/pixel.png' } }), }) assert.equal(res.status, 400) assert.match((await res.json()).message, /brand_assets\.logo/) } finally { await app.close() } }) test('a successful upload invalidates the cached HTML shell', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.get = async () => null settingsDb.set = async () => {} let invalidated = 0 const realInvalidate = htmlShell.invalidate htmlShell.invalidate = () => { invalidated += 1 } const app = await startSettingsApp() try { const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, { method: 'POST', body: form(PNG), }) assert.equal(res.status, 201) assert.equal(invalidated, 1, 'the favicon an admin just uploaded must not wait for the TTL') } finally { htmlShell.invalidate = realInvalidate await app.close() } })