v0.22.0 4c072b1

token_test.go RawHistoryFolder

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}

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 2

Tags 45

↑↓ moveenter openesc close