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

359 lines
14 KiB
JavaScript

// ADR-0068 WP-B — guards & cascade tests for the comment-thread BE.
//
// Covers (per ticket T-159008 acceptance criteria):
// - POST /comment-thread idempotency: calling twice returns the same child id.
// - Readonly conversation: non-owner gets 403 with comment_thread_child_id
// hint; owner posting as space bypasses the lock.
// - sender_kind = 'space' requires owner OR space admin AND a space_id that
// matches the conversation; sender_id is preserved as the real actor.
// - Edit guard: PATCH /messages/:id/content rejects non-author callers.
// - Cascade-archive: deleting a parent flips child purpose → comments_archived
// and nulls parent_conversation_id; child messages are NOT touched.
//
// Strategy mirrors messageController-move-owner-gate.test.js — mock chatShared
// + DB helpers, mount the routers under a tiny express app, drive via supertest.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Router } from 'express';
// Shared DB mocks — every test rewires its own queue per case.
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 };
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 });
vi.mock('../chatShared.js', () => ({
dbRun: (...args) => dbRunMock(...args),
dbGet: (...args) => dbGetMock(...args),
dbAll: (...args) => dbAllMock(...args),
isPostgres: () => true,
safeJsonParse: (v, fallback) => {
try { return v == null ? fallback : (typeof v === 'string' ? JSON.parse(v) : v); }
catch { return fallback; }
},
apiLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
success, created, error, badRequest, notFound, forbidden,
requireAuth: fakeRequireAuth,
getAttachmentBaseUrl: () => '',
conversationLock: { withLock: async (_id, fn) => fn() },
parseInvocationMentions: () => [],
parseInvocationCommands: () => [],
parseMentions: () => [],
parseDelegations: () => [],
parseReferenceMentions: () => [],
parseReferenceCommands: () => [],
}));
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 (msgs) => msgs),
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(async () => 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');
function buildApp() {
const app = express();
app.use(express.json());
const router = Router();
registerConversationCrudRoutes(router);
registerMessageRoutes(router);
app.use('/api/v3/chat', router);
return app;
}
// ---------------------------------------------------------------------------
// POST /conversations/:id/comment-thread — idempotency (ticket AC #2)
// ---------------------------------------------------------------------------
describe('POST /conversations/:id/comment-thread — ADR-0068 WP-B1 idempotency', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbRunMock.mockReset();
});
it('returns existing child on second call (same id, no 4xx)', async () => {
// First call: parent lookup returns parent, no existing child, INSERT
// returns id=555, participant insert OK, child re-read returns full row.
dbGetMock
.mockResolvedValueOnce({ id: 1, title: 'Announcements', space_id: 11 }) // parent
.mockResolvedValueOnce(null) // no existing
.mockResolvedValueOnce({ id: 555, parent_conversation_id: 1, purpose: 'comments', title: '💬 Comments — Announcements' }); // re-read
dbRunMock
.mockResolvedValueOnce({ lastInsertRowid: 555 }) // INSERT conversations RETURNING id
.mockResolvedValueOnce({ lastInsertRowid: 1 }); // INSERT participant
const app = buildApp();
const first = await request(app)
.post('/api/v3/chat/conversations/1/comment-thread')
.set('x-test-user-id', '7')
.send({});
expect(first.status).toBe(201);
expect(first.body.data.child_id).toBe(555);
expect(first.body.data.found).toBe(false);
// Second call: parent lookup returns parent, existing child found,
// idempotent path returns 200 with same id.
dbGetMock.mockReset();
dbRunMock.mockReset();
dbGetMock
.mockResolvedValueOnce({ id: 1, title: 'Announcements', space_id: 11 }) // parent
.mockResolvedValueOnce({ id: 555, parent_conversation_id: 1, purpose: 'comments', title: '💬 Comments — Announcements' }); // existing
dbRunMock.mockResolvedValueOnce({ lastInsertRowid: 1 }); // participant idempotent insert
const second = await request(app)
.post('/api/v3/chat/conversations/1/comment-thread')
.set('x-test-user-id', '7')
.send({});
expect(second.status).toBe(200);
expect(second.body.data.child_id).toBe(555);
expect(second.body.data.found).toBe(true);
});
it('404 when parent is missing', async () => {
dbGetMock.mockResolvedValueOnce(null); // parent lookup miss
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);
});
});
// ---------------------------------------------------------------------------
// POST /conversations/:id/messages — readonly guard (ticket AC #3)
// ---------------------------------------------------------------------------
describe('POST /messages — ADR-0068 WP-B3 readonly guard', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbRunMock.mockReset();
canAdministerMock.mockReset();
});
it('non-owner gets 403 with comment_thread_child_id hint when readonly', async () => {
// 1) conversation load (readonly=true, space=11, owner=7)
// 2) child lookup → finds child=555
dbGetMock
.mockResolvedValueOnce({
id: 1, type: 'chat', space_id: 11, created_by: 7,
is_readonly: true, parent_conversation_id: null, purpose: null,
})
.mockResolvedValueOnce({ id: 555 }); // child for redirect hint
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/1/messages')
.set('x-test-user-id', '99') // not the owner
.send({ content: 'attempting to post in a channel' });
expect(res.status).toBe(403);
const parsed = JSON.parse(res.body.error);
expect(parsed.code).toBe('READONLY_CONVERSATION');
expect(parsed.comment_thread_child_id).toBe(555);
});
it('owner posting as space bypasses readonly lock', async () => {
// conversation load (readonly=true, space=11, owner=7)
dbGetMock
.mockResolvedValueOnce({
id: 1, type: 'chat', space_id: 11, created_by: 7,
is_readonly: true, parent_conversation_id: null, purpose: null,
})
// participant lookup
.mockResolvedValueOnce({ conversation_id: 1, user_id: 7, role: 'admin' })
// re-read after insert
.mockResolvedValueOnce({ id: 9001, conversation_id: 1, sender_id: 7, sender_kind: 'space', sender_space_id: 11, content: 'hi', mentions: '[]', attachments: '[]', metadata: '{}' });
canAdministerMock.mockResolvedValueOnce(true); // space admin = yes
dbRunMock
.mockResolvedValueOnce({ lastInsertRowid: 9001 }) // INSERT messages
.mockResolvedValueOnce({ rowCount: 1 }); // UPDATE conversations.updated_at
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/1/messages')
.set('x-test-user-id', '7')
.send({ content: 'hi', sender_kind: 'space', sender_space_id: 11 });
expect(res.status).toBe(201);
expect(res.body.data.sender_kind).toBe('space');
expect(res.body.data.sender_id).toBe(7); // audit trail — real actor preserved
});
it('rejects sender_kind="space" without ownership', async () => {
dbGetMock.mockResolvedValueOnce({
id: 1, type: 'chat', space_id: 11, created_by: 7,
is_readonly: false, parent_conversation_id: null, purpose: null,
});
canAdministerMock.mockResolvedValueOnce(false); // not a space admin
const app = buildApp();
const res = await request(app)
.post('/api/v3/chat/conversations/1/messages')
.set('x-test-user-id', '99')
.send({ content: 'sneaky', sender_kind: 'space', sender_space_id: 11 });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/send-as-space requires chat owner or space admin/i);
});
it('rejects sender_space_id mismatched with conversation space', async () => {
dbGetMock.mockResolvedValueOnce({
id: 1, 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/1/messages')
.set('x-test-user-id', '7')
.send({ content: 'wrong space', sender_kind: 'space', sender_space_id: 42 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/must match the conversation's space/i);
});
});
// ---------------------------------------------------------------------------
// PATCH /messages/:messageId/content — edit guard (ticket AC §Guards)
// ---------------------------------------------------------------------------
describe('PATCH /messages/:id/content — ADR-0068 WP-B4 edit guard', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbRunMock.mockReset();
});
it('rejects edits by a non-author caller (persona does not transfer rights)', async () => {
// Message authored by user 7, even if it shipped as a 'space' persona.
dbGetMock.mockResolvedValueOnce({
id: 9001, conversation_id: 1, sender_id: 7, content: 'orig', is_deleted: 0,
});
const app = buildApp();
const res = await request(app)
.patch('/api/v3/chat/messages/9001/content')
.set('x-test-user-id', '8') // someone else
.send({ content: 'tampered' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/your own messages/i);
expect(dbRunMock).not.toHaveBeenCalled();
});
it('allows the real author to edit', async () => {
dbGetMock.mockResolvedValueOnce({
id: 9001, conversation_id: 1, sender_id: 7, content: 'orig', is_deleted: 0,
});
dbRunMock.mockResolvedValueOnce({ rowCount: 1 });
const app = buildApp();
const res = await request(app)
.patch('/api/v3/chat/messages/9001/content')
.set('x-test-user-id', '7')
.send({ content: 'patched' });
expect(res.status).toBe(200);
expect(dbRunMock).toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// DELETE /conversations/:id — cascade-archive (ticket AC §Cascade)
// ---------------------------------------------------------------------------
describe('DELETE /conversations/:id — ADR-0068 WP-B cascade-archive', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbRunMock.mockReset();
});
it('archives child and detaches parent_conversation_id; does not touch child messages', async () => {
// 4 dbRun calls in order:
// 1. UPDATE conversations (archive children)
// 2. DELETE FROM messages WHERE conversation_id = parent
// 3. DELETE FROM conversation_participants WHERE conversation_id = parent
// 4. DELETE FROM conversations WHERE id = parent
dbRunMock
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 5 })
.mockResolvedValueOnce({ rowCount: 2 })
.mockResolvedValueOnce({ rowCount: 1 });
const app = buildApp();
const res = await request(app)
.delete('/api/v3/chat/conversations/1')
.set('x-test-user-id', '7');
expect(res.status).toBe(200);
expect(res.body.data.deleted).toBe(true);
// First call must be the archive UPDATE — verify shape.
const firstCall = dbRunMock.mock.calls[0];
expect(firstCall[0]).toMatch(/UPDATE\s+conversations/i);
expect(firstCall[0]).toMatch(/purpose\s*=\s*'comments_archived'/i);
expect(firstCall[0]).toMatch(/parent_conversation_id\s*=\s*NULL/i);
expect(firstCall[0]).toMatch(/WHERE\s+parent_conversation_id\s*=\s*\$1\s+AND\s+purpose\s*=\s*'comments'/i);
expect(firstCall[1]).toEqual(['1']);
// None of the subsequent DELETEs touch the child rows — they all bind to
// the parent conversation_id, which after the UPDATE no longer matches
// any child rows (parent_conversation_id was nulled).
const deleteMessages = dbRunMock.mock.calls[1][0];
expect(deleteMessages).toMatch(/DELETE\s+FROM\s+messages\s+WHERE\s+conversation_id\s*=\s*\$1/i);
expect(dbRunMock.mock.calls[1][1]).toEqual(['1']);
});
});