Files
multica/server/internal/handler/comment_invocation_notice_test.go
T
1eb4b5d1f8 MUL-8203: record permission-blocked mentions as system replies (#9624)
* fix(comments): leave system replies for denied mentions (MUL-8203)

Co-authored-by: multica-agent <[email protected]>

* fix(comments): address refusal notice review nits (MUL-8203)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Yushen <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-28 15:30:58 +08:00

215 lines
10 KiB
Go

package handler
import (
"context"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/testutil"
"github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/pkg/protocol"
)
func TestCommentInvocationDeniedNoticePersistsWithoutDisclosure(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
privateID, _, readerID := privateAgentTestFixture(t)
allowedID := createHandlerTestAgent(t, "Allowed notice target", nil)
squadID := createCommentTriggerPreviewSquad(t, "Denied notice squad", privateID)
foreignWorkspace := dbfx.Workspace(t, "Other notice workspace", "notice-"+uuid.NewString())
foreignID := dbfx.Agent(t, "Other workspace secret", handlerTestRuntimeID(t), testutil.Cols{"workspace_id": foreignWorkspace})
missingID := uuid.NewString()
issueID := dbfx.Issue(t, "Persist denied mentions")
dbfx.Cleanup(t, `DELETE FROM comment WHERE issue_id = $1`, issueID)
dbfx.Cleanup(t, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
content := fmt.Sprintf("[@Private alias](mention://agent/%s) [@Missing alias](mention://agent/%s) [@Foreign alias](mention://agent/%s) [@Team alias](mention://squad/%s) [@Allowed alias](mention://agent/%s)", privateID, missingID, foreignID, squadID, allowedID)
preview := previewCommentTriggersForTest(t, issueID, map[string]any{"content": content})
if len(preview.Blocked) != 4 {
t.Fatalf("blocked preview = %+v, want four targets", preview.Blocked)
}
var count int
dbfx.QueryRow(t, `SELECT count(*) FROM comment WHERE issue_id = $1`, issueID).Scan(&count)
if count != 0 {
t.Fatal("preview wrote a comment")
}
h := *testHandler
h.Bus = events.New()
var notices []CommentResponse
h.Bus.Subscribe(protocol.EventCommentCreated, func(event events.Event) {
if payload, ok := event.Payload.(map[string]any); ok {
if comment, ok := payload["comment"].(CommentResponse); ok && comment.AuthorType == "system" {
notices = append(notices, comment)
}
}
})
var posted CommentResponse
testutil.Call(t, h.CreateComment, withURLParam(newRequest(http.MethodPost, "/api/issues/"+issueID+"/comments", map[string]any{"content": content}), "id", issueID)).Want(http.StatusCreated).JSON(&posted)
if len(posted.TriggerOutcomes) != 5 || findCommentOutcome(t, posted.TriggerOutcomes, allowedID).Status != DispatchQueued {
t.Fatalf("partial submission outcomes = %+v", posted.TriggerOutcomes)
}
if len(notices) != 1 {
t.Fatalf("broadcast system notices = %d, want one for this submission", len(notices))
}
notice := notices[0]
if notice.ParentID == nil || *notice.ParentID != posted.ID || notice.Type != "system" {
t.Fatalf("notice is not a system reply to the submitted comment: %+v", notice)
}
for _, alias := range []string{"Private alias", "Missing alias", "Foreign alias", "Team alias"} {
if !strings.Contains(notice.Content, alias) {
t.Errorf("notice omitted %q: %s", alias, notice.Content)
}
}
if strings.Count(notice.Content, "Target not found or permission not granted") != 4 {
t.Errorf("denied targets do not share the generic reason: %s", notice.Content)
}
for _, secret := range []string{"private-access-test-agent", "Other workspace secret", "[email protected]", "Allowed alias"} {
if strings.Contains(notice.Content, secret) {
t.Errorf("notice exposed an unrelated or private value %q", secret)
}
}
if len(util.ParseMentions(notice.Content)) != 0 {
t.Fatalf("notice contains actionable mentions: %s", notice.Content)
}
dbfx.QueryRow(t, `SELECT count(*) FROM agent_task_queue WHERE issue_id = $1`, issueID).Scan(&count)
if count != 1 || countQueuedCommentTriggerTasks(t, issueID, allowedID) != 1 {
t.Fatal("denied mentions or the system notice created an extra task")
}
var comments []CommentResponse
testutil.Call(t, h.ListComments, withURLParam(newRequestAs(readerID, http.MethodGet, "/api/issues/"+issueID+"/comments", nil), "id", issueID)).Want(http.StatusOK).JSON(&comments)
if len(comments) != 2 || comments[1].ID != notice.ID || comments[1].Content != notice.Content {
t.Fatalf("another reader did not receive the saved notice: %+v", comments)
}
var timeline []TimelineEntry
testutil.Call(t, h.ListTimeline, withURLParam(newRequestAs(readerID, http.MethodGet, "/api/issues/"+issueID+"/timeline", nil), "id", issueID)).Want(http.StatusOK).JSON(&timeline)
for _, entry := range timeline {
if entry.ID == notice.ID && entry.Content != nil && *entry.Content == notice.Content {
return
}
}
t.Fatal("saved notice is absent from the timeline")
}
func TestCommentInvocationDeniedNoticeForAgentEditsKeepsHistory(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
privateID, _, _ := privateAgentTestFixture(t)
leaderID := createHandlerTestAgent(t, "Denied handoff leader", nil)
issueID := dbfx.Issue(t, "Denied agent handoff", testutil.Cols{"status": "in_progress"})
dbfx.Cleanup(t, `DELETE FROM comment WHERE issue_id = $1`, issueID)
sourceTaskID := dbfx.Task(t, leaderID, testutil.Cols{"issue_id": issueID, "runtime_id": handlerTestRuntimeID(t), "status": "running", "originator_user_id": testUserID, "accountable_user_id": testUserID})
content := fmt.Sprintf("[@Worker alias](mention://agent/%s) take over", privateID)
request := func(method, path string, body any) *http.Request {
req := newRequest(method, path, body)
req.Header.Set("X-Agent-ID", leaderID)
req.Header.Set("X-Task-ID", sourceTaskID)
return req
}
var posted CommentResponse
testutil.Call(t, testHandler.CreateComment, withURLParam(request(http.MethodPost, "/api/issues/"+issueID+"/comments", map[string]any{"content": content}), "id", issueID)).Want(http.StatusCreated).JSON(&posted)
if posted.AuthorType != "agent" || findCommentOutcome(t, posted.TriggerOutcomes, privateID).Status != DispatchBlocked {
t.Fatalf("agent handoff = %+v", posted)
}
var firstNoticeID, firstNotice string
dbfx.QueryRow(t, `SELECT id, content FROM comment WHERE parent_id = $1 AND author_type = 'system'`, posted.ID).Scan(&firstNoticeID, &firstNotice)
submittedAt, err := time.Parse(time.RFC3339Nano, posted.UpdatedAt)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(firstNotice, submittedAt.UTC().Format("Jan 2, 2006 at 15:04:05 MST")) || strings.Contains(firstNotice, "revision") {
t.Fatalf("notice should identify the submission time without internal version details: %s", firstNotice)
}
edit := func(text string) CommentResponse {
var updated CommentResponse
testutil.Call(t, testHandler.UpdateComment, withURLParam(request(http.MethodPut, "/api/comments/"+posted.ID, map[string]any{"content": text}), "commentId", posted.ID)).Want(http.StatusOK).JSON(&updated)
return updated
}
edit(content)
edit("Handoff withdrawn")
updated := edit(content)
var notices []CommentResponse
_, notices = listComments(t, issueID, "?thread="+posted.ID)
if len(notices) != 3 || notices[1].ID != firstNoticeID || notices[1].Content != firstNotice {
t.Fatalf("edits changed the old receipt or created an extra receipt: %+v", notices)
}
editedAt, err := time.Parse(time.RFC3339Nano, updated.UpdatedAt)
if err != nil {
t.Fatal(err)
}
if notices[2].ID == firstNoticeID || !strings.Contains(notices[2].Content, editedAt.UTC().Format("Jan 2, 2006 at 15:04:05 MST")) || strings.Contains(notices[2].Content, "revision") {
t.Fatalf("edited-back submission did not get its own historical receipt: %+v", notices[2])
}
var tasks int
dbfx.QueryRow(t, `SELECT count(*) FROM agent_task_queue WHERE issue_id = $1`, issueID).Scan(&tasks)
if tasks != 1 {
t.Fatalf("handoff or notices created extra runs: %d tasks", tasks)
}
stored, err := testHandler.Queries.GetComment(context.Background(), parseUUID(firstNoticeID))
if err != nil || stored.AuthorType != "system" || stored.Content != firstNotice {
t.Fatalf("historical notice changed: %+v, %v", stored, err)
}
}
func TestCommentInvocationDeniedNoticeSkipsOtherSubmissions(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
privateID, _, _ := privateAgentTestFixture(t)
allowedID := createHandlerTestAgent(t, "Successful notice target", nil)
for _, tc := range []struct{ name, content string }{
{"successful", fmt.Sprintf("[@Allowed](mention://agent/%s) proceed", allowedID)},
{"note", fmt.Sprintf("/note [@Private](mention://agent/%s) reference only", privateID)},
{"code", fmt.Sprintf("`[@Private](mention://agent/%s)` is an example", privateID)},
} {
t.Run(tc.name, func(t *testing.T) {
issueID := dbfx.Issue(t, "No permission notice for "+tc.name)
dbfx.Cleanup(t, `DELETE FROM comment WHERE issue_id = $1`, issueID)
dbfx.Cleanup(t, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testutil.Call(t, testHandler.CreateComment, withURLParam(newRequest(http.MethodPost, "/api/issues/"+issueID+"/comments", map[string]any{"content": tc.content}), "id", issueID)).Want(http.StatusCreated)
var comments int
dbfx.QueryRow(t, `SELECT count(*) FROM comment WHERE issue_id = $1`, issueID).Scan(&comments)
if comments != 1 {
t.Fatalf("submission added a refusal notice: %d comments", comments)
}
})
}
}
func TestCommentInvocationDeniedNoticeSkipsDeletedParent(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
privateID, _, _ := privateAgentTestFixture(t)
issueID := dbfx.Issue(t, "Delete before permission notice")
h := *testHandler
h.Bus = events.New()
h.Bus.Subscribe(protocol.EventCommentCreated, func(event events.Event) {
payload, ok := event.Payload.(map[string]any)
if !ok {
return
}
comment, ok := payload["comment"].(CommentResponse)
if ok && comment.AuthorType == "member" {
dbfx.Exec(t, `DELETE FROM comment WHERE id = $1 AND workspace_id = $2`, comment.ID, testWorkspaceID)
}
})
var posted CommentResponse
testutil.Call(t, h.CreateComment, withURLParam(newRequest(http.MethodPost, "/api/issues/"+issueID+"/comments", map[string]any{
"content": fmt.Sprintf("[@Private](mention://agent/%s)", privateID),
}), "id", issueID)).Want(http.StatusCreated).JSON(&posted)
if findCommentOutcome(t, posted.TriggerOutcomes, privateID).Status != DispatchBlocked {
t.Fatal("notice failure changed the admission result")
}
var comments int
dbfx.QueryRow(t, `SELECT count(*) FROM comment WHERE issue_id = $1`, issueID).Scan(&comments)
if comments != 0 {
t.Fatalf("notice left an orphan after deletion: %d comments", comments)
}
}