Files
gitmost/apps/server/src/core/ai-chat/chat-markdown.util.spec.ts
T
agent_coder 438ef091f9 fix(#288 review): markdown-safe-escape the untrusted page title in chat export
F1: pc.title (untrusted cross-user page title) was interpolated raw into the
markdown export heading. Reusing escapeAttr alone (the prompt sink's XML-attribute
sanitizer, strips < > ") is insufficient here because the sink is MARKDOWN: link
/image syntax survives, so a title like ![x](http://evil) or [phish](http://evil)
injects a remote image / clickable link into the downloaded .md disguised as a
trusted system annotation. Add markdownHeadingSafe() = escapeAttr() + backslash-
escape [ and ] (disables both [text](url) and ![text](url); a bare (url) is inert).
F2: cover the title branch — a title that collapses to empty via escapeAttr falls
to the bare heading (no ("")), and a link/image-injection title is neutralized
(non-vacuous vs the escapeAttr-only version).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:46:44 +03:00

458 lines
15 KiB
TypeScript

import { buildChatMarkdown, normalizeLang } from './chat-markdown.util';
import type { AiChatMessage } from '@docmost/db/types/entity.types';
/**
* normalizeLang: the client sends `i18n.language` — a FULL locale tag like
* 'en-US' / 'ru-RU', NOT a bare 'en'/'ru'. A `@IsIn(['en','ru'])` DTO rejected
* that with a 400 (caught in real-browser testing); the export now accepts any
* string and normalizes here. Guards that regression.
*/
describe('normalizeLang', () => {
it("maps any 'ru…' locale tag to ru", () => {
expect(normalizeLang('ru')).toBe('ru');
expect(normalizeLang('ru-RU')).toBe('ru');
expect(normalizeLang('RU-ru')).toBe('ru');
});
it('maps everything else (incl. region-qualified English) to en', () => {
expect(normalizeLang('en')).toBe('en');
expect(normalizeLang('en-US')).toBe('en');
expect(normalizeLang('fr-FR')).toBe('en');
expect(normalizeLang(undefined)).toBe('en');
expect(normalizeLang('')).toBe('en');
});
});
/**
* Unit tests for the SERVER Markdown export (#183). Mirrors the coverage of the
* (now-removed) client chat-markdown tests: heading/metadata, role labels, text
* + tool blocks, token footers, the interrupted-turn note, and NULL-status
* (legacy) rows. The export embeds a live `new Date().toISOString()` timestamp;
* we never assert it, only the deterministic structure.
*/
function row(partial: Partial<AiChatMessage>): AiChatMessage {
return {
id: partial.id ?? 'id',
chatId: partial.chatId ?? 'chat-1',
workspaceId: partial.workspaceId ?? 'ws-1',
userId: partial.userId ?? null,
role: partial.role ?? 'user',
content: partial.content ?? null,
toolCalls: partial.toolCalls ?? null,
metadata: partial.metadata ?? null,
status: partial.status ?? null,
createdAt: partial.createdAt ?? ('2026-06-21T00:00:00.000Z' as never),
updatedAt: partial.updatedAt ?? ('2026-06-21T00:00:00.000Z' as never),
deletedAt: partial.deletedAt ?? null,
} as AiChatMessage;
}
describe('buildChatMarkdown (server) — structure', () => {
it('emits the title heading, chat id and message count', () => {
const md = buildChatMarkdown({
title: 'My chat',
chatId: 'chat-123',
rows: [],
});
expect(md).toContain('# My chat');
expect(md).toContain('- Chat ID: `chat-123`');
expect(md).toContain('- Messages: 0');
});
it('falls back to "Untitled chat" with no title (en)', () => {
const md = buildChatMarkdown({ title: null, chatId: 'c', rows: [] });
expect(md).toContain('# Untitled chat');
});
it('localizes fixed labels with lang=ru (structure stays English)', () => {
const md = buildChatMarkdown({
title: null,
chatId: 'c',
lang: 'ru',
rows: [row({ role: 'assistant', content: 'hi' })],
});
expect(md).toContain('# Без названия');
expect(md).toContain('## 1. ИИ-агент');
// Structural words remain English.
expect(md).toContain('- Chat ID:');
});
it('numbers messages and labels roles (You / AI agent)', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({ role: 'user', content: 'question' }),
row({ role: 'assistant', content: 'answer' }),
],
});
expect(md).toContain('## 1. You');
expect(md).toContain('question');
expect(md).toContain('## 2. AI agent');
expect(md).toContain('answer');
});
it('renders a tool part with fenced input/output and the friendly label', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'done',
metadata: {
parts: [
{
type: 'tool-getPage',
state: 'output-available',
input: { id: 'p1' },
output: { title: 'Hello' },
},
{ type: 'text', text: 'done' },
],
} as never,
}),
],
});
expect(md).toContain('**Tool: Read page** (`getPage`) — done');
expect(md).toContain('Input:');
expect(md).toContain('"id": "p1"');
expect(md).toContain('Output:');
expect(md).toContain('"title": "Hello"');
});
// #186 re-review pt 1: restore the parity coverage of the removed client spec —
// error state, unknown-tool fallback (en + ru), and the circular-stringify catch.
it('renders a tool part in the error state with its errorText', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
metadata: {
parts: [
{
type: 'tool-getPage',
state: 'output-error',
input: { id: 'p1' },
errorText: 'page not found',
},
],
} as never,
}),
],
});
expect(md).toContain('**Tool: Read page** (`getPage`) — error');
expect(md).toContain('**Error:** page not found');
});
it('falls back to "Ran tool <name>" for an unknown tool (en) and the ru variant', () => {
const parts = [
{
type: 'tool-mysteryTool',
state: 'output-available',
output: { ok: 1 },
},
];
const en = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [row({ role: 'assistant', metadata: { parts } as never })],
});
expect(en).toContain('**Tool: Ran tool mysteryTool** (`mysteryTool`)');
const ru = buildChatMarkdown({
title: 'T',
chatId: 'c',
lang: 'ru',
rows: [row({ role: 'assistant', metadata: { parts } as never })],
});
expect(ru).toContain('Выполнил инструмент mysteryTool');
});
it('does not throw on a circular tool output (falls back to String)', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(() =>
buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
metadata: {
parts: [
{
type: 'tool-getPage',
state: 'output-available',
output: circular,
},
],
} as never,
}),
],
}),
).not.toThrow();
});
it('emits a token footer + total when usage is present', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'a',
metadata: {
usage: {
inputTokens: 100,
outputTokens: 20,
totalTokens: 120,
reasoningTokens: 8,
},
} as never,
}),
],
});
expect(md).toContain('- Total tokens: 120');
expect(md).toContain(
'_Tokens — in: 100, out: 20, reasoning: 8, total: 120_',
);
});
it('flags a still-streaming (interrupted) row', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({ role: 'assistant', content: 'partial', status: 'streaming' }),
],
});
expect(md).toContain('still being generated');
});
it('does NOT flag a completed row', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [row({ role: 'assistant', content: 'final', status: 'completed' })],
});
expect(md).not.toContain('still being generated');
});
it('renders a legacy NULL-status row (no parts) from plain content', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({ role: 'assistant', content: 'legacy answer', status: null }),
],
});
expect(md).toContain('legacy answer');
expect(md).not.toContain('still being generated');
});
it('renders a persisted error', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: '',
status: 'error',
metadata: { error: '401: Unauthorized' } as never,
}),
],
});
expect(md).toContain('**⚠️ Error:** 401: Unauthorized');
});
// #274 observability: an assistant row whose turn started with a user edit to
// the open page carries metadata.pageChanged = { title, diff }; the export
// renders the diff the agent saw, before the message body.
it('renders the persisted page-change diff block for an assistant row', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'answer',
metadata: {
pageChanged: { title: 'Doc', diff: '@@ -1 +1 @@\n-old\n+new' },
} as never,
}),
],
});
expect(md).toContain(
'The user edited this page before this turn; the diff the agent saw:',
);
expect(md).toContain('("Doc")');
expect(md).toContain('-old');
expect(md).toContain('+new');
// The diff sits before the message body (chronological: change, then reply).
expect(md.indexOf('-old')).toBeLessThan(md.indexOf('answer'));
});
it('does not render the page-change block when metadata.pageChanged is absent', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [row({ role: 'assistant', content: 'answer' })],
});
expect(md).not.toContain(
'The user edited this page before this turn; the diff the agent saw:',
);
});
// #288 F1/F2: an empty page title must render the BARE heading with no
// `("…")` suffix (the `pc.title ? … : …` false branch).
it('renders the page-change heading with no title suffix when title is empty', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'answer',
metadata: {
pageChanged: { title: '', diff: '@@ -1 +1 @@\n-old\n+new' },
} as never,
}),
],
});
// Bare heading, single line, no parenthesized title.
expect(md).toContain(
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
);
expect(md).not.toContain('("');
expect(md).toContain('-old');
});
// #288 F1: the page title is UNTRUSTED cross-user data, so a title carrying a
// newline / backtick / `"` / `<`/`>` must be neutralized by escapeAttr before
// it is interpolated into the `> **…**` blockquote heading — otherwise it
// could break the blockquote onto multiple lines or inject markup/HTML into
// the downloaded .md. escapeAttr strips `<>"` and collapses whitespace runs to
// a single space, so `Ev"il\n> `x` <b>` becomes ``Evil `x` b``.
it('escapes an untrusted page title in the page-change heading', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'answer',
metadata: {
pageChanged: {
title: 'Ev"il\n> `x` <b>',
diff: '@@ -1 +1 @@\n-old\n+new',
},
} as never,
}),
],
});
// The heading stays a single blockquote line with the escaped title.
expect(md).toContain(
'> **📝 The user edited this page before this turn; the diff the agent saw: ("Evil `x` b")**',
);
// No raw attribute/markup breakers survived from the title.
expect(md).not.toContain('Ev"il');
expect(md).not.toContain('<b>');
});
// #288 review F1: escapeAttr ALONE is insufficient for this MARKDOWN sink —
// link/image syntax survives it. A cross-user title with `![x](url)` /
// `[phish](url)` must NOT become a working remote image or clickable link in
// the downloaded .md; markdownHeadingSafe backslash-escapes `[`/`]` so both are
// inert. (Non-vacuous: fails against the escapeAttr-only version, which left
// `](https://` intact.)
it('neutralizes markdown link/image syntax in an untrusted page title', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'answer',
metadata: {
pageChanged: {
title:
'![x](https://attacker.example/t.png) and [click](https://phish.example)',
diff: '@@ -1 +1 @@\n-old\n+new',
},
} as never,
}),
],
});
// No WORKING image/link syntax survives — the `[…]` sits escaped as `\[…\]`,
// so the unescaped `![x](` image and `[click](` link markers are gone. (We
// deliberately do NOT assert `not.toContain('](https://')`: after escaping the
// literal `\](https://` still contains `](https://` as a raw substring — that
// check would false-fail even though the link is inert.)
expect(md).not.toContain('![x](');
expect(md).not.toContain('[click](');
// The brackets are backslash-escaped, so `[text](url)`/`![text](url)` are inert.
expect(md).toContain('\\[');
expect(md).toContain('\\]');
// The heading stays a SINGLE blockquote line (no newline injected).
const headingLine = md
.split('\n')
.find((l) => l.includes('the diff the agent saw:'));
expect(headingLine).toBeDefined();
expect(headingLine).toContain('\\[x\\]');
expect(headingLine).toContain('\\[click\\]');
});
// #288 internal review Finding 2: a NON-empty title made up entirely of
// escapeAttr breakers (`<>"`) escapes to '' — the ternary must then fall to the
// BARE heading with NO `("…")` suffix. Locks the ternary-on-escaped-value
// behavior (distinct from the empty-string input test above).
it('renders the bare heading for a title that escapes to empty', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'answer',
metadata: {
pageChanged: { title: '<>"', diff: '@@ -1 +1 @@\n-old\n+new' },
} as never,
}),
],
});
expect(md).toContain(
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
);
expect(md).not.toContain('("');
expect(md).toContain('-old');
});
it('escapes embedded triple-backtick fences with a longer delimiter', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'x',
metadata: {
parts: [
{
type: 'tool-getPage',
state: 'output-available',
output: '```inner```',
},
],
} as never,
}),
],
});
// A 4-backtick fence wraps content that itself contains a 3-backtick run.
expect(md).toContain('````');
});
});