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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||