v0.25.0 4492065

web_adapters_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"errors"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17	"bnema.dev/ermite/internal/ports/mocks"
18	"bnema.dev/ermite/internal/usecase"
19)
20
21// cookieLifetime: the session lifetime, cut short by an expiry that comes
22// first, and no session at all when under a second is left.
23func TestCookieLifetime(t *testing.T) {
24	t.Parallel()
25	now := time.Now()
26	tests := []struct {
27		name    string
28		expires time.Time
29		want    time.Duration // approximate, within a second
30		ok      bool
31	}{
32		{"a full session", now.Add(usecase.SessionTTL), usecase.SessionTTL, true},
33		{"the expiry is truncated to the second: the slack counts as full", now.Add(usecase.SessionTTL - 500*time.Millisecond), usecase.SessionTTL, true},
34		{"cut short by the token that made the link", now.Add(30 * time.Minute), 30 * time.Minute, true},
35		{"two seconds left", now.Add(2 * time.Second), 2 * time.Second, true},
36		{"under a second left", now.Add(400 * time.Millisecond), 0, false},
37		{"already over", now.Add(-time.Hour), 0, false},
38	}
39	for _, tc := range tests {
40		t.Run(tc.name, func(t *testing.T) {
41			t.Parallel()
42			got, ok := cookieLifetime(tc.expires)
43			assert.Equal(t, tc.ok, ok)
44			if tc.ok {
45				assert.InDelta(t, tc.want.Seconds(), got.Seconds(), 1.5)
46			}
47		})
48	}
49}
50
51// webPasskeys.FinishLogin turns the use case's answer into the web's: a refusal
52// is ports.ErrLinkRejected, any other failure is passed on, and a success has
53// the cookie lifetime of its session.
54func TestWebPasskeysFinishLogin(t *testing.T) {
55	t.Parallel()
56	ctx := context.Background()
57	boom := errors.New("disk on fire")
58
59	// newPasskeys builds the use cases over mocks; the verifier answers verify.
60	newPasskeys := func(t *testing.T, verify func() (domain.Passkey, error), stored domain.Passkey) (webPasskeys, string) {
61		t.Helper()
62		clock := mocks.NewClock(t)
63		clock.EXPECT().Now().RunAndReturn(time.Now).Maybe()
64		sessionStore := mocks.NewSessionStore(t)
65		sessionStore.EXPECT().Key(mock.Anything).Return([]byte("0123456789abcdef0123456789abcdef"), nil)
66		sessionStore.EXPECT().Epoch(mock.Anything).Return(uint64(0), nil).Maybe()
67		sessions, err := usecase.NewSessions(ctx, sessionStore, clock, "https://forge.example")
68		require.NoError(t, err)
69
70		store := mocks.NewPasskeyStore(t)
71		store.EXPECT().UserHandle(mock.Anything).Return([]byte("handle"), nil).Maybe()
72		store.EXPECT().Get(mock.Anything, mock.Anything).Return(stored, nil).Maybe()
73		store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil).Maybe()
74		verifier := mocks.NewPasskeyVerifier(t)
75		verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("c"), nil)
76		verifier.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
77			RunAndReturn(func([]byte, []byte, []byte, func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
78				return verify()
79			}).Maybe() // not reached for an unknown ceremony
80		pk := webPasskeys{usecase.NewPasskeys(store, verifier, sessions, clock)}
81		id, _, err := pk.BeginLogin(ctx, "192.0.2.1")
82		require.NoError(t, err)
83		return pk, id
84	}
85	stored := domain.Passkey{ID: []byte("cred-1")}
86
87	t.Run("a refusal is ErrLinkRejected", func(t *testing.T) {
88		t.Parallel()
89		pk, id := newPasskeys(t, func() (domain.Passkey, error) {
90			return domain.Passkey{}, domain.ErrUnauthenticated
91		}, stored)
92		token, lifetime, err := pk.FinishLogin(ctx, id, []byte(`{}`))
93		assert.ErrorIs(t, err, ports.ErrLinkRejected)
94		assert.Empty(t, token)
95		assert.Zero(t, lifetime)
96	})
97
98	t.Run("an unknown ceremony is a refusal too", func(t *testing.T) {
99		t.Parallel()
100		pk, _ := newPasskeys(t, func() (domain.Passkey, error) { return stored, nil }, stored)
101		_, _, err := pk.FinishLogin(ctx, "unknown", []byte(`{}`))
102		assert.ErrorIs(t, err, ports.ErrLinkRejected)
103	})
104
105	t.Run("another error is passed on as it is", func(t *testing.T) {
106		t.Parallel()
107		pk, id := newPasskeys(t, func() (domain.Passkey, error) { return domain.Passkey{}, boom }, stored)
108		token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`))
109		require.ErrorIs(t, err, boom)
110		assert.NotErrorIs(t, err, ports.ErrLinkRejected)
111		assert.Empty(t, token)
112	})
113
114	t.Run("a success gives the token and the lifetime of the session", func(t *testing.T) {
115		t.Parallel()
116		pk, id := newPasskeys(t, func() (domain.Passkey, error) { return stored, nil }, stored)
117		token, lifetime, err := pk.FinishLogin(ctx, id, []byte(`{}`))
118		require.NoError(t, err)
119		assert.NotEmpty(t, token)
120		assert.InDelta(t, usecase.SessionTTL.Seconds(), lifetime.Seconds(), 1.5)
121	})
122}

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