package mcpsrv
import (
"context"
"fmt"
"strings"
"testing"
"time"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/prosediff"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// The two revisions of the document under review. The comments were written
// against fxCommentBase; fxCommentHead is what the agent has since pushed, and
// every anchor is resolved against it — which is the whole point of resolving
// at read time rather than storing a state.
const (
fxCommentBase = "# Storage\n\nThe first paragraph.\n\nThe second paragraph, as it was.\n\n" +
"# Gone\n\nA section that was removed.\n"
fxCommentHead = "# Storage\n\nThe first paragraph.\n\nThe second paragraph, rewritten.\n"
)
// fakeCommenter stands in for *service.Service. It holds no database, no git
// and no repository, which is what lets these tests run against real anchor
// resolution — service.AnchorThreads is called for real, on real segmented
// documents, because that is the part with something to get wrong.
//
// Its Threads mirrors service.Threads' ACL rather than owning one: the check
// lives in service/ and this only stands in for it, so that "the tool surfaces
// the refusal instead of swallowing it" can be tested at all.
type fakeCommenter struct {
threads []service.Thread
docs []service.ProposalDoc
sawPrincipal authn.Principal
sawThreadID int
sawBody string
diffCalls int
}
func (f *fakeCommenter) Threads(_ context.Context, p authn.Principal, _ int) ([]service.Thread, error) {
f.sawPrincipal = p
if !p.CanRead() {
return nil, fmt.Errorf("%w: %s may not read review threads", service.ErrForbidden, p)
}
return f.threads, nil
}
func (f *fakeCommenter) ReplyTo(_ context.Context, p authn.Principal, threadID int, body string) (service.Comment, error) {
f.sawPrincipal, f.sawThreadID, f.sawBody = p, threadID, body
// The attribution rule itself is service.ReplyTo's; reproduced here only so
// the mapping back out has something shaped like a stored reply to map.
author, agent := p.Owner, false
if p.IsAgent() {
if p.Agent == "" || p.Session == "" {
return service.Comment{}, fmt.Errorf("%w: an agent reply must carry its identity and session", service.ErrInvalid)
}
author, agent = p.Agent, true
}
return service.Comment{
ID: 99, ParentID: threadID, Body: body, Author: author, Agent: agent,
Created: time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC),
}, nil
}
func (f *fakeCommenter) GetProposal(_ context.Context, id int) (service.Proposal, error) {
return service.Proposal{ID: id, Branch: fmt.Sprintf("proposals/%d", id)}, nil
}
func (f *fakeCommenter) ProposalDiff(context.Context, service.Proposal) ([]service.ProposalDoc, error) {
f.diffCalls++
return f.docs, nil
}
// anchorTo builds the anchor a comment on the named block of src would have
// been stored with, using service.AnchorOf — the same conversion the web form
// makes, so the fixtures cannot anchor to something no surface could produce.
func anchorTo(t *testing.T, src, text string) core.CommentAnchor {
t.Helper()
for i, b := range prosediff.Segment([]byte(src)) {
if strings.TrimSpace(b.Text) != text {
continue
}
a, err := service.AnchorOf("SPEC-0007", []byte(src), i, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf(%q): %v", text, err)
}
return a
}
t.Fatalf("no block %q in the fixture", text)
return core.CommentAnchor{}
}
func commentFixture(t *testing.T) *fakeCommenter {
t.Helper()
resolved := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC)
thread := func(id int, body, block string, resolvedAt *time.Time, replies ...service.Comment) service.Thread {
return service.Thread{
Root: service.Comment{
ID: id, Body: body, Author: "bigbes",
Created: time.Date(2026, 7, 19, 8, 0, 0, 0, time.UTC),
},
DocPath: "specs/0007-storage.md",
Anchor: anchorTo(t, fxCommentBase, block),
Replies: replies,
Resolved: resolvedAt,
// Block is -1 out of service.Threads and stays so until anchoring
// runs, which is exactly what the tool must not report as 0.
Block: -1,
}
}
return &fakeCommenter{
threads: []service.Thread{
thread(1, "this paragraph is still wrong", "The first paragraph.", nil,
service.Comment{
ID: 4, ParentID: 1, Body: "on it", Author: "claude-code/spec-writer", Agent: true,
Created: time.Date(2026, 7, 19, 8, 30, 0, 0, time.UTC),
}),
thread(2, "reword this", "The second paragraph, as it was.", nil),
thread(3, "and this section", "A section that was removed.", &resolved),
},
docs: []service.ProposalDoc{{
Path: "specs/0007-storage.md",
Base: []byte(fxCommentBase),
Proposed: []byte(fxCommentHead),
}},
}
}
func agentPrincipal() authn.Principal {
return authn.Principal{
Kind: authn.KindAgent,
Owner: "bigbes",
Agent: "claude-code/spec-writer",
Session: "sess-1",
}
}
// Listing reports each thread's fit against the revision the branch is at NOW,
// not against the one the comment was written on. All three states appear here
// because an agent that cannot tell them apart will go and edit the wrong
// paragraph: "anchored" is a live critique, "edited" may already be answered,
// and "outdated" describes text that is no longer in the proposal at all.
func TestCommentListReportsAnchorStateAtTheCurrentRevision(t *testing.T) {
c := commentFixture(t)
ctx := authn.WithPrincipal(context.Background(), agentPrincipal())
out, err := commentHandler(ctx, c, commentInput{Proposal: 7})
if err != nil {
t.Fatalf("commentHandler: %v", err)
}
if out.Proposal != 7 || len(out.Threads) != 3 {
t.Fatalf("output = %+v, want proposal 7 and 3 threads", out)
}
// The untouched paragraph: the commented text is still there verbatim.
if got := out.Threads[0]; got.State != string(core.AnchorExact) || got.Block < 0 {
t.Errorf("thread 1 = state %q block %d, want anchored at a real block", got.State, got.Block)
}
// The rewritten paragraph: same position under the same heading, new text.
if got := out.Threads[1]; got.State != string(core.AnchorEdited) || got.Block < 0 {
t.Errorf("thread 2 = state %q block %d, want edited at a real block", got.State, got.Block)
}
// The deleted section: no block to point at, and -1 rather than 0, which
// would name the first block of the document.
if got := out.Threads[2]; got.State != string(core.AnchorOutdated) || got.Block != -1 {
t.Errorf("thread 3 = state %q block %d, want outdated at no block", got.State, got.Block)
}
// Everything the agent needs to find the block, and to know who is waiting.
first := out.Threads[0]
if first.Thread != 1 || first.Document != "specs/0007-storage.md" || first.DocID != "SPEC-0007" {
t.Errorf("thread identity = %+v, want thread 1 on SPEC-0007", first)
}
if len(first.Heading) != 1 || first.Heading[0] != "Storage" || first.Side != string(core.SideNew) {
t.Errorf("anchor = heading %v side %q, want [Storage]/new", first.Heading, first.Side)
}
if !first.Open || first.Author != "bigbes" || first.Agent {
t.Errorf("root = %+v, want an open thread authored by the owner", first)
}
if len(first.Replies) != 1 || first.Replies[0].Thread != 1 || !first.Replies[0].Agent {
t.Errorf("replies = %+v, want the agent's one reply, attributed to it", first.Replies)
}
if out.Threads[2].Open {
t.Error("a resolved thread must not report itself open; the owner closed it")
}
if out.Reply != nil {
t.Error("listing returned a reply it never wrote")
}
}
// A reply carries the agent's provenance to service.ReplyTo unaltered. It
// matters because ReplyTo refuses an agent whose identity or session is empty:
// a tool that forwarded a stripped principal would turn every agent reply into
// an invalid-argument error, and one that substituted the owner would forge
// attribution.
func TestCommentReplyCarriesTheAgentIdentityAndSession(t *testing.T) {
c := commentFixture(t)
principal := agentPrincipal()
ctx := authn.WithPrincipal(context.Background(), principal)
out, err := commentHandler(ctx, c, commentInput{Proposal: 7, Thread: 2, Body: "reworded in the next push"})
if err != nil {
t.Fatalf("commentHandler: %v", err)
}
// Principal is no longer comparable with == — it carries a grant set — so
// its rendering stands in: that names the kind, the agent, the session and
// the owner, which is everything this assertion is about.
if c.sawPrincipal.String() != principal.String() {
t.Errorf("principal = %+v, want the one on the context %+v", c.sawPrincipal, principal)
}
if c.sawThreadID != 2 || c.sawBody != "reworded in the next push" {
t.Errorf("service saw thread %d body %q, want 2 and the reply text", c.sawThreadID, c.sawBody)
}
if out.Reply == nil {
t.Fatal("reply missing from the output")
}
if out.Reply.Author != "claude-code/spec-writer" || !out.Reply.Agent || out.Reply.Thread != 2 {
t.Errorf("reply = %+v, want it attributed to the agent on thread 2", out.Reply)
}
if out.Reply.Created != "2026-07-24T12:00:00Z" {
t.Errorf("created = %q, want an RFC3339 timestamp", out.Reply.Created)
}
if out.Threads != nil {
t.Error("a reply must not claim to have listed threads")
}
// Replying reads no revision: anchor fit is a listing question.
if c.diffCalls != 0 {
t.Errorf("ProposalDiff called %d times while replying", c.diffCalls)
}
}
// A thread id is a global integer, so a mistyped one names a real thread on
// some other proposal and service.ReplyTo — which takes only the id — would
// accept it. The reply would land where the agent cannot see it.
func TestCommentReplyRefusesAThreadThatIsNotOnThisProposal(t *testing.T) {
c := commentFixture(t)
ctx := authn.WithPrincipal(context.Background(), agentPrincipal())
_, err := commentHandler(ctx, c, commentInput{Proposal: 7, Thread: 404, Body: "answering"})
if err == nil {
t.Fatal("a thread from another proposal was accepted")
}
if !strings.Contains(err.Error(), "no review thread 404") {
t.Errorf("error = %v, want it to name the thread", err)
}
if c.sawThreadID != 0 {
t.Errorf("the reply reached the service anyway, on thread %d", c.sawThreadID)
}
}
// thread and body are one argument in two halves. Half of them is a caller that
// meant to reply, and answering the other question — listing — would look like
// the reply had been posted.
func TestCommentRequiresThreadAndBodyTogether(t *testing.T) {
c := commentFixture(t)
ctx := authn.WithPrincipal(context.Background(), agentPrincipal())
if _, err := commentHandler(ctx, c, commentInput{Proposal: 7, Thread: 1}); err == nil {
t.Error("a reply with no body was accepted")
}
if _, err := commentHandler(ctx, c, commentInput{Proposal: 7, Body: " "}); err == nil {
t.Error("a body with no thread was accepted")
}
if _, err := commentHandler(ctx, c, commentInput{Body: "x", Thread: 1}); err == nil {
t.Error("a call naming no proposal was accepted")
}
if c.sawThreadID != 0 || c.diffCalls != 0 {
t.Error("a malformed call reached the service")
}
}
// The ACL is service.Threads'. What this pins is that the tool forwards the
// caller and surfaces the refusal, rather than reporting an empty thread list —
// which would read as "nobody has commented" to an agent asking what to fix.
func TestCommentRefusesAPrincipalThatMayNotRead(t *testing.T) {
c := commentFixture(t)
ctx := authn.WithPrincipal(context.Background(), authn.Anonymous())
_, err := commentHandler(ctx, c, commentInput{Proposal: 7})
if err == nil {
t.Fatal("an anonymous caller read the review threads")
}
if !strings.Contains(err.Error(), "forbidden") {
t.Errorf("error = %v, want the service's refusal", err)
}
// The refusal lands before anything reads git, because the ACL check is the
// first call the handler makes in either mode.
if c.diffCalls != 0 {
t.Errorf("ProposalDiff ran for a caller that may not read")
}
// And it cannot write either: the same refusal covers the reply path.
if _, err := commentHandler(ctx, c, commentInput{Proposal: 7, Thread: 1, Body: "hi"}); err == nil {
t.Fatal("an anonymous caller replied to a review thread")
}
if c.sawThreadID != 0 {
t.Error("the reply reached the service despite the refusal")
}
}
// A proposal nobody has commented on is not an error, and it is not worth a
// revision read: anchoring resolves nothing when there is nothing to anchor.
func TestCommentListOfAnUncommentedProposalReadsNoRevision(t *testing.T) {
c := commentFixture(t)
c.threads = nil
ctx := authn.WithPrincipal(context.Background(), agentPrincipal())
out, err := commentHandler(ctx, c, commentInput{Proposal: 7})
if err != nil {
t.Fatalf("commentHandler: %v", err)
}
if len(out.Threads) != 0 || out.Proposal != 7 {
t.Errorf("output = %+v, want proposal 7 with no threads", out)
}
if c.diffCalls != 0 {
t.Errorf("ProposalDiff ran %d times for a proposal with no threads", c.diffCalls)
}
}