1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "testing"
7
8 "connectrpc.com/connect"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 pb "bnema.dev/ermite-api/gen/ermite"
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16 "bnema.dev/ermite/internal/ports/mocks"
17)
18
19func TestTokenServiceCreateToken(t *testing.T) {
20 t.Parallel()
21 tests := []struct {
22 name string
23 req *pb.CreateTokenRequest
24 usecase error
25 wantCode connect.Code
26 wantCall bool
27 }{
28 {name: "ok", req: &pb.CreateTokenRequest{Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"demo"}}, wantCall: true},
29 {name: "unspecified scope", req: &pb.CreateTokenRequest{Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_UNSPECIFIED}}, wantCode: connect.CodeInvalidArgument},
30 {name: "invalid repo name", req: &pb.CreateTokenRequest{Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"../x"}}, wantCode: connect.CodeInvalidArgument},
31 {name: "forbidden", req: &pb.CreateTokenRequest{Label: "ci"}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true},
32 {name: "anonymous", req: &pb.CreateTokenRequest{Label: "ci"}, usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantCall: true},
33 {name: "invalid fields", req: &pb.CreateTokenRequest{}, usecase: domain.ErrInvalid, wantCode: connect.CodeInvalidArgument, wantCall: true},
34 }
35 for _, tt := range tests {
36 t.Run(tt.name, func(t *testing.T) {
37 t.Parallel()
38 tokens := mocks.NewTokenService(t)
39 if tt.wantCall {
40 tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).
41 Return(ports.CreatedToken{Token: domain.Token{ID: "t1", Label: "ci"}, Secret: "s3cret"}, tt.usecase).Once()
42 }
43 resp, err := (&tokenService{tokens}).CreateToken(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.Equal(t, "s3cret", resp.Msg.GetSecret())
50 assert.Equal(t, "t1", resp.Msg.GetToken().GetId())
51 })
52 }
53}
54
55func TestTokenServiceCreateTokenPassesParams(t *testing.T) {
56 t.Parallel()
57 tokens := mocks.NewTokenService(t)
58 tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool {
59 return p.Label == "ci" && len(p.Scopes) == 1 && len(p.Repos) == 1 && p.Repos[0].String() == "demo"
60 })).Return(ports.CreatedToken{}, nil).Once()
61 _, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
62 Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"demo"},
63 }))
64 require.NoError(t, err)
65}
66
67func TestTokenServiceBranchPrefixes(t *testing.T) {
68 t.Parallel()
69 tokens := mocks.NewTokenService(t)
70 tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool {
71 return assert.ObjectsAreEqual([]string{"agent/claude/", "bot/"}, p.BranchPrefixes)
72 })).Return(ports.CreatedToken{
73 Token: domain.Token{ID: "t1", BranchPrefixes: []string{"agent/claude/", "bot/"}},
74 Secret: "s",
75 }, nil).Once()
76 resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
77 Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"},
78 }))
79 require.NoError(t, err)
80 assert.Equal(t, []string{"agent/claude/", "bot/"}, resp.Msg.GetToken().GetBranchPrefixes())
81
82 // A malformed prefix is the use case's invalid_token: invalid_argument.
83 tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).Return(ports.CreatedToken{}, domain.ErrInvalidToken).Once()
84 _, err = (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
85 Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent"},
86 }))
87 requireCode(t, connect.CodeInvalidArgument, err)
88
89 // Listing returns them too; a token without any has none.
90 tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 0, "").
91 Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a", BranchPrefixes: []string{"x/"}}, {ID: "b"}}}, nil).Once()
92 list, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{}))
93 require.NoError(t, err)
94 assert.Equal(t, []string{"x/"}, list.Msg.GetTokens()[0].GetBranchPrefixes())
95 assert.Empty(t, list.Msg.GetTokens()[1].GetBranchPrefixes())
96}
97
98func TestTokenServiceRevokeToken(t *testing.T) {
99 t.Parallel()
100 tests := []struct {
101 name string
102 usecase error
103 wantCode connect.Code
104 }{
105 {name: "ok"},
106 {name: "not found", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound},
107 {name: "forbidden", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
108 }
109 for _, tt := range tests {
110 t.Run(tt.name, func(t *testing.T) {
111 t.Parallel()
112 tokens := mocks.NewTokenService(t)
113 tokens.EXPECT().Revoke(mock.Anything, domain.Owner(), "t1").Return(tt.usecase).Once()
114 _, err := (&tokenService{tokens}).RevokeToken(ownerCtx(), connect.NewRequest(&pb.RevokeTokenRequest{Id: "t1"}))
115 if tt.wantCode != 0 {
116 requireCode(t, tt.wantCode, err)
117 return
118 }
119 require.NoError(t, err)
120 })
121 }
122}
123
124func TestTokenServiceListTokens(t *testing.T) {
125 t.Parallel()
126 tokens := mocks.NewTokenService(t)
127 tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 5, "tok").
128 Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a"}, {ID: "b"}}, NextPageToken: "n"}, nil).Once()
129 resp, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{PageSize: 5, PageToken: "tok"}))
130 require.NoError(t, err)
131 assert.Len(t, resp.Msg.GetTokens(), 2)
132 assert.Equal(t, "n", resp.Msg.GetNextPageToken())
133
134 tokens.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.TokenPage{}, domain.ErrInvalidPageToken).Once()
135 _, err = (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{}))
136 requireCode(t, connect.CodeInvalidArgument, err)
137}