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}