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
362 lines
14 KiB
TypeScript
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 {');
|
|
});
|
|
});
|