v0.26.0 df1db36

session_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"encoding/base64"
7	"testing"
8	"time"
9
10	"connectrpc.com/connect"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	pb "bnema.dev/ermite-api/gen/ermite"
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18	"bnema.dev/ermite/internal/ports/mocks"
19)
20
21func TestSessionServiceCreateLoginLink(t *testing.T) {
22	t.Parallel()
23	exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
24	tests := []struct {
25		name     string
26		usecase  error
27		wantCode connect.Code
28	}{
29		{name: "ok"},
30		{name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
31		{name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
32	}
33	for _, tt := range tests {
34		t.Run(tt.name, func(t *testing.T) {
35			t.Parallel()
36			sessions := mocks.NewSessionService(t)
37			sessions.EXPECT().CreateLoginLink(mock.Anything, domain.Owner()).
38				Return(ports.LoginLink{URL: "https://x/-/login/n", ExpiresAt: exp}, tt.usecase).Once()
39			resp, err := (&sessionService{sessions: sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
40			if tt.wantCode != 0 {
41				requireCode(t, tt.wantCode, err)
42				return
43			}
44			require.NoError(t, err)
45			assert.Equal(t, "https://x/-/login/n", resp.Msg.GetUrl())
46			assert.True(t, exp.Equal(resp.Msg.GetExpiresAt().AsTime()))
47		})
48	}
49}
50
51func TestSessionServiceSignOutEverywhere(t *testing.T) {
52	t.Parallel()
53	tests := []struct {
54		name     string
55		usecase  error
56		wantCode connect.Code
57	}{
58		{name: "ok"},
59		{name: "forbidden", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
60	}
61	for _, tt := range tests {
62		t.Run(tt.name, func(t *testing.T) {
63			t.Parallel()
64			sessions := mocks.NewSessionService(t)
65			sessions.EXPECT().SignOutEverywhere(mock.Anything, domain.Owner()).Return(tt.usecase).Once()
66			_, err := (&sessionService{sessions: sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{}))
67			if tt.wantCode != 0 {
68				requireCode(t, tt.wantCode, err)
69				return
70			}
71			require.NoError(t, err)
72		})
73	}
74}
75
76func TestSessionServiceListPasskeys(t *testing.T) {
77	t.Parallel()
78	created := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
79	used := created.Add(time.Hour)
80	tests := []struct {
81		name     string
82		list     []domain.Passkey
83		usecase  error
84		wantCode connect.Code
85	}{
86		{name: "none", list: nil},
87		{name: "two", list: []domain.Passkey{
88			{ID: []byte{0xfb, 0xff, 0x01}, Name: "phone", PublicKey: []byte("secret"), CreatedAt: created, LastUsedAt: used, BackupState: true},
89			{ID: []byte("hw"), Name: "key", CreatedAt: created},
90		}},
91		{name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
92		{name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
93	}
94	for _, tt := range tests {
95		t.Run(tt.name, func(t *testing.T) {
96			t.Parallel()
97			passkeys := mocks.NewPasskeyService(t)
98			passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(tt.list, tt.usecase).Once()
99			resp, err := (&sessionService{passkeys: passkeys}).ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{}))
100			if tt.wantCode != 0 {
101				requireCode(t, tt.wantCode, err)
102				return
103			}
104			require.NoError(t, err)
105			require.Len(t, resp.Msg.GetPasskeys(), len(tt.list))
106			for i, want := range tt.list {
107				got := resp.Msg.GetPasskeys()[i]
108				assert.Equal(t, base64.RawURLEncoding.EncodeToString(want.ID), got.GetId())
109				assert.Equal(t, want.Name, got.GetName())
110				assert.Equal(t, want.BackupState, got.GetSynced())
111				assert.True(t, want.CreatedAt.Equal(got.GetCreatedAt().AsTime()))
112				if want.LastUsedAt.IsZero() {
113					assert.Nil(t, got.GetLastUsedAt(), "never used")
114				} else {
115					assert.True(t, want.LastUsedAt.Equal(got.GetLastUsedAt().AsTime()))
116				}
117			}
118		})
119	}
120}
121
122func TestSessionServiceRemovePasskey(t *testing.T) {
123	t.Parallel()
124	id := []byte{0xfb, 0xff, 0x01, 'x'}
125	enc := base64.RawURLEncoding.EncodeToString(id)
126	tests := []struct {
127		name     string
128		id       string
129		usecase  error
130		called   bool
131		wantCode connect.Code
132	}{
133		{name: "ok", id: enc, called: true},
134		{name: "unknown id", id: enc, called: true, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound},
135		{name: "not the owner", id: enc, called: true, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
136		{name: "anonymous", id: enc, called: true, usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
137		{name: "not base64url", id: "not base64!", wantCode: connect.CodeInvalidArgument},
138		{name: "padded base64", id: base64.URLEncoding.EncodeToString(id), wantCode: connect.CodeInvalidArgument},
139		{name: "standard alphabet", id: "+/8B", wantCode: connect.CodeInvalidArgument},
140		{name: "empty", id: "", wantCode: connect.CodeInvalidArgument},
141	}
142	for _, tt := range tests {
143		t.Run(tt.name, func(t *testing.T) {
144			t.Parallel()
145			passkeys := mocks.NewPasskeyService(t)
146			if tt.called {
147				passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(tt.usecase).Once()
148			}
149			_, err := (&sessionService{passkeys: passkeys}).RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: tt.id}))
150			if tt.wantCode != 0 {
151				requireCode(t, tt.wantCode, err)
152				return
153			}
154			require.NoError(t, err)
155		})
156	}
157}
158
159// Without passkeys the RPCs answer failed_precondition and call nothing.
160func TestSessionServicePasskeysUnavailable(t *testing.T) {
161	t.Parallel()
162	svc := &sessionService{sessions: mocks.NewSessionService(t)}
163	_, err := svc.ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{}))
164	requireCode(t, connect.CodeFailedPrecondition, err)
165	assert.Contains(t, err.Error(), "passkeys are not available on this server")
166	_, err = svc.RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: "AQ"}))
167	requireCode(t, connect.CodeFailedPrecondition, err)
168}

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