1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10
11 "bnema.dev/ermite/internal/domain"
12 "bnema.dev/ermite/internal/ports"
13)
14
15// branchMerger holds mr:merge but may only write the branches under
16// agent/claude/.
17var branchMerger = domain.TokenPrincipal(domain.Token{
18 ID: "bm", Label: "claude", BranchPrefixes: []string{"agent/claude/"},
19 Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge},
20})
21
22// AC5: a merge writes the target, and the source when it is deleted; a token
23// limited to branch prefixes may do neither outside them.
24func TestMergeWithBranchLimitedToken(t *testing.T) {
25 t.Parallel()
26
27 t.Run("into main is forbidden and nothing moves", func(t *testing.T) {
28 t.Parallel()
29 r := newMRRig(t)
30 main := r.seed()
31 head := r.feature("agent/claude/feat")
32 mr := r.open("agent/claude/feat") // targets main
33 r.drain()
34
35 for _, del := range []bool{false, true} {
36 _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: del})
37 require.ErrorIs(t, err, domain.ErrForbidden)
38 assert.Contains(t, err.Error(), "this token may not write main")
39 }
40 assert.Equal(t, main, r.branchHead("main"), "main did not move")
41 assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays")
42 d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
43 require.NoError(t, err)
44 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
45 assert.Empty(t, r.drain(), "no event")
46
47 // The same merge by an unlimited token works.
48 _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
49 require.NoError(t, err)
50 assert.Equal(t, head, r.branchHead("main"))
51 })
52
53 t.Run("into a branch under the prefixes is allowed, deleting the source too", func(t *testing.T) {
54 t.Parallel()
55 r := newMRRig(t)
56 r.seed()
57 r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
58 r.push("agent/claude/base")
59 r.sh("checkout", "-q", "-B", "agent/claude/feat", "agent/claude/base")
60 head := r.commit(author, "f.txt", "f\n", "add f")
61 r.push("agent/claude/feat")
62 mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
63 Title: "feat", Source: "agent/claude/feat", Target: "agent/claude/base",
64 })
65 require.NoError(t, err)
66
67 res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
68 require.NoError(t, err)
69 assert.True(t, res.SourceDeleted)
70 assert.Equal(t, head, r.branchHead("agent/claude/base"))
71 _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
72 assert.ErrorIs(t, err, domain.ErrNotFound)
73 })
74
75 t.Run("deleting a source outside the prefixes is forbidden, merging without deleting is not", func(t *testing.T) {
76 t.Parallel()
77 r := newMRRig(t)
78 r.seed()
79 r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
80 r.push("agent/claude/base")
81 base := r.branchHead("agent/claude/base")
82 r.sh("checkout", "-q", "-B", "other", "agent/claude/base")
83 head := r.commit(author, "o.txt", "o\n", "add o")
84 r.push("other")
85 mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
86 Title: "other", Source: "other", Target: "agent/claude/base",
87 })
88 require.NoError(t, err)
89
90 _, err = r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
91 require.ErrorIs(t, err, domain.ErrForbidden)
92 assert.Contains(t, err.Error(), "this token may not write other")
93 assert.Equal(t, base, r.branchHead("agent/claude/base"), "not merged")
94 assert.Equal(t, head, r.branchHead("other"), "the source stays")
95
96 res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head})
97 require.NoError(t, err)
98 assert.False(t, res.SourceDeleted)
99 assert.Equal(t, head, r.branchHead("agent/claude/base"))
100 assert.Equal(t, head, r.branchHead("other"), "the source stays")
101 })
102
103 t.Run("a squash into main is forbidden too", func(t *testing.T) {
104 t.Parallel()
105 r := newMRRig(t)
106 main := r.seed()
107 head := r.feature("agent/claude/feat")
108 mr := r.open("agent/claude/feat")
109 _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
110 require.ErrorIs(t, err, domain.ErrForbidden)
111 assert.Equal(t, main, r.branchHead("main"))
112 })
113}