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>
This commit is contained in:
2026-07-11 01:02:29 +03:00
parent 3411bda2d1
commit 5f3f720d9a
16 changed files with 670 additions and 57 deletions
@@ -1,5 +1,6 @@
import { Kysely, sql } from 'kysely';
import { randomUUID } from 'node:crypto';
import { Logger } from '@nestjs/common';
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
import { getTestDb, destroyTestDb, createWorkspace } from './db';
@@ -54,7 +55,11 @@ describe('AiMcpServerRepo tool_allowlist jsonb round-trip [integration]', () =>
expect(Array.isArray(found?.toolAllowlist)).toBe(true);
});
it('an empty allowlist is normalized to null (no restriction), not []', async () => {
// #476 (deliberate behaviour change): an empty allowlist used to be
// normalized to SQL NULL, which downstream means "no restriction" — so an
// admin's deny-all `[]` silently became allow-all. It must now round-trip as
// a real jsonb `[]` (deny-all), distinct from NULL.
it('an empty allowlist round-trips as jsonb [] (deny-all), not null (#476)', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
@@ -62,7 +67,27 @@ describe('AiMcpServerRepo tool_allowlist jsonb round-trip [integration]', () =>
url: 'https://example.com/mcp',
toolAllowlist: [],
});
// The column is SQL NULL, so jsonb_typeof returns SQL NULL (JS null).
// The column holds a real (empty) jsonb ARRAY, not SQL NULL.
expect(await jsonbTypeof(row.id)).toBe('array');
expect((await repo.findById(row.id, ws))?.toolAllowlist).toEqual([]);
});
it('update to [] persists jsonb [] and update to null clears to SQL NULL (#476)', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
toolAllowlist: ['search'],
});
// Deny-all via update: [] must survive as a real jsonb array.
await repo.update(row.id, ws, { toolAllowlist: [] });
expect(await jsonbTypeof(row.id)).toBe('array');
expect((await repo.findById(row.id, ws))?.toolAllowlist).toEqual([]);
// Explicit clear (null) still means "no restriction" = SQL NULL.
await repo.update(row.id, ws, { toolAllowlist: null });
expect(await jsonbTypeof(row.id)).toBeNull();
expect((await repo.findById(row.id, ws))?.toolAllowlist).toBeNull();
});
@@ -92,23 +117,60 @@ describe('AiMcpServerRepo tool_allowlist jsonb round-trip [integration]', () =>
expect(healed?.toolAllowlist).toEqual(['alpha', 'beta']);
});
it('FAIL-OPEN: a present-but-corrupt tool_allowlist reads back as null (no restriction)', async () => {
// #185 re-review pt 8: normalizeRow's fail-open branch — the column is
// PRESENT but does not parse into a string[] (here a jsonb string scalar
// holding non-array JSON). The read must degrade to `null` ("no restriction"),
// not crash. (A warn is logged with the server id; not asserted here.)
const id = randomUUID();
await sql`
INSERT INTO ai_mcp_servers (id, workspace_id, name, transport, url, tool_allowlist)
VALUES (
${id}, ${ws}, ${`srv-${id}`}, 'http', 'https://example.com/mcp',
to_jsonb(${'{"not":"an array"}'}::text)
)
`.execute(db);
// Sanity: the column is present (a jsonb string scalar), not SQL NULL.
expect(await jsonbTypeof(id)).toBe('string');
// ...yet the read degrades to null (fail-open).
expect((await repo.findById(id, ws))?.toolAllowlist).toBeNull();
// #476 (deliberate behaviour change, replaces the old FAIL-OPEN pin): a
// present-but-corrupt tool_allowlist used to degrade to null ("no
// restriction"), silently handing the agent ALL of the server's tools. It
// must now FAIL CLOSED to `[]` (deny-all) and log an error.
it('FAIL-CLOSED: a present-but-corrupt tool_allowlist reads back as [] (deny-all) + error log (#476)', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
try {
// The column is PRESENT but does not parse into a string[] — a jsonb
// string scalar holding unparseable text (a truncated legacy write).
const id = randomUUID();
await sql`
INSERT INTO ai_mcp_servers (id, workspace_id, name, transport, url, tool_allowlist)
VALUES (
${id}, ${ws}, ${`srv-${id}`}, 'http', 'https://example.com/mcp',
to_jsonb(${'{oops'}::text)
)
`.execute(db);
// Sanity: the column is present (a jsonb string scalar), not SQL NULL.
expect(await jsonbTypeof(id)).toBe('string');
// ...and the read degrades to [] (fail-closed deny-all), not null.
expect((await repo.findById(id, ws))?.toolAllowlist).toEqual([]);
// The narrowing is not silent: an error names the server id (never the
// corrupt contents).
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(`Corrupt tool_allowlist for MCP server ${id}`),
);
expect(
errorSpy.mock.calls.some((c) => String(c[0]).includes('{oops')),
).toBe(false);
} finally {
errorSpy.mockRestore();
}
});
it('FAIL-CLOSED: corrupt non-array JSON (an object) also reads back as [] (#476)', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
try {
const id = randomUUID();
await sql`
INSERT INTO ai_mcp_servers (id, workspace_id, name, transport, url, tool_allowlist)
VALUES (
${id}, ${ws}, ${`srv-${id}`}, 'http', 'https://example.com/mcp',
to_jsonb(${'{"not":"an array"}'}::text)
)
`.execute(db);
expect(await jsonbTypeof(id)).toBe('string');
expect((await repo.findById(id, ws))?.toolAllowlist).toEqual([]);
} finally {
errorSpy.mockRestore();
}
});
});