v0.14.0 815fb98

e2e_branches_test.go RawHistoryFolder

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}

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 3

Tags 44

↑↓ moveenter openesc close