Files
docmost-sync/test/auth-utils.test.ts
vvzvlad d9d8538846 test(sync): implement test-strategy Phase 1-2 (pure unit/golden/property), +102 tests
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.
2026-06-17 01:01:26 +03:00

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