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