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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 21 | 21 | ||
| 22 | 22 | func TestServerServiceGetVersion(t *testing.T) { | |
| 23 | 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 | 26 | resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |
| 26 | 27 | require.NoError(t, err) | |
| 27 | 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 | 31 | // The caller gets a copy: changing it does not change what is advertised. | |
| 31 | 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 | 36 | func TestServerServiceGetSigningKey(t *testing.T) { | |
| @@ -62,7 +63,6 @@ func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) { | |||
| 62 | 63 | return resp.Msg.GetFeatures() | |
| 63 | 64 | } | |
| 64 | 65 | assert.Equal(t, Features, list(Config{})) | |
| 65 | - | assert.NotContains(t, list(Config{}), "passkeys") | |
| 66 | 66 | got := list(Config{Passkeys: mocks.NewPasskeyService(t)}) | |
| 67 | 67 | assert.Equal(t, append(slices.Clone(Features), "passkeys"), got) | |
| 68 | 68 | 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 | + | } | |||