mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
1136 lines
29 KiB
TypeScript
1136 lines
29 KiB
TypeScript
// oxlint-disable eslint/no-script-url
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { parseInstalledTheme, validateThemeCss } from './customThemes';
|
|
import type { InstalledTheme } from './customThemes';
|
|
|
|
describe('validateThemeCss', () => {
|
|
describe('valid CSS', () => {
|
|
it('should accept valid :root with CSS variables', () => {
|
|
const css = `:root {
|
|
--color-primary: #007bff;
|
|
--color-secondary: #6c757d;
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
expect(validateThemeCss(css)).toBe(css.trim());
|
|
});
|
|
|
|
it('should accept :root with single CSS variable', () => {
|
|
const css = `:root {
|
|
--color-primary: #007bff;
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
|
|
it('should accept :root with empty content', () => {
|
|
const css = `:root {
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
|
|
it('should accept CSS with multi-line comments inside :root', () => {
|
|
const css = `:root {
|
|
/* Primary color */
|
|
--color-primary: #007bff;
|
|
/* Secondary color */
|
|
--color-secondary: #6c757d;
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
|
|
it('should accept CSS with whitespace variations', () => {
|
|
const css = `:root{--color-primary:#007bff;--color-secondary:#6c757d;}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
|
|
it('should accept CSS with multi-line format', () => {
|
|
const css = `:root {
|
|
--color-primary: #007bff;
|
|
--color-secondary: #6c757d;
|
|
--spacing-small: 4px;
|
|
--spacing-medium: 8px;
|
|
--spacing-large: 16px;
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('invalid CSS - missing :root', () => {
|
|
it.each([
|
|
{
|
|
description: 'CSS without :root wrapper',
|
|
css: `--color-primary: #007bff;`,
|
|
},
|
|
{
|
|
description: 'CSS with only CSS variables',
|
|
css: `--color-primary: #007bff;
|
|
--color-secondary: #6c757d;`,
|
|
},
|
|
])('should reject $description', ({ css }) => {
|
|
expect(() => validateThemeCss(css)).toThrow(
|
|
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('invalid CSS - additional selectors', () => {
|
|
it.each([
|
|
{
|
|
description: 'additional selectors outside :root',
|
|
css: `:root {
|
|
--color-primary: #007bff;
|
|
}
|
|
.some-class {
|
|
color: red;
|
|
}`,
|
|
expectedError:
|
|
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
|
},
|
|
{
|
|
description: 'multiple selectors',
|
|
css: `:root {
|
|
--color-primary: #007bff;
|
|
}
|
|
:root.light {
|
|
--color-primary: #ffffff;
|
|
}`,
|
|
expectedError:
|
|
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
|
},
|
|
{
|
|
description: 'media queries',
|
|
css: `:root {
|
|
--color-primary: #007bff;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--color-primary: #ffffff;
|
|
}
|
|
}`,
|
|
expectedError:
|
|
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
|
},
|
|
{
|
|
description: 'custom selector before :root',
|
|
css: `.before {
|
|
color: red;
|
|
}
|
|
:root {
|
|
--color-primary: #007bff;
|
|
}`,
|
|
expectedError:
|
|
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
|
},
|
|
])('should reject CSS with $description', ({ css, expectedError }) => {
|
|
expect(() => validateThemeCss(css)).toThrow(expectedError);
|
|
});
|
|
|
|
it('should reject CSS with nested selectors inside :root', () => {
|
|
const css = `:root {
|
|
--color-primary: #007bff;
|
|
.some-class {
|
|
color: red;
|
|
}
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).toThrow(
|
|
'Theme CSS contains nested blocks or additional selectors. Only CSS variable declarations are allowed inside :root { ... }.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('invalid CSS - forbidden at-rules', () => {
|
|
it.each([
|
|
{
|
|
description: '@import',
|
|
css: `:root {
|
|
@import url('other.css');
|
|
--color-primary: #007bff;
|
|
}`,
|
|
},
|
|
{
|
|
description: '@media',
|
|
css: `:root {
|
|
--color-primary: #007bff;
|
|
@media (max-width: 600px) {
|
|
--color-primary: #ff0000;
|
|
}
|
|
}`,
|
|
},
|
|
{
|
|
description: '@keyframes',
|
|
css: `:root {
|
|
--color-primary: #007bff;
|
|
@keyframes fade {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
}`,
|
|
},
|
|
])('should reject CSS with $description', ({ css }) => {
|
|
expect(() => validateThemeCss(css)).toThrow(/forbidden at-rules/);
|
|
});
|
|
});
|
|
|
|
describe('invalid CSS - non-CSS-variable properties', () => {
|
|
it.each([
|
|
{
|
|
description: 'regular CSS properties',
|
|
property: 'color',
|
|
css: `:root {
|
|
--color-primary: #007bff;
|
|
color: red;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'background property',
|
|
property: 'background',
|
|
css: `:root {
|
|
background: #ffffff;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'margin property',
|
|
property: 'margin',
|
|
css: `:root {
|
|
--color-primary: #007bff;
|
|
margin: 0;
|
|
}`,
|
|
},
|
|
])('should reject CSS with $description', ({ property, css }) => {
|
|
expect(() => validateThemeCss(css)).toThrow(
|
|
`Invalid property "${property}". Only CSS custom properties (starting with --) are allowed.`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('invalid CSS - malformed declarations', () => {
|
|
it('should reject CSS with missing colon', () => {
|
|
const css = `:root {
|
|
--color-primary #007bff;
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).toThrow(
|
|
'Invalid CSS declaration: "--color-primary #007bff"',
|
|
);
|
|
});
|
|
|
|
it('should accept CSS with missing semicolon on the last declaration', () => {
|
|
const css = `:root {
|
|
--color-primary: #007bff;
|
|
--color-secondary: #6c757d
|
|
}`;
|
|
|
|
// CSS doesn't require a semicolon for the last declaration, so this should pass
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle CSS with only comments', () => {
|
|
const css = `:root {
|
|
/* Comment only */
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
|
|
it('should preserve original CSS format in return value', () => {
|
|
const css = `:root {
|
|
--color-primary: #007bff;
|
|
--color-secondary: #6c757d;
|
|
}`;
|
|
|
|
const result = validateThemeCss(css);
|
|
expect(result).toBe(css.trim());
|
|
});
|
|
|
|
it('should handle CSS with extra whitespace', () => {
|
|
const css = ` :root {
|
|
--color-primary: #007bff;
|
|
} `;
|
|
|
|
const result = validateThemeCss(css);
|
|
expect(result).toBe(css.trim());
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
description: 'empty string',
|
|
css: '',
|
|
},
|
|
{
|
|
description: 'whitespace only',
|
|
css: ' ',
|
|
},
|
|
])('should reject $description', ({ css }) => {
|
|
expect(() => validateThemeCss(css)).toThrow(
|
|
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('invalid CSS - dangerous property values (XSS prevention)', () => {
|
|
describe('URL functions - should reject all url() functions', () => {
|
|
it.each([
|
|
{
|
|
description: 'javascript: protocol in url()',
|
|
css: `:root {
|
|
--bg-image: url("javascript:alert(1)");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'javascript: protocol without quotes',
|
|
css: `:root {
|
|
--bg-image: url(javascript:alert(1));
|
|
}`,
|
|
},
|
|
{
|
|
description: 'javascript: protocol in single quotes',
|
|
css: `:root {
|
|
--bg-image: url('javascript:alert(document.cookie)');
|
|
}`,
|
|
},
|
|
{
|
|
description: 'javascript: protocol with case variations',
|
|
css: `:root {
|
|
--bg-image: url("JaVaScRiPt:alert(1)");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'javascript: protocol with encoded characters',
|
|
css: `:root {
|
|
--bg-image: url("javascript%3Aalert(1)");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'data: URL containing JavaScript',
|
|
css: `:root {
|
|
--bg-image: url("data:text/html,<script>alert(1)</script>");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'data: URL containing iframe',
|
|
css: `:root {
|
|
--bg-image: url("data:text/html,<iframe src=javascript:alert(1)></iframe>");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'vbscript: protocol',
|
|
css: `:root {
|
|
--bg-image: url("vbscript:msgbox(1)");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'external HTTP URL',
|
|
css: `:root {
|
|
--bg-image: url("http://example.com/image.png");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'external HTTPS URL',
|
|
css: `:root {
|
|
--bg-image: url("https://example.com/image.png");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'protocol-relative URL',
|
|
css: `:root {
|
|
--bg-image: url("//example.com/image.png");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'local relative URL',
|
|
css: `:root {
|
|
--bg-image: url("./image.png");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'parent directory relative URL',
|
|
css: `:root {
|
|
--bg-image: url("../images/bg.png");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'simple filename',
|
|
css: `:root {
|
|
--bg-image: url("image.png");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'absolute path',
|
|
css: `:root {
|
|
--bg-image: url("/images/bg.png");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'unquoted URL',
|
|
css: `:root {
|
|
--bg-image: url(image.png);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'single-quoted local URL',
|
|
css: `:root {
|
|
--bg-image: url('./image.png');
|
|
}`,
|
|
},
|
|
{
|
|
description: 'data URL',
|
|
css: `:root {
|
|
--bg-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'JavaScript URL in multiline format',
|
|
css: `:root {
|
|
--bg-image: url(
|
|
"javascript:alert(1)"
|
|
);
|
|
}`,
|
|
},
|
|
])('should reject CSS with $description', ({ css }) => {
|
|
expect(() => validateThemeCss(css)).toThrow();
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
description: 'javascript: protocol in value without url()',
|
|
css: `:root {
|
|
--color-primary: javascript:alert(1);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'expression() function (IE XSS)',
|
|
css: `:root {
|
|
--bg-image: expression(alert(1));
|
|
}`,
|
|
},
|
|
{
|
|
description: '-moz-binding (Firefox XSS)',
|
|
css: `:root {
|
|
--bg-image: -moz-binding(url("data:text/xml;charset=utf-8,<x></x>"));
|
|
}`,
|
|
},
|
|
{
|
|
description: 'behavior property',
|
|
css: `:root {
|
|
--behavior: url("data:text/xml;charset=utf-8,<x></x>");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'multiple dangerous patterns',
|
|
css: `:root {
|
|
--bg-image: url("javascript:alert(1)");
|
|
--color-primary: #007bff;
|
|
--bg-image2: url("http://evil.com/image.png");
|
|
}`,
|
|
},
|
|
{
|
|
description: 'encoded JavaScript URL',
|
|
css: `:root {
|
|
--bg-image: url("\\6a\\61\\76\\61\\73\\63\\72\\69\\70\\74\\3a\\61\\6c\\65\\72\\74\\28\\31\\29");
|
|
}`,
|
|
},
|
|
{
|
|
description: '<script> tag in value',
|
|
css: `:root {
|
|
--color-primary: <script>alert(1)</script>;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'onerror event handler in value',
|
|
css: `:root {
|
|
--bg-image: url("x" onerror="alert(1)");
|
|
}`,
|
|
},
|
|
])('should reject CSS with $description', ({ css }) => {
|
|
expect(() => validateThemeCss(css)).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('invalid CSS - function calls (security)', () => {
|
|
describe('var() function - should accept var(--name) only (no fallbacks)', () => {
|
|
it.each([
|
|
{
|
|
description: 'simple var() call',
|
|
css: `:root {
|
|
--color-primary: var(--other-var);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'var() with whitespace',
|
|
css: `:root {
|
|
--color-primary: var( --other-var );
|
|
}`,
|
|
},
|
|
{
|
|
description: 'var() in uppercase',
|
|
css: `:root {
|
|
--color-primary: VAR(--other-var);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'var() with mixed case',
|
|
css: `:root {
|
|
--color-primary: VaR(--other-var);
|
|
}`,
|
|
},
|
|
])('should accept CSS with $description', ({ css }) => {
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('var() function - should reject var() with fallback or invalid form', () => {
|
|
it.each([
|
|
{
|
|
description: 'var() with fallback',
|
|
css: `:root {
|
|
--color-primary: var(--other-var, #007bff);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'var() with invalid variable name (no --)',
|
|
css: `:root {
|
|
--color-primary: var(not-a-custom-prop);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'var() with empty variable name',
|
|
css: `:root {
|
|
--color-primary: var(--);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'var() with unbalanced parentheses',
|
|
css: `:root {
|
|
--color-primary: var(--other-var;
|
|
}`,
|
|
},
|
|
])('should reject CSS with $description', ({ css }) => {
|
|
expect(() => validateThemeCss(css)).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('other function calls - should reject all except rgb/rgba/hsl/hsla and var()', () => {
|
|
it.each([
|
|
{
|
|
description: 'calc() function',
|
|
css: `:root {
|
|
--spacing: calc(10px + 5px);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'min() function',
|
|
css: `:root {
|
|
--width: min(100px, 50%);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'max() function',
|
|
css: `:root {
|
|
--width: max(100px, 50%);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'clamp() function',
|
|
css: `:root {
|
|
--width: clamp(100px, 50%, 200px);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'linear-gradient() function',
|
|
css: `:root {
|
|
--bg: linear-gradient(red, blue);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'rgba() with calc inside (nested function)',
|
|
css: `:root {
|
|
--color: rgba(calc(255), 0, 0, 1);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'attr() function',
|
|
css: `:root {
|
|
--content: attr(data-value);
|
|
}`,
|
|
},
|
|
{
|
|
description: 'counter() function',
|
|
css: `:root {
|
|
--counter: counter(my-counter);
|
|
}`,
|
|
},
|
|
{
|
|
description:
|
|
'url() function (already tested but included for completeness)',
|
|
css: `:root {
|
|
--bg: url("image.png");
|
|
}`,
|
|
},
|
|
])('should reject CSS with $description', ({ css }) => {
|
|
expect(() => validateThemeCss(css)).toThrow();
|
|
});
|
|
});
|
|
|
|
it('should allow rgb() function', () => {
|
|
const css = `:root {
|
|
--color-primary: rgb(0, 123, 255);
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
|
|
it('should allow rgba() function', () => {
|
|
const css = `:root {
|
|
--color-primary: rgba(0, 123, 255, 0.5);
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
|
|
it('should allow hsl() function', () => {
|
|
const css = `:root {
|
|
--color-primary: hsl(210, 100%, 50%);
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
|
|
it('should allow hsla() function', () => {
|
|
const css = `:root {
|
|
--color-primary: hsla(210, 100%, 50%, 0.5);
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('valid CSS - CSS keywords', () => {
|
|
it.each([
|
|
{
|
|
description: 'transparent keyword',
|
|
css: `:root {
|
|
--color-primary: transparent;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'initial keyword',
|
|
css: `:root {
|
|
--color-primary: initial;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'inherit keyword',
|
|
css: `:root {
|
|
--color-primary: inherit;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'auto keyword',
|
|
css: `:root {
|
|
--spacing: auto;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'unset keyword',
|
|
css: `:root {
|
|
--color-primary: unset;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'revert keyword',
|
|
css: `:root {
|
|
--color-primary: revert;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'none keyword',
|
|
css: `:root {
|
|
--border: none;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'normal keyword',
|
|
css: `:root {
|
|
--font-weight: normal;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'keywords with mixed case',
|
|
css: `:root {
|
|
--color-primary: TRANSPARENT;
|
|
--color-secondary: InHeRiT;
|
|
--spacing: AuTo;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'multiple keywords in different properties',
|
|
css: `:root {
|
|
--color-primary: transparent;
|
|
--color-secondary: initial;
|
|
--spacing: auto;
|
|
--border: none;
|
|
}`,
|
|
},
|
|
])('should allow CSS with $description', ({ css }) => {
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('invalid CSS - additional at-rules', () => {
|
|
it.each([
|
|
{
|
|
description: '@namespace',
|
|
css: `:root {
|
|
@namespace url("http://www.w3.org/1999/xhtml");
|
|
--color-primary: #007bff;
|
|
}`,
|
|
},
|
|
{
|
|
description: '@page',
|
|
css: `:root {
|
|
@page {
|
|
margin: 1cm;
|
|
}
|
|
--color-primary: #007bff;
|
|
}`,
|
|
},
|
|
{
|
|
description: '@layer',
|
|
css: `:root {
|
|
@layer base {
|
|
--color-primary: #007bff;
|
|
}
|
|
}`,
|
|
},
|
|
{
|
|
description: '@container',
|
|
css: `:root {
|
|
@container (max-width: 600px) {
|
|
--color-primary: #007bff;
|
|
}
|
|
}`,
|
|
},
|
|
{
|
|
description: '@scope',
|
|
css: `:root {
|
|
@scope (.parent) {
|
|
--color-primary: #007bff;
|
|
}
|
|
}`,
|
|
},
|
|
{
|
|
description: '@starting-style',
|
|
css: `:root {
|
|
@starting-style {
|
|
--color-primary: #007bff;
|
|
}
|
|
}`,
|
|
},
|
|
{
|
|
description: '@property',
|
|
css: `:root {
|
|
@property --custom-color {
|
|
syntax: '<color>';
|
|
inherits: false;
|
|
}
|
|
--color-primary: #007bff;
|
|
}`,
|
|
},
|
|
{
|
|
description: '@font-palette-values',
|
|
css: `:root {
|
|
@font-palette-values {
|
|
--color-primary: #007bff;
|
|
}
|
|
}`,
|
|
},
|
|
])('should reject CSS with $description', ({ css }) => {
|
|
expect(() => validateThemeCss(css)).toThrow(/forbidden at-rules/);
|
|
});
|
|
});
|
|
|
|
describe('invalid CSS - parsing edge cases and potential vulnerabilities', () => {
|
|
it.each([
|
|
{
|
|
description: 'quoted strings in value (should be rejected)',
|
|
css: `:root {
|
|
--color-primary: "red";
|
|
}`,
|
|
},
|
|
{
|
|
description: 'single-quoted strings in value (should be rejected)',
|
|
css: `:root {
|
|
--color-primary: 'red';
|
|
}`,
|
|
},
|
|
{
|
|
description: 'value with multiple spaces',
|
|
css: `:root {
|
|
--spacing: 10px 20px;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'value with comma-separated values',
|
|
css: `:root {
|
|
--font-family: Arial, sans-serif;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'property name with invalid characters',
|
|
css: `:root {
|
|
--color[primary]: #007bff;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'property name ending with dash',
|
|
css: `:root {
|
|
--color-primary-: #007bff;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'value starting with special character',
|
|
css: `:root {
|
|
--value: !important;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'value with backslash escapes',
|
|
css: `:root {
|
|
--value: \\00;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'unicode escape sequences',
|
|
css: `:root {
|
|
--value: \\6A\\61\\76\\61\\73\\63\\72\\69\\70\\74;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'value with newline characters',
|
|
css: `:root {
|
|
--value: 10px\n20px;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'value with tab characters',
|
|
css: `:root {
|
|
--value: 10px\t20px;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'empty property name',
|
|
css: `:root {
|
|
--: #007bff;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'property with only dashes',
|
|
css: `:root {
|
|
----: #007bff;
|
|
}`,
|
|
},
|
|
{
|
|
description: 'multiple colons in declaration',
|
|
css: `:root {
|
|
--color-primary: rgb(255: 0: 0);
|
|
}`,
|
|
},
|
|
])('should reject CSS with $description', ({ css }) => {
|
|
expect(() => validateThemeCss(css)).toThrow();
|
|
});
|
|
|
|
it('should handle CSS with valid values containing spaces in allowed contexts', () => {
|
|
// RGB values with spaces are allowed
|
|
const css = `:root {
|
|
--color-primary: rgb( 0 , 123 , 255 );
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).not.toThrow();
|
|
});
|
|
|
|
it('should reject CSS with value containing parentheses outside allowed functions', () => {
|
|
const css = `:root {
|
|
--value: (something);
|
|
}`;
|
|
|
|
expect(() => validateThemeCss(css)).toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('parseInstalledTheme', () => {
|
|
describe('valid theme JSON', () => {
|
|
it('should parse valid theme with all required fields', () => {
|
|
const theme: InstalledTheme = {
|
|
id: 'theme-abc123',
|
|
name: 'My Theme',
|
|
repo: 'owner/repo',
|
|
cssContent: ':root { --color-primary: #007bff; }',
|
|
};
|
|
const json = JSON.stringify(theme);
|
|
|
|
const result = parseInstalledTheme(json);
|
|
|
|
expect(result).toEqual(theme);
|
|
expect(result?.id).toBe('theme-abc123');
|
|
expect(result?.name).toBe('My Theme');
|
|
expect(result?.cssContent).toBe(':root { --color-primary: #007bff; }');
|
|
});
|
|
|
|
it('should parse theme with additional fields', () => {
|
|
const theme = {
|
|
id: 'theme-xyz789',
|
|
name: 'Extended Theme',
|
|
repo: 'owner/repo',
|
|
cssContent: ':root { --color-primary: #007bff; }',
|
|
extraField: 'should be ignored',
|
|
version: 1,
|
|
};
|
|
const json = JSON.stringify(theme);
|
|
|
|
const result = parseInstalledTheme(json);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.id).toBe('theme-xyz789');
|
|
expect(result?.name).toBe('Extended Theme');
|
|
expect(result?.cssContent).toBe(':root { --color-primary: #007bff; }');
|
|
});
|
|
|
|
it('should parse theme with empty string fields', () => {
|
|
const theme: InstalledTheme = {
|
|
id: '',
|
|
name: '',
|
|
repo: 'owner/repo',
|
|
cssContent: '',
|
|
};
|
|
const json = JSON.stringify(theme);
|
|
|
|
const result = parseInstalledTheme(json);
|
|
|
|
expect(result).toEqual(theme);
|
|
expect(result?.id).toBe('');
|
|
expect(result?.name).toBe('');
|
|
expect(result?.cssContent).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('missing required fields', () => {
|
|
it.each([
|
|
{
|
|
description: 'id is missing',
|
|
theme: {
|
|
name: 'My Theme',
|
|
repo: 'owner/repo',
|
|
cssContent: ':root { --color-primary: #007bff; }',
|
|
},
|
|
},
|
|
{
|
|
description: 'name is missing',
|
|
theme: {
|
|
id: 'theme-abc123',
|
|
repo: 'owner/repo',
|
|
cssContent: ':root { --color-primary: #007bff; }',
|
|
},
|
|
},
|
|
{
|
|
description: 'cssContent is missing',
|
|
theme: {
|
|
id: 'theme-abc123',
|
|
name: 'My Theme',
|
|
repo: 'owner/repo',
|
|
},
|
|
},
|
|
])('should return null when $description', ({ theme }) => {
|
|
const json = JSON.stringify(theme);
|
|
const result = parseInstalledTheme(json);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('invalid field types', () => {
|
|
it.each([
|
|
{
|
|
description: 'id is not a string',
|
|
theme: {
|
|
id: 123,
|
|
name: 'My Theme',
|
|
repo: 'owner/repo',
|
|
cssContent: ':root { --color-primary: #007bff; }',
|
|
},
|
|
},
|
|
{
|
|
description: 'id is null',
|
|
theme: {
|
|
id: null,
|
|
name: 'My Theme',
|
|
repo: 'owner/repo',
|
|
cssContent: ':root { --color-primary: #007bff; }',
|
|
},
|
|
},
|
|
{
|
|
description: 'id is an object',
|
|
theme: {
|
|
id: { value: 'theme-abc123' },
|
|
name: 'My Theme',
|
|
repo: 'owner/repo',
|
|
cssContent: ':root { --color-primary: #007bff; }',
|
|
},
|
|
},
|
|
{
|
|
description: 'name is not a string',
|
|
theme: {
|
|
id: 'theme-abc123',
|
|
name: 456,
|
|
repo: 'owner/repo',
|
|
cssContent: ':root { --color-primary: #007bff; }',
|
|
},
|
|
},
|
|
{
|
|
description: 'name is null',
|
|
theme: {
|
|
id: 'theme-abc123',
|
|
name: null,
|
|
repo: 'owner/repo',
|
|
cssContent: ':root { --color-primary: #007bff; }',
|
|
},
|
|
},
|
|
{
|
|
description: 'cssContent is not a string',
|
|
theme: {
|
|
id: 'theme-abc123',
|
|
name: 'My Theme',
|
|
repo: 'owner/repo',
|
|
cssContent: { content: ':root { --color-primary: #007bff; }' },
|
|
},
|
|
},
|
|
{
|
|
description: 'cssContent is null',
|
|
theme: {
|
|
id: 'theme-abc123',
|
|
name: 'My Theme',
|
|
repo: 'owner/repo',
|
|
cssContent: null,
|
|
},
|
|
},
|
|
{
|
|
description: 'cssContent is a number',
|
|
theme: {
|
|
id: 'theme-abc123',
|
|
name: 'My Theme',
|
|
repo: 'owner/repo',
|
|
cssContent: 12345,
|
|
},
|
|
},
|
|
])('should return null when $description', ({ theme }) => {
|
|
const json = JSON.stringify(theme);
|
|
const result = parseInstalledTheme(json);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('invalid JSON structure', () => {
|
|
it.each([
|
|
{
|
|
description: 'parsed value is null',
|
|
json: JSON.stringify(null),
|
|
},
|
|
{
|
|
description: 'parsed value is not an object',
|
|
json: JSON.stringify('not an object'),
|
|
},
|
|
{
|
|
description: 'parsed value is an array',
|
|
json: JSON.stringify([
|
|
{
|
|
id: 'theme-abc123',
|
|
name: 'My Theme',
|
|
repo: 'owner/repo',
|
|
cssContent: ':root { --color-primary: #007bff; }',
|
|
},
|
|
]),
|
|
},
|
|
{
|
|
description: 'parsed value is a number',
|
|
json: JSON.stringify(42),
|
|
},
|
|
{
|
|
description: 'parsed value is a boolean',
|
|
json: JSON.stringify(true),
|
|
},
|
|
])('should return null when $description', ({ json }) => {
|
|
const result = parseInstalledTheme(json);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('invalid JSON string', () => {
|
|
it.each([
|
|
{
|
|
description: 'malformed JSON',
|
|
json: '{ invalid json }',
|
|
},
|
|
{
|
|
description: 'incomplete JSON',
|
|
json: '{"id": "theme-abc123", "name":',
|
|
},
|
|
{
|
|
description: 'empty object',
|
|
json: '{}',
|
|
},
|
|
])('should return null for $description', ({ json }) => {
|
|
const result = parseInstalledTheme(json);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it.each([
|
|
{
|
|
description: 'undefined input',
|
|
input: undefined as undefined,
|
|
},
|
|
{
|
|
description: 'empty string',
|
|
input: '',
|
|
},
|
|
{
|
|
description: 'whitespace-only string',
|
|
input: ' ',
|
|
},
|
|
{
|
|
description:
|
|
'empty array (parsed value should be distinguished from object)',
|
|
input: '[]',
|
|
},
|
|
])('should return null for $description', ({ input }) => {
|
|
const result = parseInstalledTheme(input);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should handle theme with all fields including optional repo', () => {
|
|
const theme: InstalledTheme = {
|
|
id: 'theme-full',
|
|
name: 'Full Theme',
|
|
repo: 'owner/repo',
|
|
cssContent: ':root { --color-primary: #007bff; }',
|
|
};
|
|
const json = JSON.stringify(theme);
|
|
|
|
const result = parseInstalledTheme(json);
|
|
|
|
expect(result).toEqual(theme);
|
|
expect(result?.repo).toBe('owner/repo');
|
|
});
|
|
});
|
|
});
|