Files
docmost-sync/test/client-upload.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

324 lines
13 KiB
TypeScript

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