feat(branding): port logo upload pipeline for internal PDFs
Phase 1 / commit 2 of 14 — adds the admin-facing logo upload that the
brand-kit Header pulls in for every internal-only PDF.
Server pipeline (src/lib/services/logo.service.ts):
- magic-byte format check via sharp metadata
- rejects animated/multi-frame inputs
- SVGs sanitized via svgo preset-default + post-pass regex check
(rejects <script>, on*=, javascript:, external href, <foreignObject>),
then rasterized to PNG at 300 DPI
- HEIC/HEIF/AVIF/WEBP all auto-converted to PNG by sharp
- optional crop coords applied server-side (bounds-checked first)
- auto-trim near-white borders
- resize so longest edge <= 1200px, sRGB, palette-PNG
- rejects undersized output (< 200px any side) or > 1MB
- atomic system_settings upsert; soft-archives prior file row + storage object
API:
GET /api/v1/admin/branding/logo current logo metadata
POST /api/v1/admin/branding/logo multipart upload + crop
DELETE /api/v1/admin/branding/logo clear; future PDFs fall back
to port-name text header
GET /api/v1/admin/branding/logo/sample-pdf renders branding-sample.tsx
with the current logo so
admins can spot-check
letterboxing in real shell
UI:
src/components/admin/branding/pdf-logo-uploader.tsx
- react-image-crop with Wide 3:1 / Square 1:1 / Freeform aspect toggle
- file picker accepts PNG/JPEG/WEBP/SVG/HEIC/HEIF/AVIF (up to 5 MB)
- dark-band preview swatch shows how the logo lands in the header
- post-upload warnings panel surfaces every server-side normalization
(resized, trimmed, JPEG no-alpha warning, SVG rasterized, etc.)
- "Test with sample PDF" button streams a real PDF for spot-check
- "Remove" tears down the file + storage object + setting
Wired into the existing /admin/branding settings page beneath the
Identity and Email-branding cards.
Audit:
Two new AuditAction enum values added: branding.logo.uploaded and
branding.logo.archived. Captured per upload + per archived prior logo.
Tests:
tests/unit/logo-service.test.ts (11 tests): sharp pipeline happy path,
undersized rejection, empty/oversized rejection, non-image rejection,
out-of-bounds crop rejection, in-bounds crop, SVG rasterization, SVG
with embedded script rejection, SVG with external href rejection,
JPEG-with-no-alpha warning collection.
1308/1308 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
106
tests/unit/logo-service.test.ts
Normal file
106
tests/unit/logo-service.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Pure-function tests for the logo sharp pipeline (no DB / storage).
|
||||
* The `setPortLogo` write-path is exercised by integration tests + Playwright.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { processLogoUpload } from '@/lib/services/logo.service';
|
||||
|
||||
async function makePng(
|
||||
w: number,
|
||||
h: number,
|
||||
color: { r: number; g: number; b: number; alpha: number } = { r: 255, g: 0, b: 0, alpha: 1 },
|
||||
) {
|
||||
return sharp({ create: { width: w, height: h, channels: 4, background: color } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
describe('processLogoUpload — sharp pipeline', () => {
|
||||
it('accepts a healthy PNG with alpha and resizes if needed', async () => {
|
||||
const buf = await makePng(2400, 800);
|
||||
const result = await processLogoUpload(buf);
|
||||
expect(result.originalFormat).toBe('png');
|
||||
expect(result.finalDimensions.width).toBeLessThanOrEqual(1200);
|
||||
expect(result.finalDimensions.height).toBeLessThanOrEqual(1200);
|
||||
expect(result.pngBuffer.subarray(1, 4).toString('ascii')).toBe('PNG');
|
||||
expect(result.warnings).toContain('trimmed');
|
||||
expect(result.warnings).toContain('resized');
|
||||
});
|
||||
|
||||
it('rejects undersized images', async () => {
|
||||
const buf = await makePng(100, 100);
|
||||
await expect(processLogoUpload(buf)).rejects.toThrow(/too small/i);
|
||||
});
|
||||
|
||||
it('rejects empty buffers', async () => {
|
||||
await expect(processLogoUpload(Buffer.alloc(0))).rejects.toThrow(/empty/i);
|
||||
});
|
||||
|
||||
it('rejects buffers that exceed the raw size cap', async () => {
|
||||
// 6 MB of zero bytes — fails the raw size cap before sharp parses.
|
||||
const buf = Buffer.alloc(6 * 1024 * 1024);
|
||||
await expect(processLogoUpload(buf)).rejects.toThrow(/MB/i);
|
||||
});
|
||||
|
||||
it('rejects non-image bytes', async () => {
|
||||
const buf = Buffer.from('this is not an image at all');
|
||||
await expect(processLogoUpload(buf)).rejects.toThrow(/supported image format/i);
|
||||
});
|
||||
|
||||
it('rejects out-of-bounds crop coordinates', async () => {
|
||||
const buf = await makePng(800, 800);
|
||||
await expect(processLogoUpload(buf, { x: 0, y: 0, width: 1000, height: 1000 })).rejects.toThrow(
|
||||
/out of image bounds/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an in-bounds crop', async () => {
|
||||
const buf = await makePng(800, 800);
|
||||
const result = await processLogoUpload(buf, { x: 100, y: 100, width: 500, height: 500 });
|
||||
expect(result.finalDimensions.width).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rasterizes SVG input to PNG', async () => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
|
||||
<rect width="400" height="400" fill="#1d4ed8"/>
|
||||
<circle cx="200" cy="200" r="100" fill="#ffffff"/>
|
||||
</svg>`;
|
||||
const buf = Buffer.from(svg, 'utf8');
|
||||
const result = await processLogoUpload(buf);
|
||||
expect(result.originalFormat).toBe('svg');
|
||||
expect(result.warnings).toContain('svg-rasterized');
|
||||
// Output is PNG even though input was SVG.
|
||||
expect(result.pngBuffer.subarray(1, 4).toString('ascii')).toBe('PNG');
|
||||
});
|
||||
|
||||
it('rejects SVG with embedded script', async () => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">
|
||||
<script>alert('xss')</script>
|
||||
<rect width="400" height="400" fill="#1d4ed8"/>
|
||||
</svg>`;
|
||||
const buf = Buffer.from(svg, 'utf8');
|
||||
await expect(processLogoUpload(buf)).rejects.toThrow(/disallowed nodes/i);
|
||||
});
|
||||
|
||||
it('rejects SVG with external href', async () => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">
|
||||
<image href="https://evil.example.com/x.png" width="400" height="400"/>
|
||||
</svg>`;
|
||||
const buf = Buffer.from(svg, 'utf8');
|
||||
await expect(processLogoUpload(buf)).rejects.toThrow(/disallowed nodes/i);
|
||||
});
|
||||
|
||||
it('flags JPEG sources with no alpha', async () => {
|
||||
const buf = await sharp({
|
||||
create: { width: 1200, height: 1200, channels: 3, background: { r: 200, g: 50, b: 50 } },
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
const result = await processLogoUpload(buf);
|
||||
expect(result.warnings).toContain('jpeg-source');
|
||||
expect(result.warnings).toContain('no-alpha');
|
||||
});
|
||||
});
|
||||
@@ -67,14 +67,10 @@ describe('pdf brand kit', () => {
|
||||
</Section>
|
||||
<Section title="Table">
|
||||
<Badge text="Active" tone="success" />
|
||||
<DataTable
|
||||
<DataTable<{ name: string; score: number }>
|
||||
columns={[
|
||||
{ header: 'Name', render: (r: { name: string }) => r.name },
|
||||
{
|
||||
header: 'Score',
|
||||
align: 'right',
|
||||
render: (r: { score: number }) => String(r.score),
|
||||
},
|
||||
{ header: 'Name', render: (r) => r.name },
|
||||
{ header: 'Score', align: 'right', render: (r) => String(r.score) },
|
||||
]}
|
||||
rows={[
|
||||
{ name: 'Alpha', score: 1 },
|
||||
|
||||
Reference in New Issue
Block a user