Raise coverage from 2.6% to 68% statements by adding 19 test files (~480 tests) covering every module in test-strategy-report.md. No production code changed — tests reach private logic via (client as any), mock HTTP with axios-mock-adapter on the real axios instance (interceptors intact), and mock the Hocuspocus provider with vi.mock + real yjs + fake timers. Coverage: auth-utils/filters/page-lock/json-edit 100%, diff 99%, node-ops 96%, transforms 95%, collaboration 86%, layout 91%, client.ts 41% (transport). - node-ops/transforms/json-edit/page-lock/filters: pure tree/text ops, immutability + clone guarantees, throw-vs-noop contracts - markdown-converter + markdown-document envelope + fast-check round-trip property test - diff, docmost-schema (sanitizeCssColor/clampCalloutType security guards) - collaboration: pure (buildCollabWsUrl/buildYDoc) + write-path (mutatePageContent read-transform-write, false-success suppression) - client.ts: isSafeUrl/validateDoc* XSS guards, vm-sandbox, REST pagination, 401 re-auth interceptor, login dedup, uploadImage/createPage multipart guards - collectRecentSince edge cases; loadSettingsOrExit invalid-value branch - env-gated E2E skeleton (DOCMOST_E2E) Two genuine markdown round-trip non-idempotency bugs are documented as it.fails (code-mark excludes other marks; block-image injects a blank line). Latent: isSafeUrl allows file:// on link context. Adds dev-deps: fast-check, @vitest/coverage-v8, axios-mock-adapter; adds the "coverage" npm script.
185 lines
7.5 KiB
TypeScript
185 lines
7.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
sanitizeCssColor,
|
|
clampCalloutType,
|
|
} from '../packages/docmost-client/src/lib/docmost-schema.js';
|
|
|
|
/**
|
|
* Unit tests for the two pure guard functions of the Docmost schema.
|
|
*
|
|
* `sanitizeCssColor` is a SECURITY boundary: its return value is interpolated
|
|
* straight into an inline `style="..."` attribute by Docmost, so any value it
|
|
* accepts must be incapable of breaking out of that attribute. These tests pin
|
|
* both the accept set (well-formed CSS <color> syntax) and, more importantly,
|
|
* the reject set (anything containing the characters an attacker would need to
|
|
* inject: quotes, angle brackets, semicolons, colons, parentheses with letters,
|
|
* or a `url(...)` / `expression(...)` payload).
|
|
*/
|
|
|
|
describe('sanitizeCssColor', () => {
|
|
describe('accepts well-formed CSS colors', () => {
|
|
it('accepts simple named colors (letters only), returned verbatim', () => {
|
|
expect(sanitizeCssColor('red')).toBe('red');
|
|
expect(sanitizeCssColor('rebeccapurple')).toBe('rebeccapurple');
|
|
// Case is preserved (the regex is case-insensitive for letters).
|
|
expect(sanitizeCssColor('Black')).toBe('Black');
|
|
expect(sanitizeCssColor('transparent')).toBe('transparent');
|
|
});
|
|
|
|
it('accepts #rgb and #rgba short hex', () => {
|
|
expect(sanitizeCssColor('#abc')).toBe('#abc');
|
|
expect(sanitizeCssColor('#abcd')).toBe('#abcd');
|
|
expect(sanitizeCssColor('#FFF')).toBe('#FFF');
|
|
});
|
|
|
|
it('accepts #rrggbb and #rrggbbaa long hex', () => {
|
|
expect(sanitizeCssColor('#aabbcc')).toBe('#aabbcc');
|
|
expect(sanitizeCssColor('#AABBCCDD')).toBe('#AABBCCDD');
|
|
expect(sanitizeCssColor('#0f0f0f')).toBe('#0f0f0f');
|
|
});
|
|
|
|
it('accepts rgb()/rgba() functional notation', () => {
|
|
expect(sanitizeCssColor('rgb(255, 0, 0)')).toBe('rgb(255, 0, 0)');
|
|
expect(sanitizeCssColor('rgba(0, 128, 255, 0.5)')).toBe(
|
|
'rgba(0, 128, 255, 0.5)',
|
|
);
|
|
// CSS Color 4 space-separated + slash-alpha syntax (digits, %, /, spaces).
|
|
expect(sanitizeCssColor('rgb(100% 0% 0% / 50%)')).toBe(
|
|
'rgb(100% 0% 0% / 50%)',
|
|
);
|
|
});
|
|
|
|
it('accepts hsl()/hsla() functional notation', () => {
|
|
expect(sanitizeCssColor('hsl(120, 50%, 50%)')).toBe('hsl(120, 50%, 50%)');
|
|
expect(sanitizeCssColor('hsla(240, 100%, 50%, 0.3)')).toBe(
|
|
'hsla(240, 100%, 50%, 0.3)',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('trims surrounding whitespace before validating', () => {
|
|
it('trims leading/trailing whitespace and returns the trimmed value', () => {
|
|
// The implementation trims first, then matches; the returned value is the
|
|
// trimmed string, not the original padded input.
|
|
expect(sanitizeCssColor(' red ')).toBe('red');
|
|
expect(sanitizeCssColor('\t#aabbcc\n')).toBe('#aabbcc');
|
|
expect(sanitizeCssColor(' rgb(1, 2, 3) ')).toBe('rgb(1, 2, 3)');
|
|
});
|
|
});
|
|
|
|
describe('rejects CSS-injection / breakout payloads (returns null)', () => {
|
|
it('rejects a style-attribute breakout via extra declarations', () => {
|
|
// The classic "set a valid color, then smuggle another declaration".
|
|
expect(sanitizeCssColor('red; --x: url(x)')).toBeNull();
|
|
expect(sanitizeCssColor('red; background: url(http://x)')).toBeNull();
|
|
});
|
|
|
|
it('rejects an attribute breakout via quotes and angle brackets', () => {
|
|
expect(sanitizeCssColor('red"><script>')).toBeNull();
|
|
expect(sanitizeCssColor("red'><img src=x>")).toBeNull();
|
|
});
|
|
|
|
it('rejects expression(...) payloads', () => {
|
|
// `expression` is letters-only but the trailing "(...)" is not part of the
|
|
// named-color alternative and `expression` is not an allowed function, so
|
|
// the anchored regex rejects the whole string.
|
|
expect(sanitizeCssColor('expression(alert(1))')).toBeNull();
|
|
});
|
|
|
|
it('rejects url(...) payloads', () => {
|
|
expect(sanitizeCssColor('url(http://x)')).toBeNull();
|
|
expect(sanitizeCssColor('rgb(url(x))')).toBeNull();
|
|
});
|
|
|
|
it('rejects any value containing a semicolon', () => {
|
|
expect(sanitizeCssColor('red;')).toBeNull();
|
|
expect(sanitizeCssColor('rgb(1,2,3);')).toBeNull();
|
|
});
|
|
|
|
it('rejects any value containing embedded quotes', () => {
|
|
expect(sanitizeCssColor('"red"')).toBeNull();
|
|
expect(sanitizeCssColor("'red'")).toBeNull();
|
|
expect(sanitizeCssColor('rgb(1,2,"3")')).toBeNull();
|
|
});
|
|
|
|
it('rejects any value containing angle brackets', () => {
|
|
expect(sanitizeCssColor('<red>')).toBeNull();
|
|
expect(sanitizeCssColor('red>')).toBeNull();
|
|
});
|
|
|
|
it('rejects functional notation carrying letters inside the parens', () => {
|
|
// The function body allowlist is [0-9.,%/\s] only — no letters survive,
|
|
// so a smuggled identifier/keyword inside cannot slip through.
|
|
expect(sanitizeCssColor('rgb(red)')).toBeNull();
|
|
expect(sanitizeCssColor('rgb(1, var(--x), 3)')).toBeNull();
|
|
});
|
|
|
|
it('rejects a hex value of an unsupported length', () => {
|
|
// Only 3/4/6/8 hex digits are allowed; 5 and 7 are not.
|
|
expect(sanitizeCssColor('#aabbc')).toBeNull();
|
|
expect(sanitizeCssColor('#aabbccd')).toBeNull();
|
|
expect(sanitizeCssColor('#xyz')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('rejects empty / whitespace-only / non-string input (returns null)', () => {
|
|
it('returns null for an empty string', () => {
|
|
expect(sanitizeCssColor('')).toBeNull();
|
|
});
|
|
|
|
it('returns null for a whitespace-only string', () => {
|
|
expect(sanitizeCssColor(' ')).toBeNull();
|
|
expect(sanitizeCssColor('\t\n')).toBeNull();
|
|
});
|
|
|
|
it('returns null for null and undefined', () => {
|
|
expect(sanitizeCssColor(null)).toBeNull();
|
|
expect(sanitizeCssColor(undefined)).toBeNull();
|
|
});
|
|
|
|
it('returns null for non-string types', () => {
|
|
// The guard's first line is a `typeof value !== "string"` check.
|
|
expect(sanitizeCssColor(123 as unknown as string)).toBeNull();
|
|
expect(sanitizeCssColor({} as unknown as string)).toBeNull();
|
|
expect(sanitizeCssColor([] as unknown as string)).toBeNull();
|
|
expect(sanitizeCssColor(true as unknown as string)).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* `clampCalloutType` maps an arbitrary string to one of the four allowed
|
|
* Docmost callout types, normalizing case and falling back to "info" for
|
|
* anything unknown / empty / nullish.
|
|
*/
|
|
describe('clampCalloutType', () => {
|
|
it('passes each allowed value through unchanged', () => {
|
|
expect(clampCalloutType('info')).toBe('info');
|
|
expect(clampCalloutType('warning')).toBe('warning');
|
|
expect(clampCalloutType('danger')).toBe('danger');
|
|
expect(clampCalloutType('success')).toBe('success');
|
|
});
|
|
|
|
it('normalizes mixed/upper case to lower case', () => {
|
|
expect(clampCalloutType('WARNING')).toBe('warning');
|
|
expect(clampCalloutType('Danger')).toBe('danger');
|
|
expect(clampCalloutType('SuCcEsS')).toBe('success');
|
|
expect(clampCalloutType('INFO')).toBe('info');
|
|
});
|
|
|
|
it('falls back to "info" for unknown values', () => {
|
|
expect(clampCalloutType('note')).toBe('info');
|
|
expect(clampCalloutType('error')).toBe('info');
|
|
expect(clampCalloutType('tip')).toBe('info');
|
|
});
|
|
|
|
it('falls back to "info" for empty, whitespace and nullish input', () => {
|
|
expect(clampCalloutType('')).toBe('info');
|
|
expect(clampCalloutType(null)).toBe('info');
|
|
expect(clampCalloutType(undefined)).toBe('info');
|
|
// Surrounding whitespace is NOT trimmed before the allowlist check, so
|
|
// " warning " is unknown and falls back to "info".
|
|
expect(clampCalloutType(' warning ')).toBe('info');
|
|
});
|
|
});
|