Files
multica/server/internal/handler/comment_content_sanitize_test.go
Bohan Jiang 6caa3397ea fix(comment): sanitize NUL/invalid bytes in comment content and trigger preview (#5391)
Agent-written comment content can carry a byte PostgreSQL's TEXT column
rejects -- reachably an embedded NUL (0x00, SQLSTATE 22021) that survives a
JSON round trip. CreateComment/UpdateComment inserted content verbatim, so
such a byte failed the INSERT with an opaque 500 the CLI renders as "server
unavailable" and retries. This is the plausible cause of #5388 (a large
--content-file body erroring while shell-re-encoded --content-stdin
succeeded); the reporter's exact bytes are unconfirmed.

- Apply the existing sanitizeNullBytes() helper (already used on skill
  import for this PG failure mode) to comment content before the empty
  check, in CreateComment and UpdateComment.
- Apply the SAME normalization in PreviewCommentTriggers so the trigger
  preview and the real create/update side effect agree; otherwise a mention
  hidden behind a NUL reads as inert in preview but enqueues the agent on
  submit.
- Tests: NUL content posts (201, byte stripped) instead of 500; the preview
  target set matches the enqueue set for a NUL-hidden mention on both the
  create and edit paths.

Refs #5388

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 19:56:17 +08:00

117 lines
4.8 KiB
Go

package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/util"
)
// TestCreateComment_StripsNullBytesInsteadOf500 pins the fix for GH #5388.
//
// A comment whose content carries a byte PostgreSQL's TEXT type cannot store —
// most commonly an embedded NUL (SQLSTATE 22021) that survives a JSON round
// trip from `--content-file` — must post successfully with the offending byte
// stripped, not fail the INSERT with an opaque 500 the CLI renders as a
// generic "server unavailable" (and then retries forever).
func TestCreateComment_StripsNullBytesInsteadOf500(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
issueID := createTestIssue(t, "null-byte comment fixture (GH #5388)", "todo", "medium")
t.Cleanup(func() { deleteTestIssue(t, issueID) })
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "diagnosis body\x00 with a stray NUL byte",
})
r = withURLParam(r, "id", issueID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment with NUL byte: expected 201, got %d: %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
got, _ := body["content"].(string)
if strings.ContainsRune(got, '\x00') {
t.Fatalf("stored content still contains a NUL byte: %q", got)
}
if want := "diagnosis body with a stray NUL byte"; got != want {
t.Fatalf("stored content: expected %q (NUL stripped), got %q", want, got)
}
}
// TestCommentTriggers_NullByteHiddenMention_PreviewMatchesEnqueue guards the
// preview/side-effect divergence raised in review of the #5388 fix.
//
// CreateComment/UpdateComment sanitize content (strip NUL) before storing and
// triggering, so a mention hidden behind a NUL — valid only AFTER the byte is
// stripped — would enqueue an agent. PreviewCommentTriggers must sanitize with
// the same entry point, or it would report an empty target set while submit
// silently enqueues the agent. This pins parity for both the create and edit
// paths (the fix touches both).
func TestCommentTriggers_NullByteHiddenMention_PreviewMatchesEnqueue(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
issueID := createCommentTriggerPreviewIssue(t, "NUL-hidden mention parity (GH #5388 review)", "", "")
// Distinct agents for create vs edit: a single (issue, agent) pair allows
// only one pending task (idx_one_pending_task_per_issue_agent), which would
// mask the second enqueue.
createTarget := createHandlerTestAgent(t, "Preview NUL Parity Create", nil)
editTarget := createHandlerTestAgent(t, "Preview NUL Parity Edit", nil)
// mentionWithHiddenNull places the NUL between the agent UUID and the
// closing ')', so MentionRe does not match the raw text but does match once
// the byte is stripped.
mentionWithHiddenNull := func(agentID string) string {
return fmt.Sprintf("please take a look [@Target](mention://agent/%s\x00)", agentID)
}
previewAgentIDs := func(resp CommentTriggerPreviewResponse) map[string]bool {
ids := make(map[string]bool, len(resp.Agents))
for _, a := range resp.Agents {
ids[a.ID] = true
}
return ids
}
// --- create path ---
createContent := mentionWithHiddenNull(createTarget)
if n := len(util.ParseMentions(createContent)); n != 0 {
t.Fatalf("precondition: raw NUL content should not parse as a mention, got %d", n)
}
preview := previewCommentTriggersForTest(t, issueID, map[string]any{"content": createContent})
if ids := previewAgentIDs(preview); len(ids) != 1 || !ids[createTarget] {
t.Fatalf("create preview targets = %v, want exactly {%s}", ids, createTarget)
}
postCommentForTriggerPreviewTest(t, issueID, map[string]any{"content": createContent})
if got := countQueuedCommentTriggerTasks(t, issueID, createTarget); got != 1 {
t.Fatalf("create enqueued %d tasks for the mentioned agent, want 1 (parity with preview)", got)
}
// --- edit path ---
editContent := mentionWithHiddenNull(editTarget)
plainID := postCommentForTriggerPreviewTest(t, issueID, map[string]any{"content": "no mentions here yet"})
editPreview := previewCommentTriggersForTest(t, issueID, map[string]any{
"content": editContent,
"editing_comment_id": plainID,
})
if ids := previewAgentIDs(editPreview); len(ids) != 1 || !ids[editTarget] {
t.Fatalf("edit preview targets = %v, want exactly {%s}", ids, editTarget)
}
updateCommentForTriggerPreviewTest(t, plainID, map[string]any{"content": editContent})
if got := countQueuedCommentTriggerTasks(t, issueID, editTarget); got != 1 {
t.Fatalf("edit enqueued %d tasks for the mentioned agent, want 1 (parity with preview)", got)
}
}