godcrm/backend/routes/v3/chat/__tests__/adr-0068-wp-b.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

551 lines
23 KiB
JavaScript

// ADR-0068 WP-B BE — comment-thread + channel-readonly + send-as-space.
//
// Covers all four guards locked in the chat sign-off (2026-05-19, T-159008):
// 1. POST /conversations/:id/comment-thread idempotency (twice → same id, no 4xx).
// 2. is_readonly=true blocks non-owner sends (403 + comment_thread_child_id hint).
// 3. is_readonly=true + sender_kind='space' + chat owner → message goes through
// with sender_id preserved (audit) and sender_space_id stamped.
// 4. PATCH /messages/:id/content — only the real author (sender_id) may edit.
//
// Plus the cascade-archive contract verified at the DELETE handler boundary:
// 5. DELETE /conversations/:id first runs the UPDATE that flips comment-thread
// children to purpose='comments_archived' before any message DELETE.
//
// Strategy mirrors messageController-move-owner-gate.test.js: mock chatShared.js
// + the heavy agent helpers, build the routers in-memory, hit them via supertest.
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() },
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 () => []),
}));
const { default: registerMessageRoutes } = await import('../messageController.js');
const { default: registerConversationCrudRoutes } = await import('../conversationCrudController.js');
const { default: registerConversationExtrasRoutes } = await import('../conversationExtrasController.js');
function buildApp() {
const app = express();
app.use(express.json());
const router = Router();
registerConversationCrudRoutes(router);
registerConversationExtrasRoutes(router);
registerMessageRoutes(router);
app.use('/api/v3/chat', router);
return app;
}
// -----------------------------------------------------------------------------
// 1. POST /conversations/:id/comment-thread — idempotency
// -----------------------------------------------------------------------------
describe('ADR-0068 WP-B1 — POST /conversations/:id/comment-thread', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbRunMock.mockReset();
});
it('returns the existing child on the second call (no INSERT)', async () => {
// First call path is exercised by the integration suite; here we cover the
// pure idempotency branch — existing child already in the DB.
const existingChild = { id: 555, parent_conversation_id: 100, purpose: 'comments', title: 'Comments — #100' };
dbGetMock
.mockResolvedValueOnce({ id: 100, title: 'Parent', space_id: 11 }) // parent lookup
.mockResolvedValueOnce(existingChild); // existing-child lookup
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/100/comment-thread')
.set('x-test-user-id', '7')
.send({});
expect(res.status).toBe(200);
expect(res.body.data.found).toBe(true);
expect(res.body.data.child_id).toBe(555);
// No INSERT into conversations should have happened.
const insertConversationCalls = dbRunMock.mock.calls.filter(([sql]) =>
typeof sql === 'string' && /INSERT INTO conversations/i.test(sql)
);
expect(insertConversationCalls).toHaveLength(0);
});
it('rejects with 400 when conversation id is invalid', async () => {
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/notanumber/comment-thread')
.set('x-test-user-id', '7')
.send({});
expect(res.status).toBe(400);
});
it('returns 404 when parent does not exist', async () => {
dbGetMock.mockResolvedValueOnce(null); // parent lookup
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/9999/comment-thread')
.set('x-test-user-id', '7')
.send({});
expect(res.status).toBe(404);
});
});
// -----------------------------------------------------------------------------
// 2-3. POST /conversations/:id/messages — readonly guard + send-as-space
// -----------------------------------------------------------------------------
describe('ADR-0068 WP-B3 — readonly guard on POST /messages', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbRunMock.mockReset();
canAdministerMock.mockReset();
});
it('non-owner is blocked with 403 and comment_thread_child_id hint', async () => {
// 1) conversation lookup (is_readonly = true, owner = 7)
dbGetMock.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: true, parent_conversation_id: null, purpose: null,
});
// 2) comment-thread child lookup for the redirect hint
dbGetMock.mockResolvedValueOnce({ id: 555 });
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '8') // not the owner
.send({ content: 'forbidden words' });
expect(res.status).toBe(403);
const body = JSON.parse(res.body.error);
expect(body.code).toBe('READONLY_CONVERSATION');
expect(body.comment_thread_child_id).toBe(555);
});
it('owner posting as space bypasses the readonly lock', async () => {
// 1) conversation (readonly, owner=7)
dbGetMock.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: true, parent_conversation_id: null, purpose: null,
});
// 2) participant lookup
dbGetMock.mockResolvedValueOnce({ conversation_id: 10, user_id: 7, role: 'admin' });
// 3) re-read of inserted message
dbGetMock.mockResolvedValueOnce({
id: 901, conversation_id: 10, sender_id: 7, role: 'user', content: 'hello',
content_type: 'text', mentions: '[]', attachments: '[]',
sender_kind: 'space', sender_space_id: 11,
});
// 4) space_id lookup for the auto-respond path (line ~115)
dbGetMock.mockResolvedValueOnce({ space_id: 11 });
dbRunMock.mockResolvedValue({ lastInsertRowid: 901 });
canAdministerMock.mockResolvedValue(false); // owner gate passes via created_by
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'hello', sender_kind: 'space', sender_space_id: 11 });
// POST /messages → 201 Created (canonical response helper).
expect(res.status).toBe(201);
// The INSERT carried sender_id=7 (real actor) AND the persona stamp.
const insertCall = dbRunMock.mock.calls.find(([sql]) =>
typeof sql === 'string' && /INSERT INTO messages/i.test(sql)
);
expect(insertCall).toBeDefined();
const params = insertCall[1];
// params order: [id, userId, role, content, content_type, mentionsJson,
// attachmentsJson, parent_id, metadataJson, sender_kind, sender_space_id]
expect(params[1]).toBe(7); // sender_id = real user (audit)
expect(params[9]).toBe('space'); // sender_kind
expect(params[10]).toBe(11); // sender_space_id
});
it('non-owner attempting send-as-space gets 403', async () => {
dbGetMock.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
});
canAdministerMock.mockResolvedValue(false);
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '8')
.send({ content: 'impersonation attempt', sender_kind: 'space', sender_space_id: 11 });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/send-as-space/i);
});
it('send-as-space across different spaces is rejected with 400', async () => {
dbGetMock.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
});
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'wrong space', sender_kind: 'space', sender_space_id: 999 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/must match the conversation's space/);
});
it('rejects unknown sender_kind values', async () => {
dbGetMock.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
});
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'agent injection', sender_kind: 'agent', sender_space_id: 11 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid sender_kind/);
});
});
// -----------------------------------------------------------------------------
// 4. PATCH /messages/:id/content — edit-only-real-actor
// -----------------------------------------------------------------------------
describe('ADR-0068 WP-B4 — edit-only-real-actor on PATCH /messages/:id/content', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbRunMock.mockReset();
});
it('blocks edits by a non-author with 403', async () => {
dbGetMock.mockResolvedValueOnce({ id: 901, conversation_id: 10, sender_id: 7, content: 'old', is_deleted: 0 });
const app = buildApp();
const res = await request(app)
.patch('/api/v3/chat/messages/901/content')
.set('x-test-user-id', '8') // different user
.send({ content: 'tampered' });
expect(res.status).toBe(403);
expect(dbRunMock).not.toHaveBeenCalled();
});
it('allows the original author to edit', async () => {
dbGetMock.mockResolvedValueOnce({ id: 901, conversation_id: 10, sender_id: 7, content: 'old', is_deleted: 0 });
dbRunMock.mockResolvedValue({});
const app = buildApp();
const res = await request(app)
.patch('/api/v3/chat/messages/901/content')
.set('x-test-user-id', '7')
.send({ content: 'fresh' });
expect(res.status).toBe(200);
expect(dbRunMock).toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------------
// 5. DELETE /conversations/:id — cascade-archive contract
// -----------------------------------------------------------------------------
describe('ADR-0068 WP-B Cascade — DELETE /conversations/:id', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbRunMock.mockReset();
});
it('archives comment-thread children BEFORE wiping the parent', async () => {
dbRunMock.mockResolvedValue({});
const app = buildApp();
const res = await request(app)
.delete('/api/v3/chat/conversations/100')
.set('x-test-user-id', '7');
expect(res.status).toBe(200);
// Inspect the order of dbRun calls; archive must come first, then the
// hard-delete cascade on messages/participants/conversation.
const sqls = dbRunMock.mock.calls.map(([sql]) => sql);
const archiveIdx = sqls.findIndex(s => /UPDATE conversations[\s\S]*purpose\s*=\s*'comments_archived'/i.test(s));
const messagesDeleteIdx = sqls.findIndex(s => /DELETE FROM messages/i.test(s));
const conversationsDeleteIdx = sqls.findIndex(s => /DELETE FROM conversations/i.test(s));
expect(archiveIdx).toBeGreaterThanOrEqual(0);
expect(messagesDeleteIdx).toBeGreaterThan(archiveIdx);
expect(conversationsDeleteIdx).toBeGreaterThan(archiveIdx);
});
});
// -----------------------------------------------------------------------------
// 6. GET /conversations/:id/messages — persona JOIN on `spaces`
// -----------------------------------------------------------------------------
describe('ADR-0068 WP-B5 — persona enrichment on GET /messages', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbRunMock.mockReset();
dbAllMock.mockReset();
});
it('JOINs spaces and surfaces nested sender_space {id,name,icon,color} for sender_kind=space rows', async () => {
// Row 1 — user post, no persona fields populated by the JOIN.
// Row 2 — space-persona post, JOIN columns populated.
// Row 3 — space-persona post BUT referenced space row deleted (FK
// preserved on the message, JOIN returns NULLs).
dbAllMock.mockResolvedValueOnce([
{
id: 901, conversation_id: 10, sender_id: 7, content: 'plain user',
content_type: 'text', mentions: '[]', attachments: '[]', metadata: null,
sender_kind: 'user', sender_space_id: null,
sender_name: 'Alice', sender_avatar: null, sender_user_type: 'human',
_sender_space_name: null, _sender_space_icon: null, _sender_space_color: null,
created_at: '2026-05-19T10:00:00Z',
},
{
id: 902, conversation_id: 10, sender_id: 7, content: 'broadcast',
content_type: 'text', mentions: '[]', attachments: '[]', metadata: null,
sender_kind: 'space', sender_space_id: 11,
sender_name: 'Alice', sender_avatar: null, sender_user_type: 'human',
_sender_space_name: 'Development', _sender_space_icon: '🛠', _sender_space_color: '#0ea5e9',
created_at: '2026-05-19T10:01:00Z',
},
{
id: 903, conversation_id: 10, sender_id: 7, content: 'orphaned persona',
content_type: 'text', mentions: '[]', attachments: '[]', metadata: null,
sender_kind: 'space', sender_space_id: 999,
sender_name: 'Alice', sender_avatar: null, sender_user_type: 'human',
_sender_space_name: null, _sender_space_icon: null, _sender_space_color: null,
created_at: '2026-05-19T10:02:00Z',
},
]);
// conversation lookup (Promise.all sibling).
dbGetMock
.mockResolvedValueOnce({
settings: null, 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,
})
// contextStats
.mockResolvedValueOnce({ total_tokens_in: 0, total_tokens_out: 0, total_messages: 3, text_messages: 3, tool_calls: 0, thinking_steps: 0 })
// lastAgentMsg
.mockResolvedValueOnce(null)
// active_agent_status row
.mockResolvedValueOnce(null)
// active_plan row
.mockResolvedValueOnce(null)
// getCommentThreadChildId → existing-child lookup (returns null = no child)
.mockResolvedValueOnce(null);
const app = buildApp();
const res = await request(app)
.get('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7');
expect(res.status).toBe(200);
// 1) The SQL fired by dbAll must include the persona JOIN + underscore aliases.
const messagesCall = dbAllMock.mock.calls.find(([sql]) =>
typeof sql === 'string' && /FROM messages m/i.test(sql)
);
expect(messagesCall).toBeDefined();
const sql = messagesCall[0];
expect(sql).toMatch(/LEFT JOIN spaces s ON m\.sender_space_id = s\.id/);
expect(sql).toMatch(/s\.name as _sender_space_name/);
expect(sql).toMatch(/s\.icon as _sender_space_icon/);
expect(sql).toMatch(/s\.theme_primary as _sender_space_color/);
// 2) Internal underscore aliases MUST NOT leak in the response.
const messages = res.body.data.messages;
expect(messages).toHaveLength(3);
for (const m of messages) {
expect(m).not.toHaveProperty('_sender_space_name');
expect(m).not.toHaveProperty('_sender_space_icon');
expect(m).not.toHaveProperty('_sender_space_color');
}
// 3) Plain user message — kind='user', sender_space=null.
const userMsg = messages.find(m => m.id === 901);
expect(userMsg.sender_kind).toBe('user');
expect(userMsg.sender_space_id).toBeNull();
expect(userMsg.sender_space).toBeNull();
// 4) Space-persona message — kind='space', nested payload populated.
const spaceMsg = messages.find(m => m.id === 902);
expect(spaceMsg.sender_kind).toBe('space');
expect(spaceMsg.sender_space_id).toBe(11);
expect(spaceMsg.sender_space).toEqual({
id: 11, name: 'Development', icon: '🛠', color: '#0ea5e9',
});
// Real-actor identity is preserved alongside persona stamp (audit invariant).
expect(spaceMsg.sender_id).toBe(7);
expect(spaceMsg.sender_name).toBe('Alice');
// 5) Orphaned persona (space row deleted, FK preserved on message) —
// raw sender_space_id still surfaced for FE fallback rendering, nested
// payload null so FE can render "(deleted space)" placeholder.
const orphanMsg = messages.find(m => m.id === 903);
expect(orphanMsg.sender_kind).toBe('space');
expect(orphanMsg.sender_space_id).toBe(999);
expect(orphanMsg.sender_space).toBeNull();
});
it('coerces NULL sender_kind to "user" on legacy rows', async () => {
dbAllMock.mockResolvedValueOnce([
{
id: 800, conversation_id: 10, sender_id: 7, content: 'legacy',
content_type: 'text', mentions: '[]', attachments: '[]', metadata: null,
sender_kind: null, sender_space_id: null,
sender_name: 'Alice', sender_avatar: null, sender_user_type: 'human',
_sender_space_name: null, _sender_space_icon: null, _sender_space_color: null,
created_at: '2026-05-19T09:00:00Z',
},
]);
dbGetMock
.mockResolvedValueOnce({
settings: null, 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,
})
.mockResolvedValueOnce({ total_tokens_in: 0, total_tokens_out: 0, total_messages: 1, text_messages: 1, tool_calls: 0, thinking_steps: 0 })
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null);
const app = buildApp();
const res = await request(app)
.get('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7');
expect(res.status).toBe(200);
const [legacy] = res.body.data.messages;
expect(legacy.sender_kind).toBe('user');
expect(legacy.sender_space).toBeNull();
});
it('POST /messages response carries the nested sender_space for send-as-space', async () => {
// 1) conversation (not readonly, owner=7).
dbGetMock.mockResolvedValueOnce({
id: 10, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
});
// 2) participant lookup.
dbGetMock.mockResolvedValueOnce({ conversation_id: 10, user_id: 7, role: 'admin' });
// 3) re-read of inserted message (SELECT * FROM messages WHERE id = $1).
dbGetMock.mockResolvedValueOnce({
id: 950, conversation_id: 10, sender_id: 7, role: 'user', content: 'hello',
content_type: 'text', mentions: '[]', attachments: '[]', metadata: '{}',
sender_kind: 'space', sender_space_id: 11,
});
// 4) sender_space enrichment dbGet on spaces (POST-only, parity with GET).
dbGetMock.mockResolvedValueOnce({
id: 11, name: 'Development', icon: '🛠', theme_primary: '#0ea5e9',
});
// 5) space_id lookup for the auto-respond path.
dbGetMock.mockResolvedValueOnce({ space_id: 11 });
dbRunMock.mockResolvedValue({ lastInsertRowid: 950 });
canAdministerMock.mockResolvedValue(true);
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/10/messages')
.set('x-test-user-id', '7')
.send({ content: 'hello', sender_kind: 'space', sender_space_id: 11 });
expect(res.status).toBe(201);
const body = res.body.data;
expect(body.sender_kind).toBe('space');
expect(body.sender_space_id).toBe(11);
expect(body.sender_space).toEqual({
id: 11, name: 'Development', icon: '🛠', color: '#0ea5e9',
});
// Real actor preserved on response (audit) even when posting as a space.
expect(body.sender_id).toBe(7);
});
});