From 73c36f2fa98373757d5d982e551771955a6e9297 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 05:39:35 +0300 Subject: [PATCH] test(share-ai): drive the non-text message-part 400 path (#103) Covers the #63 guard: a message with a non-text part -> 400 'Unsupported message content'; a message mixing text + a non-text part still 400s (before the 413 size check). Co-Authored-By: Claude Opus 4.8 --- .../public-share-chat.controller.spec.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/apps/server/src/core/ai-chat/public-share-chat.controller.spec.ts b/apps/server/src/core/ai-chat/public-share-chat.controller.spec.ts index 13c19f8b..08b20b43 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.controller.spec.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.controller.spec.ts @@ -209,6 +209,56 @@ describe('resolveShareAssistantRequest (extracted controller funnel)', () => { expect(await statusOf(deps, body({ messages: [huge] }))).toBe(413); }); + it('a message with a non-text part => 400 Unsupported message content', async () => { + // The anonymous path runs no tools, so a client-supplied tool/file/data part + // is never legitimate and is rejected before it can reach the model context. + const { deps } = makeDeps(); + const nonText = { + role: 'user', + parts: [{ type: 'tool-call' }], + }; + let caught: HttpException | null = null; + try { + await resolveShareAssistantRequest(deps, { + workspaceId: 'ws-1', + body: body({ messages: [nonText] }) as never, + }); + } catch (err) { + caught = err instanceof HttpException ? err : null; + } + expect(caught).toBeInstanceOf(HttpException); + expect(caught!.getStatus()).toBe(400); + expect(caught!.message).toBe('Unsupported message content'); + }); + + it('a message mixing a text part AND a non-text part => still 400 (rejected before the 413 size check)', async () => { + // A forged non-text part smuggled alongside a legit text part is still + // rejected: the non-text guard runs BEFORE the char-cap (413) check, so even + // an over-long mixed message surfaces the 400, not the size error. + const { deps } = makeDeps(); + const mixed = { + role: 'user', + parts: [ + { type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }, + { type: 'tool-call' }, + ], + }; + let caught: HttpException | null = null; + try { + await resolveShareAssistantRequest(deps, { + workspaceId: 'ws-1', + body: body({ messages: [mixed] }) as never, + }); + } catch (err) { + caught = err instanceof HttpException ? err : null; + } + expect(caught).toBeInstanceOf(HttpException); + // The non-text guard wins over the 413 size cap even though the text part + // alone would exceed MAX_SHARE_MESSAGE_CHARS. + expect(caught!.getStatus()).toBe(400); + expect(caught!.message).toBe('Unsupported message content'); + }); + it('the quota gate is checked BEFORE the payload caps (429 wins over 413)', async () => { // Over-cap workspace AND an over-long message: the 429 must surface first, so // an over-cap caller is rejected without even paying the payload-cap scan.