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 } // localAgent is agentPrincipal as spec's own agent_token resolves it: no owner // of its own, no grants, nothing to authorize against. func localAgent() authn.Principal { p := agentPrincipal() p.Plane = authn.PlaneLocal p.TokenName = "laptop" return p } // 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{ "local agent token": localAgent(), "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 applies to both planes identically: // an agent credential moves refs under the proposal prefix and nothing else, no // matter how wide the grant set behind it is. func TestRefsRuleIsUnchangedOnBothPlanes(t *testing.T) { planes := map[string]authn.Principal{ "local": localAgent(), "instance": instanceAgent(t, "*"), } newHash := plumbing.NewHash("1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809") for name, p := range planes { t.Run(name, func(t *testing.T) { kind, err := principalKind(p) require.NoError(t, err) assert.Equal(t, gitx.PrincipalAgent, kind, "both planes are agents 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 on either plane") 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 on either plane") // Inside it: permitted, on both planes. 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 on both planes. 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 TestProvenanceStillRequiredOnBothPlanes(t *testing.T) { svc, _ := newService(t) for name, p := range map[string]authn.Principal{ "local": localAgent(), "instance": 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 } // An instance whose config has no [tokens.sr.ht] section has no such daemon. // The plane must then be absent rather than broken: spec starts, and its own // agent token is the only door — which is exactly what the instance ran before // tokens.sr.ht existed. func TestInstancePlane_AbsentSectionIsNotAnError(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) { plane, err := instancePlane(conf, deadDB(t)) require.NoError(t, err, "a missing origin is an answer, not a misconfiguration") assert.Nil(t, plane) }) } } 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", NewTokenStore(nil), 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 it is offered and a config supports it, and // builds the same local-only resolver it always did when it is not. func TestNew_InstancePlaneIsOptional(t *testing.T) { cfg := testConfig(t, t.TempDir()) svc, err := New(cfg, deadDB(t)) require.NoError(t, err) assert.False(t, svc.Resolver().HasInstancePlane(), "no option means no instance plane, exactly as before it existed") svc, err = New(cfg, deadDB(t), WithInstanceTokens(tokensConf(nil))) require.NoError(t, err) assert.False(t, svc.Resolver().HasInstancePlane(), "an instance without a [tokens.sr.ht] section must still start, on the local plane") 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, once per plane: the credential every agent is configured // with today and a tokens.sr.ht token carrying spec:propose both open a // proposal, with the same provenance recorded on it. func TestProposeOpensProposalOnBothPlanes(t *testing.T) { for name, p := range map[string]authn.Principal{ "local agent token": localAgent(), "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") }