Work through test-strategy-report.md, high-ROI no-refactor subset (no regen). - R-Infra: vitest resolve.alias docmost-client -> packages/docmost-client/src (fixes the dist-vs-src coverage artifact: canonicalize 0% -> real) - R-Cfg-1: export parseArgs + tests - canonicalize: align family / comment.resolved kept / link non-default + fixpoint & docsCanonicallyEqual reflexive/symmetric properties (0 -> 100%) - markdown-converter golden matrix: columns/embed/audio/pdf, drawio data-align rule, inline-mark matrix, textAlign, escaping idempotence, table sanitization (61 -> 79%) - schema parse-closures via generateJSON (TextStyle/comment/mention/Highlight/Column) - node-ops (immutability, table edge cases, makeFreshId property), transforms (setCalloutRange/insertMarkerAfter/commentsToFootnotes + renumber property) - stabilize normalize-on-write fixpoint (0 -> 100%); diff coarse-fallback; client-utils; firstDivergence; corpus fixtures details/columns/mention - 593 -> 695 green; build clean; corpus STABLE Deferred (Phase 3-4, refactor-gated): pull/collab/client-REST/git-merge integration.
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
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('returns undefined when the response carries no token on either path', async () => {
|
|
// Neither data.data.token nor data.token is present. The source does
|
|
// `data.data?.token || data.token`, so both are undefined and it returns
|
|
// undefined rather than throwing (documented behaviour / latent bug, §7).
|
|
mock.onPost(`${BASE_URL}/auth/collab-token`).reply(200, { data: {} });
|
|
|
|
const token = await getCollabToken(BASE_URL, API_TOKEN);
|
|
|
|
expect(token).toBeUndefined();
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('throws "No authToken cookie" for an EMPTY set-cookie array (distinct from missing header)', async () => {
|
|
// An empty array is TRUTHY, so it passes the `!cookies` missing-header guard
|
|
// and reaches the `.find(...)` — which returns undefined, so the distinct
|
|
// "No authToken cookie found" error fires (not the missing-header one).
|
|
mock.onPost(`${BASE_URL}/auth/login`).reply(200, {}, { 'set-cookie': [] });
|
|
|
|
await expect(
|
|
performLogin(BASE_URL, 'user@example.com', 'pw'),
|
|
).rejects.toThrow('No authToken cookie 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);
|
|
});
|
|
});
|
|
});
|