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}