maestro/src/bridge/branding-api.test.ts
2026-06-03 05:08:00 +00:00

212 lines
7.6 KiB
TypeScript

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]);
});
});
});