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:
275
test/auth-utils.test.ts
Normal file
275
test/auth-utils.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import {
|
||||
getCollabToken,
|
||||
performLogin,
|
||||
} from '../packages/docmost-client/src/lib/auth-utils.js';
|
||||
|
||||
// Both the source module and this test import the default axios instance,
|
||||
// which is hoisted to the workspace-root node_modules. MockAdapter installed
|
||||
// on that shared instance therefore intercepts the bare axios.post calls made
|
||||
// inside auth-utils.ts, and axios.isAxiosError still recognises the synthetic
|
||||
// errors MockAdapter throws.
|
||||
const BASE_URL = 'https://docmost.example.com/api';
|
||||
const API_TOKEN = 'test-api-token';
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
// Snapshot DEBUG so individual tests can toggle it and we always restore the
|
||||
// original value (the source gates secret-leaking branches on process.env.DEBUG).
|
||||
const ORIGINAL_DEBUG = process.env.DEBUG;
|
||||
|
||||
afterEach(() => {
|
||||
// Drop all registered handlers between tests.
|
||||
mock.reset();
|
||||
// Restore the env regardless of what a test set, to avoid cross-test leakage
|
||||
// and to keep the developer's real DEBUG value intact after the run.
|
||||
if (ORIGINAL_DEBUG === undefined) {
|
||||
delete process.env.DEBUG;
|
||||
} else {
|
||||
process.env.DEBUG = ORIGINAL_DEBUG;
|
||||
}
|
||||
});
|
||||
|
||||
describe('getCollabToken', () => {
|
||||
it('reads the token from the wrapped data.data.token path', async () => {
|
||||
mock
|
||||
.onPost(`${BASE_URL}/auth/collab-token`)
|
||||
.reply(200, { data: { token: 'wrapped-token' } });
|
||||
|
||||
const token = await getCollabToken(BASE_URL, API_TOKEN);
|
||||
|
||||
expect(token).toBe('wrapped-token');
|
||||
});
|
||||
|
||||
it('falls back to the top-level data.token when data.data is absent', async () => {
|
||||
mock
|
||||
.onPost(`${BASE_URL}/auth/collab-token`)
|
||||
.reply(200, { token: 'flat-token' });
|
||||
|
||||
const token = await getCollabToken(BASE_URL, API_TOKEN);
|
||||
|
||||
expect(token).toBe('flat-token');
|
||||
});
|
||||
|
||||
it('prefers data.data.token over a sibling top-level token', async () => {
|
||||
mock
|
||||
.onPost(`${BASE_URL}/auth/collab-token`)
|
||||
.reply(200, { data: { token: 'wrapped' }, token: 'flat' });
|
||||
|
||||
const token = await getCollabToken(BASE_URL, API_TOKEN);
|
||||
|
||||
expect(token).toBe('wrapped');
|
||||
});
|
||||
|
||||
it('sends the Bearer Authorization header built from the api token', async () => {
|
||||
let seenAuth: string | undefined;
|
||||
mock.onPost(`${BASE_URL}/auth/collab-token`).reply((config) => {
|
||||
seenAuth = config.headers?.Authorization as string | undefined;
|
||||
return [200, { data: { token: 'ok' } }];
|
||||
});
|
||||
|
||||
await getCollabToken(BASE_URL, API_TOKEN);
|
||||
|
||||
expect(seenAuth).toBe(`Bearer ${API_TOKEN}`);
|
||||
});
|
||||
|
||||
it('throws an Error whose .status carries the 401 so reauth callers can detect it', async () => {
|
||||
// No DEBUG set: the body must NOT be leaked (see secret-leak guard test).
|
||||
delete process.env.DEBUG;
|
||||
mock
|
||||
.onPost(`${BASE_URL}/auth/collab-token`)
|
||||
.reply(401, { secret: 'do-not-leak' });
|
||||
|
||||
await expect(getCollabToken(BASE_URL, API_TOKEN)).rejects.toMatchObject({
|
||||
status: 401,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the response body from the error message when DEBUG is unset (secret-leak guard)', async () => {
|
||||
delete process.env.DEBUG;
|
||||
mock
|
||||
.onPost(`${BASE_URL}/auth/collab-token`)
|
||||
.reply(401, { secret: 'do-not-leak' });
|
||||
|
||||
await expect(getCollabToken(BASE_URL, API_TOKEN)).rejects.toThrow(
|
||||
/Failed to get collab token: 401/,
|
||||
);
|
||||
// The serialized body must be absent without DEBUG.
|
||||
let captured: any;
|
||||
try {
|
||||
await getCollabToken(BASE_URL, API_TOKEN);
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect(captured.message).not.toContain('do-not-leak');
|
||||
expect(captured.status).toBe(401);
|
||||
});
|
||||
|
||||
it('includes the response body in the error message when DEBUG is set', async () => {
|
||||
process.env.DEBUG = '1';
|
||||
mock
|
||||
.onPost(`${BASE_URL}/auth/collab-token`)
|
||||
.reply(403, { detail: 'forbidden-detail' });
|
||||
|
||||
let captured: any;
|
||||
try {
|
||||
await getCollabToken(BASE_URL, API_TOKEN);
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect(captured.status).toBe(403);
|
||||
// With DEBUG the JSON-serialized body is appended to the message.
|
||||
expect(captured.message).toContain('forbidden-detail');
|
||||
expect(captured.message).toContain('Failed to get collab token: 403');
|
||||
});
|
||||
|
||||
it('rethrows a non-axios error unwrapped (status untouched)', async () => {
|
||||
const boom = new Error('plain failure');
|
||||
// A handler that throws a non-axios error from inside the request lifecycle.
|
||||
mock.onPost(`${BASE_URL}/auth/collab-token`).reply(() => {
|
||||
throw boom;
|
||||
});
|
||||
|
||||
let captured: any;
|
||||
try {
|
||||
await getCollabToken(BASE_URL, API_TOKEN);
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
// It must be the exact same error object, not a wrapped one, and it must
|
||||
// not have had a .status grafted onto it by the axios-error branch.
|
||||
expect(captured).toBe(boom);
|
||||
expect(captured.status).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('performLogin', () => {
|
||||
it('extracts the authToken value from a single set-cookie header', async () => {
|
||||
mock.onPost(`${BASE_URL}/auth/login`).reply(200, {}, {
|
||||
'set-cookie': ['authToken=abc123; Path=/; HttpOnly'],
|
||||
});
|
||||
|
||||
const token = await performLogin(BASE_URL, 'user@example.com', 'pw');
|
||||
|
||||
expect(token).toBe('abc123');
|
||||
});
|
||||
|
||||
it('does not truncate a base64 cookie value containing "=" padding', async () => {
|
||||
// The value contains "=" characters; slicing from the FIRST "=" must keep
|
||||
// the full base64 payload (a naive split on "=" would lose the padding).
|
||||
const value = 'eyJhbGciOiJIUzI1NiJ9.payload==';
|
||||
mock.onPost(`${BASE_URL}/auth/login`).reply(200, {}, {
|
||||
'set-cookie': [`authToken=${value}; Path=/; HttpOnly; SameSite=Lax`],
|
||||
});
|
||||
|
||||
const token = await performLogin(BASE_URL, 'user@example.com', 'pw');
|
||||
|
||||
expect(token).toBe(value);
|
||||
});
|
||||
|
||||
it('picks the authToken cookie out of multiple cookies present', async () => {
|
||||
mock.onPost(`${BASE_URL}/auth/login`).reply(200, {}, {
|
||||
'set-cookie': [
|
||||
'sessionId=zzz; Path=/',
|
||||
'authToken=the-token; Path=/; HttpOnly',
|
||||
'theme=dark; Path=/',
|
||||
],
|
||||
});
|
||||
|
||||
const token = await performLogin(BASE_URL, 'user@example.com', 'pw');
|
||||
|
||||
expect(token).toBe('the-token');
|
||||
});
|
||||
|
||||
it('does NOT match authTokenRefresh (exact cookie-name guard)', async () => {
|
||||
// Only an authTokenRefresh cookie is present; an exact-name match must
|
||||
// reject it and report no authToken cookie rather than returning its value.
|
||||
mock.onPost(`${BASE_URL}/auth/login`).reply(200, {}, {
|
||||
'set-cookie': ['authTokenRefresh=refresh-value; Path=/; HttpOnly'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
performLogin(BASE_URL, 'user@example.com', 'pw'),
|
||||
).rejects.toThrow('No authToken cookie found in login response');
|
||||
});
|
||||
|
||||
it('prefers authToken over a co-present authTokenRefresh cookie', async () => {
|
||||
mock.onPost(`${BASE_URL}/auth/login`).reply(200, {}, {
|
||||
'set-cookie': [
|
||||
'authTokenRefresh=refresh-value; Path=/',
|
||||
'authToken=real-token; Path=/; HttpOnly',
|
||||
],
|
||||
});
|
||||
|
||||
const token = await performLogin(BASE_URL, 'user@example.com', 'pw');
|
||||
|
||||
expect(token).toBe('real-token');
|
||||
});
|
||||
|
||||
it('throws when the set-cookie header is missing', async () => {
|
||||
// No set-cookie header at all in the response.
|
||||
mock.onPost(`${BASE_URL}/auth/login`).reply(200, {});
|
||||
|
||||
await expect(
|
||||
performLogin(BASE_URL, 'user@example.com', 'pw'),
|
||||
).rejects.toThrow('No Set-Cookie header found in login response');
|
||||
});
|
||||
|
||||
describe('error logging (DEBUG gating)', () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs only the status (not the body) on an axios error without DEBUG, and rethrows', async () => {
|
||||
delete process.env.DEBUG;
|
||||
mock
|
||||
.onPost(`${BASE_URL}/auth/login`)
|
||||
.reply(401, { secret: 'login-do-not-leak' });
|
||||
|
||||
await expect(
|
||||
performLogin(BASE_URL, 'user@example.com', 'pw'),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
const logged = errorSpy.mock.calls.flat();
|
||||
// The body object must not be among the logged arguments.
|
||||
expect(logged).toContain(401);
|
||||
expect(
|
||||
logged.some(
|
||||
(a) =>
|
||||
typeof a === 'object' &&
|
||||
a !== null &&
|
||||
(a as any).secret === 'login-do-not-leak',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('logs the response body on an axios error when DEBUG is set, and rethrows', async () => {
|
||||
process.env.DEBUG = '1';
|
||||
mock
|
||||
.onPost(`${BASE_URL}/auth/login`)
|
||||
.reply(401, { detail: 'verbose-login-detail' });
|
||||
|
||||
await expect(
|
||||
performLogin(BASE_URL, 'user@example.com', 'pw'),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
const logged = errorSpy.mock.calls.flat();
|
||||
expect(
|
||||
logged.some(
|
||||
(a) =>
|
||||
typeof a === 'object' &&
|
||||
a !== null &&
|
||||
(a as any).detail === 'verbose-login-detail',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user