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:
323
test/client-upload.test.ts
Normal file
323
test/client-upload.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
// Security-guard tests for DocmostClient.uploadImage and DocmostClient.createPage.
|
||||
//
|
||||
// Both methods send multipart bodies via BARE default axios (`axios.post`)
|
||||
// rather than the per-instance client, because a FormData stream is single-use
|
||||
// and cannot be replayed by this.client's 401 response interceptor. They each
|
||||
// implement their own one-shot 401/403 re-auth that rebuilds the FormData.
|
||||
//
|
||||
// We therefore mock BOTH adapters:
|
||||
// - the default `axios` (via `new MockAdapter(axios)`) for `/auth/login`,
|
||||
// `/files/upload` and `/pages/import` (the bare multipart POSTs), and
|
||||
// - the per-instance client (via `new MockAdapter((client as any).client)`)
|
||||
// for the JSON endpoints `/pages/info`, `/pages/sidebar-pages`,
|
||||
// `/pages/update`.
|
||||
//
|
||||
// For the filesystem we use REAL temp files written under os.tmpdir() so the
|
||||
// real statSync/readFileSync path is exercised (and cleaned up afterEach).
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import axios from 'axios';
|
||||
import { writeFileSync, mkdtempSync, rmSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { DocmostClient } from '../packages/docmost-client/src/client.js';
|
||||
|
||||
const API_URL = 'http://docmost.test/api';
|
||||
const SPACE_ID = 'space-1';
|
||||
const PAGE_ID = 'page-1';
|
||||
|
||||
// A 1x1 transparent PNG (valid-ish bytes; content is irrelevant to the guards).
|
||||
const PNG_BYTES = Buffer.from(
|
||||
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082',
|
||||
'hex',
|
||||
);
|
||||
|
||||
// A login mock that satisfies performLogin(): it reads the authToken cookie
|
||||
// from the Set-Cookie header. Returns it as an array, as a real server would.
|
||||
function mockLogin(mockAxios: MockAdapter): void {
|
||||
mockAxios.onPost(`${API_URL}/auth/login`).reply(200, {}, {
|
||||
'set-cookie': ['authToken=test-jwt-token; HttpOnly; Path=/'],
|
||||
});
|
||||
}
|
||||
|
||||
describe('DocmostClient multipart security guards', () => {
|
||||
let tmpDir: string;
|
||||
let mockAxios: MockAdapter; // default axios -> bare multipart + login
|
||||
let mockClient: MockAdapter; // per-instance client -> JSON endpoints
|
||||
let client: DocmostClient;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'docmost-upload-test-'));
|
||||
client = new DocmostClient(API_URL, 'user@test', 'pw');
|
||||
mockAxios = new MockAdapter(axios);
|
||||
mockClient = new MockAdapter((client as any).client);
|
||||
mockLogin(mockAxios);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockAxios.restore();
|
||||
mockClient.restore();
|
||||
delete process.env.DEBUG;
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Helper: write a real image fixture and return its absolute path.
|
||||
function writeImage(name: string, bytes: Buffer = PNG_BYTES): string {
|
||||
const p = join(tmpDir, name);
|
||||
writeFileSync(p, bytes);
|
||||
return p;
|
||||
}
|
||||
|
||||
describe('uploadImage — host-fs trust boundary', () => {
|
||||
it('rejects an unsupported extension BEFORE any stat/read', async () => {
|
||||
// Point at a path that does NOT exist on disk. If the extension guard
|
||||
// runs first (as it must), we get the "unsupported image type" error and
|
||||
// never reach statSync — which would otherwise throw a "Cannot stat"
|
||||
// error. The error message proves the guard order.
|
||||
const badPath = join(tmpDir, 'does-not-exist.txt');
|
||||
await expect(client.uploadImage(PAGE_ID, badPath)).rejects.toThrow(
|
||||
/unsupported image type \.txt; supported: png, jpg, jpeg, gif, webp, svg/,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports "(none)" for a path with no extension (still before stat)', async () => {
|
||||
const noExt = join(tmpDir, 'noextension');
|
||||
await expect(client.uploadImage(PAGE_ID, noExt)).rejects.toThrow(
|
||||
/unsupported image type \(none\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a directory (non-regular file) after the extension passes', async () => {
|
||||
// A directory whose name ends in .png passes the extension allowlist but
|
||||
// must fail the stat.isFile() guard.
|
||||
const dirPath = join(tmpDir, 'a-directory.png');
|
||||
mkdirSync(dirPath);
|
||||
await expect(client.uploadImage(PAGE_ID, dirPath)).rejects.toThrow(
|
||||
new RegExp(`Not a regular file: "${dirPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a stat failure for a missing supported-extension file', async () => {
|
||||
// Extension is valid, but the file does not exist -> statSync throws and
|
||||
// is wrapped in "Cannot stat image file ...".
|
||||
const missing = join(tmpDir, 'missing.png');
|
||||
await expect(client.uploadImage(PAGE_ID, missing)).rejects.toThrow(
|
||||
/Cannot stat image file at ".*missing\.png": /,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a file larger than the 20 MiB cap', async () => {
|
||||
// Write a real file just over 20 MiB so the real statSync size path is
|
||||
// exercised. 20 MiB == 20 * 1024 * 1024; one extra byte trips the cap.
|
||||
const MAX = 20 * 1024 * 1024;
|
||||
const bigPath = join(tmpDir, 'big.png');
|
||||
writeFileSync(bigPath, Buffer.alloc(MAX + 1));
|
||||
await expect(client.uploadImage(PAGE_ID, bigPath)).rejects.toThrow(
|
||||
new RegExp(`Image too large: ${MAX + 1} bytes exceeds the ${MAX}-byte cap`),
|
||||
);
|
||||
// No multipart POST should have been attempted for an oversized file.
|
||||
const uploadPosts = mockAxios.history.post.filter((r) =>
|
||||
String(r.url).includes('/files/upload'),
|
||||
);
|
||||
expect(uploadPosts.length).toBe(0);
|
||||
});
|
||||
|
||||
it('uploads a valid image and returns attachment metadata + image node', async () => {
|
||||
const imgPath = writeImage('ok.png');
|
||||
mockAxios.onPost(`${API_URL}/files/upload`).reply(200, {
|
||||
data: { id: 'att-1', fileName: 'ok.png', fileSize: 1234 },
|
||||
});
|
||||
|
||||
const res = await client.uploadImage(PAGE_ID, imgPath);
|
||||
expect(res.attachmentId).toBe('att-1');
|
||||
expect(res.fileName).toBe('ok.png');
|
||||
expect(res.fileSize).toBe(1234);
|
||||
expect(res.src).toBe('/api/files/att-1/ok.png');
|
||||
expect(res.imageNode.type).toBe('image');
|
||||
expect(res.imageNode.attrs.attachmentId).toBe('att-1');
|
||||
expect(res.imageNode.attrs.size).toBe(1234);
|
||||
});
|
||||
|
||||
it('falls back to the local stat size when the response omits fileSize', async () => {
|
||||
const imgPath = writeImage('nosize.png'); // PNG_BYTES length
|
||||
const expectedSize = PNG_BYTES.length;
|
||||
mockAxios.onPost(`${API_URL}/files/upload`).reply(200, {
|
||||
// No fileSize in the attachment payload.
|
||||
data: { id: 'att-2', fileName: 'nosize.png' },
|
||||
});
|
||||
|
||||
const res = await client.uploadImage(PAGE_ID, imgPath);
|
||||
expect(res.fileSize).toBe(expectedSize);
|
||||
// The image node's size attr also uses the resolved (local) size.
|
||||
expect(res.imageNode.attrs.size).toBe(expectedSize);
|
||||
});
|
||||
|
||||
it('rebuilds a FRESH FormData and retries exactly once on a 401', async () => {
|
||||
const imgPath = writeImage('retry.png');
|
||||
// First multipart POST -> 401; second -> 200. login() is also issued
|
||||
// between the two by the re-auth branch (already mocked by mockLogin).
|
||||
mockAxios
|
||||
.onPost(`${API_URL}/files/upload`)
|
||||
.replyOnce(401)
|
||||
.onPost(`${API_URL}/files/upload`)
|
||||
.replyOnce(200, { data: { id: 'att-3', fileName: 'retry.png', fileSize: 7 } });
|
||||
|
||||
const res = await client.uploadImage(PAGE_ID, imgPath);
|
||||
expect(res.attachmentId).toBe('att-3');
|
||||
|
||||
// Exactly two upload POSTs (the original + one retry), and a fresh login
|
||||
// was triggered in between.
|
||||
const uploadPosts = mockAxios.history.post.filter((r) =>
|
||||
String(r.url).includes('/files/upload'),
|
||||
);
|
||||
expect(uploadPosts.length).toBe(2);
|
||||
const loginPosts = mockAxios.history.post.filter((r) =>
|
||||
String(r.url).includes('/auth/login'),
|
||||
);
|
||||
// One initial ensureAuthenticated() login + one re-auth login.
|
||||
expect(loginPosts.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('does NOT retry more than once on repeated 401s', async () => {
|
||||
const imgPath = writeImage('always401.png');
|
||||
// Every upload POST returns 401. After the single retry also 401s, the
|
||||
// error must propagate (no infinite loop). The second 401 is a raw
|
||||
// AxiosError thrown out of the retry branch (not re-caught).
|
||||
mockAxios.onPost(`${API_URL}/files/upload`).reply(401);
|
||||
|
||||
await expect(client.uploadImage(PAGE_ID, imgPath)).rejects.toBeTruthy();
|
||||
const uploadPosts = mockAxios.history.post.filter((r) =>
|
||||
String(r.url).includes('/files/upload'),
|
||||
);
|
||||
// Exactly two attempts: original + one retry.
|
||||
expect(uploadPosts.length).toBe(2);
|
||||
});
|
||||
|
||||
it('sanitizes a non-auth AxiosError and does NOT leak the response body', async () => {
|
||||
const imgPath = writeImage('leak.png');
|
||||
const SECRET = 'SUPER_SECRET_TOKEN_abc123';
|
||||
mockAxios.onPost(`${API_URL}/files/upload`).reply(500, {
|
||||
message: SECRET,
|
||||
internal: { stack: SECRET },
|
||||
});
|
||||
|
||||
let caught: Error | undefined;
|
||||
try {
|
||||
await client.uploadImage(PAGE_ID, imgPath);
|
||||
} catch (e) {
|
||||
caught = e as Error;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
// Surfaces only status/statusText, never the body.
|
||||
expect(caught!.message).toMatch(/Image upload failed: 500/);
|
||||
expect(caught!.message).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it('throws on a response missing att.id', async () => {
|
||||
const imgPath = writeImage('noid.png');
|
||||
mockAxios.onPost(`${API_URL}/files/upload`).reply(200, {
|
||||
data: { fileName: 'noid.png' }, // missing id
|
||||
});
|
||||
await expect(client.uploadImage(PAGE_ID, imgPath)).rejects.toThrow(
|
||||
/Unexpected \/files\/upload response:/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a response missing att.fileName', async () => {
|
||||
const imgPath = writeImage('noname.png');
|
||||
mockAxios.onPost(`${API_URL}/files/upload`).reply(200, {
|
||||
data: { id: 'att-x' }, // missing fileName
|
||||
});
|
||||
await expect(client.uploadImage(PAGE_ID, imgPath)).rejects.toThrow(
|
||||
/Unexpected \/files\/upload response:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPage — multipart import guards', () => {
|
||||
it('rejects when the parent page is not found', async () => {
|
||||
// getPage(parentPageId) -> getPageRaw -> POST /pages/info. Make it 404 so
|
||||
// getPage throws and createPage maps it to the parent-not-found error.
|
||||
mockClient.onPost('/pages/info').reply(404, { message: 'nope' });
|
||||
|
||||
await expect(
|
||||
client.createPage('Title', '# body', SPACE_ID, 'missing-parent'),
|
||||
).rejects.toThrow('Parent page with ID missing-parent not found.');
|
||||
|
||||
// No import POST should have happened — the parent check runs first.
|
||||
const importPosts = mockAxios.history.post.filter((r) =>
|
||||
String(r.url).includes('/pages/import'),
|
||||
);
|
||||
expect(importPosts.length).toBe(0);
|
||||
});
|
||||
|
||||
it('creates a page (no parent) and re-sets the title after import', async () => {
|
||||
// Import succeeds via bare axios.
|
||||
mockAxios
|
||||
.onPost(`${API_URL}/pages/import`)
|
||||
.reply(200, { data: { id: 'new-page-1' } });
|
||||
// Title restore goes through the per-instance client.
|
||||
mockClient.onPost('/pages/update').reply(200, { data: { ok: true } });
|
||||
// Final getPage(newPageId): /pages/info + /pages/sidebar-pages.
|
||||
mockClient
|
||||
.onPost('/pages/info')
|
||||
.reply(200, {
|
||||
data: { id: 'new-page-1', spaceId: SPACE_ID, title: 'My Title', content: null },
|
||||
});
|
||||
mockClient
|
||||
.onPost('/pages/sidebar-pages')
|
||||
.reply(200, { data: { items: [], meta: { hasNextPage: false } } });
|
||||
|
||||
const res = await client.createPage('My Title', '# body', SPACE_ID);
|
||||
expect((res as any).success).toBe(true);
|
||||
|
||||
// The title was re-set via /pages/update with the exact (un-mangled) title.
|
||||
const updatePosts = mockClient.history.post.filter((r) =>
|
||||
String(r.url).includes('/pages/update'),
|
||||
);
|
||||
expect(updatePosts.length).toBe(1);
|
||||
const body = JSON.parse(String(updatePosts[0].data));
|
||||
expect(body).toMatchObject({ pageId: 'new-page-1', title: 'My Title' });
|
||||
});
|
||||
|
||||
it('rebuilds FormData and retries the import exactly once on a 401', async () => {
|
||||
mockAxios
|
||||
.onPost(`${API_URL}/pages/import`)
|
||||
.replyOnce(401)
|
||||
.onPost(`${API_URL}/pages/import`)
|
||||
.replyOnce(200, { data: { id: 'new-page-2' } });
|
||||
mockClient.onPost('/pages/update').reply(200, { data: { ok: true } });
|
||||
mockClient
|
||||
.onPost('/pages/info')
|
||||
.reply(200, {
|
||||
data: { id: 'new-page-2', spaceId: SPACE_ID, title: 'T', content: null },
|
||||
});
|
||||
mockClient
|
||||
.onPost('/pages/sidebar-pages')
|
||||
.reply(200, { data: { items: [], meta: { hasNextPage: false } } });
|
||||
|
||||
const res = await client.createPage('T', '# body', SPACE_ID);
|
||||
expect((res as any).success).toBe(true);
|
||||
|
||||
const importPosts = mockAxios.history.post.filter((r) =>
|
||||
String(r.url).includes('/pages/import'),
|
||||
);
|
||||
expect(importPosts.length).toBe(2);
|
||||
});
|
||||
|
||||
it('rethrows a non-auth import error without retrying', async () => {
|
||||
mockAxios.onPost(`${API_URL}/pages/import`).reply(500, { message: 'boom' });
|
||||
|
||||
await expect(
|
||||
client.createPage('T', '# body', SPACE_ID),
|
||||
).rejects.toBeTruthy();
|
||||
|
||||
const importPosts = mockAxios.history.post.filter((r) =>
|
||||
String(r.url).includes('/pages/import'),
|
||||
);
|
||||
// Exactly one attempt: a non-401/403 error is rethrown, never retried.
|
||||
expect(importPosts.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user