godcrm/backend/routes/v3/chat/__tests__/adr-0068-wp-c.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

546 lines
21 KiB
JavaScript

// ADR-0068 WP-C BE — server-fetch reply_to validation + GET enrichment.
//
// Locks the canonical contract negotiated 2026-05-19:
// POST /conversations/:id/messages accepts reply_to: {message_id, fragment?, range?}
// ONLY. The source message's sender_id / content / sender_kind are read
// from the DB at validation time — client-supplied sender/content are
// rejected by being silently ignored (they never reach the persisted shape).
//
// GET /conversations/:id/messages walks metadata.reply_to.message_id values,
// batch-fetches sources from the SAME conversation, and attaches a `replyTo`
// field to each message with live (Telegram-style staleness) sender_name and
// content preview (≤200 chars).
//
// Mirrors adr-0068-wp-b.test.js's mock topology — chatShared, the heavy
// agent helpers, the inflight service.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Router } from 'express';
const dbGetMock = vi.fn();
const dbRunMock = vi.fn();
const dbAllMock = vi.fn();
const canAdministerMock = vi.fn();
function fakeRequireAuth(req, _res, next) {
const id = Number(req.headers['x-test-user-id']);
req.user = { userId: id, id, role: req.headers['x-test-user-role'] || 'user' };
next();
}
const success = (res, data) => res.status(200).json({ success: true, data });
const created = (res, data) => res.status(201).json({ success: true, data });
const error = (res, code, msg, status = 500) =>
res.status(status).json({ success: false, error: code, message: msg });
const badRequest = (res, msg) => res.status(400).json({ success: false, error: msg });
const notFound = (res, msg) => res.status(404).json({ success: false, error: msg });
const forbidden = (res, msg) => res.status(403).json({ success: false, error: msg });
const unauthorized = (res, msg) => res.status(401).json({ success: false, error: msg });
vi.mock('../chatShared.js', () => ({
dbRun: (...args) => dbRunMock(...args),
dbGet: (...args) => dbGetMock(...args),
dbAll: (...args) => dbAllMock(...args),
isPostgres: () => true,
safeJsonParse: (v, fb) => { try { return v ? JSON.parse(v) : (fb ?? null); } catch { return fb ?? null; } },
apiLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
success, created, error, badRequest, notFound, forbidden, unauthorized,
requireAuth: fakeRequireAuth,
getAttachmentBaseUrl: () => '',
conversationLock: { acquire: async (_id, fn) => fn(), withLock: async (_id, fn) => fn() },
parseInvocationMentions: () => [],
parseInvocationCommands: () => [],
parseMentions: () => [],
parseDelegations: () => [],
parseReferenceMentions: () => [],
parseReferenceCommands: () => [],
BUBBLE_PAGE_SIZE: 50,
}));
vi.mock('../../../../services/messageMoveService.js', () => ({
moveMessages: vi.fn(),
MoveValidationError: class extends Error {},
MoveAuthError: class extends Error {},
}));
vi.mock('../../../../services/criterionTicketSpawnService.js', () => ({
spawnTicketFromCriterion: vi.fn(),
SpawnValidationError: class extends Error {},
}));
vi.mock('../../../../services/EffectiveRoleService.js', () => ({
canAdminister: (...args) => canAdministerMock(...args),
}));
vi.mock('../chatAgentHelpers.js', () => ({
resolveMentionedUser: vi.fn(),
resolveAgentUser: vi.fn(),
findAiAgentByCommand: vi.fn(),
autoJoinAgentToConversation: vi.fn(),
resolveAgentInfoForMessages: vi.fn(async (rows) => rows),
validateSubAgentRowIds: vi.fn(async () => []),
enrichSubAgents: vi.fn(async () => []),
}));
vi.mock('../chatAgentExecution.js', () => ({
executeAgentResponse: vi.fn(),
}));
vi.mock('../chatAgentAutoRespond.js', () => ({
getAutoRespondAgents: vi.fn(async () => []),
shouldAutoRespondWithAI: vi.fn(() => false),
getDefaultAgentForConversation: vi.fn(async () => null),
}));
vi.mock('../../../../services/inflight/queryActive.js', () => ({
queryActiveInflight: vi.fn(async () => []),
}));
vi.mock('../../../../services/chat/rowAttachment.js', () => ({
getCommentThreadChildId: vi.fn(async () => null),
resolveAttachedRow: vi.fn(async () => null),
BOUND_TABLE_ID_CONVERSATIONS: 0,
CONVERSATIONS_SENTINEL: 0,
isConversationSentinel: (v) => Number(v) === 0,
}));
const { default: registerMessageRoutes } = await import('../messageController.js');
function buildApp() {
const app = express();
app.use(express.json());
const router = Router();
registerMessageRoutes(router);
app.use('/api/v3/chat', router);
return app;
}
// Helper — builds a stack of dbGet responses for the POST happy path:
// 1) conversation (for readonly + persona checks)
// 2) source message (loadAndValidateReplyTo)
// 3) participant lookup
// 4) inserted message row read-back (SELECT * FROM messages WHERE id = $1)
// + enrichment dbAll for replyTo.
function setupPostHappyPath({ conversationRow, sourceRow, insertedRow, enrichmentRows }) {
dbGetMock
.mockResolvedValueOnce(conversationRow) // conversation pre-fetch
.mockResolvedValueOnce(sourceRow) // reply_to source fetch
.mockResolvedValueOnce({ conversation_id: conversationRow.id, user_id: 7, role: 'admin' }) // participant
.mockResolvedValueOnce(insertedRow); // SELECT inserted
dbRunMock
.mockResolvedValueOnce({ lastInsertRowid: insertedRow.id }) // INSERT
.mockResolvedValueOnce({}); // UPDATE conversations.updated_at
if (enrichmentRows !== undefined) {
dbAllMock.mockResolvedValueOnce(enrichmentRows); // batch source fetch for response enrichment
}
}
// -----------------------------------------------------------------------------
// 1. POST validation — server-fetch source-of-truth
// -----------------------------------------------------------------------------
describe('ADR-0068 WP-C — POST /messages reply_to validation (server-fetch)', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbRunMock.mockReset();
dbAllMock.mockReset();
});
it('rejects with 400 when reply_to is not an object', async () => {
dbGetMock.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
});
const res = await request(buildApp())
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'hi', reply_to: 'invalid' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/reply_to/);
});
it('rejects with 400 when message_id is missing or not positive', async () => {
dbGetMock.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
});
const res = await request(buildApp())
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'hi', reply_to: { message_id: -5 } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/reply_to.message_id/);
});
it('returns 404 when reply_to.message_id does not exist', async () => {
dbGetMock
.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
})
.mockResolvedValueOnce(null); // source lookup → not found
const res = await request(buildApp())
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'hi', reply_to: { message_id: 999 } });
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/);
});
it('rejects 400 when source message belongs to a different conversation (anti cross-quote)', async () => {
dbGetMock
.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
})
.mockResolvedValueOnce({
id: 555, conversation_id: 99, content: 'foreign message', sender_id: 8, sender_kind: 'user', is_deleted: 0,
});
const res = await request(buildApp())
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'hi', reply_to: { message_id: 555 } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/different conversation/);
});
it('rejects 400 when source message is deleted', async () => {
dbGetMock
.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
})
.mockResolvedValueOnce({
id: 555, conversation_id: 10, content: 'gone', sender_id: 8, sender_kind: 'user', is_deleted: 1,
});
const res = await request(buildApp())
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'hi', reply_to: { message_id: 555 } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/deleted/);
});
it('rejects 400 when fragment is not a substring of the live source content', async () => {
dbGetMock
.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
})
.mockResolvedValueOnce({
id: 555, conversation_id: 10,
content: 'The quick brown fox jumps over the lazy dog',
sender_id: 8, sender_kind: 'user', is_deleted: 0,
});
const res = await request(buildApp())
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'reply', reply_to: { message_id: 555, fragment: 'NEVER APPEARED' } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not a substring/);
});
it('rejects 400 when range is out of bounds', async () => {
dbGetMock
.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
})
.mockResolvedValueOnce({
id: 555, conversation_id: 10, content: 'short', sender_id: 8, sender_kind: 'user', is_deleted: 0,
});
const res = await request(buildApp())
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'reply', reply_to: { message_id: 555, range: [0, 9999] } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/out of bounds/);
});
it('rejects 400 when fragment + range do not align with source content', async () => {
dbGetMock
.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
})
.mockResolvedValueOnce({
id: 555, conversation_id: 10,
content: 'The quick brown fox',
sender_id: 8, sender_kind: 'user', is_deleted: 0,
});
const res = await request(buildApp())
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'reply', reply_to: {
message_id: 555,
fragment: 'quick',
range: [10, 15],
} });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/range does not align/);
});
it('persists only {message_id, fragment, range} — client-supplied sender/content ignored', async () => {
setupPostHappyPath({
conversationRow: {
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
},
sourceRow: {
id: 555, conversation_id: 10,
content: 'The quick brown fox jumps over the lazy dog',
sender_id: 8, sender_kind: 'user', is_deleted: 0,
},
insertedRow: {
id: 9001, conversation_id: 10, sender_id: 7, role: 'user',
content: 'I reply',
metadata: JSON.stringify({ reply_to: { message_id: 555, fragment: 'quick brown', range: [4, 15] } }),
attachments: '[]', mentions: '[]',
},
enrichmentRows: [{
id: 555, conversation_id: 10,
content: 'The quick brown fox jumps over the lazy dog',
sender_id: 8, sender_kind: 'user', is_deleted: 0, sender_name: 'Source User',
}],
});
const res = await request(buildApp())
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({
content: 'I reply',
reply_to: {
message_id: 555,
fragment: 'quick brown',
range: [4, 15],
// These should be silently dropped (server reads them from DB):
sender: 'CLIENT_LIE',
content: 'CLIENT_LIE_CONTENT',
},
});
expect(res.status).toBe(201);
// Inspect the INSERT metadata payload — must NOT include client-supplied sender/content.
const insertCall = dbRunMock.mock.calls.find(([sql]) =>
typeof sql === 'string' && /INSERT INTO messages/.test(sql)
);
expect(insertCall).toBeDefined();
const metadataJson = insertCall[1][8]; // 9th param: metadata JSON
const persisted = JSON.parse(metadataJson);
expect(persisted.reply_to).toEqual({
message_id: 555,
fragment: 'quick brown',
range: [4, 15],
});
expect(persisted.reply_to).not.toHaveProperty('sender');
expect(persisted.reply_to).not.toHaveProperty('content');
});
it('returns response with enriched replyTo (live sender_name + content preview) on POST', async () => {
setupPostHappyPath({
conversationRow: {
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
},
sourceRow: {
id: 555, conversation_id: 10,
content: 'Source content here',
sender_id: 8, sender_kind: 'user', is_deleted: 0,
},
insertedRow: {
id: 9002, conversation_id: 10, sender_id: 7, role: 'user',
content: 'reply',
metadata: JSON.stringify({ reply_to: { message_id: 555 } }),
attachments: '[]', mentions: '[]',
},
enrichmentRows: [{
id: 555, conversation_id: 10,
content: 'Source content here',
sender_id: 8, sender_kind: 'user', is_deleted: 0, sender_name: 'Source User',
}],
});
const res = await request(buildApp())
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'reply', reply_to: { message_id: 555 } });
expect(res.status).toBe(201);
expect(res.body.data.replyTo).toBeDefined();
expect(res.body.data.replyTo.id).toBe(555);
expect(res.body.data.replyTo.content).toBe('Source content here');
expect(res.body.data.replyTo.sender_name).toBe('Source User');
expect(res.body.data.replyTo.sender_kind).toBe('user');
});
});
// -----------------------------------------------------------------------------
// 2. GET enrichment — Telegram-style staleness + cross-conversation defense
// -----------------------------------------------------------------------------
describe('ADR-0068 WP-C — GET /messages reply_to enrichment', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbRunMock.mockReset();
dbAllMock.mockReset();
});
// Helper: stack dbAll calls for GET /messages. The handler calls dbAll twice:
// 1) the main messages SELECT
// 2) the reply_to enrichment batch SELECT
// and dbGet once for the conversation row.
function setupGetWithMessages(rawMessages, enrichmentRows = []) {
dbAllMock
.mockResolvedValueOnce(rawMessages) // main messages query
.mockResolvedValueOnce(enrichmentRows); // enrichment batch
dbGetMock.mockResolvedValueOnce({
settings: '{}', bound_table_id: null, bound_row_id: null,
is_processing: false, processing_started_at: null,
processing_agent_id: null, processing_agent_name: null,
parent_conversation_id: null, purpose: null, is_readonly: false,
});
}
it('attaches live source content (Telegram-style staleness)', async () => {
setupGetWithMessages(
[
{
id: 9001, conversation_id: 10, sender_id: 7, content: 'reply',
content_type: 'text', mentions: '[]', attachments: '[]',
metadata: JSON.stringify({ reply_to: { message_id: 555 } }),
parent_id: null, sender_type: 'human', tool_results: null,
created_at: new Date().toISOString(),
},
],
[{
id: 555, conversation_id: 10,
content: 'UPDATED source content after edit',
sender_id: 8, sender_kind: 'user', is_deleted: 0, sender_name: 'Source User',
}]
);
const res = await request(buildApp())
.get('/api/v3/chat/conversations/10/messages?limit=50')
.set('x-test-user-id', '7');
expect(res.status).toBe(200);
const m = res.body.data.messages[0];
expect(m.replyTo).toEqual(expect.objectContaining({
id: 555,
content: 'UPDATED source content after edit',
sender_name: 'Source User',
sender_kind: 'user',
}));
});
it('marks reply_to as deleted=true when source is missing from the batch result', async () => {
setupGetWithMessages(
[
{
id: 9001, conversation_id: 10, sender_id: 7, content: 'reply',
content_type: 'text', mentions: '[]', attachments: '[]',
metadata: JSON.stringify({ reply_to: { message_id: 555, fragment: 'gone' } }),
parent_id: null, sender_type: 'human', tool_results: null,
created_at: new Date().toISOString(),
},
],
[] // source not returned — either deleted or filtered by conversation_id
);
const res = await request(buildApp())
.get('/api/v3/chat/conversations/10/messages?limit=50')
.set('x-test-user-id', '7');
expect(res.status).toBe(200);
const m = res.body.data.messages[0];
expect(m.replyTo).toEqual({
id: 555,
deleted: true,
fragment: 'gone',
range: null,
});
});
it('truncates long source content to 200 chars and sets truncated=true', async () => {
const longContent = 'a'.repeat(500);
setupGetWithMessages(
[
{
id: 9001, conversation_id: 10, sender_id: 7, content: 'reply',
content_type: 'text', mentions: '[]', attachments: '[]',
metadata: JSON.stringify({ reply_to: { message_id: 555 } }),
parent_id: null, sender_type: 'human', tool_results: null,
created_at: new Date().toISOString(),
},
],
[{
id: 555, conversation_id: 10, content: longContent,
sender_id: 8, sender_kind: 'user', is_deleted: 0, sender_name: 'Long Source',
}]
);
const res = await request(buildApp())
.get('/api/v3/chat/conversations/10/messages?limit=50')
.set('x-test-user-id', '7');
expect(res.status).toBe(200);
const m = res.body.data.messages[0];
expect(m.replyTo.content).toHaveLength(200);
expect(m.replyTo.truncated).toBe(true);
});
it('passes conversation_id to the enrichment batch query (cross-conversation defense)', async () => {
setupGetWithMessages(
[
{
id: 9001, conversation_id: 10, sender_id: 7, content: 'reply',
content_type: 'text', mentions: '[]', attachments: '[]',
metadata: JSON.stringify({ reply_to: { message_id: 555 } }),
parent_id: null, sender_type: 'human', tool_results: null,
created_at: new Date().toISOString(),
},
],
[]
);
const res = await request(buildApp())
.get('/api/v3/chat/conversations/10/messages?limit=50')
.set('x-test-user-id', '7');
expect(res.status).toBe(200);
// Second dbAll call is the enrichment query — its SQL must filter by
// conversation_id, and the params must include conversationId.
const enrichmentCall = dbAllMock.mock.calls[1];
expect(enrichmentCall).toBeDefined();
const [sql, params] = enrichmentCall;
expect(sql).toMatch(/conversation_id\s*=\s*\$2/);
// params: [ids, conversationId]
expect(params[1]).toBe('10');
});
it('does not call enrichment when no messages carry metadata.reply_to', async () => {
setupGetWithMessages(
[
{
id: 9001, conversation_id: 10, sender_id: 7, content: 'plain',
content_type: 'text', mentions: '[]', attachments: '[]',
metadata: '{}',
parent_id: null, sender_type: 'human', tool_results: null,
created_at: new Date().toISOString(),
},
],
[]
);
const res = await request(buildApp())
.get('/api/v3/chat/conversations/10/messages?limit=50')
.set('x-test-user-id', '7');
expect(res.status).toBe(200);
// Only the main messages query should have hit dbAll.
expect(dbAllMock).toHaveBeenCalledTimes(1);
expect(res.body.data.messages[0].replyTo).toBeUndefined();
});
});