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 } // cliPrincipal is agentPrincipal as `specsrht doc propose` builds it: an agent // a local process asserted, on no credential plane and therefore with no grant // set. The resolver never produces one — it is why Plane outlived the local // agent-token plane it used to distinguish. func cliPrincipal() authn.Principal { return agentPrincipal() } // 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 principal that carries no grants passes untouched: the owner's // cookie is a person rather than a machine credential, and the CLI's agent // presented nothing to have a grant clipped out of. func TestReadToolsPassEveryUngrantedCredential(t *testing.T) { for name, p := range map[string]authn.Principal{ "locally asserted CLI agent": cliPrincipal(), "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. func TestProposeToolAcceptsTheGrantedCredentials(t *testing.T) { svc := realWriter(t) for name, p := range map[string]authn.Principal{ "locally asserted CLI agent": cliPrincipal(), "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 TestProposeToolStillDemandsProvenance(t *testing.T) { svc := realWriter(t) for name, p := range map[string]authn.Principal{ "locally asserted CLI agent": cliPrincipal(), "instance token": 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) }) } }