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.
This commit is contained in:
474
test/client-pure.test.ts
Normal file
474
test/client-pure.test.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
// Unit tests for the PURE (private) helper methods of DocmostClient plus the
|
||||
// transformPage vm sandbox. 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. Private methods are pure (no I/O) except for
|
||||
// transformPage, whose network calls (ensureAuthenticated / listComments /
|
||||
// getPageRaw) we stub on the instance so the dryRun path runs offline.
|
||||
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 {');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocmostClient.transformPage sandbox (dryRun, no socket)', () => {
|
||||
// The dryRun path performs: ensureAuthenticated -> listComments -> getPageRaw
|
||||
// -> runTransform (the vm sandbox) -> assertYjsEncodable -> diff. We stub the
|
||||
// three network methods so the REAL sandbox runs offline. getPageRaw returns
|
||||
// a minimal current doc; the transform operates on a structuredClone of it.
|
||||
function stubbedClient(currentDoc: any = { type: 'doc', content: [{ type: 'paragraph' }] }): any {
|
||||
const c = makeClient();
|
||||
c.ensureAuthenticated = async () => {};
|
||||
c.listComments = async () => [];
|
||||
c.getPageRaw = async () => ({ content: currentDoc });
|
||||
return c;
|
||||
}
|
||||
|
||||
it('runs a transform that returns the doc and reports pushed:false in dryRun', async () => {
|
||||
const c = stubbedClient();
|
||||
const res = await c.transformPage('pid', '(doc, ctx) => doc', { dryRun: true });
|
||||
expect(res.pushed).toBe(false);
|
||||
expect(res).toHaveProperty('diff');
|
||||
expect(Array.isArray(res.log)).toBe(true);
|
||||
});
|
||||
|
||||
it('runs a transform that mutates the doc and the change shows in the diff', async () => {
|
||||
const c = stubbedClient({ type: 'doc', content: [{ type: 'paragraph' }] });
|
||||
const transform = `(doc) => { doc.content.push({ type: 'paragraph', content: [{ type: 'text', text: 'added' }] }); return doc; }`;
|
||||
const res = await c.transformPage('pid', transform, { dryRun: true });
|
||||
expect(res.pushed).toBe(false);
|
||||
// diffDocs returns a non-empty diff when content changed.
|
||||
expect(res.diff).toBeTruthy();
|
||||
});
|
||||
|
||||
it('captures console.log output into the returned log array', async () => {
|
||||
const c = stubbedClient();
|
||||
const res = await c.transformPage('pid', `(doc) => { console.log('hello', 1); return doc; }`, { dryRun: true });
|
||||
expect(res.log).toContain('hello 1');
|
||||
});
|
||||
|
||||
it('sandbox has NO access to require/process/module/fs (they are undefined)', async () => {
|
||||
const c = stubbedClient();
|
||||
// The transform asserts these globals are undefined INSIDE the sandbox and
|
||||
// throws if any leaked. If the transform completes, none leaked.
|
||||
const transform = `(doc) => {
|
||||
if (typeof require !== 'undefined') throw new Error('require leaked');
|
||||
if (typeof process !== 'undefined') throw new Error('process leaked');
|
||||
if (typeof module !== 'undefined') throw new Error('module leaked');
|
||||
if (typeof global !== 'undefined') throw new Error('global leaked');
|
||||
return doc;
|
||||
}`;
|
||||
await expect(c.transformPage('pid', transform, { dryRun: true })).resolves.toMatchObject({ pushed: false });
|
||||
});
|
||||
|
||||
it('a transform that tries to require("fs") throws (no module loader in the sandbox)', async () => {
|
||||
const c = stubbedClient();
|
||||
const transform = `(doc) => { const fs = require('fs'); return doc; }`;
|
||||
// require is not defined in the new context -> ReferenceError at run time.
|
||||
await expect(c.transformPage('pid', transform, { dryRun: true })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when the transform does not evaluate to a function', async () => {
|
||||
const c = stubbedClient();
|
||||
await expect(c.transformPage('pid', '42', { dryRun: true }))
|
||||
.rejects.toThrow(/must evaluate to a function/);
|
||||
});
|
||||
|
||||
it('throws when the transform returns a non-doc value', async () => {
|
||||
const c = stubbedClient();
|
||||
await expect(c.transformPage('pid', '(doc) => ({ type: "paragraph" })', { dryRun: true }))
|
||||
.rejects.toThrow(/must return a ProseMirror doc node/);
|
||||
await expect(c.transformPage('pid', '(doc) => null', { dryRun: true }))
|
||||
.rejects.toThrow(/must return a ProseMirror doc node/);
|
||||
});
|
||||
|
||||
it('throws on a transform that does not compile', async () => {
|
||||
const c = stubbedClient();
|
||||
await expect(c.transformPage('pid', '(doc) => {{{', { dryRun: true }))
|
||||
.rejects.toThrow(/did not compile/);
|
||||
});
|
||||
|
||||
it('invokes validateDocStructure on the result (malformed node rejected)', async () => {
|
||||
const c = stubbedClient();
|
||||
// Return a doc whose child has a non-string type -> validateDocStructure throws.
|
||||
const transform = `(doc) => ({ type: 'doc', content: [{ type: 123 }] })`;
|
||||
await expect(c.transformPage('pid', transform, { dryRun: true }))
|
||||
.rejects.toThrow(/string `type`/);
|
||||
});
|
||||
|
||||
it('invokes validateDocUrls on the result (unsafe href rejected)', async () => {
|
||||
const c = stubbedClient();
|
||||
const transform = `(doc) => ({ type: 'doc', content: [
|
||||
{ type: 'paragraph', content: [
|
||||
{ type: 'text', text: 'x', marks: [{ type: 'link', attrs: { href: 'javascript:alert(1)' } }] }
|
||||
] }
|
||||
] })`;
|
||||
await expect(c.transformPage('pid', transform, { dryRun: true }))
|
||||
.rejects.toThrow(/unsafe link href rejected/);
|
||||
});
|
||||
|
||||
it('enforces a vm wall-clock timeout (option present in source)', () => {
|
||||
// Asserting the option rather than waiting 5s: the source sets
|
||||
// { timeout: 5000 } on both runInNewContext calls. We verify the timeout
|
||||
// literal exists in the compiled source so an accidental removal is caught.
|
||||
// (Reading the source keeps this fast and avoids a real 5s busy loop.)
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('node:fs');
|
||||
const url = require('node:url');
|
||||
const path = require('node:path');
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const src = fs.readFileSync(path.join(here, '..', 'packages', 'docmost-client', 'src', 'client.ts'), 'utf8');
|
||||
expect(src).toMatch(/timeout:\s*5000/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user