90168eb926
Апстрим 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>
448 lines
16 KiB
JavaScript
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");
|
|
});
|