package mcpsrv_test
import (
"encoding/json"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// The beads-aware tools of docs/DESIGN.mcp.md §9.2, driven through the same
// in-process MCP client as the rest of the suite (mcpsrv_test.go carries the
// plumbing, the callers and the generic fakes).
//
// The tracker fixture below is a *board*, not a table dump: it has an epic with
// two subtasks, a blocking chain two edges long, a custom status no heuristic
// could categorise, a template that must stay out of the ready set, two
// milestones and a comment thread. That is what lets these tests assert the
// projection's own answers — the lane bucketing, the ready rule, the rollup
// arithmetic — rather than that something was rendered.
//
// Four properties are worth more than the rest and each has a section below: a
// listing carries no issue body, a database that is not a tracker is refused
// with a sentence that is neither the mask nor a failure, a masked tracker
// answers exactly as a name nobody took, and the filters narrow the same set the
// board does.
// bodyMarker prefixes every long text in the fixture — descriptions, design
// notes, acceptance criteria, notes and comment bodies — so that "no bodies
// reached the caller" is a question asked of the *serialised payload* rather
// than of the Go struct. A field added to the listing later would carry a marker
// with it and turn this suite red, which is the point: the rule has to survive
// the type changing.
const bodyMarker = "BODYTEXT"
func body(what string) string {
return bodyMarker + " " + what + " — long prose an agent did not ask for"
}
// --- the tracker fixture -----------------------------------------------------
// issueColumns is the issues table as bd writes it: identity, status, the
// metadata, the four long texts, the timestamp trail and the three flags the
// ready rule reads.
var issueColumns = []string{
"id", "title", "status", "issue_type", "priority", "assignee", "created_by", "owner",
"estimated_minutes", "external_ref", "spec_id",
"description", "design", "acceptance_criteria", "notes",
"created_at", "started_at", "updated_at", "closed_at", "close_reason",
"is_blocked", "is_template", "ephemeral",
}
// row builds one row over cols from the cells it names, leaving the rest empty —
// which is what an unset column reads as through the projection anyway.
//
// A key that is not a column panics rather than being ignored: a typo in a
// fixture that silently sets nothing produces a test that passes for no reason.
func row(cols []string, cells map[string]string) []string {
index := map[string]int{}
for i, c := range cols {
index[c] = i
}
out := make([]string, len(cols))
for name, v := range cells {
i, ok := index[name]
if !ok {
panic("row: no column named " + name)
}
out[i] = v
}
return out
}
// beadsTable is one fixture table with every column typed as text, which is what
// a bare-store read hands the projection anyway (browse renders cells).
func beadsTable(name string, cols []string, rows [][]string) fakeTable {
info := make([]browse.ColumnInfo, 0, len(cols))
for _, c := range cols {
info = append(info, browse.ColumnInfo{Name: c, Type: "text", Nullable: true})
}
return fakeTable{name: name, cols: info, rows: rows}
}
// trackerTables is the fixture board:
//
// bd-1 epic open P1 alice — parent of bd-2 and bd-8, milestone:m1
// bd-2 task in_progress P0 bob — subtask of bd-1, milestone:m1, "parser"
// bd-3 task open P2 alice — blocked by bd-4, milestone:m1, all four bodies
// bd-4 task open P3 — blocked by bd-5, which is closed, so ready
// bd-5 bug closed P1 bob — milestone:m1
// bd-6 milestone open — milestone:m1's own issue
// bd-7 task open — a template: open, unblocked, NOT ready
// bd-8 task "shipped" P1 bob — subtask of bd-1, milestone:m2
//
// "shipped" is categorised as closed only through custom_statuses — no name
// heuristic reaches it — so the lanes and the rollups below prove the projection
// consulted that table.
func trackerTables() []fakeTable {
issues := [][]string{
row(issueColumns, map[string]string{
"id": "bd-1", "title": "the parser epic", "status": "open", "issue_type": "epic",
"priority": "1", "assignee": "alice", "created_at": "2026-01-01 10:00:00",
"description": body("bd-1"),
}),
row(issueColumns, map[string]string{
"id": "bd-2", "title": "write the parser", "status": "in_progress", "issue_type": "task",
"priority": "0", "assignee": "bob", "created_by": "alice",
"created_at": "2026-01-02 10:00:00", "started_at": "2026-01-03 09:00:00",
"description": body("bd-2"), "notes": body("bd-2 notes"),
}),
row(issueColumns, map[string]string{
"id": "bd-3", "title": "ship the parser", "status": "open", "issue_type": "task",
"priority": "2", "assignee": "alice", "created_by": "carol", "owner": "alice",
"estimated_minutes": "90", "external_ref": "https://example.org/tracker/3", "spec_id": "SPEC-7",
"description": body("bd-3 description"), "design": body("bd-3 design"),
"acceptance_criteria": body("bd-3 acceptance"), "notes": body("bd-3 notes"),
"created_at": "2026-01-03 10:00:00", "updated_at": "2026-01-09 09:00:00",
}),
row(issueColumns, map[string]string{
"id": "bd-4", "title": "review the grammar", "status": "open", "issue_type": "task",
"priority": "3", "created_at": "2026-01-04 10:00:00",
}),
row(issueColumns, map[string]string{
"id": "bd-5", "title": "old lexer bug", "status": "closed", "issue_type": "bug",
"priority": "1", "assignee": "bob", "created_at": "2026-01-05 10:00:00",
"closed_at": "2026-01-05 12:00:00", "close_reason": "fixed while writing bd-2",
}),
row(issueColumns, map[string]string{
"id": "bd-6", "title": "the m1 milestone", "status": "open", "issue_type": "milestone",
"created_at": "2026-01-06 10:00:00",
}),
row(issueColumns, map[string]string{
"id": "bd-7", "title": "scaffold", "status": "open", "issue_type": "task",
"created_at": "2026-01-07 10:00:00", "is_template": "1",
}),
row(issueColumns, map[string]string{
"id": "bd-8", "title": "polish the output", "status": "shipped", "issue_type": "task",
"priority": "1", "assignee": "bob", "created_at": "2026-01-08 10:00:00",
}),
}
depColumns := []string{"issue_id", "depends_on_issue_id", "type", "created_at", "created_by"}
deps := [][]string{
{"bd-2", "bd-1", "parent-child", "2026-01-02 11:00:00", "alice"},
{"bd-8", "bd-1", "parent-child", "2026-01-08 11:00:00", "alice"},
{"bd-3", "bd-4", "blocks", "2026-01-03 11:00:00", "alice"},
{"bd-4", "bd-5", "blocks", "2026-01-04 11:00:00", "alice"},
}
return []fakeTable{
beadsTable("issues", issueColumns, issues),
beadsTable("dependencies", depColumns, deps),
beadsTable("labels", []string{"issue_id", "label"}, [][]string{
{"bd-1", "milestone:m1"},
{"bd-2", "milestone:m1"},
{"bd-2", "parser"},
{"bd-3", "milestone:m1"},
{"bd-5", "milestone:m1"},
{"bd-6", "milestone:m1"},
{"bd-8", "milestone:m2"},
}),
beadsTable("custom_statuses", []string{"name", "category"}, [][]string{
{"open", "open"},
{"in_progress", "in_progress"},
{"closed", "closed"},
{"shipped", "closed"},
}),
beadsTable("comments", []string{"issue_id", "author", "text", "created_at"}, [][]string{
{"bd-3", "bob", body("bd-3 comment"), "2026-01-09 10:00:00"},
}),
beadsTable("events", []string{"issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"}, [][]string{
{"bd-3", "created", "carol", "", "", "", "2026-01-03 10:00:00"},
{"bd-3", "updated", "alice", "", `{"priority":2}`, "", "2026-01-09 09:00:00"},
}),
}
}
// plainTables is a tracker with issues and nothing else: no labels table at all,
// so it carries no milestone and exercises the optional-table degradation the
// projection does.
func plainTables(prefix, title string) []fakeTable {
return []fakeTable{
beadsTable("issues", issueColumns, [][]string{
row(issueColumns, map[string]string{
"id": prefix + "-1", "title": title, "status": "open", "issue_type": "task",
"created_at": "2026-02-01 10:00:00", "description": body(prefix + "-1"),
}),
row(issueColumns, map[string]string{
"id": prefix + "-2", "title": title + " (done)", "status": "closed", "issue_type": "task",
"created_at": "2026-02-02 10:00:00",
}),
}),
beadsTable("dependencies", []string{"issue_id", "depends_on_issue_id", "type"}, nil),
}
}
// bulkTables is a tracker larger than beads.Max (2000), which is the only way to
// see the projection's own clip reported. The ids run bulk-0 upwards in table
// order, so everything from bulk-2000 on is the tail a capped read never sees —
// bulkTail names it.
//
// Two issues carry milestone:m1: bulk-3, which is read, and the last one, which
// is not. The rollup over a clipped read therefore reports half a milestone, and
// the labels table itself stays far under the cap, so the shortfall can only
// have come from the issues read.
func bulkTables(n int) []fakeTable {
rows := make([][]string, 0, n)
for i := range n {
rows = append(rows, row(issueColumns, map[string]string{
"id": "bulk-" + itoa(i), "title": "bulk issue", "status": "open", "issue_type": "task",
}))
}
return []fakeTable{
beadsTable("issues", issueColumns, rows),
beadsTable("dependencies", []string{"issue_id", "depends_on_issue_id", "type"}, nil),
beadsTable("labels", []string{"issue_id", "label"}, [][]string{
{"bulk-3", "milestone:m1"},
{bulkTail(n), "milestone:m1"},
}),
}
}
// bulkTail is the id of the last issue of a bulk tracker of n: it exists, and it
// is past the first beads.Max rows any projection here reads.
func bulkTail(n int) string { return "bulk-" + itoa(n-1) }
// labelsClipCount is the labels table size of labelsClipTables: more than
// beads.Max (2000), so the labels read alone comes back clipped.
const labelsClipCount = 2001
// labelsClipTables is a tracker whose issues table stays far under the cap
// while its labels table alone exceeds it — the case list_issues' long-standing
// table_truncated/table_total cannot report at all, because that pair means
// only the issues/dependencies read that decides total and truncated. Here
// neither of those is short, so the two answer "nothing was clipped" while a
// card's pills and the label filter are quietly thinner than the tracker really
// holds; the per-table clip list is the only place that shows up.
func labelsClipTables() []fakeTable {
labels := make([][]string, 0, labelsClipCount)
for i := range labelsClipCount {
labels = append(labels, []string{"lc-1", "label-" + itoa(i)})
}
return []fakeTable{
beadsTable("issues", issueColumns, [][]string{
row(issueColumns, map[string]string{
"id": "lc-1", "title": "an issue with more labels than fit", "status": "open",
"issue_type": "task", "created_at": "2026-04-01 10:00:00",
}),
}),
beadsTable("dependencies", []string{"issue_id", "depends_on_issue_id", "type"}, nil),
beadsTable("labels", []string{"issue_id", "label"}, labels),
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
// bulkIssues is the size of the bulk tracker: more than beads.Max, and not a
// multiple of it, so a clip is visible as a number rather than as a round one.
const bulkIssues = 2100
// trackerStore is a fixture store of two branches over the tables given. Both
// branches serve the same tables, which is enough for the ref questions these
// tools raise: which ref was read, and what a ref that resolves to neither
// answers.
func trackerStore(tables []fakeTable) *fakeSession {
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "b001"}, {Name: "wip", Head: "b002"}},
commits: []browse.CommitInfo{
{Hash: "b001", Author: "alice", Date: headTime, Message: "the tracker"},
{Hash: "b002", Author: "alice", Date: headTime, Message: "work in progress"},
},
tables: tables,
}
}
// beadsFixtures are the tracker databases this suite adds to the shared fixture
// set: one of every visibility, so the matrix has something to say about each,
// plus the special shapes (no milestones, larger than the cap, and the two
// memory arms).
//
// The private one carries a memory as well as issues, so that every property the
// matrix holds for a tracker's issues is asserted for its memories by the same
// loop — including that nothing about it leaks to a caller who may not read it.
//
// They are added rather than folded into fixtures() so that the listing suites
// keep asserting about exactly the databases they were written for.
func beadsFixtures() []fixture {
return []fixture{
{name: "board", visibility: core.VisibilityPublic, session: trackerStore(trackerTables())},
{name: "backlog", visibility: core.VisibilityUnlisted, session: trackerStore(plainTables("bl", "an unlisted task"))},
{
name: "roadmap",
visibility: core.VisibilityPrivate,
acl: map[int]core.AccessMode{bobID: core.AccessRO},
session: withMemories(
trackerStore(plainTables("rm", "SECRETROADMAP the private plan")),
map[string]string{memoryKeyPrefix + "escrow": "SECRETROADMAP: the release key lives in the vault"},
),
},
{name: "bulk", visibility: core.VisibilityPublic, session: trackerStore(bulkTables(bulkIssues))},
{name: "labelsclip", visibility: core.VisibilityPublic, session: trackerStore(labelsClipTables())},
{name: "memories", visibility: core.VisibilityPublic, session: memoryStore()},
{
// A tracker whose config table holds settings and no memory at all: the
// half of "nothing to remember" that a missing config table is the other
// half of.
name: "settings",
visibility: core.VisibilityPublic,
session: withConfig(
trackerStore(plainTables("st", "a task in a tracker that remembers nothing")),
map[string]string{"issue_prefix": "st", "compact_tier2_days": "30"},
),
},
}
}
func beadsFixtureNamed(t *testing.T, name string) fixture {
t.Helper()
for _, f := range beadsFixtures() {
if f.name == name {
return f
}
}
t.Fatalf("no beads fixture named %q", name)
return fixture{}
}
// beadsFakes builds the shared fakes with the tracker fixtures added to them.
func beadsFakes() (*fakeRepos, *fakeOpener) {
repos, opener := newFakeRepos(), newFakeOpener()
for _, fx := range beadsFixtures() {
id := len(repos.repos) + 1
repos.repos = append(repos.repos, &core.Repo{
ID: id,
Name: fx.name,
Description: "the " + fx.name + " tracker",
OwnerID: aliceID,
OwnerName: "alice",
Path: storePath("alice", fx.name),
Visibility: fx.visibility,
})
for userID, mode := range fx.acl {
if repos.acl[id] == nil {
repos.acl[id] = map[int]core.AccessMode{}
}
repos.acl[id][userID] = mode
}
if fx.session != nil {
opener.sessions[storePath("alice", fx.name)] = fx.session
}
}
return repos, opener
}
func beadsServer(t *testing.T) *mcp.ClientSession {
t.Helper()
repos, opener := beadsFakes()
return connect(t, newServer(t, repos, opener), nil)
}
// --- the shapes a client decodes --------------------------------------------
//
// Spelled out here rather than exported from the package: they are this
// surface's contract, and a test that reused the production structs would pass
// no matter what those structs said.
type (
issueCardResult struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Priority string `json:"priority"`
Assignee string `json:"assignee"`
Labels []string `json:"labels"`
BlockedBy int `json:"blocked_by"`
Blocks int `json:"blocks"`
Ready bool `json:"ready"`
Lane string `json:"lane"`
Category string `json:"category"`
}
listIssuesResult struct {
Ref string `json:"ref"`
Issues []issueCardResult `json:"issues"`
Total int `json:"total"`
Limit int `json:"limit"`
Truncated bool `json:"truncated"`
TableTruncated bool `json:"table_truncated"`
TableTotal int `json:"table_total"`
Clipped []clippedTableResult `json:"clipped"`
}
clippedTableResult struct {
Table string `json:"table"`
Shown int `json:"shown"`
Total int `json:"total"`
Effect string `json:"effect"`
}
issueResult struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
IssueType string `json:"issue_type"`
Priority string `json:"priority"`
Lane string `json:"lane"`
Assignee string `json:"assignee"`
CreatedBy string `json:"created_by"`
Owner string `json:"owner"`
EstimatedMinutes string `json:"estimated_minutes"`
ExternalRef string `json:"external_ref"`
SpecID string `json:"spec_id"`
Description string `json:"description"`
Design string `json:"design"`
AcceptanceCriteria string `json:"acceptance_criteria"`
Notes string `json:"notes"`
CreatedAt string `json:"created_at"`
StartedAt string `json:"started_at"`
UpdatedAt string `json:"updated_at"`
ClosedAt string `json:"closed_at"`
CloseReason string `json:"close_reason"`
Labels []string `json:"labels"`
}
edgeResult struct {
IssueID string `json:"issue_id"`
Title string `json:"title"`
Type string `json:"type"`
Status string `json:"status"`
Closed bool `json:"closed"`
}
treeNodeResult struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Status string `json:"status"`
Closed bool `json:"closed"`
Depth int `json:"depth"`
}
subtaskResult struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Category string `json:"category"`
Priority string `json:"priority"`
Assignee string `json:"assignee"`
Blocked bool `json:"blocked"`
}
commentResult struct {
Author string `json:"author"`
Text string `json:"text"`
CreatedAt string `json:"created_at"`
}
activityResult struct {
Kind string `json:"kind"`
Event string `json:"event"`
Actor string `json:"actor"`
Summary string `json:"summary"`
Text string `json:"text"`
CreatedAt string `json:"created_at"`
}
getIssueResult struct {
Ref string `json:"ref"`
Issue issueResult `json:"issue"`
IsEpic bool `json:"is_epic"`
DependsOn []edgeResult `json:"depends_on"`
DependedOnBy []edgeResult `json:"depended_on_by"`
DependsTree []treeNodeResult `json:"depends_tree"`
DependentTree []treeNodeResult `json:"dependent_tree"`
Subtasks []subtaskResult `json:"subtasks"`
SubtaskDone int `json:"subtask_done"`
SubtaskTotal int `json:"subtask_total"`
Comments []commentResult `json:"comments"`
History []activityResult `json:"history"`
TableTruncated bool `json:"table_truncated"`
TableTotal int `json:"table_total"`
}
// getIssueMissResult is the payload that travels with the one refusal on this
// surface that carries one: get_issue over a clipped read. Issue is a pointer
// here precisely because the assertion is that it arrives as null — an empty
// issue object would be a placeholder a client could not tell from an issue
// with no fields set.
getIssueMissResult struct {
Ref string `json:"ref"`
Issue *issueResult `json:"issue"`
TableTruncated bool `json:"table_truncated"`
TableTotal int `json:"table_total"`
}
milestoneMemberResult struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Priority string `json:"priority"`
Assignee string `json:"assignee"`
Category string `json:"category"`
}
milestoneEpicResult struct {
Issue milestoneMemberResult `json:"issue"`
Done int `json:"done"`
Total int `json:"total"`
Children []milestoneMemberResult `json:"children"`
}
milestoneResult struct {
Name string `json:"name"`
Label string `json:"label"`
Total int `json:"total"`
Done int `json:"done"`
InProgress int `json:"in_progress"`
Open int `json:"open"`
Heads []milestoneMemberResult `json:"heads"`
Epics []milestoneEpicResult `json:"epics"`
Loose []milestoneMemberResult `json:"loose"`
}
listMilestonesResult struct {
Ref string `json:"ref"`
Milestones []milestoneResult `json:"milestones"`
Unlabeled int `json:"unlabeled"`
Total int `json:"total"`
TableTruncated bool `json:"table_truncated"`
TableTotal int `json:"table_total"`
}
)
func listIssues(t *testing.T, s *mcp.ClientSession, a map[string]any) listIssuesResult {
t.Helper()
var out listIssuesResult
decode(t, call(t, s, "list_issues", a), &out)
return out
}
func getIssue(t *testing.T, s *mcp.ClientSession, a map[string]any) getIssueResult {
t.Helper()
var out getIssueResult
decode(t, call(t, s, "get_issue", a), &out)
return out
}
func listMilestones(t *testing.T, s *mcp.ClientSession, a map[string]any) listMilestonesResult {
t.Helper()
var out listMilestonesResult
decode(t, call(t, s, "list_milestones", a), &out)
return out
}
// decodeMiss reads the structured payload of an *error* result, which decode
// refuses on purpose. One answer here carries one — get_issue over a read that
// was clipped, where "not found" is a fact about the read rather than about the
// tracker — and this is how a client would pick it up.
func decodeMiss(t *testing.T, res *mcp.CallToolResult, out any) {
t.Helper()
require.True(t, res.IsError, "not an error result: %s", resultJSON(t, res))
require.NotNil(t, res.StructuredContent, "an error result with no payload to decode")
raw, err := json.Marshal(res.StructuredContent)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(raw, out))
}
// filter builds the nested filter argument, so a call in a test reads as the one
// constraint it is about.
func filter(kv ...any) map[string]any {
if len(kv)%2 != 0 {
panic("filter: odd key/value list")
}
out := map[string]any{}
for i := 0; i < len(kv); i += 2 {
out[kv[i].(string)] = kv[i+1]
}
return out
}
func cardIDs(res listIssuesResult) []string {
out := make([]string, 0, len(res.Issues))
for _, c := range res.Issues {
out = append(out, c.ID)
}
return out
}
// --- the list carries no bodies ---------------------------------------------
// The list/detail split of docs/DESIGN.mcp.md §9.2, asserted over the payload
// that actually goes over the wire rather than over the Go struct: a field added
// to the card later would carry a body marker into this string and turn the test
// red, which is the whole reason the fixture marks its long texts.
func TestListIssuesCarriesNoIssueBodies(t *testing.T) {
session := beadsServer(t)
res := call(t, session, "list_issues", args("board"))
require.False(t, res.IsError, "%s", errorText(res))
payload := resultJSON(t, res)
assert.NotContains(t, payload, bodyMarker,
"a listing carries identity and metadata; every long text in the fixture is marked and none may appear")
for _, field := range []string{"description", "design", "acceptance_criteria", "notes", "comment"} {
assert.NotContains(t, payload, `"`+field+`"`,
"the card type has no field a body could arrive in, and that is structural")
}
// The same call through the same tracker with get_issue does carry them, so
// the assertion above is about where bodies live and not about a fixture that
// has none.
detail := getIssue(t, session, args("board", "id", "bd-3"))
assert.Contains(t, detail.Issue.Description, bodyMarker)
}
// --- what list_issues answers -----------------------------------------------
func TestListIssuesAnswersTheBoardsCards(t *testing.T) {
got := listIssues(t, beadsServer(t), args("board"))
assert.Equal(t, "main", got.Ref, "the default branch, named back")
assert.Equal(t, 200, got.Limit, "the default of docs/DESIGN.mcp.md §9.3")
assert.Equal(t, 8, got.Total)
assert.False(t, got.Truncated)
assert.False(t, got.TableTruncated)
assert.Equal(t, 8, got.TableTotal)
// The board's own parade order: Rolling, Lined Up, Stalled, Past Stand, and
// within a lane by priority, then age, then id.
assert.Equal(t, []string{"bd-2", "bd-1", "bd-4", "bd-6", "bd-7", "bd-3", "bd-5", "bd-8"}, cardIDs(got))
byID := map[string]issueCardResult{}
for _, c := range got.Issues {
byID[c.ID] = c
}
assert.Equal(t, issueCardResult{
ID: "bd-3", Title: "ship the parser", Type: "task", Priority: "2", Assignee: "alice",
Labels: []string{"milestone:m1"}, BlockedBy: 1, Blocks: 0, Ready: false,
Lane: "Stalled", Category: "open",
}, byID["bd-3"], "an open issue with an open blocker is Stalled, and the blocker was derived from the edges")
assert.Equal(t, "Rolling", byID["bd-2"].Lane)
assert.Equal(t, "in_progress", byID["bd-2"].Category)
assert.ElementsMatch(t, []string{"milestone:m1", "parser"}, byID["bd-2"].Labels)
assert.Equal(t, 2, byID["bd-1"].Blocks, "two subtasks point at the epic")
assert.Equal(t, 0, byID["bd-1"].BlockedBy, "and hierarchy is not a blocker")
// "shipped" is closed only because custom_statuses says so: no name heuristic
// reaches it, so this card proves the projection consulted that table.
assert.Equal(t, "Past Stand", byID["bd-8"].Lane)
assert.Equal(t, "closed", byID["bd-8"].Category)
}
// Each filter narrows the listing exactly as the board narrows: the filtering is
// beads.Filter's, and these are the cases that would catch a second
// implementation drifting from it.
func TestListIssuesFiltersNarrowAsTheBoardDoes(t *testing.T) {
session := beadsServer(t)
for _, tc := range []struct {
name string
f map[string]any
want []string
}{
{"status open", filter("status", "open"), []string{"bd-1", "bd-3", "bd-4", "bd-6", "bd-7"}},
{"status in_progress", filter("status", "in_progress"), []string{"bd-2"}},
{"status closed", filter("status", "closed"), []string{"bd-5", "bd-8"}},
{"type", filter("type", "bug"), []string{"bd-5"}},
{"priority", filter("priority", "0"), []string{"bd-2"}},
{"assignee", filter("assignee", "bob"), []string{"bd-2", "bd-5", "bd-8"}},
{"label", filter("label", "milestone:m1"), []string{"bd-1", "bd-2", "bd-3", "bd-5", "bd-6"}},
{"q over the title", filter("q", "parser"), []string{"bd-1", "bd-2", "bd-3"}},
{"q over the id", filter("q", "bd-7"), []string{"bd-7"}},
{"q is case-insensitive", filter("q", "PARSER"), []string{"bd-1", "bd-2", "bd-3"}},
{"two filters at once", filter("assignee", "bob", "status", "closed"), []string{"bd-5", "bd-8"}},
{"a filter nothing matches", filter("assignee", "nobody"), nil},
} {
t.Run(tc.name, func(t *testing.T) {
got := listIssues(t, session, args("board", "filter", tc.f))
assert.ElementsMatch(t, tc.want, cardIDs(got))
assert.Equal(t, len(tc.want), got.Total, "the total is the matched set, not the board")
})
}
}
// The ready filter is bd's ready set — open, unblocked, not a template — and it
// has to agree with the per-card flag: two readings of "ready" on one surface is
// how the board and this tool would start disagreeing.
func TestListIssuesReadyIsTheProjectionsReadySet(t *testing.T) {
session := beadsServer(t)
ready := listIssues(t, session, args("board", "filter", filter("ready", true)))
assert.ElementsMatch(t, []string{"bd-1", "bd-4", "bd-6"}, cardIDs(ready),
"bd-3 is blocked, bd-7 is a template, bd-2 is in progress and bd-5/bd-8 are closed")
var flagged []string
for _, c := range listIssues(t, session, args("board")).Issues {
if c.Ready {
flagged = append(flagged, c.ID)
}
}
assert.ElementsMatch(t, cardIDs(ready), flagged, "the filter and the flag are one rule")
// bd-4 is the case worth naming: it has a dependency, and the dependency is
// closed, so it does not block.
assert.False(t, listIssues(t, session, args("board")).Issues[0].Ready, "bd-2 is in progress")
for _, c := range ready.Issues {
if c.ID == "bd-4" {
assert.Equal(t, 1, c.BlockedBy, "a closed blocker is still a dependency")
return
}
}
t.Fatal("bd-4 was not in the ready set")
}
// An unrecognised category is refused rather than matched against nothing: an
// empty board would read as "no work is under way", which is a false statement
// about the tracker.
func TestListIssuesRefusesAnUnknownStatus(t *testing.T) {
res := call(t, beadsServer(t), "list_issues", args("board", "filter", filter("status", "in-progress")))
require.True(t, res.IsError)
text := errorText(res)
assert.Contains(t, text, "in-progress")
assert.Contains(t, text, "in_progress", "and the three categories are named")
assert.Contains(t, text, "closed")
}
// The cap of docs/DESIGN.mcp.md §9.3 is applied after filtering and *stated*: the
// answer carries the limit really used and the number of matches it stopped
// short of.
func TestListIssuesCapsTheLimitAndSaysSo(t *testing.T) {
session := beadsServer(t)
t.Run("a limit above the cap is answered at the cap", func(t *testing.T) {
got := listIssues(t, session, args("board", "limit", 5000))
assert.Equal(t, 500, got.Limit, "the cap, reported rather than silently applied")
assert.Len(t, got.Issues, 8, "the tracker is smaller than the cap")
assert.False(t, got.Truncated)
})
t.Run("a limit below the matches clips and says so", func(t *testing.T) {
got := listIssues(t, session, args("board", "limit", 3))
assert.Equal(t, 3, got.Limit)
require.Len(t, got.Issues, 3)
assert.Equal(t, 8, got.Total, "the honest denominator is every match")
assert.True(t, got.Truncated)
})
t.Run("applied after filtering", func(t *testing.T) {
got := listIssues(t, session, args("board", "filter", filter("status", "closed"), "limit", 1))
assert.Equal(t, 2, got.Total, "two issues matched")
assert.Len(t, got.Issues, 1)
assert.True(t, got.Truncated)
})
t.Run("a non-positive limit is refused", func(t *testing.T) {
for _, limit := range []int{0, -1} {
res := call(t, session, "list_issues", args("board", "limit", limit))
require.True(t, res.IsError, "limit %d", limit)
assert.Contains(t, errorText(res), "limit must be a positive number of issues")
}
})
}
// The projection reads at most beads.Max rows per table, and a board computed
// over a clipped table is a count a caller cannot check any other way. It is a
// separate flag from the limit's truncation because it is a different fact.
func TestListIssuesReportsTheProjectionsOwnClip(t *testing.T) {
got := listIssues(t, beadsServer(t), args("bulk", "limit", 10))
assert.True(t, got.TableTruncated, "the tracker has more issues than the projection reads in one pass")
assert.Equal(t, bulkIssues, got.TableTotal, "and the true row count is reported beside it")
assert.Equal(t, 2000, got.Total, "the board was computed over the rows that were read")
assert.Len(t, got.Issues, 10)
assert.True(t, got.Truncated, "the limit clipped the list too, and the two are told apart")
require.Len(t, got.Clipped, 1, "the issues table is the only one clipped in this fixture")
assert.Equal(t, "issues", got.Clipped[0].Table)
assert.Equal(t, 2000, got.Clipped[0].Shown)
assert.Equal(t, bulkIssues, got.Clipped[0].Total)
assert.NotEmpty(t, got.Clipped[0].Effect, "each entry names what the clip costs this listing")
}
// table_truncated/table_total have only ever meant the issues/dependencies read
// that decides total and truncated. A clipped labels table moves neither — the
// issues table here is read whole — so it would be invisible without a place of
// its own; clipped is that place, named per table with what was read against
// what exists and what the clip costs this listing.
func TestListIssuesCarriesThePerTableClipList(t *testing.T) {
session := beadsServer(t)
t.Run("a complete read carries an empty list", func(t *testing.T) {
got := listIssues(t, session, args("board"))
assert.Empty(t, got.Clipped)
payload := resultJSON(t, call(t, session, "list_issues", args("board")))
assert.Contains(t, payload, `"clipped":[]`,
"asserted on the serialised payload: an empty slice, never a null field")
})
t.Run("a clipped labels table shows up with its effect", func(t *testing.T) {
got := listIssues(t, session, args("labelsclip"))
assert.False(t, got.TableTruncated, "the issues table itself was read whole")
assert.Equal(t, 1, got.TableTotal)
require.Len(t, got.Clipped, 1)
clip := got.Clipped[0]
assert.Equal(t, "labels", clip.Table)
assert.Equal(t, 2000, clip.Shown, "the projection's own per-table cap")
assert.Equal(t, labelsClipCount, clip.Total)
assert.Contains(t, clip.Effect, "label",
"the effect line names what a reader loses, not just that something was clipped")
payload := resultJSON(t, call(t, session, "list_issues", args("labelsclip")))
assert.Contains(t, payload, `"table":"labels"`)
assert.Contains(t, payload, `"shown":2000`)
assert.Contains(t, payload, `"total":`+itoa(labelsClipCount))
})
}
// An omitted ref is the tracker's default branch and the answer names it; a
// named one is read as given.
func TestABeadsToolDefaultsTheRef(t *testing.T) {
session := beadsServer(t)
assert.Equal(t, "main", listIssues(t, session, args("board")).Ref)
assert.Equal(t, "wip", listIssues(t, session, args("board", "ref", "wip")).Ref)
assert.Equal(t, "wip", getIssue(t, session, args("board", "id", "bd-1", "ref", "wip")).Ref)
assert.Equal(t, "wip", listMilestones(t, session, args("board", "ref", "wip")).Ref)
}
// --- what get_issue answers --------------------------------------------------
func TestGetIssueCarriesEveryModelledField(t *testing.T) {
got := getIssue(t, beadsServer(t), args("board", "id", "bd-3"))
assert.Equal(t, issueResult{
ID: "bd-3", Title: "ship the parser", Status: "open", IssueType: "task", Priority: "2",
Lane: "Lined Up", Assignee: "alice", CreatedBy: "carol", Owner: "alice",
EstimatedMinutes: "90", ExternalRef: "https://example.org/tracker/3", SpecID: "SPEC-7",
Description: body("bd-3 description"),
Design: body("bd-3 design"),
AcceptanceCriteria: body("bd-3 acceptance"),
Notes: body("bd-3 notes"),
CreatedAt: "2026-01-03 10:00:00", UpdatedAt: "2026-01-09 09:00:00",
Labels: []string{"milestone:m1"},
}, got.Issue)
assert.False(t, got.IsEpic)
assert.Empty(t, got.Subtasks)
}
// Both directions, and they are not the same list: what an issue waits on and
// what waits on it are answered separately, each with the far end resolved to a
// title and a status.
func TestGetIssueAnswersBothDependencyDirections(t *testing.T) {
got := getIssue(t, beadsServer(t), args("board", "id", "bd-4"))
assert.Equal(t, []edgeResult{
{IssueID: "bd-5", Title: "old lexer bug", Type: "blocks", Status: "closed", Closed: true},
}, got.DependsOn, "bd-4 waits on a bug that is already closed")
assert.Equal(t, []edgeResult{
{IssueID: "bd-3", Title: "ship the parser", Type: "blocks", Status: "open", Closed: false},
}, got.DependedOnBy)
}
// The transitive tree reaches past the direct edges, which is the only reason it
// is carried at all: bd-3 waits on bd-4, and bd-4 waits on bd-5.
func TestGetIssueFlattensTheTransitiveTree(t *testing.T) {
got := getIssue(t, beadsServer(t), args("board", "id", "bd-3"))
assert.Equal(t, []treeNodeResult{
{ID: "bd-4", Title: "review the grammar", Type: "blocks", Status: "open", Depth: 0},
{ID: "bd-5", Title: "old lexer bug", Type: "blocks", Status: "closed", Closed: true, Depth: 1},
}, got.DependsTree)
assert.Empty(t, got.DependentTree, "nothing depends on bd-3, transitively or otherwise")
}
func TestGetIssueOfAnEpicRollsUpItsSubtasks(t *testing.T) {
got := getIssue(t, beadsServer(t), args("board", "id", "bd-1"))
assert.True(t, got.IsEpic)
assert.Equal(t, 2, got.SubtaskTotal)
assert.Equal(t, 1, got.SubtaskDone, "bd-8 is \"shipped\", which custom_statuses categorises as closed")
assert.Equal(t, []subtaskResult{
{ID: "bd-2", Title: "write the parser", Status: "in_progress", Category: "in_progress", Priority: "0", Assignee: "bob"},
{ID: "bd-8", Title: "polish the output", Status: "shipped", Category: "closed", Priority: "1", Assignee: "bob"},
}, got.Subtasks, "open work leads and closed subtasks sink")
ids := make([]string, 0, len(got.DependedOnBy))
for _, e := range got.DependedOnBy {
ids = append(ids, e.IssueID)
}
assert.ElementsMatch(t, []string{"bd-2", "bd-8"}, ids, "the subtasks are edges too")
}
// The history is the comments and the audit trail merged and time-ordered, with
// the dependency links beads records on the edge row rather than as events.
func TestGetIssueMergesTheHistory(t *testing.T) {
got := getIssue(t, beadsServer(t), args("board", "id", "bd-3"))
require.Len(t, got.Comments, 1)
assert.Equal(t, "bob", got.Comments[0].Author)
assert.Contains(t, got.Comments[0].Text, bodyMarker)
var summaries []string
for _, a := range got.History {
summaries = append(summaries, a.Summary)
}
assert.Equal(t, []string{
"created the issue",
"added dependency on bd-4",
"updated priority to 2",
"commented",
}, summaries, "oldest first, and humanised by the projection")
}
// An id the tracker does not carry is an ordinary answer about a database the
// caller is looking straight at — it names the id and the database, and it is
// not the masked not-found.
func TestGetIssueOfAnUnknownIDIsAPlainMiss(t *testing.T) {
res := call(t, beadsServer(t), "get_issue", args("board", "id", "bd-999"))
require.True(t, res.IsError)
text := errorText(res)
assert.Contains(t, text, "bd-999")
assert.Contains(t, text, "~alice/board")
assert.Contains(t, text, "main", "and says where it looked")
assert.NotContains(t, text, "no database", "this is not the masked not-found")
}
// An issue read out of a tracker larger than the cap is a whole issue, and the
// answer still says what it was read from: the edges, the thread and the history
// below it were assembled from a prefix of the tables.
func TestGetIssueReportsTheProjectionsOwnClip(t *testing.T) {
session := beadsServer(t)
clipped := getIssue(t, session, args("bulk", "id", "bulk-7"))
assert.Equal(t, "bulk-7", clipped.Issue.ID, "an issue inside the rows read is answered whole")
assert.True(t, clipped.TableTruncated, "and the read it came out of is reported")
assert.Equal(t, bulkIssues, clipped.TableTotal, "with the tracker's true row count beside it")
whole := getIssue(t, session, args("board", "id", "bd-3"))
assert.False(t, whole.TableTruncated, "a tracker read whole claims no clip")
assert.Equal(t, 8, whole.TableTotal, "and reports its size anyway, so the flag is checkable")
}
// The id is in the tracker and past the rows the projection read. "No such
// issue" would be a claim this service cannot support, so it says what it
// actually knows — naming the cap, the true total, and the way past both.
func TestGetIssueOfAnIDPastTheCapIsNotAbsence(t *testing.T) {
tail := bulkTail(bulkIssues)
res := call(t, beadsServer(t), "get_issue", args("bulk", "id", tail))
require.True(t, res.IsError, "the issue was not read, so this is not an answer about the issue")
text := errorText(res)
assert.Contains(t, text, tail, "the id is named back")
assert.Contains(t, text, "2000", "the cap the read stopped at")
assert.Contains(t, text, itoa(bulkIssues), "and the tracker's true total")
assert.NotContains(t, text, "has no issue", "which is the claim a clipped read cannot make")
assert.Contains(t, text, "read_rows", "and the way past the cap is named")
var out getIssueMissResult
decodeMiss(t, res, &out)
assert.Nil(t, out.Issue, "not read is a null issue, never an empty one")
assert.True(t, out.TableTruncated)
assert.Equal(t, bulkIssues, out.TableTotal)
assert.Equal(t, "main", out.Ref)
}
// The two ways an issue can be missing are one field apart, not one adjective
// apart: a caller decides between "does not exist" and "was not read" without
// reading the sentence.
func TestTheTwoMissesAreToldApartByThePayload(t *testing.T) {
session := beadsServer(t)
complete := call(t, session, "get_issue", args("board", "id", "bd-999"))
require.True(t, complete.IsError)
assert.Nil(t, complete.StructuredContent,
"a complete read's miss is the plain refusal it always was")
assert.Contains(t, errorText(complete), "has no issue")
clipped := call(t, session, "get_issue", args("bulk", "id", bulkTail(bulkIssues)))
require.True(t, clipped.IsError)
require.NotNil(t, clipped.StructuredContent,
"and the clipped read's miss carries what makes it checkable")
var out getIssueMissResult
decodeMiss(t, clipped, &out)
assert.True(t, out.TableTruncated, "the one field the two answers differ in")
assert.Equal(t, bulkIssues, out.TableTotal)
}
func TestGetIssueNeedsAnID(t *testing.T) {
res := call(t, beadsServer(t), "get_issue", args("board", "id", " "))
require.True(t, res.IsError)
assert.Contains(t, errorText(res), "issue")
}
// --- what list_milestones answers --------------------------------------------
func TestListMilestonesRollsUpTheMembers(t *testing.T) {
got := listMilestones(t, beadsServer(t), args("board"))
assert.Equal(t, "main", got.Ref)
assert.Equal(t, 8, got.Total, "every issue read")
assert.Equal(t, 2, got.Unlabeled, "bd-4 and bd-7 carry no milestone label")
require.Len(t, got.Milestones, 2)
m1 := got.Milestones[0]
assert.Equal(t, "m1", m1.Name)
assert.Equal(t, "milestone:m1", m1.Label)
assert.Equal(t, 5, m1.Total)
assert.Equal(t, 1, m1.Done, "bd-5")
assert.Equal(t, 1, m1.InProgress, "bd-2")
assert.Equal(t, 3, m1.Open, "bd-1, bd-3 and bd-6")
assert.Equal(t, m1.Total, m1.Done+m1.InProgress+m1.Open, "the three partition the total")
// The shallow hierarchy: the milestone's own issue, then its epics with the
// members nested under them, then the leftovers. Every member appears once.
require.Len(t, m1.Heads, 1)
assert.Equal(t, "bd-6", m1.Heads[0].ID)
require.Len(t, m1.Epics, 1)
assert.Equal(t, "bd-1", m1.Epics[0].Issue.ID)
assert.Equal(t, 1, m1.Epics[0].Total, "only bd-2 is both a child of bd-1 and a member of m1")
assert.Equal(t, 0, m1.Epics[0].Done)
require.Len(t, m1.Epics[0].Children, 1)
assert.Equal(t, "bd-2", m1.Epics[0].Children[0].ID)
assert.Equal(t, "in_progress", m1.Epics[0].Children[0].Category)
assert.Equal(t, []string{"bd-3", "bd-5"}, memberIDs(m1.Loose), "open work leads, closed sinks")
assert.Equal(t, 1+1+len(m1.Epics[0].Children)+len(m1.Loose), m1.Total,
"heads + epics + their children + loose is the whole membership")
m2 := got.Milestones[1]
assert.Equal(t, "m2", m2.Name)
assert.Equal(t, 1, m2.Total)
assert.Equal(t, 1, m2.Done, "bd-8 is \"shipped\"")
assert.Equal(t, []string{"bd-8"}, memberIDs(m2.Loose),
"its epic is not a member of m2, so it does not nest")
}
// A tracker that uses no milestone labels answers an empty list — an answer, not
// an error, and not an empty *tracker* either: every issue is reported as
// unlabeled.
func TestListMilestonesOnATrackerWithNoMilestones(t *testing.T) {
repos, opener := beadsFakes()
session := connect(t, newServer(t, repos, opener), bob())
res := call(t, session, "list_milestones", args("backlog"))
require.False(t, res.IsError, "%s", errorText(res))
var out listMilestonesResult
decode(t, res, &out)
assert.Empty(t, out.Milestones)
assert.Equal(t, 2, out.Total)
assert.Equal(t, 2, out.Unlabeled)
}
// A rollup computed over a clipped read is not arithmetic about the whole
// tracker, and the answer says so in list_issues' own two fields rather than
// presenting short counts as the tracker's.
func TestListMilestonesReportsTheProjectionsOwnClip(t *testing.T) {
session := beadsServer(t)
got := listMilestones(t, session, args("bulk"))
assert.True(t, got.TableTruncated, "the rollup below is arithmetic over a partial read")
assert.Equal(t, bulkIssues, got.TableTotal, "issues that exist")
assert.Equal(t, 2000, got.Total, "issues that were read")
assert.Equal(t, 1999, got.Unlabeled, "every read issue but bulk-3 carries no milestone label")
require.Len(t, got.Milestones, 1)
assert.Equal(t, 1, got.Milestones[0].Total,
"m1's other member is the tail issue, which sits past the cap — the count is short and the answer says why")
whole := listMilestones(t, session, args("board"))
assert.False(t, whole.TableTruncated, "a tracker read whole claims no clip")
assert.Equal(t, whole.Total, whole.TableTotal, "nothing was left behind, so the two agree")
}
// A milestone member carries only what the rollup computes. The dependency
// counts and the ready flag are absent rather than zero, because a 0 and a false
// an agent cannot check are four lies per member.
func TestAMilestoneMemberCarriesNoUncomputedFields(t *testing.T) {
payload := resultJSON(t, call(t, beadsServer(t), "list_milestones", args("board")))
assert.NotContains(t, payload, "blocked_by")
assert.NotContains(t, payload, `"ready"`)
assert.NotContains(t, payload, bodyMarker, "and no bodies either")
}
func memberIDs(members []milestoneMemberResult) []string {
out := make([]string, 0, len(members))
for _, m := range members {
out = append(out, m.ID)
}
return out
}
// --- a database that is not a tracker ----------------------------------------
// beadsCall is one tool and the arguments it needs for a database, so a property
// worth holding for the whole chapter is written once and asserted for each.
type beadsCall struct {
name string
args func(db string) map[string]any
}
func beadsTools() []beadsCall {
return []beadsCall{
{"list_issues", func(db string) map[string]any { return args(db) }},
{"get_issue", func(db string) map[string]any { return args(db, "id", "bd-1") }},
{"list_milestones", func(db string) map[string]any { return args(db) }},
{"list_memories", func(db string) map[string]any { return args(db) }},
}
}
// MCP's tool list is static per server, so these tools are advertised for every
// database on the instance. One whose tables are not a tracker gets an
// explanatory refusal that names the generic tools — and it is three-way
// distinguishable: not the masked not-found (that database exists and is
// readable), and not a protocol error (the call was understood and answered).
func TestANonBeadsDatabaseIsRefusedWithTheWayToReadItAnyway(t *testing.T) {
repos, opener := beadsFakes()
server := newServer(t, repos, opener)
for _, tool := range beadsTools() {
t.Run(tool.name, func(t *testing.T) {
session := connect(t, server, nil)
res, err := session.CallTool(t.Context(), &mcp.CallToolParams{
Name: tool.name,
Arguments: tool.args("notes"),
})
require.NoError(t, err, "a database that is not a tracker is an answer, not a protocol failure")
require.True(t, res.IsError)
text := errorText(res)
assert.Contains(t, text, "~alice/notes")
assert.Contains(t, text, "not a beads issue tracker")
assert.Contains(t, text, "list_tables", "and names the way to read it anyway")
assert.Contains(t, text, "read_rows")
assert.NotContains(t, text, "no database", "the database exists and this caller may read it")
// And it is not what a masked database answers, which is the distinction
// the whole sentence exists to keep.
masked := call(t, connect(t, server, carol()), tool.name, tool.args("secrets"))
require.True(t, masked.IsError)
assert.NotEqual(t, errorText(masked), text)
})
}
}
// A ref that resolves to neither a branch nor a commit is answered about the
// database, not about the fingerprint: the tracker was never read, so calling it
// "not a beads tracker" would be a claim this service did not check.
func TestABeadsToolReportsAnUnresolvableRef(t *testing.T) {
repos, opener := beadsFakes()
server := newServer(t, repos, opener)
for _, tool := range beadsTools() {
t.Run(tool.name, func(t *testing.T) {
a := tool.args("board")
a["ref"] = "nope"
res := call(t, connect(t, server, nil), tool.name, a)
require.True(t, res.IsError)
text := errorText(res)
assert.Contains(t, text, "nope")
assert.Contains(t, text, "~alice/board")
assert.NotContains(t, text, "no database")
assert.NotContains(t, text, "not a beads issue tracker")
})
}
}
// --- the visibility matrix ---------------------------------------------------
// Every beads tool, every visibility, every principal: either the tool answers,
// or the database is reported as not existing. There is no third outcome and in
// particular no "forbidden" — a refusal of its own shape is exactly the
// distinction the masked not-found exists to erase (docs/DESIGN.mcp.md §4.3).
//
// The masked answer is compared against the answer for a database that genuinely
// does not exist, with the name substituted: the two must differ by nothing but
// the name the caller itself supplied.
func TestTheBeadsVisibilityMatrix(t *testing.T) {
repos, opener := beadsFakes()
server := newServer(t, repos, opener)
callers := []struct {
name string
ac *auth.AuthContext
}{
{"anonymous", nil},
{"a stranger", carol()},
{"a grantee", bob()},
{"the owner", alice()},
}
// The id every tracker fixture carries, so get_issue can be asked about each
// of them without the answer depending on which one it is.
ids := map[string]string{"board": "bd-1", "backlog": "bl-1", "roadmap": "rm-1"}
for _, tool := range beadsTools() {
for _, db := range []string{"board", "backlog", "roadmap"} {
for _, who := range callers {
t.Run(tool.name+"/"+db+"/"+who.name, func(t *testing.T) {
session := connect(t, server, who.ac)
a := tool.args(db)
if _, ok := a["id"]; ok {
a["id"] = ids[db]
}
res := call(t, session, tool.name, a)
if readable(beadsFixtureNamed(t, db), coreCaller(who.ac)) {
assert.False(t, res.IsError, "%s", errorText(res))
return
}
require.True(t, res.IsError, "a database this caller may not read must not answer")
absent := tool.args("nosuch")
if _, ok := absent["id"]; ok {
absent["id"] = ids[db]
}
missing := call(t, session, tool.name, absent)
require.True(t, missing.IsError)
assert.Equal(t,
strings.Replace(errorText(missing), "nosuch", db, 1),
errorText(res),
"a masked database answers exactly as one that does not exist")
})
}
}
}
}
// Nothing about a tracker the caller may not read leaks through the refusal —
// not an issue id, not a title, not a body. The database's own name is in the
// sentence because the caller put it there.
func TestNothingAboutAMaskedTrackerLeaks(t *testing.T) {
repos, opener := beadsFakes()
server := newServer(t, repos, opener)
for _, tool := range beadsTools() {
t.Run(tool.name, func(t *testing.T) {
a := tool.args("roadmap")
if _, ok := a["id"]; ok {
a["id"] = "rm-1"
}
body := resultJSON(t, call(t, connect(t, server, carol()), tool.name, a))
assert.NotContains(t, body, "SECRETROADMAP", "not a title")
assert.NotContains(t, body, "rm-2", "not an id it did not ask with")
assert.NotContains(t, body, bodyMarker, "and certainly not a body")
})
}
}
// A grantee reads the private tracker whole: the matrix proves the tools answer,
// and this proves they answer with its actual contents rather than an empty
// shell.
func TestAGranteeReadsAPrivateTracker(t *testing.T) {
repos, opener := beadsFakes()
session := connect(t, newServer(t, repos, opener), bob())
got := listIssues(t, session, args("roadmap"))
assert.Equal(t, 2, got.Total)
assert.Equal(t, []string{"rm-1", "rm-2"}, cardIDs(got))
detail := getIssue(t, session, args("roadmap", "id", "rm-1"))
assert.Equal(t, "SECRETROADMAP the private plan", detail.Issue.Title)
}
// --- sessions ----------------------------------------------------------------
// One session per call, closed by the handler that opened it — on the refusal
// path too, which is the one a helper is most likely to leak.
func TestEveryBeadsToolClosesTheSessionItOpened(t *testing.T) {
for _, tool := range beadsTools() {
t.Run(tool.name, func(t *testing.T) {
t.Run("an answer", func(t *testing.T) {
repos, opener := beadsFakes()
session := connect(t, newServer(t, repos, opener), nil)
call(t, session, tool.name, tool.args("board"))
assert.Equal(t, []string{storePath("alice", "board")}, opener.opened,
"exactly the one database the call named")
assert.Equal(t, 1, opener.sessions[storePath("alice", "board")].closes)
})
t.Run("a database that is not a tracker", func(t *testing.T) {
repos, opener := beadsFakes()
session := connect(t, newServer(t, repos, opener), nil)
call(t, session, tool.name, tool.args("notes"))
assert.Equal(t, 1, opener.sessions[storePath("alice", "notes")].closes,
"the refusal path closes what it opened")
})
})
}
}