detached f42a3e9

test(passkeys): cover the web adapter mapping, a failing Remove and the full-store eviction

- webPasskeys.FinishLogin and cookieLifetime: refusal, other error, success and
  the not-ok path
- Remove returns a store error and leaves the sessions valid
- a full client on a full store loses only its own oldest ceremony
- the clone is refused end to end, and the test fails without the check
- no tautological feature assertion in the rpc test
commit
f42a3e97008a62baddd3ba308c6845f221dff756
author
brice · · 2026-10-12 16:18 UTC
parent
005cbda
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +126 -4
M internal/adapters/rpc/server_test.go +4 -4
@@ -21,15 +21,16 @@ import (
2121
2222 func TestServerServiceGetVersion(t *testing.T) {
2323 t.Parallel()
24- svc := &serverService{version: "v1.2.3", features: slices.Clone(Features), key: mocks.NewSigningKeyService(t)}
24+ advertised := []string{"one", "two"}
25+ svc := &serverService{version: "v1.2.3", features: advertised, key: mocks.NewSigningKeyService(t)}
2526 resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
2627 require.NoError(t, err)
2728 assert.Equal(t, "v1.2.3", resp.Msg.GetVersion())
28- assert.Equal(t, Features, resp.Msg.GetFeatures())
29+ assert.Equal(t, []string{"one", "two"}, resp.Msg.GetFeatures())
2930
3031 // The caller gets a copy: changing it does not change what is advertised.
3132 resp.Msg.Features[0] = "tampered"
32- assert.NotEqual(t, "tampered", Features[0])
33+ assert.Equal(t, []string{"one", "two"}, advertised)
3334 }
3435
3536 func TestServerServiceGetSigningKey(t *testing.T) {
@@ -62,7 +63,6 @@ func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) {
6263 return resp.Msg.GetFeatures()
6364 }
6465 assert.Equal(t, Features, list(Config{}))
65- assert.NotContains(t, list(Config{}), "passkeys")
6666 got := list(Config{Passkeys: mocks.NewPasskeyService(t)})
6767 assert.Equal(t, append(slices.Clone(Features), "passkeys"), got)
6868 assert.NotContains(t, Features, "passkeys", "the shared list is not modified")
@@ -21,15 +21,16 @@ import (
21 21
22 func TestServerServiceGetVersion(t *testing.T) {22 func TestServerServiceGetVersion(t *testing.T) {
23 t.Parallel()23 t.Parallel()
24- svc := &serverService{version: "v1.2.3", features: slices.Clone(Features), key: mocks.NewSigningKeyService(t)}24+ advertised := []string{"one", "two"}
25+ svc := &serverService{version: "v1.2.3", features: advertised, key: mocks.NewSigningKeyService(t)}
25 resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))26 resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
26 require.NoError(t, err)27 require.NoError(t, err)
27 assert.Equal(t, "v1.2.3", resp.Msg.GetVersion())28 assert.Equal(t, "v1.2.3", resp.Msg.GetVersion())
28- assert.Equal(t, Features, resp.Msg.GetFeatures())29+ assert.Equal(t, []string{"one", "two"}, resp.Msg.GetFeatures())
29 30
30 // The caller gets a copy: changing it does not change what is advertised.31 // The caller gets a copy: changing it does not change what is advertised.
31 resp.Msg.Features[0] = "tampered"32 resp.Msg.Features[0] = "tampered"
32- assert.NotEqual(t, "tampered", Features[0])33+ assert.Equal(t, []string{"one", "two"}, advertised)
33 }34 }
34 35
35 func TestServerServiceGetSigningKey(t *testing.T) {36 func TestServerServiceGetSigningKey(t *testing.T) {
@@ -62,7 +63,6 @@ func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) {
62 return resp.Msg.GetFeatures()63 return resp.Msg.GetFeatures()
63 }64 }
64 assert.Equal(t, Features, list(Config{}))65 assert.Equal(t, Features, list(Config{}))
65- assert.NotContains(t, list(Config{}), "passkeys")
66 got := list(Config{Passkeys: mocks.NewPasskeyService(t)})66 got := list(Config{Passkeys: mocks.NewPasskeyService(t)})
67 assert.Equal(t, append(slices.Clone(Features), "passkeys"), got)67 assert.Equal(t, append(slices.Clone(Features), "passkeys"), got)
68 assert.NotContains(t, Features, "passkeys", "the shared list is not modified")68 assert.NotContains(t, Features, "passkeys", "the shared list is not modified")
A internal/app/web_adapters_test.go +122 -0
@@ -0,0 +1,122 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
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.
23+func 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.
54+func 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+}
@@ -0,0 +1,122 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
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.
23+func 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.
54+func 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 43

↑↓ moveenter openesc close