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