Files
gitmost/apps/server/src/core/ai-chat/ai-chat.controller.bound-chat.spec.ts
T
claude code agent 227 0df6242128 fix(ai-chat): resolve page slugId to uuid in bound-chat, fixing 22P02 500 (#312)
POST /api/ai-chat/bound-chat 500'd with Postgres 22P02 because the client
sends a page slugId (10-char nanoid) in the request `pageId` field, which the
server passed straight into the UUID `page_id` column. The chat-to-document
binding silently broke (client fail-softs to a new chat) and every slug-URL
page open logged a 500.

Fix: resolve the incoming id to a real page UUID on the server. PageRepo.findById
already accepts both a uuid and a slugId (isValidUUID→slugId fallback), so
boundChat now resolves the page first, guards it against a foreign/unknown
workspace (returns {chatId:null} before any chat lookup — no cross-workspace
probe), and looks up the latest chat by the resolved page.id (real uuid).

Client: renamed the local pageId→slugId for clarity (the value is a slugId);
the wire body key stays `pageId` so the DTO is unchanged. DTO left @IsString()
(a @IsUUID() would only turn the 500 into a 400 and still break binding).

Test: bound-chat spec asserts a slugId resolves and findLatestByPage is called
with the real uuid; a foreign-workspace page → {chatId:null} without a chat
lookup (no leak); an unknown id → {chatId:null}, no throw.

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

93 lines
3.5 KiB
TypeScript

import { AiChatController } from './ai-chat.controller';
import type { User, Workspace } from '@docmost/db/types/entity.types';
/**
* Wiring spec for the #191 `POST /ai-chat/bound-chat` endpoint, hardened for
* #312. `dto.pageId` carries either a page slugId (10-char nanoid, off a slug
* URL) or a page uuid, so the controller must FIRST resolve it to a real page
* uuid via PageRepo.findById (which accepts both) — passing the raw slugId into
* the uuid ai_chats.page_id column caused a Postgres 22P02 500. Only then is the
* caller's most-recent OWN chat for that page looked up (by the resolved uuid),
* and a page in a different workspace (or an unknown id) yields { chatId: null }
* without ever touching the chat lookup. Exercised with hand-rolled mocks, no
* Nest graph and no DB.
*/
describe('AiChatController.boundChat', () => {
const user = { id: 'u1' } as User;
const workspace = { id: 'ws1' } as Workspace;
function makeController(opts: { page: unknown; chat?: unknown }) {
const aiChatRepo = {
findLatestByPage: jest.fn().mockResolvedValue(opts.chat),
};
const pageRepo = {
findById: jest.fn().mockResolvedValue(opts.page),
};
const controller = new AiChatController(
{} as never,
aiChatRepo as never,
{} as never,
{} as never,
pageRepo as never,
);
return { controller, aiChatRepo, pageRepo };
}
it('resolves a slugId to the page uuid and returns the owned chat id', async () => {
const { controller, aiChatRepo, pageRepo } = makeController({
// findById accepts a slugId and returns the page with its real uuid.
page: { id: 'page-uuid-1', workspaceId: 'ws1' },
chat: { id: 'c1', creatorId: 'u1' },
});
// The client sends a 10-char nanoid slugId, NOT a uuid.
const res = await controller.boundChat(
{ pageId: 'i82qXsivsx' },
user,
workspace,
);
expect(pageRepo.findById).toHaveBeenCalledWith('i82qXsivsx');
// findLatestByPage must receive the RESOLVED uuid, never the raw slugId.
expect(aiChatRepo.findLatestByPage).toHaveBeenCalledWith(
'u1',
'ws1',
'page-uuid-1',
);
expect(res).toEqual({ chatId: 'c1' });
});
it('returns { chatId: null } for a page in a DIFFERENT workspace without a chat lookup', async () => {
const { controller, aiChatRepo, pageRepo } = makeController({
page: { id: 'page-uuid-2', workspaceId: 'other-ws' },
});
const res = await controller.boundChat(
{ pageId: 'foreignSlug' },
user,
workspace,
);
expect(pageRepo.findById).toHaveBeenCalledWith('foreignSlug');
// No cross-workspace leak: the chat lookup must never run.
expect(aiChatRepo.findLatestByPage).not.toHaveBeenCalled();
expect(res).toEqual({ chatId: null });
});
it('returns { chatId: null } for an unknown id without throwing or looking up a chat', async () => {
const { controller, aiChatRepo } = makeController({ page: undefined });
const res = await controller.boundChat(
{ pageId: 'does-not-exist' },
user,
workspace,
);
expect(aiChatRepo.findLatestByPage).not.toHaveBeenCalled();
expect(res).toEqual({ chatId: null });
});
it('returns { chatId: null } when the resolved page has no owned chat', async () => {
const { controller } = makeController({
page: { id: 'page-uuid-3', workspaceId: 'ws1' },
chat: undefined,
});
const res = await controller.boundChat({ pageId: 'p3' }, user, workspace);
expect(res).toEqual({ chatId: null });
});
});