package mcpsrv
import (
"context"
"database/sql"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-ecore/grants"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// mustGrants parses a grant string or fails the test.
func mustGrants(t *testing.T, s string) grants.Grants {
t.Helper()
g, err := grants.Parse(s)
require.NoError(t, err, "parse grants %q", s)
return g
}
// instancePrincipal is agentPrincipal as a tokens.sr.ht working token resolves
// it: the same agent, with a grant set behind it.
func instancePrincipal(t *testing.T, grantString string) authn.Principal {
t.Helper()
p := agentPrincipal()
p.Plane = authn.PlaneInstance
p.Grants = mustGrants(t, grantString)
return p
}
// localPrincipal is agentPrincipal as spec's own agent_token resolves it.
func localPrincipal() authn.Principal {
p := agentPrincipal()
p.Plane = authn.PlaneLocal
return p
}
// Every tool that serves content asks for spec:read, and asks for it per tool
// rather than at the Gate — /mcp carries the write tools too, and a surface-wide
// read grant would refuse a propose-only token at `initialize`, before it named
// anything.
func TestReadToolsRequireTheReadGrant(t *testing.T) {
refused := instancePrincipal(t, "spec:propose")
t.Run("spec_read", func(t *testing.T) {
ctx := authn.WithPrincipal(context.Background(), refused)
_, err := readHandler(ctx, Backend{}, readInput{Space: "~bigbes/rfcs", Document: "SPEC-0007"})
assert.ErrorIs(t, err, authn.ErrMissingGrant)
})
t.Run("spec_list", func(t *testing.T) {
ctx := authn.WithPrincipal(context.Background(), refused)
_, err := listHandler(ctx, Backend{}, listInput{})
assert.ErrorIs(t, err, authn.ErrMissingGrant)
})
t.Run("spec_search", func(t *testing.T) {
ctx := authn.WithPrincipal(context.Background(), refused)
_, err := searchHandler(ctx, Backend{}, searchInput{Query: "storage"})
assert.ErrorIs(t, err, authn.ErrMissingGrant)
})
t.Run("spec_comment", func(t *testing.T) {
ctx := authn.WithPrincipal(context.Background(), refused)
_, err := commentHandler(ctx, commentFixture(t), commentInput{Proposal: 7})
assert.ErrorIs(t, err, authn.ErrMissingGrant)
})
}
// And every credential that carries no grants passes untouched, which is what
// keeps the agents configured today working: the local agent token and the
// owner's cookie have nothing to check.
func TestReadToolsPassEveryUngrantedCredential(t *testing.T) {
for name, p := range map[string]authn.Principal{
"local agent token": localPrincipal(),
"owner cookie": {Kind: authn.KindOwner, Owner: "bigbes"},
"instance token, spec:read": instancePrincipal(t, "spec:read"),
"instance token, universal": instancePrincipal(t, "*"),
"instance token, both actions": instancePrincipal(t, "spec:read spec:propose"),
} {
t.Run(name, func(t *testing.T) {
ctx := authn.WithPrincipal(context.Background(), p)
assert.NoError(t, requireRead(ctx))
// Listing threads goes all the way through with a fake service.
out, err := commentHandler(ctx, commentFixture(t), commentInput{Proposal: 7})
require.NoError(t, err)
assert.NotEmpty(t, out.Threads)
})
}
}
// realWriter builds an actual *service.Service over a database nothing answers
// on, so that spec_propose's grant check here is service.Propose's own and not a
// copy of it. Propose refuses a principal before it opens anything, which is
// what makes an unreachable pool enough.
func realWriter(t *testing.T) *service.Service {
t.Helper()
root := t.TempDir()
pool, err := sql.Open("postgres",
"postgres://nobody@127.0.0.1:1/nothing?sslmode=disable&connect_timeout=1")
require.NoError(t, err)
t.Cleanup(func() { pool.Close() })
svc, err := service.New(service.Config{
Repos: filepath.Join(root, "repos"),
Cache: filepath.Join(root, "cache"),
Origin: "https://spec.srht.bigb.es",
ConnectionString: "postgres://nobody@127.0.0.1:1/nothing",
Instance: authn.Instance{
OwnerName: "bigbes",
OwnerEmail: "bigbes@gmail.com",
AgentEmail: "agent@spec.srht.bigb.es",
},
}, pool)
require.NoError(t, err)
return svc
}
// proposeCall is one well-formed spec_propose, so the only thing a test varies
// is the credential behind it.
func proposeCall() proposeInput {
return proposeInput{
Space: "~bigbes/rfcs",
IfMatch: "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809",
Title: "Add a note",
Message: "write it",
Documents: []proposeDoc{{Path: "notes/a.md", Content: "---\nid: N-1\n---\n"}},
}
}
// spec_propose asks for spec:propose, through the same service.Propose the REST
// surface calls — the rule is spelled once, below both write surfaces, and MCP
// gets it by going through it rather than by repeating it here.
func TestProposeToolRequiresTheProposeGrant(t *testing.T) {
svc := realWriter(t)
for _, grantString := range []string{"spec:read", "bench:upload"} {
t.Run(grantString, func(t *testing.T) {
ctx := authn.WithPrincipal(context.Background(), instancePrincipal(t, grantString))
_, err := proposeHandler(ctx, svc, proposeCall())
require.Error(t, err)
assert.ErrorIs(t, err, service.ErrForbidden,
"the refusal is the one this surface already uses for an unauthorised call")
assert.ErrorIs(t, err, authn.ErrMissingGrant)
})
}
}
// And every credential that may propose clears it — the local agent token above
// all, which carries no grants and must keep working exactly as it does today.
func TestProposeToolAcceptsTheGrantedCredentials(t *testing.T) {
svc := realWriter(t)
for name, p := range map[string]authn.Principal{
"local agent token": localPrincipal(),
"instance token, spec:propose": instancePrincipal(t, "spec:propose"),
"instance token, universal": instancePrincipal(t, "*"),
} {
t.Run(name, func(t *testing.T) {
ctx := authn.WithPrincipal(context.Background(), p)
// The write cannot land — there is no space and no database — but it
// must not be turned away at the ACL.
_, err := proposeHandler(ctx, svc, proposeCall())
require.Error(t, err, "the fixture has no space, so this cannot succeed")
assert.NotErrorIs(t, err, service.ErrForbidden, "%s must clear the write ACL", name)
assert.NotErrorIs(t, err, authn.ErrMissingGrant)
})
}
}
// A grant is not a substitute for provenance. An instance token with the widest
// grant there is still cannot write without saying who wrote it — the refusal
// changes from authorization to provenance, and does not go away.
func TestProposeToolStillDemandsProvenanceOnBothPlanes(t *testing.T) {
svc := realWriter(t)
for name, p := range map[string]authn.Principal{
"local": localPrincipal(),
"instance": instancePrincipal(t, "*"),
} {
t.Run(name, func(t *testing.T) {
noSession := p
noSession.Session = ""
ctx := authn.WithPrincipal(context.Background(), noSession)
_, err := proposeHandler(ctx, svc, proposeCall())
require.Error(t, err)
assert.NotErrorIs(t, err, service.ErrForbidden,
"a missing session is a provenance failure, not an authorization one")
_, err = noSession.AgentWriteFor("1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809")
assert.ErrorIs(t, err, authn.ErrMissingProvenance)
})
}
}