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") }