Files
docmost-sync/test/docmost-schema.test.ts
vvzvlad 90d8f86fda test: add full test suite for docmost-client and remaining modules
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.
2026-06-16 22:50:04 +03:00

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');
});
});