Files
gitmost/packages/mcp/test/unit/collab-session.test.mjs
T
agent_vscode 5f3f720d9a ci(#476): гейты наблюдаемых свойств перед publish — image-smoke, migration-order на push, allowlist fail-closed, property-тесты
Retrospective of 22.06-10.07 merges showed one recurring miss class: local
logic verified, integration property never checked (#361, #353, #452, #172,
#435). This lands four gates so each of those classes fails BEFORE the
:develop image is pushed:

1. Image boot-smoke in the publish job (develop.yml + scripts/ci/image-smoke.sh):
   the exact image watchtower pulls is booted against postgres/redis services
   before the push — /api/health (startup migrator, #361-boot/#353), auth/setup,
   client dist served, hashed assets immutable + brotli (#452).
2. migration-order gate now also runs on push (test.yml): direct pushes used to
   bypass the PR-only gate; base = event.before, zero-SHA skips, force-push
   fails closed.
3. External-MCP tool allowlist fails closed (#172 class): corrupt stored value
   now reads as [] (deny-all) with an error log instead of null (allow-all);
   [] round-trips as jsonb [] via jsonbBind({preserveEmpty}) and means deny-all
   in the toolset filter. The settings form sends null for an empty tag field
   so existing "unrestricted" servers are not silently narrowed.
4. Property tests for the silent-degradation classes: converter fixpoint
   through the live server path (mcp e2e), and CollabSession cache-key
   stability under per-call fresh tokens (#435/#439 lesson) incl. a negative
   control with the token cache disabled.

Closes #476

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:02:29 +03:00

437 lines
18 KiB
JavaScript

import { test, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import {
acquireCollabSession,
destroyAllSessions,
__setCollabProviderFactory,
__sessionCountForTests,
} from "../../build/lib/collab-session.js";
import { withPageLock } from "../../build/lib/page-lock.js";
import { DocmostClient } from "../../build/client.js";
// A stand-in for HocuspocusProvider: it shares the ydoc (so the real yjs
// read/transform/write in CollabSession.mutate runs unchanged), auto-completes
// the connect+sync handshake on a microtask (unaffected by mock timers), and
// exposes hooks to drive disconnect/close/auth-failure and the unsyncedChanges
// ack. There is no collaboration server in the test env, so every test drives
// the provider through this fake.
class FakeProvider extends EventEmitter {
static instances = [];
static connectCount = 0;
static reset() {
FakeProvider.instances = [];
FakeProvider.connectCount = 0;
}
static last() {
return FakeProvider.instances[FakeProvider.instances.length - 1];
}
constructor(config, opts = {}) {
super();
this.config = config;
this.ydoc = config.document;
this.synced = false;
this.unsyncedChanges = opts.unsynced ?? 0;
this.destroyed = false;
FakeProvider.instances.push(this);
FakeProvider.connectCount += 1;
if (opts.autoSync !== false) {
// Real HocuspocusProvider fires onSynced asynchronously after the
// handshake; a microtask reproduces that without depending on timers.
queueMicrotask(() => {
if (this.destroyed) return;
this.config.onConnect?.();
this.synced = true;
this.config.onSynced?.();
});
}
}
destroy() {
this.destroyed = true;
}
// --- test drivers ---
_disconnect() {
this.config.onDisconnect?.();
}
_close() {
this.config.onClose?.();
}
_authFail() {
this.config.onAuthenticationFailed?.();
}
_ack() {
this.unsyncedChanges = 0;
this.emit("unsyncedChanges", { number: 0 });
}
}
/** Build a provider factory that stamps every provider with the given opts. */
function factory(opts = {}) {
return (config) => new FakeProvider(config, opts);
}
/** A minimal, schema-valid ProseMirror doc for a write. */
function docWith(text) {
return {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: String(text) }] },
],
};
}
const ENV_KEYS = [
"MCP_COLLAB_SESSION_IDLE_MS",
"MCP_COLLAB_SESSION_MAX_AGE_MS",
"MCP_COLLAB_SESSION_MAX_ENTRIES",
"MCP_COLLAB_TOKEN_TTL_MS",
];
let savedEnv;
beforeEach(() => {
savedEnv = {};
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
FakeProvider.reset();
__setCollabProviderFactory(factory());
});
afterEach(() => {
destroyAllSessions();
__setCollabProviderFactory(null);
mock.timers.reset();
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
});
test("acquire opens one provider; reuse returns the SAME live session", async () => {
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.equal(a, b, "same (wsUrl,page,token) must reuse the session");
assert.equal(FakeProvider.connectCount, 1, "exactly one connect/sync");
assert.equal(__sessionCountForTests(), 1);
});
test("N mutates on one page open the provider ONCE (coalesced series)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
for (let i = 0; i < 20; i++) {
const r = await session.mutate(() => docWith(`edit ${i}`));
assert.ok(r && r.doc, "each mutate resolves a MutationResult");
}
assert.equal(
FakeProvider.connectCount,
1,
"20 mutates must not reconnect — one live provider for the whole series",
);
});
test("mutate preserves the transform-abort no-op report (null transform)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
const r = await session.mutate(() => null);
assert.equal(r.verify.changed, false);
assert.equal(r.verify.summary, "no changes (transform aborted)");
});
test("registry key includes the token: different tokens => different sessions", async () => {
const a = await acquireCollabSession("page-1", "tok-A", "http://h/api");
const b = await acquireCollabSession("page-1", "tok-B", "http://h/api");
assert.notEqual(a, b, "different tokens must never share a session");
assert.equal(FakeProvider.connectCount, 2);
assert.equal(__sessionCountForTests(), 2);
// Same token reuses.
const a2 = await acquireCollabSession("page-1", "tok-A", "http://h/api");
assert.equal(a, a2);
assert.equal(FakeProvider.connectCount, 2);
});
test("disconnect at any time kills the session and removes it from the registry", async () => {
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 1);
FakeProvider.last()._disconnect();
assert.equal(__sessionCountForTests(), 0, "dead session is deregistered");
// Re-acquire opens a brand new provider.
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(a, b);
assert.equal(FakeProvider.connectCount, 2);
});
test("an in-flight mutate rejects with the connection-closed text on disconnect", async () => {
__setCollabProviderFactory(factory({ unsynced: 1 })); // stay pending after write
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
const p = session.mutate(() => docWith("x"));
// The write happened synchronously; persistence is pending. Now the socket drops.
FakeProvider.last()._disconnect();
await assert.rejects(
p,
// Assert the #437 diagnostic hint tail too (pageId + transient/retry cue),
// so a refactor that drops hint() can't pass this vacuously.
/Collaboration connection closed before the update was persisted\/synced \(pageId page-1; transient/,
);
});
test("auth failure rejects the pending open with the auth error text", async () => {
__setCollabProviderFactory(factory({ autoSync: false }));
const p = acquireCollabSession("page-1", "tok", "http://h/api");
// Let the provider be constructed, then fail auth.
await Promise.resolve();
FakeProvider.last()._authFail();
await assert.rejects(
p,
/Authentication failed for collaboration connection/,
);
assert.equal(__sessionCountForTests(), 0);
});
test("mutate-error invalidates the session (caller destroys; re-acquire is fresh)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
await assert.rejects(
session.mutate(() => {
throw new Error("afterText not found");
}),
/afterText not found/,
);
// The production caller destroys on failure; mirror that here.
session.destroy("mutate failed");
assert.equal(__sessionCountForTests(), 0);
const fresh = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(fresh, session);
assert.equal(FakeProvider.connectCount, 2);
});
test("a pending write resolves when the server acks (unsyncedChanges -> 0)", async () => {
__setCollabProviderFactory(factory({ unsynced: 1 }));
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
const p = session.mutate(() => docWith("y"));
FakeProvider.last()._ack();
const r = await p;
assert.ok(r.doc);
});
test("concurrent mutate on one session: the second rejects, the first is unaffected", async () => {
__setCollabProviderFactory(factory({ unsynced: 1 })); // first stays pending after write
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
// First mutate: write happens synchronously, persistence ack still pending.
const first = session.mutate(() => docWith("first"));
// Second overlapping mutate on the SAME session must fail fast.
await assert.rejects(
session.mutate(() => docWith("second")),
/mutate already in-flight; caller must serialize \(hold the page lock\)/,
);
// The first op is untouched: its rejector was not clobbered. Ack it now.
FakeProvider.last()._ack();
const r = await first;
assert.ok(r.doc, "the first in-flight mutate still resolves on its own ack");
assert.equal(FakeProvider.connectCount, 1, "no reconnect from the rejected overlap");
});
test("sequential mutates on one session both succeed (the guard doesn't break serialized use)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
// Await the first fully, then run the second: inflightReject was cleared by
// localFinish before the first settled, so the guard is clear for the second.
const r1 = await session.mutate(() => docWith("one"));
assert.ok(r1.doc, "first sequential mutate resolves");
const r2 = await session.mutate(() => docWith("two"));
assert.ok(r2.doc, "second sequential mutate resolves — guard not tripped");
assert.equal(FakeProvider.connectCount, 1, "sequential mutates reuse one provider");
});
test("connect timeout rejects with the connect-timeout text and fires the metric hook", async () => {
mock.timers.enable({ apis: ["setTimeout"] });
__setCollabProviderFactory(factory({ autoSync: false }));
let metricFired = 0;
const p = acquireCollabSession("page-1", "tok", "http://h/api", {
onConnectTimeout: () => {
metricFired += 1;
},
});
mock.timers.tick(25000);
await assert.rejects(
p,
// Assert the #437 diagnostic hint tail too (pageId + transient/retry cue).
/Connection timeout to collaboration server \(pageId page-1; transient/,
);
assert.equal(metricFired, 1);
assert.equal(__sessionCountForTests(), 0);
});
test("idle TTL destroys the session; re-acquire reconnects", async () => {
mock.timers.enable({ apis: ["setTimeout"] });
process.env.MCP_COLLAB_SESSION_IDLE_MS = "1000";
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 1);
mock.timers.tick(1000); // idle fires
assert.equal(__sessionCountForTests(), 0, "idle timeout destroyed it");
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(a, b);
assert.equal(FakeProvider.connectCount, 2);
});
test("max age is enforced at acquire (destroy + open fresh), idle held off", async () => {
mock.timers.enable({ apis: ["setTimeout", "Date"] });
process.env.MCP_COLLAB_SESSION_MAX_AGE_MS = "1000";
process.env.MCP_COLLAB_SESSION_IDLE_MS = "10000000"; // never fires in this test
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
mock.timers.tick(2000); // past max age, below idle
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(a, b, "a session past its max age must be replaced");
assert.equal(FakeProvider.connectCount, 2);
});
test("registry cap: least-recently-used session is destroy-evicted", async () => {
process.env.MCP_COLLAB_SESSION_MAX_ENTRIES = "2";
const s1 = await acquireCollabSession("page-1", "tok", "http://h/api");
const p1prov = FakeProvider.last();
const s2 = await acquireCollabSession("page-2", "tok", "http://h/api");
const p2prov = FakeProvider.last();
assert.equal(__sessionCountForTests(), 2);
// Touch page-1 so page-2 becomes the least-recently-used entry.
assert.equal(await acquireCollabSession("page-1", "tok", "http://h/api"), s1);
assert.equal(FakeProvider.connectCount, 2, "reuse must not reconnect");
// A third distinct page (cap 2) must evict the LRU (page-2), not page-1.
const s3 = await acquireCollabSession("page-3", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 2);
assert.equal(FakeProvider.connectCount, 3);
assert.equal(p2prov.destroyed, true, "the LRU provider was destroy-evicted");
assert.equal(p1prov.destroyed, false, "the MRU page-1 survived");
// page-1 still reused (survived), page-3 still reused.
assert.equal(await acquireCollabSession("page-1", "tok", "http://h/api"), s1);
assert.equal(await acquireCollabSession("page-3", "tok", "http://h/api"), s3);
assert.equal(FakeProvider.connectCount, 3, "no extra reconnects");
});
test("MCP_COLLAB_SESSION_IDLE_MS=0 disables the cache (legacy provider-per-op)", async () => {
process.env.MCP_COLLAB_SESSION_IDLE_MS = "0";
const s1 = await acquireCollabSession("page-1", "tok", "http://h/api");
// Never registered (ephemeral).
assert.equal(__sessionCountForTests(), 0);
const r1 = await s1.mutate(() => docWith("a"));
assert.ok(r1.doc);
// After its single op the ephemeral session self-destroyed.
assert.equal(FakeProvider.instances[0].destroyed, true);
// A second op opens a BRAND NEW provider (no reuse).
const s2 = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(s1, s2);
assert.equal(FakeProvider.connectCount, 2);
});
test("replaceImage-shaped flow: acquire under an EXTERNAL page lock does not deadlock and reuses one session", async () => {
const pageId = "page-lock";
// Mirror replaceImage: hold ONE withPageLock across scan (read-only) + write,
// each going through the non-locking acquireCollabSession.
const result = await withPageLock(pageId, async () => {
// pass 1: read-only scan (transform returns null -> no write)
const scan = await acquireCollabSession(pageId, "tok", "http://h/api");
const scanRes = await scan.mutate(() => null);
assert.equal(scanRes.verify.changed, false);
// pass 2: the actual write, same held lock
const write = await acquireCollabSession(pageId, "tok", "http://h/api");
return write.mutate(() => docWith("repointed"));
});
assert.ok(result.doc, "the locked scan+write completed without deadlock");
assert.equal(
FakeProvider.connectCount,
1,
"both passes under the held lock reuse ONE live session",
);
});
// --- #439: the collab-token cache is what makes the session cache ACTUALLY hit ---
//
// WHY these two tests exist (the #435 incident): the session registry keys on
// (wsUrl, pageId, token) for identity isolation, but BOTH production token
// sources mint a FRESH JWT on every call (the in-app provider re-signs a JWT
// whose iat/exp changes every second; the external MCP POSTs /auth/collab-token
// per call). The fresh token per call made the session-registry key unstable,
// so the prod hit-rate was 0% — connect storms, 25s timeouts, zombie sessions —
// while every other test in this file stayed green because they pass a FIXED
// "tok" string. The #439 fix is the per-client collab-token cache
// (DocmostClient.getCollabTokenWithReauth + MCP_COLLAB_TOKEN_TTL_MS); these
// tests drive the token through it with a source that returns a DIFFERENT
// fresh JWT per mint, exactly like prod, so a regression in EITHER the token
// cache or the registry keying turns them red.
//
// getCollabTokenWithReauth is TS-private, but the compiled JS exposes it; the
// tests call it directly because that is exactly the per-op composition of the
// production call sites (updatePage etc.: mint the token, then acquire).
test("#439 token cache ON: fresh-JWT-per-mint source, two ops => ONE connect (session cache hits)", async () => {
process.env.MCP_COLLAB_TOKEN_TTL_MS = "300000"; // cache ON (explicit, not default-dependent)
let mints = 0;
const client = new DocmostClient({
apiUrl: "http://h/api",
getToken: async () => "user-jwt",
// Like both prod sources: a DIFFERENT fresh JWT on every mint.
getCollabToken: async () => `fresh-jwt-${++mints}`,
});
// Op 1: mint the collab token through the client, then acquire + mutate.
const tok1 = await client.getCollabTokenWithReauth();
const s1 = await acquireCollabSession("page-1", tok1, "http://h/api");
await s1.mutate(() => docWith("one"));
// Op 2: the same identity mints again — the cache must serve the SAME token.
const tok2 = await client.getCollabTokenWithReauth();
const s2 = await acquireCollabSession("page-1", tok2, "http://h/api");
await s2.mutate(() => docWith("two"));
assert.equal(mints, 1, "the second op is served from the token cache");
assert.equal(tok2, tok1, "stable token => stable session-registry key");
assert.equal(s2, s1, "the live session is reused");
assert.equal(
FakeProvider.connectCount,
1,
"two mutations over one identity must cost exactly ONE real connect",
);
assert.equal(__sessionCountForTests(), 1);
});
test("#439 negative control: token cache OFF (TTL=0) reproduces the #435 churn — two ops => TWO connects", async () => {
process.env.MCP_COLLAB_TOKEN_TTL_MS = "0"; // explicit 0 disables the cache (fetch-per-call legacy)
let mints = 0;
const client = new DocmostClient({
apiUrl: "http://h/api",
getToken: async () => "user-jwt",
getCollabToken: async () => `fresh-jwt-${++mints}`,
});
const tok1 = await client.getCollabTokenWithReauth();
const s1 = await acquireCollabSession("page-1", tok1, "http://h/api");
await s1.mutate(() => docWith("one"));
const tok2 = await client.getCollabTokenWithReauth();
const s2 = await acquireCollabSession("page-1", tok2, "http://h/api");
await s2.mutate(() => docWith("two"));
assert.equal(mints, 2, "without the cache every op mints its own token");
assert.notEqual(tok2, tok1, "unstable token => unstable session-registry key");
assert.notEqual(s2, s1, "no session reuse");
assert.equal(
FakeProvider.connectCount,
2,
"a full reconnect per op — the #435 storm in miniature",
);
// The first session lingers under its now-unreachable key until its idle
// TTL — the zombie-session symptom of the incident.
assert.equal(__sessionCountForTests(), 2);
});
test("destroyAllSessions tears down every cached session", async () => {
await acquireCollabSession("page-1", "tok", "http://h/api");
await acquireCollabSession("page-2", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 2);
const provs = [...FakeProvider.instances];
destroyAllSessions();
assert.equal(__sessionCountForTests(), 0);
assert.ok(provs.every((p) => p.destroyed), "all providers destroyed");
});