106 lines
3.6 KiB
TypeScript
106 lines
3.6 KiB
TypeScript
import sharp from 'sharp';
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
BUILD_SOCIAL_CARD_HEIGHT,
|
|
BUILD_SOCIAL_CARD_WIDTH,
|
|
buildBuildSocialCardSvg,
|
|
renderBuildSocialCardPng,
|
|
type BuildSocialCardInput,
|
|
} from './build-social-card';
|
|
|
|
const input: BuildSocialCardInput = {
|
|
title: 'Vanguard Counter-Offensive',
|
|
author: 'Agent Kelso',
|
|
primaryWeapon: 'Police M4',
|
|
secondaryWeapon: 'Super 90',
|
|
firepower: 1280,
|
|
toughness: 940,
|
|
engineering: 615,
|
|
supportingAttributes: [
|
|
{ label: 'Critical Hit Chance', value: '18.5%' },
|
|
{ label: 'Headshot Damage', value: '42%' },
|
|
{ label: 'Armor', value: '12,400' },
|
|
],
|
|
visibility: 'public',
|
|
directUrl: 'shd.build/builds/vanguard-counter-offensive',
|
|
};
|
|
|
|
describe('build social card', () => {
|
|
it('escapes XML injection and keeps inputs out of markup and URLs', () => {
|
|
const attack = `</text><script href="https://attacker.invalid">alert('x') & more</script>`;
|
|
const svg = buildBuildSocialCardSvg({
|
|
...input,
|
|
title: attack,
|
|
author: `A\u0000gent <&> "one" 'two'`,
|
|
directUrl: `javascript:alert(1)\n${attack}`,
|
|
supportingAttributes: [{ label: attack, value: attack }],
|
|
});
|
|
|
|
expect(svg).not.toContain('<script');
|
|
expect(svg).not.toContain('href="https://attacker.invalid"');
|
|
expect(svg).not.toContain('\u0000');
|
|
expect(svg).toContain(
|
|
'</text><script href="https://attacker.invalid">',
|
|
);
|
|
expect(svg).toContain(
|
|
'A gent <&> "one" 'two'',
|
|
);
|
|
expect(svg).not.toMatch(/<[^>]+\s(?:href|xlink:href)\s*=/gu);
|
|
});
|
|
|
|
it('wraps and truncates long Unicode text without splitting graphemes', () => {
|
|
const family = '👨👩👧👦';
|
|
const svg = buildBuildSocialCardSvg({
|
|
...input,
|
|
title: `${family.repeat(31)} ${family.repeat(31)} ${family.repeat(31)}`,
|
|
author: `Zoë ${'e\u0301'.repeat(60)}`,
|
|
directUrl: `shd.build/builds/${'路'.repeat(130)}`,
|
|
});
|
|
|
|
const titleBlock =
|
|
svg.match(/<text class="title">(.*?)<\/text>/u)?.[1] ?? '';
|
|
expect(titleBlock.match(/<tspan/gu)).toHaveLength(2);
|
|
expect(titleBlock).toContain('…');
|
|
expect(titleBlock).not.toMatch(
|
|
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/gu,
|
|
);
|
|
expect(svg).toContain('é');
|
|
expect(svg).not.toContain('é');
|
|
expect(svg).toContain(`${'路'.repeat(20)}`);
|
|
expect(svg).toContain('…');
|
|
});
|
|
|
|
it('builds deterministic SVG with bounded supporting facts', () => {
|
|
const withExtraFacts = {
|
|
...input,
|
|
supportingAttributes: [
|
|
...input.supportingAttributes!,
|
|
{ label: 'Must not render', value: '999' },
|
|
],
|
|
};
|
|
const first = buildBuildSocialCardSvg(withExtraFacts);
|
|
const second = buildBuildSocialCardSvg(withExtraFacts);
|
|
|
|
expect(first).toBe(second);
|
|
expect(first).toContain('width="1200" height="630"');
|
|
expect(first).toContain('SHD</text><text');
|
|
expect(first).toContain('.build');
|
|
expect(first).toContain('font-family:"DejaVu Sans Condensed",sans-serif');
|
|
expect(first).toContain('SUPPORTING ATTRIBUTES / 03');
|
|
expect(first).not.toContain('Must not render');
|
|
});
|
|
|
|
it('renders a valid 1200x630 PNG through Sharp', async () => {
|
|
const png = await renderBuildSocialCardPng(input);
|
|
const metadata = await sharp(png).metadata();
|
|
|
|
expect(png.subarray(0, 8)).toEqual(
|
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
);
|
|
expect(metadata.format).toBe('png');
|
|
expect(metadata.width).toBe(BUILD_SOCIAL_CARD_WIDTH);
|
|
expect(metadata.height).toBe(BUILD_SOCIAL_CARD_HEIGHT);
|
|
expect(png.byteLength).toBeLessThan(1_000_000);
|
|
});
|
|
});
|