Files
docmost-sync/test/client-pure.test.ts
vvzvlad 480f4c3747 test(sync): client-core REST integration (axios-mock-adapter)
Phase-4 REST-binding coverage for the god-object client (test-only; product
code untouched).

- deletePage/restorePage/listTrash: bodies use pageId (not id), no
  permanentlyDelete, trash is per-space + paginates (SPEC §8 deletion mirroring)
- listRecentSince: /pages/recent body (limit:100, spaceId omitted when unset,
  cursor threaded, envelope unwrap, cutoff)
- movePage (default position, parentPageId:null); getPage subpages degradation +
  {{SUBPAGES}}; getPageJson default content
- validateDocUrls bare/edge node-shape tolerance (no XSS-reject duplication —
  that stays in client-pure)
- seam: preauthed client + MockAdapter on the private axios instance, restored
  in afterEach; no real network
2026-06-17 02:03:42 +03:00

362 lines
14 KiB
TypeScript

// Unit tests for the PURE (private) helper methods of DocmostClient. The
// constructor only calls axios.create (no network), so a real instance can be
// built and its private methods exercised via an `(client as any)` cast. These
// private methods are pure (no I/O).
import { describe, it, expect } from 'vitest';
import { DocmostClient } from '../packages/docmost-client/src/client.js';
// Build a fresh client per test. The given URL ends with /api so appUrl tests
// have something to strip; other tests do not depend on the suffix.
function makeClient(apiUrl = 'https://docmost.example/api'): any {
return new DocmostClient(apiUrl, 'a@b.c', 'pw') as any;
}
describe('DocmostClient.isSafeUrl (SECURITY)', () => {
// --- dangerous schemes must be rejected in both contexts ---
const dangerousLink = ['javascript:alert(1)', 'vbscript:msgbox(1)', 'data:text/html,<script>'];
for (const url of dangerousLink) {
it(`rejects dangerous scheme on a link: ${url}`, () => {
const c = makeClient();
expect(c.isSafeUrl(url, 'link')).toBe(false);
});
}
// file: is in the dangerous set; for links scheme!==js/vbscript/data passes,
// BUT file: is only rejected for src. Confirm documented link behaviour:
// links only block javascript/vbscript/data. file: is allowed on a link.
it('allows file: on a link (links only block js/vbscript/data per docstring)', () => {
const c = makeClient();
expect(c.isSafeUrl('file:///etc/passwd', 'link')).toBe(true);
});
it('rejects file: on a src', () => {
const c = makeClient();
expect(c.isSafeUrl('file:///etc/passwd', 'src')).toBe(false);
});
it('rejects data: on a src', () => {
const c = makeClient();
expect(c.isSafeUrl('data:image/png;base64,AAAA', 'src')).toBe(false);
});
// --- whitespace / control-char smuggled schemes (the classic XSS bypass) ---
it('rejects a tab-smuggled javascript scheme on a link (java\\tscript:)', () => {
const c = makeClient();
const smuggled = 'java\tscript:alert(1)';
// CRITICAL: a bypass here would allow stored XSS. Must be rejected.
expect(c.isSafeUrl(smuggled, 'link')).toBe(false);
});
it('rejects a newline-smuggled javascript scheme on a link', () => {
const c = makeClient();
expect(c.isSafeUrl('java\nscript:alert(1)', 'link')).toBe(false);
});
it('rejects a leading-whitespace javascript scheme on a link', () => {
const c = makeClient();
expect(c.isSafeUrl(' javascript:alert(1)', 'link')).toBe(false);
});
it('rejects a NUL/control-char smuggled javascript scheme on a link', () => {
const c = makeClient();
// \x00 and \x01 are stripped before the scheme is parsed.
expect(c.isSafeUrl('java\x00script:alert(1)', 'link')).toBe(false);
expect(c.isSafeUrl('\x01javascript:alert(1)', 'link')).toBe(false);
});
it('rejects a tab-smuggled vbscript scheme on a src', () => {
const c = makeClient();
expect(c.isSafeUrl('vb\tscript:foo', 'src')).toBe(false);
});
// --- safe / allowed values ---
it('allows http and https in both contexts', () => {
const c = makeClient();
expect(c.isSafeUrl('http://example.com', 'link')).toBe(true);
expect(c.isSafeUrl('https://example.com', 'link')).toBe(true);
expect(c.isSafeUrl('http://example.com/x.png', 'src')).toBe(true);
expect(c.isSafeUrl('https://example.com/x.png', 'src')).toBe(true);
});
it('allows mailto in both contexts', () => {
const c = makeClient();
expect(c.isSafeUrl('mailto:a@b.c', 'link')).toBe(true);
expect(c.isSafeUrl('mailto:a@b.c', 'src')).toBe(true);
});
it('allows a scheme-less relative/absolute path (link and src)', () => {
const c = makeClient();
expect(c.isSafeUrl('/api/files/123/x.png', 'src')).toBe(true);
expect(c.isSafeUrl('relative/path', 'src')).toBe(true);
expect(c.isSafeUrl('#anchor', 'link')).toBe(true);
expect(c.isSafeUrl('/some/page', 'link')).toBe(true);
});
it('allows an arbitrary non-dangerous scheme on a link but not on a src', () => {
const c = makeClient();
// link: any scheme except js/vbscript/data is accepted (e.g. ftp, tel).
expect(c.isSafeUrl('ftp://host/file', 'link')).toBe(true);
expect(c.isSafeUrl('tel:+123', 'link')).toBe(true);
// src: only http/https/mailto (and scheme-less) are accepted.
expect(c.isSafeUrl('ftp://host/file', 'src')).toBe(false);
expect(c.isSafeUrl('tel:+123', 'src')).toBe(false);
});
// --- empty / non-string inputs ---
it('treats an empty/whitespace string as safe (empty href/src is harmless)', () => {
const c = makeClient();
expect(c.isSafeUrl('', 'link')).toBe(true);
expect(c.isSafeUrl(' ', 'src')).toBe(true);
});
it('rejects non-string inputs', () => {
const c = makeClient();
expect(c.isSafeUrl(undefined, 'link')).toBe(false);
expect(c.isSafeUrl(null, 'src')).toBe(false);
expect(c.isSafeUrl(123, 'link')).toBe(false);
expect(c.isSafeUrl({}, 'src')).toBe(false);
});
});
describe('DocmostClient.validateDocUrls', () => {
it('passes a clean doc with safe link href and safe media src', () => {
const c = makeClient();
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'hi', marks: [{ type: 'link', attrs: { href: 'https://ok.com' } }] },
],
},
{ type: 'image', attrs: { src: '/api/files/1/x.png' } },
],
};
expect(() => c.validateDocUrls(doc)).not.toThrow();
});
it('throws on an unsafe href on a link mark', () => {
const c = makeClient();
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'x', marks: [{ type: 'link', attrs: { href: 'javascript:alert(1)' } }] },
],
},
],
};
expect(() => c.validateDocUrls(doc)).toThrow(/unsafe link href rejected/);
});
// Every media node type the method enumerates must have its src validated.
const mediaTypes = ['image', 'attachment', 'video', 'embed', 'youtube', 'drawio', 'excalidraw', 'audio', 'pdf'];
for (const type of mediaTypes) {
it(`throws on unsafe src on a ${type} node`, () => {
const c = makeClient();
const doc = { type: 'doc', content: [{ type, attrs: { src: 'javascript:alert(1)' } }] };
expect(() => c.validateDocUrls(doc)).toThrow(new RegExp(`unsafe ${type} src rejected`));
});
it(`throws on unsafe url on a ${type} node`, () => {
const c = makeClient();
const doc = { type: 'doc', content: [{ type, attrs: { url: 'data:text/html,x' } }] };
expect(() => c.validateDocUrls(doc)).toThrow(new RegExp(`unsafe ${type} url rejected`));
});
}
it('recurses into nested children', () => {
const c = makeClient();
const doc = {
type: 'doc',
content: [
{ type: 'blockquote', content: [{ type: 'paragraph', content: [{ type: 'image', attrs: { src: 'vbscript:x' } }] }] },
],
};
expect(() => c.validateDocUrls(doc)).toThrow(/unsafe image src rejected/);
});
it('throws when nesting exceeds the max depth (>200)', () => {
const c = makeClient();
// Build a chain 250 deep of content-wrapping nodes.
let node: any = { type: 'paragraph' };
for (let i = 0; i < 250; i++) node = { type: 'doc', content: [node] };
expect(() => c.validateDocUrls(node)).toThrow(/exceeds the maximum depth of 200/);
});
it('ignores non-object / null input without throwing', () => {
const c = makeClient();
expect(() => c.validateDocUrls(null)).not.toThrow();
expect(() => c.validateDocUrls('string')).not.toThrow();
});
});
describe('DocmostClient.validateDocStructure', () => {
it('passes a valid doc', () => {
const c = makeClient();
const doc = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi', marks: [{ type: 'bold' }] }] }],
};
expect(() => c.validateDocStructure(doc)).not.toThrow();
});
it('throws when type is not a string (or node not an object)', () => {
const c = makeClient();
expect(() => c.validateDocStructure({ type: 123 })).toThrow(/string `type`/);
expect(() => c.validateDocStructure(null)).toThrow(/string `type`/);
expect(() => c.validateDocStructure('nope')).toThrow(/string `type`/);
expect(() => c.validateDocStructure({})).toThrow(/string `type`/);
});
it('throws when content is present but not an array', () => {
const c = makeClient();
expect(() => c.validateDocStructure({ type: 'doc', content: {} })).toThrow(/`content` must be an array/);
});
it('throws when marks is present but not an array', () => {
const c = makeClient();
expect(() => c.validateDocStructure({ type: 'paragraph', marks: 'bold' })).toThrow(/`marks` must be an array/);
});
it('throws when a mark is malformed (no string type)', () => {
const c = makeClient();
expect(() => c.validateDocStructure({ type: 'text', text: 'x', marks: [{ foo: 1 }] }))
.toThrow(/every mark must be an object with a string `type`/);
});
it('throws when a text node has a non-string text', () => {
const c = makeClient();
expect(() => c.validateDocStructure({ type: 'text', text: 123 })).toThrow(/text node must have a string `text`/);
});
it('throws when nesting exceeds the max depth (>200)', () => {
const c = makeClient();
let node: any = { type: 'paragraph' };
for (let i = 0; i < 250; i++) node = { type: 'doc', content: [node] };
expect(() => c.validateDocStructure(node)).toThrow(/nesting exceeds the maximum depth of 200/);
});
});
describe('DocmostClient.imageMimeFromPath', () => {
const cases: Array<[string, string]> = [
['x.png', 'image/png'],
['x.jpg', 'image/jpeg'],
['x.jpeg', 'image/jpeg'],
['x.gif', 'image/gif'],
['x.webp', 'image/webp'],
['x.svg', 'image/svg+xml'],
];
for (const [path, mime] of cases) {
it(`maps ${path} -> ${mime}`, () => {
const c = makeClient();
expect(c.imageMimeFromPath(path)).toBe(mime);
});
}
it('is case-insensitive on the extension (.PNG)', () => {
const c = makeClient();
expect(c.imageMimeFromPath('/tmp/IMG.PNG')).toBe('image/png');
expect(c.imageMimeFromPath('/tmp/IMG.JpEg')).toBe('image/jpeg');
});
it('throws on an unknown extension', () => {
const c = makeClient();
expect(() => c.imageMimeFromPath('x.tiff')).toThrow(/unsupported image type/);
});
it('throws when there is no extension', () => {
const c = makeClient();
expect(() => c.imageMimeFromPath('/tmp/noext')).toThrow(/unsupported image type/);
});
});
describe('DocmostClient.buildImageNode', () => {
it('uses fileSize when present', () => {
const c = makeClient();
const node = c.buildImageNode({ id: 'abc', fileName: 'pic.png', fileSize: 4096 });
expect(node.type).toBe('image');
expect(node.attrs.size).toBe(4096);
expect(node.attrs.attachmentId).toBe('abc');
expect(node.attrs.src).toBe('/api/files/abc/pic.png');
});
it('defaults size to null when fileSize is undefined', () => {
const c = makeClient();
const node = c.buildImageNode({ id: 'abc', fileName: 'pic.png' });
expect(node.attrs.size).toBeNull();
});
it('defaults align to center and width to null', () => {
const c = makeClient();
const node = c.buildImageNode({ id: 'a', fileName: 'p.png' });
expect(node.attrs.align).toBe('center');
expect(node.attrs.width).toBeNull();
});
it('honours an explicit align', () => {
const c = makeClient();
const node = c.buildImageNode({ id: 'a', fileName: 'p.png' }, 'right');
expect(node.attrs.align).toBe('right');
});
it('omits alt when not provided, sets it when provided', () => {
const c = makeClient();
const noAlt = c.buildImageNode({ id: 'a', fileName: 'p.png' });
expect('alt' in noAlt.attrs).toBe(false);
const withAlt = c.buildImageNode({ id: 'a', fileName: 'p.png' }, 'center', 'a cat');
expect(withAlt.attrs.alt).toBe('a cat');
});
});
describe('DocmostClient.appUrl getter', () => {
it('strips a trailing /api', () => {
expect(makeClient('https://docmost.example/api').appUrl).toBe('https://docmost.example');
});
it('strips a trailing /api/', () => {
expect(makeClient('https://docmost.example/api/').appUrl).toBe('https://docmost.example');
});
it('leaves a non-/api URL intact', () => {
expect(makeClient('https://docmost.example').appUrl).toBe('https://docmost.example');
// /api in the middle of the path is not a trailing suffix, so untouched.
expect(makeClient('https://docmost.example/apidocs').appUrl).toBe('https://docmost.example/apidocs');
});
});
describe('DocmostClient.shareUrl', () => {
it('composes appUrl + share key + slugId', () => {
const c = makeClient('https://docmost.example/api');
expect(c.shareUrl('KEY123', 'slug-abc')).toBe('https://docmost.example/share/KEY123/p/slug-abc');
});
it('uses the stripped appUrl (no /api in the share URL)', () => {
const c = makeClient('https://docmost.example/api/');
expect(c.shareUrl('k', 's')).toBe('https://docmost.example/share/k/p/s');
});
});
describe('DocmostClient.parseCommentContent', () => {
it('parses a JSON string', () => {
const c = makeClient();
expect(c.parseCommentContent('{"type":"doc","content":[]}')).toEqual({ type: 'doc', content: [] });
});
it('passes a non-string value through unchanged', () => {
const c = makeClient();
const obj = { type: 'doc', content: [] };
expect(c.parseCommentContent(obj)).toBe(obj);
expect(c.parseCommentContent(null)).toBeNull();
expect(c.parseCommentContent(42)).toBe(42);
});
it('falls back to the raw string on invalid JSON', () => {
const c = makeClient();
expect(c.parseCommentContent('not json {')).toBe('not json {');
});
});