detached 977138d

test(app): cover branch-limited tokens end to end

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close