1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "strings"
7 "testing"
8
9 "connectrpc.com/connect"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"
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 "bnema.dev/ermite/internal/ports/mocks"
18)
19
20func TestGitServiceGetTree(t *testing.T) {
21 t.Parallel()
22 tests := []struct {
23 name string
24 req *pb.GetTreeRequest
25 usecase error
26 wantCode connect.Code
27 wantCall bool
28 }{
29 {name: "ok", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "src"}, wantCall: true},
30 {name: "invalid repo", req: &pb.GetTreeRequest{Repo: "../x", Revision: "main"}, wantCode: connect.CodeInvalidArgument},
31 {name: "invalid path", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "a/../b"}, wantCode: connect.CodeInvalidArgument},
32 {name: "repo not visible", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
33 {name: "path is a file", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "f"}, usecase: domain.ErrNotDirectory, wantCode: connect.CodeInvalidArgument, wantCall: true},
34 }
35 for _, tt := range tests {
36 t.Run(tt.name, func(t *testing.T) {
37 t.Parallel()
38 browse := mocks.NewBrowseService(t)
39 if tt.wantCall {
40 browse.EXPECT().GetTree(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything, false).
41 Return(domain.Tree{}, tt.usecase).Once()
42 }
43 resp, err := (&gitService{browse}).GetTree(ownerCtx(), connect.NewRequest(tt.req))
44 if tt.wantCode != 0 {
45 requireCode(t, tt.wantCode, err)
46 return
47 }
48 require.NoError(t, err)
49 assert.Empty(t, resp.Msg.GetEntries())
50 })
51 }
52}
53
54func mustObjectID(t *testing.T, s string) domain.ObjectID {
55 t.Helper()
56 id, err := domain.ParseObjectID(s)
57 require.NoError(t, err)
58 return id
59}
60
61func TestGitServiceListPaths(t *testing.T) {
62 t.Parallel()
63 commit := mustObjectID(t, strings.Repeat("a", 40))
64 tree := mustObjectID(t, strings.Repeat("b", 40))
65 tests := []struct {
66 name string
67 req *pb.ListPathsRequest
68 list domain.PathList
69 usecase error
70 wantCode connect.Code
71 wantCall bool
72 }{
73 {
74 name: "ok", req: &pb.ListPathsRequest{Repo: "demo", Revision: "main"}, wantCall: true,
75 list: domain.PathList{CommitID: commit, TreeID: tree, Paths: []string{"a.go", "b/c.go"}, Truncated: true},
76 },
77 {name: "default revision", req: &pb.ListPathsRequest{Repo: "demo"}, wantCall: true, list: domain.PathList{CommitID: commit, TreeID: tree}},
78 {name: "invalid repo", req: &pb.ListPathsRequest{Repo: "../x"}, wantCode: connect.CodeInvalidArgument},
79 {name: "repo not visible", req: &pb.ListPathsRequest{Repo: "demo"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
80 {name: "revision not found", req: &pb.ListPathsRequest{Repo: "demo", Revision: "nope"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
81 }
82 for _, tt := range tests {
83 t.Run(tt.name, func(t *testing.T) {
84 t.Parallel()
85 browse := mocks.NewBrowseService(t)
86 if tt.wantCall {
87 browse.EXPECT().ListPaths(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).
88 Return(tt.list, tt.usecase).Once()
89 }
90 resp, err := (&gitService{browse}).ListPaths(ownerCtx(), connect.NewRequest(tt.req))
91 if tt.wantCode != 0 {
92 requireCode(t, tt.wantCode, err)
93 return
94 }
95 require.NoError(t, err)
96 assert.Equal(t, tt.list.CommitID.String(), resp.Msg.GetCommitId())
97 assert.Equal(t, tt.list.TreeID.String(), resp.Msg.GetTreeId())
98 assert.Equal(t, tt.list.Paths, resp.Msg.GetPaths())
99 assert.Equal(t, tt.list.Truncated, resp.Msg.GetTruncated())
100 })
101 }
102}
103
104func TestGitServiceListRefs(t *testing.T) {
105 t.Parallel()
106 tests := []struct {
107 name string
108 req *pb.ListRefsRequest
109 usecase error
110 wantCode connect.Code
111 wantCall bool
112 }{
113 {name: "ok", req: &pb.ListRefsRequest{Repo: "demo", PageSize: 10}, wantCall: true},
114 {name: "unknown kind", req: &pb.ListRefsRequest{Repo: "demo", Kinds: []pb.RefKind{pb.RefKind(99)}}, wantCode: connect.CodeInvalidArgument},
115 {name: "invalid repo", req: &pb.ListRefsRequest{Repo: ""}, wantCode: connect.CodeInvalidArgument},
116 {name: "bad page token", req: &pb.ListRefsRequest{Repo: "demo", PageToken: "zz"}, usecase: domain.ErrInvalidPageToken, wantCode: connect.CodeInvalidArgument, wantCall: true},
117 {name: "not found", req: &pb.ListRefsRequest{Repo: "demo"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
118 }
119 for _, tt := range tests {
120 t.Run(tt.name, func(t *testing.T) {
121 t.Parallel()
122 browse := mocks.NewBrowseService(t)
123 if tt.wantCall {
124 browse.EXPECT().ListRefs(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).
125 Return(ports.RefPage{NextPageToken: "n"}, tt.usecase).Once()
126 }
127 resp, err := (&gitService{browse}).ListRefs(ownerCtx(), connect.NewRequest(tt.req))
128 if tt.wantCode != 0 {
129 requireCode(t, tt.wantCode, err)
130 return
131 }
132 require.NoError(t, err)
133 assert.Equal(t, "n", resp.Msg.GetNextPageToken())
134 })
135 }
136}
137
138func TestGitServiceGetCommitAndResolveRevision(t *testing.T) {
139 t.Parallel()
140 invalid := []struct{ name, repo, rev string }{
141 {"invalid repo", "../x", "main"},
142 {"invalid revision", "demo", "a b"},
143 }
144 for _, tt := range invalid {
145 t.Run("GetCommit "+tt.name, func(t *testing.T) {
146 t.Parallel()
147 _, err := (&gitService{mocks.NewBrowseService(t)}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: tt.repo, Revision: tt.rev}))
148 requireCode(t, connect.CodeInvalidArgument, err)
149 })
150 t.Run("ResolveRevision "+tt.name, func(t *testing.T) {
151 t.Parallel()
152 _, err := (&gitService{mocks.NewBrowseService(t)}).ResolveRevision(ownerCtx(), connect.NewRequest(&pb.ResolveRevisionRequest{Repo: tt.repo, Revision: tt.rev}))
153 requireCode(t, connect.CodeInvalidArgument, err)
154 })
155 }
156
157 t.Run("GetCommit not found", func(t *testing.T) {
158 t.Parallel()
159 browse := mocks.NewBrowseService(t)
160 browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))
162 requireCode(t, connect.CodeNotFound, err)
163 })
164 t.Run("ResolveRevision ok", func(t *testing.T) {
165 t.Parallel()
166 browse := mocks.NewBrowseService(t)
167 browse.EXPECT().ResolveRevision(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).
168 Return(domain.ResolvedRevision{Ref: "refs/heads/main"}, nil).Once()
169 resp, err := (&gitService{browse}).ResolveRevision(ownerCtx(), connect.NewRequest(&pb.ResolveRevisionRequest{Repo: "demo", Revision: "main"}))
170 require.NoError(t, err)
171 assert.Equal(t, "refs/heads/main", resp.Msg.GetRef())
172 })
173}
174
175func TestGitServiceListCommits(t *testing.T) {
176 t.Parallel()
177 tests := []struct {
178 name string
179 req *pb.ListCommitsRequest
180 usecase error
181 wantCode connect.Code
182 wantCall bool
183 }{
184 {name: "ok", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main"}, wantCall: true},
185 {name: "invalid path", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main", Path: "/abs/../x"}, wantCode: connect.CodeInvalidArgument},
186 {name: "bad page size", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main", PageSize: -1}, usecase: domain.ErrInvalidPageSize, wantCode: connect.CodeInvalidArgument, wantCall: true},
187 {name: "not found", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "nope"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
188 }
189 for _, tt := range tests {
190 t.Run(tt.name, func(t *testing.T) {
191 t.Parallel()
192 browse := mocks.NewBrowseService(t)
193 if tt.wantCall {
194 browse.EXPECT().ListCommits(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).
195 Return(ports.HistoryPage{}, tt.usecase).Once()
196 }
197 _, err := (&gitService{browse}).ListCommits(ownerCtx(), connect.NewRequest(tt.req))
198 if tt.wantCode != 0 {
199 requireCode(t, tt.wantCode, err)
200 return
201 }
202 require.NoError(t, err)
203 })
204 }
205}
206
207// GetBlob validates before it opens anything or touches the stream.
208func TestGitServiceGetBlobRejectsBadInput(t *testing.T) {
209 t.Parallel()
210 tests := []struct {
211 name string
212 req *pb.GetBlobRequest
213 }{
214 {"invalid repo", &pb.GetBlobRequest{Repo: "../x", Revision: "main", Path: "f"}},
215 {"missing path", &pb.GetBlobRequest{Repo: "demo", Revision: "main"}},
216 {"invalid path", &pb.GetBlobRequest{Repo: "demo", Revision: "main", Path: "a/../b"}},
217 }
218 for _, tt := range tests {
219 t.Run(tt.name, func(t *testing.T) {
220 t.Parallel()
221 err := (&gitService{mocks.NewBrowseService(t)}).GetBlob(ownerCtx(), connect.NewRequest(tt.req), nil)
222 requireCode(t, connect.CodeInvalidArgument, err)
223 })
224 }
225}