212 lines
7.6 KiB
TypeScript
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]);
|
|
});
|
|
});
|
|
});
|