Files
gitmost/packages/mcp/test/mock/reauth.test.mjs
T
agent_coder 90168eb926 fix(mcp): курсорная пагинация вместо офсетной — устранить тихую потерю страниц (#442)
Апстрим 78b1c1a4 перевёл серверные эндпоинты на КУРСОРНУЮ пагинацию, а
ValidationPipe({whitelist:true}) молча вырезает неизвестное поле `page`.
MCP-клиент так и слал офсетный `page` → сервер отдавал ту же первую двадцатку
с hasNextPage:true, цикл выкручивался до MAX_PAGES=50 одинаковых запросов, дети
№21+ не выгружались (с поддеревьями). Дедуп `visited` гасил дубли → «дырявое»
дерево без ошибок. Netmap: 20/299 страниц терялось, 160 запросов вместо 62.

- A: enumerateSpacePages → один POST /pages/tree (весь спейс/поддерево разом);
  fallback на курсорный BFS при 404/405 (stock upstream). Возврат {pages,
  truncated}; truncated честный — true только при реальном упоре fallback-BFS в
  MAX_NODES.
- B: listSidebarPages → курсорный цикл, limit:100, guard на неподвижный курсор
  (!next || next===cursor → break) — если протокол снова разойдётся, не крутит
  дубли молча; warn при упоре в MAX_PAGES.
- C: paginateAll (/spaces, /shares) → та же курсорная миграция + guard.
- D: check_new_comments — /pages/tree поддерева включает корень
  (getPageAndDescendants), убран лишний getPageRaw; в fallback корень
  засевается явно (иначе его комменты терялись — регрессия того же класса).
- listComments: do/while → for с MAX_PAGES + guard неподвижного курсора
  (был безлимитный — тот же сценарий #442 дал бы бесконечный цикл).

Внутренний цикл: 2 прохода. Первый нашёл потерю комментов корня в fallback
поддерева (data-loss) → засев корня; догрёб honest-truncated, warn в
listSidebarPages, guard в listComments. Второй проход — APPROVE, форма возврата
{pages,truncated} распространена на оба вызова без пропусков.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:08:56 +03:00

448 lines
16 KiB
JavaScript

// Mock-HTTP tests for the re-auth / multipart / pagination paths in
// DocmostClient that the live e2e (which always starts with a FRESH token)
// can never reach: expired-token replay, concurrent-login dedup, the
// no-infinite-loop guard, exact cookie parsing, and the paginateAll loop
// guards. A local http.createServer stands in for Docmost so everything
// stays deterministic and offline.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
// Read a request body to completion (used to assert /auth/login receives the
// email/password JSON, and just to drain the stream before responding).
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
});
req.on("end", () => resolve(raw));
});
}
// Start an http server bound to an ephemeral port and resolve once it is
// listening, returning the server plus the api base URL the client should use.
function startServer(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
});
});
}
function closeServer(server) {
return new Promise((resolve) => server.close(resolve));
}
// JSON helper.
function sendJson(res, status, obj, extraHeaders = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
res.end(JSON.stringify(obj));
}
// Track every server so the after() hook can guarantee nothing is left open.
const openServers = [];
async function spawn(handler) {
const { server, baseURL } = await startServer(handler);
openServers.push(server);
return { server, baseURL };
}
after(async () => {
await Promise.all(openServers.map((s) => closeServer(s)));
});
// -----------------------------------------------------------------------------
// 1) 401-then-200: the interceptor re-logs-in and replays the request once.
// -----------------------------------------------------------------------------
test("401 on a JSON endpoint triggers re-login and a successful replay", async () => {
let loginCalls = 0;
let infoCalls = 0;
let replayedAuthHeader = null;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
loginCalls++;
// Hand back a fresh token via Set-Cookie (HttpOnly, like Docmost).
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=fresh-token-123; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/workspace/info") {
infoCalls++;
// First hit: token is stale -> 401. Second hit (the replay): 200, and
// record the Authorization header so we can confirm the new Bearer.
if (infoCalls === 1) {
sendJson(res, 401, { message: "Unauthorized" });
} else {
replayedAuthHeader = req.headers["authorization"];
sendJson(res, 200, { success: true, data: { id: "ws-1", name: "WS" } });
}
return;
}
sendJson(res, 404, { message: "not found" });
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
// Pre-seed a stale token so the FIRST /workspace/info uses it and 401s,
// exercising the interceptor replay rather than the initial-login path.
client.token = "stale-token";
client.client.defaults.headers.common["Authorization"] = "Bearer stale-token";
const result = await client.getWorkspace();
assert.equal(result.success, true);
assert.equal(loginCalls, 1, "/auth/login should be called exactly once");
assert.equal(infoCalls, 2, "the endpoint should be hit twice (401 then replay)");
assert.equal(
replayedAuthHeader,
"Bearer fresh-token-123",
"the replay must carry the freshly minted Bearer token",
);
});
// -----------------------------------------------------------------------------
// 2) Login dedup: concurrent 401s collapse into a single /auth/login.
// -----------------------------------------------------------------------------
test("concurrent 401s deduplicate into a single /auth/login call", async () => {
let loginCalls = 0;
const infoState = new Map(); // per-endpoint hit counter
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
loginCalls++;
// Delay the login response a touch so all concurrent requests are still
// in flight and genuinely share the one in-flight loginPromise.
setTimeout(() => {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=shared-token; Path=/; HttpOnly",
});
}, 40);
return;
}
// Several distinct JSON endpoints, each 401 on the first hit then 200.
const n = (infoState.get(req.url) || 0) + 1;
infoState.set(req.url, n);
if (n === 1) {
sendJson(res, 401, { message: "Unauthorized" });
} else {
sendJson(res, 200, { success: true, data: { items: [], meta: {} } });
}
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
client.token = "stale-token";
client.client.defaults.headers.common["Authorization"] = "Bearer stale-token";
// Fire several different requests concurrently; each one's first attempt 401s
// and triggers a re-login, but the in-flight loginPromise must coalesce them.
await Promise.all([
client.getWorkspace(),
client.getSpaces(),
client.search("anything"),
client.listShares(),
]);
assert.equal(
loginCalls,
1,
"all concurrent 401s must share ONE in-flight /auth/login",
);
});
// -----------------------------------------------------------------------------
// 3) Persistent 401: exactly one retry, no infinite loop; a 401 on the login
// endpoint itself is NOT retried.
// -----------------------------------------------------------------------------
test("a persistently-401 endpoint fails after exactly one retry", async () => {
let loginCalls = 0;
let infoCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
loginCalls++;
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/workspace/info") {
infoCalls++;
// ALWAYS 401, even after a fresh login: the retry guard must stop here.
sendJson(res, 401, { message: "Unauthorized" });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
client.token = "stale-token";
client.client.defaults.headers.common["Authorization"] = "Bearer stale-token";
await assert.rejects(() => client.getWorkspace());
// Original request + exactly ONE replay = 2 hits, never more (no loop).
assert.equal(infoCalls, 2, "endpoint hit at most twice (one retry only)");
assert.equal(loginCalls, 1, "re-login attempted exactly once");
});
test("a 401 on /auth/login itself is not retried", async () => {
let loginCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
loginCalls++;
// The login endpoint rejects credentials. The interceptor must NOT try
// to "re-login to fix a failed login" — that would loop forever.
sendJson(res, 401, { message: "Invalid credentials" });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "wrong-pw");
// login() -> performLogin POSTs /auth/login, gets 401; the interceptor sees
// isLoginRequest and rejects without retrying. So /auth/login is hit once.
await assert.rejects(() => client.login());
assert.equal(loginCalls, 1, "/auth/login must be attempted exactly once");
});
// -----------------------------------------------------------------------------
// 4) performLogin cookie parsing: base64 "=" padding survives intact, and a
// cookie literally named authTokenRefresh is not mistaken for authToken.
// -----------------------------------------------------------------------------
test("a token with base64 '=' padding round-trips intact to the server", async () => {
// A realistic JWT-ish value whose final segment ends in base64 "=" padding.
const paddedToken = "header.payload.c2lnbmF0dXJl==";
let sentBearer = null;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
// Include attributes AND a base64 value containing "=" so we verify the
// parser keeps everything after the FIRST "=" up to the first ";".
"Set-Cookie": `authToken=${paddedToken}; Path=/; HttpOnly; SameSite=Lax`,
});
return;
}
if (req.url === "/api/workspace/info") {
sentBearer = req.headers["authorization"];
sendJson(res, 200, { success: true, data: { id: "ws" } });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await client.login();
// The parsed token equals exactly what the server set (padding preserved).
assert.equal(client.token, paddedToken);
// And the client sends that exact token back on a subsequent request.
await client.getWorkspace();
assert.equal(sentBearer, `Bearer ${paddedToken}`);
});
test("an authTokenRefresh cookie is not mistaken for authToken", async () => {
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
// Set BOTH cookies. The exact-name match must pick authToken=real and
// ignore authTokenRefresh=should-not-match (a prefix match would grab it).
res.writeHead(200, {
"Content-Type": "application/json",
"Set-Cookie": [
"authTokenRefresh=should-not-match; Path=/; HttpOnly",
"authToken=real-token; Path=/; HttpOnly",
],
});
res.end(JSON.stringify({ success: true }));
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await client.login();
assert.equal(client.token, "real-token");
});
test("a response with ONLY authTokenRefresh (no authToken) rejects login", async () => {
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authTokenRefresh=nope; Path=/; HttpOnly",
});
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
// No authToken cookie present -> performLogin throws.
await assert.rejects(() => client.login(), /No authToken cookie/);
});
// -----------------------------------------------------------------------------
// 5) paginateAll loop guards.
// -----------------------------------------------------------------------------
test("paginateAll stops at the MAX_PAGES cap when the server always issues a fresh cursor", async () => {
let pageRequests = 0;
const LIMIT = 100;
const { baseURL } = await spawn(async (req, res) => {
const body = JSON.parse((await readBody(req)) || "{}");
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/spaces") {
pageRequests++;
// Always return a FULL page AND hasNextPage:true with a FRESH nextCursor
// that differs from the one the client just sent, so the immovable-cursor
// guard never trips — only the MAX_PAGES ceiling can stop the loop.
const items = Array.from({ length: LIMIT }, (_, i) => ({
id: `s-${pageRequests}-${i}`,
}));
sendJson(res, 200, {
success: true,
data: {
items,
meta: { hasNextPage: true, nextCursor: `cursor-${pageRequests}` },
},
});
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const all = await client.paginateAll("/spaces", {}, LIMIT);
// MAX_PAGES is 50; the loop must terminate there, not run unbounded.
assert.ok(
pageRequests <= 50,
`expected <= 50 page requests, got ${pageRequests}`,
);
assert.equal(pageRequests, 50, "should fetch exactly the MAX_PAGES cap");
assert.equal(all.length, 50 * LIMIT, "accumulates one full page per request");
});
test("paginateAll stops on the immovable-cursor guard when the server ignores the cursor param", async () => {
let pageRequests = 0;
const LIMIT = 100;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/spaces") {
pageRequests++;
// The bug class: the server IGNORES the pagination param and keeps
// returning page one with hasNextPage:true and the SAME nextCursor. The
// immovable-cursor guard must stop the loop instead of spinning to
// MAX_PAGES and duplicating items.
const items = Array.from({ length: LIMIT }, (_, i) => ({ id: `s-${i}` }));
sendJson(res, 200, {
success: true,
data: {
items,
meta: { hasNextPage: true, nextCursor: "stuck" },
},
});
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const all = await client.paginateAll("/spaces", {}, LIMIT);
// Request 1 sends no cursor and receives "stuck"; request 2 sends "stuck" and
// receives "stuck" again -> guard trips after exactly two requests, no dups.
assert.equal(pageRequests, 2, "stops once the cursor stops moving");
assert.equal(all.length, 2 * LIMIT, "no runaway accumulation past the guard");
});
test("paginateAll handles both {data:{items,meta}} and {items,meta} envelopes", async () => {
// Bare envelope: { items, meta } with no { data } wrapper.
const bareRequests = [];
const { baseURL: bareURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/groups") {
bareRequests.push(1);
// Page 1: hasNextPage true with a next cursor. Page 2: no next -> stop.
if (bareRequests.length === 1) {
sendJson(res, 200, {
items: Array.from({ length: 100 }, (_, i) => ({ id: `g${i}` })),
meta: { hasNextPage: true, nextCursor: "c2" },
});
} else {
sendJson(res, 200, {
items: [{ id: "tail" }],
meta: { hasNextPage: false, nextCursor: null },
});
}
return;
}
sendJson(res, 404, {});
});
const bareClient = new DocmostClient(bareURL, "user@example.com", "pw");
const bare = await bareClient.paginateAll("/groups", {}, 100);
assert.equal(bare.length, 101, "bare {items,meta} envelope handled");
assert.equal(bare[bare.length - 1].id, "tail");
// Wrapped envelope: { data: { items, meta } }.
const wrappedRequests = [];
const { baseURL: wrappedURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/groups") {
wrappedRequests.push(1);
// Single short page -> stops after one request.
sendJson(res, 200, {
data: {
items: [{ id: "w1" }, { id: "w2" }],
meta: { hasNextPage: false },
},
});
return;
}
sendJson(res, 404, {});
});
const wrappedClient = new DocmostClient(wrappedURL, "user@example.com", "pw");
const wrapped = await wrappedClient.paginateAll("/groups", {}, 100);
assert.equal(wrapped.length, 2, "wrapped {data:{items,meta}} envelope handled");
assert.equal(wrappedRequests.length, 1, "single short page -> one request");
});