import { describe, it, expect, beforeEach } from 'vitest'; import express from 'express'; import request from 'supertest'; import { mkdtempSync, writeFileSync, existsSync, readdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { ConfigManager } from '../config-manager.js'; import { mountBrandingApi, resolveBranding } from './branding-api.js'; function makeApp(yaml: string, withUpload = false) { const dir = mkdtempSync(join(tmpdir(), 'branding-api-')); writeFileSync(join(dir, 'config.yaml'), yaml); const cm = new ConfigManager(join(dir, 'config.yaml')); const app = express(); if (withUpload) { mountBrandingApi(app, cm, { brandingDir: join(dir, 'branding'), adminGuard: (_req, _res, next) => next(), }); } else { mountBrandingApi(app, cm); } return { app, cm, dir }; } describe('Branding API', () => { it('returns defaults when branding is not configured', async () => { const { app } = makeApp('provider:\n model: test-model\n'); const res = await request(app).get('/api/branding'); expect(res.status).toBe(200); expect(res.body).toEqual({ appName: 'MAESTRO', primaryColor: '#2563eb', loginPageTitle: 'MAESTRO', logoUrl: null, faviconUrl: null, footerText: null, }); }); it('returns configured values when branding is set', async () => { const { app } = makeApp([ 'provider:', ' model: test-model', 'branding:', ' app_name: "My Team AI"', ' primary_color: "#ff5500"', ' login_page_title: "Welcome to My Team"', ' logo_url: "/branding/logo-abc.svg"', ' favicon_url: "/branding/favicon-def.png"', ' footer_text: "© 2026 My Team"', ].join('\n')); const res = await request(app).get('/api/branding'); expect(res.status).toBe(200); expect(res.body).toEqual({ appName: 'My Team AI', primaryColor: '#ff5500', loginPageTitle: 'Welcome to My Team', logoUrl: '/branding/logo-abc.svg', faviconUrl: '/branding/favicon-def.png', footerText: '© 2026 My Team', }); }); it('falls back loginPageTitle to appName when only appName is set', async () => { const { app } = makeApp([ 'provider:', ' model: test-model', 'branding:', ' app_name: "Custom App"', ].join('\n')); const res = await request(app).get('/api/branding'); expect(res.body.appName).toBe('Custom App'); expect(res.body.loginPageTitle).toBe('Custom App'); expect(res.body.primaryColor).toBe('#2563eb'); }); it('ignores empty strings and falls back to defaults', async () => { const { app } = makeApp([ 'provider:', ' model: test-model', 'branding:', ' app_name: ""', ' primary_color: " "', ].join('\n')); const res = await request(app).get('/api/branding'); expect(res.body.appName).toBe('MAESTRO'); expect(res.body.primaryColor).toBe('#2563eb'); }); it('resolveBranding returns defaults for undefined configManager', () => { const branding = resolveBranding(undefined); expect(branding).toEqual({ appName: 'MAESTRO', primaryColor: '#2563eb', loginPageTitle: 'MAESTRO', logoUrl: null, faviconUrl: null, footerText: null, }); }); describe('reactive to config updates', () => { let cm: ConfigManager; let app: express.Application; beforeEach(() => { const built = makeApp('provider:\n model: test-model\n'); cm = built.cm; app = built.app; }); it('reflects runtime updates via ConfigManager', async () => { const before = await request(app).get('/api/branding'); expect(before.body.appName).toBe('MAESTRO'); const etag = cm.getConfigForApi().etag; cm.updateConfig({ branding: { appName: 'Hot Reload' } }, etag); const after = await request(app).get('/api/branding'); expect(after.body.appName).toBe('Hot Reload'); }); }); describe('asset upload', () => { // 1x1 transparent PNG const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; it('uploads a logo and reflects it in GET /api/branding', async () => { const { app, dir } = makeApp('provider:\n model: test-model\n', true); const upload = await request(app) .post('/api/branding/upload') .send({ kind: 'logo', filename: 'my-logo.png', contentBase64: PNG_BASE64 }); expect(upload.status).toBe(200); expect(upload.body.ok).toBe(true); expect(upload.body.url).toMatch(/^\/branding\/logo-[a-f0-9]{12}\.png$/); // File actually written to branding dir expect(existsSync(join(dir, 'branding'))).toBe(true); const files = readdirSync(join(dir, 'branding')); expect(files.some(f => f.startsWith('logo-') && f.endsWith('.png'))).toBe(true); // Reflected in public GET const get = await request(app).get('/api/branding'); expect(get.body.logoUrl).toBe(upload.body.url); }); it('rejects invalid kind', async () => { const { app } = makeApp('provider:\n model: test-model\n', true); const res = await request(app) .post('/api/branding/upload') .send({ kind: 'banner', filename: 'x.png', contentBase64: PNG_BASE64 }); expect(res.status).toBe(400); }); it('rejects disallowed extension for favicon', async () => { const { app } = makeApp('provider:\n model: test-model\n', true); const res = await request(app) .post('/api/branding/upload') .send({ kind: 'favicon', filename: 'evil.gif', contentBase64: PNG_BASE64 }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/extension/); }); it('rejects files over size limit', async () => { const { app } = makeApp('provider:\n model: test-model\n', true); const big = Buffer.alloc(257 * 1024, 0x00).toString('base64'); // > 256KB for favicon const res = await request(app) .post('/api/branding/upload') .send({ kind: 'favicon', filename: 'big.png', contentBase64: big }); expect(res.status).toBe(413); }); it('DELETE clears the config field and removes the file', async () => { const { app, dir } = makeApp('provider:\n model: test-model\n', true); const upload = await request(app) .post('/api/branding/upload') .send({ kind: 'logo', filename: 'my.png', contentBase64: PNG_BASE64 }); expect(upload.status).toBe(200); const uploadedName = upload.body.url.replace('/branding/', ''); expect(existsSync(join(dir, 'branding', uploadedName))).toBe(true); const del = await request(app).delete('/api/branding/upload?kind=logo'); expect(del.status).toBe(200); expect(existsSync(join(dir, 'branding', uploadedName))).toBe(false); const get = await request(app).get('/api/branding'); expect(get.body.logoUrl).toBeNull(); }); it('replacing an existing asset cleans up the old file', async () => { const { app, dir } = makeApp('provider:\n model: test-model\n', true); const first = await request(app) .post('/api/branding/upload') .send({ kind: 'logo', filename: 'a.png', contentBase64: PNG_BASE64 }); const firstName = first.body.url.replace('/branding/', ''); const second = await request(app) .post('/api/branding/upload') .send({ kind: 'logo', filename: 'b.png', contentBase64: PNG_BASE64 }); const secondName = second.body.url.replace('/branding/', ''); expect(firstName).not.toBe(secondName); const files = readdirSync(join(dir, 'branding')).filter(f => f.startsWith('logo-')); expect(files).toEqual([secondName]); }); }); });