package service
import (
"context"
"strings"
"testing"
"github.com/go-git/go-git/v5/plumbing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-ecore/grants"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)
// 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
}
// instanceAgent is agentPrincipal as a tokens.sr.ht working token resolves it:
// the same agent, acting for the same owner, with a grant set behind it.
func instanceAgent(t *testing.T, grantString string) authn.Principal {
t.Helper()
p := agentPrincipal()
p.Plane = authn.PlaneInstance
p.Grants = mustGrants(t, grantString)
p.UserID = 1
return p
}
// cliAgent is agentPrincipal as `specsrht doc propose` builds it: an agent a
// local process asserted, on no credential plane and with no grant set, because
// it presented no credential. It is the one agent principal the resolver never
// produces, and the reason authn.Plane outlived the local plane it used to
// distinguish.
func cliAgent() authn.Principal { return agentPrincipal() }
// proposeRequest is a well-formed open, so that the only thing a test varies is
// the principal.
func proposeRequest(p authn.Principal) ProposeRequest {
return ProposeRequest{
Space: fxSpace,
Principal: p,
Title: "Add a note",
IfMatch: headRev,
Message: "add notes/a.md",
Writes: []DocumentWrite{{Path: "notes/a.md", Content: mdDoc("N-1", "A", "body")}},
}
}
// An instance token minted without spec:propose is refused, and refused as a
// forbidden principal rather than as a bad request — the agent has to be told to
// ask for a wider grant, not to fix its document.
func TestProposeRefusesAnInstanceTokenWithoutTheProposeGrant(t *testing.T) {
svc, _ := newService(t)
for _, grantString := range []string{"spec:read", "bench:upload", "cover:read cover:upload"} {
t.Run(grantString, func(t *testing.T) {
_, err := svc.Propose(context.Background(), proposeRequest(instanceAgent(t, grantString)))
require.Error(t, err)
assert.ErrorIs(t, err, ErrForbidden)
assert.ErrorIs(t, err, authn.ErrMissingGrant)
assert.Contains(t, err.Error(), authn.ActionPropose)
})
}
}
// The grant check is a guard on the principal, like the agent-only one beside
// it: it holds with a database that cannot be reached, which is what proves it
// runs before anything is opened.
func TestProposeAcceptsTheGrantedPrincipals(t *testing.T) {
svc, _ := newService(t)
cases := map[string]authn.Principal{
"locally asserted CLI agent": cliAgent(),
"instance token, exact grant": instanceAgent(t, "spec:propose"),
"instance token, both grants": instanceAgent(t, "spec:read spec:propose"),
"instance token, universal": instanceAgent(t, "*"),
"instance token, registered row": instanceAgent(t, "spec:propose id:42"),
}
for name, p := range cases {
t.Run(name, func(t *testing.T) {
// The write cannot land — the pool is dead and the space does not
// exist — but it must not be turned away at the ACL.
_, err := svc.Propose(context.Background(), proposeRequest(p))
require.Error(t, err, "the fixture has no space, so this cannot succeed")
assert.NotErrorIs(t, err, ErrForbidden, "%s must clear the write ACL", name)
assert.NotErrorIs(t, err, authn.ErrMissingGrant)
})
}
}
// The refs rule is untouched by grants and by the removal of the local plane:
// an agent credential moves refs under the proposal prefix and nothing else, no
// matter how wide the grant set behind it is.
func TestRefsRuleIsUnchangedByGrants(t *testing.T) {
agents := map[string]authn.Principal{
"locally asserted CLI agent": cliAgent(),
"instance token, universal": instanceAgent(t, "*"),
}
newHash := plumbing.NewHash("1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809")
for name, p := range agents {
t.Run(name, func(t *testing.T) {
kind, err := principalKind(p)
require.NoError(t, err)
assert.Equal(t, gitx.PrincipalAgent, kind,
"an agent is an agent to the refs rule; a universal grant does not promote one")
// Outside the proposal prefix: refused.
err = gitx.CheckRefUpdate(kind, gitx.DefaultApprovedBranch, gitx.RefUpdate{
Ref: "refs/heads/" + gitx.DefaultApprovedBranch, New: newHash, FastForward: true,
})
assert.ErrorIs(t, err, gitx.ErrRefRejected,
"an agent may not move the approved branch")
err = gitx.CheckRefUpdate(kind, gitx.DefaultApprovedBranch, gitx.RefUpdate{
Ref: "refs/heads/scratch", New: newHash, FastForward: true,
})
assert.ErrorIs(t, err, gitx.ErrRefRejected,
"an agent may not create a branch outside the proposal prefix")
// Inside it: permitted.
err = gitx.CheckRefUpdate(kind, gitx.DefaultApprovedBranch, gitx.RefUpdate{
Ref: "refs/heads/" + core.ProposalPrefix + "7", New: newHash, FastForward: true,
})
assert.NoError(t, err)
})
}
}
// Provenance is still mandatory. A grant says what a credential was minted for;
// it says nothing about who wrote a commit, and it does not excuse a write from
// saying so.
func TestProvenanceStillRequired(t *testing.T) {
svc, _ := newService(t)
for name, p := range map[string]authn.Principal{
"locally asserted CLI agent": cliAgent(),
"instance token": instanceAgent(t, "*"),
} {
t.Run(name, func(t *testing.T) {
noSession := p
noSession.Session = ""
_, err := svc.Propose(context.Background(), proposeRequest(noSession))
require.Error(t, err)
assert.NotErrorIs(t, err, ErrForbidden,
"a missing session is a provenance failure, not an authorization one")
// The provenance builder is where it is caught, whichever plane the
// principal came in on.
_, err = noSession.AgentWriteFor(headRev)
assert.ErrorIs(t, err, authn.ErrMissingProvenance)
})
}
}
// --- the plane's own wiring ------------------------------------------------
// tokensConf is an instance config with a tokens.sr.ht section, in the shape
// GetOrigin reads.
func tokensConf(section ini.Section) ini.File {
conf := ini.File{"sr.ht": ini.Section{"owner-name": "bigbes"}}
if section != nil {
conf[TokensSection] = section
}
return conf
}
// A missing [tokens.sr.ht] origin used to be an answer — spec minted its own
// credential, so an instance without the daemon simply used the plane it
// shipped with. It has none now, so the absence is a startup failure: a daemon
// that came up like this would serve reads and refuse every agent write on the
// instance, over HTTP and over `git push` alike.
func TestInstancePlane_AbsentSectionIsAStartupError(t *testing.T) {
for name, conf := range map[string]ini.File{
"no section at all": tokensConf(nil),
"section with no origin": tokensConf(ini.Section{
"connection-string": "postgresql://tokens@postgres/tokens.sr.ht",
}),
} {
t.Run(name, func(t *testing.T) {
_, err := instancePlane(conf, deadDB(t))
require.Error(t, err, "there is no other plane left to fall back to")
assert.Contains(t, err.Error(), TokensSection)
})
}
}
func TestInstancePlane_BuiltFromTheInternalOrigin(t *testing.T) {
// The internal origin wins over the external one: the revocation check goes
// over the docker network rather than out through the proxy and back.
plane, err := instancePlane(tokensConf(ini.Section{
"origin": "https://tokens.srht.bigb.es",
"internal-origin": "http://tokens.sr.ht:5094",
}), deadDB(t))
require.NoError(t, err)
require.NotNil(t, plane)
rs, err := authn.NewResolver("bigbes", plane)
require.NoError(t, err)
assert.True(t, rs.HasInstancePlane())
}
// An origin that is present and unusable fails startup, where an operator is
// looking — not one request at a time as an unexplained 503.
func TestInstancePlane_UnusableOriginIsAStartupError(t *testing.T) {
_, err := instancePlane(tokensConf(ini.Section{"origin": "tokens.srht.bigb.es"}), deadDB(t))
require.Error(t, err)
assert.Contains(t, err.Error(), "tokens.sr.ht")
}
// Service.New wires the plane when a config supports it, refuses when the
// option is passed and the config does not, and builds a planeless resolver for
// the CLI paths that do not ask for one.
func TestNew_InstancePlaneWiring(t *testing.T) {
cfg := testConfig(t, t.TempDir())
// `specsrht doc`: authenticates nobody, so it asks for no plane and gets a
// resolver that refuses every credential rather than pretending to check
// one.
svc, err := New(cfg, deadDB(t))
require.NoError(t, err)
assert.False(t, svc.Resolver().HasInstancePlane())
// The daemon: asks for the plane, and an instance that cannot give it one
// fails here rather than at every agent's first request.
_, err = New(cfg, deadDB(t), WithInstanceTokens(tokensConf(nil)))
require.Error(t, err)
assert.Contains(t, err.Error(), TokensSection)
svc, err = New(cfg, deadDB(t), WithInstanceTokens(tokensConf(ini.Section{
"origin": "https://tokens.srht.bigb.es",
})))
require.NoError(t, err)
assert.True(t, svc.Resolver().HasInstancePlane())
}
// --- Postgres-backed integration tests (skip when SPECSRHT_TEST_PG is unset) ---
// The whole open path: a tokens.sr.ht token carrying spec:propose and the CLI's
// locally asserted agent both open a proposal, with the same provenance
// recorded on it.
func TestProposeOpensProposalForEveryAgentPrincipal(t *testing.T) {
for name, p := range map[string]authn.Principal{
"locally asserted CLI agent": cliAgent(),
"instance token": instanceAgent(t, "spec:read spec:propose"),
} {
t.Run(name, func(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
require.NoError(t, err)
base, err := sp.Repo.ApprovedHead(ctx)
require.NoError(t, err)
req := proposeRequest(p)
req.IfMatch = base.String()
res, err := svc.Propose(ctx, req)
require.NoError(t, err)
assert.Equal(t, core.StateOpen, res.Proposal.State)
assert.Equal(t, "claude-code/spec-writer", res.Proposal.Agent)
assert.True(t, strings.HasSuffix(res.URL, "/p/1"), "URL = %q", res.URL)
assert.True(t, strings.HasPrefix(res.Proposal.Branch, core.ProposalPrefix),
"an agent's branch stays under the proposal prefix: %q", res.Proposal.Branch)
})
}
}
// And the refusal, end to end: a token minted for reading only gets 403's
// sentinel out of the same call, with the space in place and nothing else to
// blame.
func TestProposeRefusesAReadOnlyInstanceTokenEndToEnd(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
require.NoError(t, err)
base, err := sp.Repo.ApprovedHead(ctx)
require.NoError(t, err)
req := proposeRequest(instanceAgent(t, "spec:read"))
req.IfMatch = base.String()
_, err = svc.Propose(ctx, req)
require.Error(t, err)
assert.ErrorIs(t, err, ErrForbidden)
assert.ErrorIs(t, err, authn.ErrMissingGrant)
open, err := svc.ListProposals(ctx, fxSpace, core.StateOpen)
require.NoError(t, err)
assert.Empty(t, open, "a refused write must leave no row behind")
}