1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "os"
7 "path/filepath"
8 "testing"
9
10 "connectrpc.com/connect"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13 "google.golang.org/protobuf/types/known/fieldmaskpb"
14
15 pb "bnema.dev/ermite-api/gen/ermite"
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18)
19
20// branchToken creates a stored token limited to branch prefixes, as root.
21func (h *harness) branchToken(scopes []domain.Scope, prefixes []string, repos ...string) (secret, id string) {
22 h.t.Helper()
23 p := ports.CreateTokenParams{Label: "claude", Scopes: scopes, BranchPrefixes: prefixes}
24 for _, r := range repos {
25 p.Repos = append(p.Repos, h.name(r))
26 }
27 got, err := h.forge.Tokens.Create(h.ctx, domain.Root(), p)
28 require.NoError(h.t, err)
29 return got.Secret, got.Token.ID
30}
31
32// AC5: a token limited to branch prefixes, through real git pushes and the API.
33func TestAC5BranchLimitedToken(t *testing.T) {
34 h := newHarness(t, harnessOpts{})
35 wt := h.workTree("main", "a.txt")
36 out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
37 require.NoError(t, err, out)
38 mainID := h.bareGit("proj", "rev-parse", "refs/heads/main")
39
40 secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite, domain.ScopeMRMerge),
41 []string{"agent/claude/"}, "proj")
42 remote := h.urlAuth(secret, "/proj.git")
43 commit := func(file string) {
44 require.NoError(t, os.WriteFile(filepath.Join(wt, file), []byte(file), 0o644))
45 h.mustGit(wt, "add", file)
46 h.mustGit(wt, "commit", "-q", "-m", file)
47 }
48 refs := func() string { return h.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") }
49
50 // It pushes, force-pushes and deletes under its prefix.
51 h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/x")
52 commit("x1")
53 out, err = h.git(wt, "push", "-q", remote, "agent/claude/x")
54 require.NoError(t, err, out)
55 assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x"))
56
57 h.mustGit(wt, "reset", "-q", "--hard", "main")
58 commit("x2")
59 out, err = h.git(wt, "push", "-q", "--force", remote, "agent/claude/x")
60 require.NoError(t, err, out)
61 assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x"))
62
63 h.mustGit(wt, "branch", "agent/claude/deep/y")
64 out, err = h.git(wt, "push", "-q", remote, "agent/claude/deep/y")
65 require.NoError(t, err, out)
66 out, err = h.git(wt, "push", "-q", remote, ":refs/heads/agent/claude/deep/y")
67 require.NoError(t, err, out)
68 _, err = h.git(h.repoDir("proj"), "rev-parse", "--verify", "-q", "refs/heads/agent/claude/deep/y")
69 require.Error(t, err, "deleted")
70
71 // Every other ref refuses the push, with a message naming it.
72 before := refs()
73 for _, tt := range []struct{ name, refspec, ref string }{
74 {"main", "HEAD:refs/heads/main", "refs/heads/main"},
75 {"force-push to main", "+HEAD:refs/heads/main", "refs/heads/main"},
76 {"delete main", ":refs/heads/main", "refs/heads/main"},
77 {"a new branch outside", "HEAD:refs/heads/other", "refs/heads/other"},
78 {"the prefix without a name", "HEAD:refs/heads/agent/claude", "refs/heads/agent/claude"},
79 {"a sibling sharing the text", "HEAD:refs/heads/agent/claudex/y", "refs/heads/agent/claudex/y"},
80 {"a tag", "HEAD:refs/tags/v1", "refs/tags/v1"},
81 {"a tag named like the prefix", "HEAD:refs/tags/agent/claude/v1", "refs/tags/agent/claude/v1"},
82 {"a notes ref", "HEAD:refs/notes/commits", "refs/notes/commits"},
83 {"a custom ref", "HEAD:refs/agent/claude/z", "refs/agent/claude/z"},
84 } {
85 out, err = h.git(wt, "push", remote, tt.refspec)
86 require.Error(t, err, tt.name)
87 assert.Contains(t, out, tt.ref+" is outside the branches this token may push (agent/claude/)", tt.name)
88 assert.Equal(t, before, refs(), "%s: nothing moved", tt.name)
89 }
90
91 // A mixed push is refused as a whole: the allowed ref does not move either.
92 commit("x3")
93 out, err = h.git(wt, "push", "--atomic", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/heads/main")
94 require.Error(t, err)
95 assert.Contains(t, out, "refs/heads/main is outside")
96 assert.Equal(t, before, refs())
97 out, err = h.git(wt, "push", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/tags/v2")
98 require.Error(t, err)
99 assert.Contains(t, out, "refs/tags/v2 is outside")
100 assert.Equal(t, before, refs(), "git receives both updates in one pack: pre-receive refuses both")
101 assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main"))
102
103 // It cannot create a repository: not by push-to-create, not by the API.
104 h.mustGit(wt, "checkout", "-q", "agent/claude/x")
105 out, err = h.git(wt, "push", "-q", h.urlAuth(secret, "/brand-new.git"), "agent/claude/x")
106 require.Error(t, err, out)
107 assert.NoDirExists(t, h.repoDir("brand-new"))
108 anyRepo, _ := h.branchToken(scopes(domain.ScopeRepoWrite), []string{"agent/claude/"}) // not limited to a repository
109 out, err = h.git(wt, "push", "-q", h.urlAuth(anyRepo, "/brand-new.git"), "agent/claude/x")
110 require.Error(t, err, out)
111 assert.NoDirExists(t, h.repoDir("brand-new"))
112 assert.Equal(t, 404, h.statusAuth("GET", "/brand-new.git/info/refs?service=git-receive-pack", anyRepo))
113 _, err = h.api(anyRepo).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"}))
114 assert.Equal(t, "permission_denied", codeName(err))
115 assert.NoDirExists(t, h.repoDir("via-api"))
116 // The same token without prefixes does both.
117 free := h.token(scopes(domain.ScopeRepoWrite))
118 _, err = h.api(free).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"}))
119 require.NoError(t, err)
120
121 // It cannot delete a repository, even with repo:admin.
122 admin, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/claude/"}, "via-api")
123 _, err = h.api(admin).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "via-api"}))
124 assert.Equal(t, "permission_denied", codeName(err))
125 assert.DirExists(t, h.repoDir("via-api"))
126 // ... nor change its settings (default branch, visibility, ...).
127 _, err = h.api(admin).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
128 Repo: "via-api", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
129 }))
130 assert.Equal(t, "permission_denied", codeName(err))
131 got, err := h.api(adminToken).repo.GetRepo(h.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "via-api"}))
132 require.NoError(t, err)
133 assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, got.Msg.GetRepo().GetVisibility())
134
135 // It never has owner rights, whatever its scopes.
136 all, _ := h.branchToken(domain.AllScopes(), []string{"agent/claude/"})
137 _, err = h.api(all).session.SignOutEverywhere(h.ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{}))
138 assert.Equal(t, "permission_denied", codeName(err))
139 _, err = h.api(all).session.CreateLoginLink(h.ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{}))
140 assert.Equal(t, "permission_denied", codeName(err))
141}
142
143// AC5: merge requests write branches: a limited token merges only into its prefixes.
144func TestAC5BranchLimitedMerge(t *testing.T) {
145 h := newHarness(t, harnessOpts{})
146 wt := h.workTree("main", "a.txt")
147 out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
148 require.NoError(t, err, out)
149 h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/feat")
150 h.writeFile(wt, "f.txt", []byte("f\n"))
151 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "agent/claude/feat")
152 feat := h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat")
153 mainID := h.bareGit("proj", "rev-parse", "refs/heads/main")
154
155 secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge), []string{"agent/claude/"}, "proj")
156 c := h.api(secret)
157 created, err := c.mr.CreateMergeRequest(h.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
158 Repo: "proj", Title: "feat", SourceBranch: "agent/claude/feat",
159 }))
160 require.NoError(t, err, "opening a request writes no ref")
161 n := created.Msg.GetMergeRequest().GetNumber()
162
163 for _, del := range []bool{false, true} {
164 _, err = c.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
165 Repo: "proj", Number: n, HeadId: feat, DeleteSourceBranch: del,
166 }))
167 assert.Equal(t, "permission_denied", codeName(err), "into main, delete=%v", del)
168 }
169 assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main"))
170 assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat"))
171
172 // The merge goes through for a token without prefixes.
173 m := h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge)))
174 _, err = m.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: feat}))
175 require.NoError(t, err)
176 assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/main"))
177}
178
179// AC5: a limited token only creates tokens within its prefixes, over the API.
180func TestAC5BranchLimitedTokenCreation(t *testing.T) {
181 h := newHarness(t, harnessOpts{})
182 secret, _ := h.branchToken(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), []string{"agent/claude/"})
183 c := h.api(secret)
184 create := func(prefixes ...string) (*connect.Response[pb.CreateTokenResponse], error) {
185 return c.token.CreateToken(h.ctx, connect.NewRequest(&pb.CreateTokenRequest{
186 Label: "child", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: prefixes,
187 }))
188 }
189 _, err := create()
190 assert.Equal(t, "permission_denied", codeName(err), "no prefixes")
191 _, err = create("agent/")
192 assert.Equal(t, "permission_denied", codeName(err), "wider")
193 _, err = create("agent/claudex/")
194 assert.Equal(t, "permission_denied", codeName(err), "sibling")
195 _, err = create("agent")
196 assert.Equal(t, "invalid_argument", codeName(err), "malformed")
197 res, err := create("agent/claude/sub/")
198 require.NoError(t, err)
199 assert.Equal(t, []string{"agent/claude/sub/"}, res.Msg.GetToken().GetBranchPrefixes())
200
201 // The listing shows the prefixes.
202 list, err := h.api(adminToken).token.ListTokens(h.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
203 require.NoError(t, err)
204 var seen int
205 for _, tk := range list.Msg.GetTokens() {
206 if tk.GetLabel() == "child" {
207 assert.Equal(t, []string{"agent/claude/sub/"}, tk.GetBranchPrefixes())
208 seen++
209 }
210 }
211 assert.Equal(t, 1, seen)
212}