1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "context"
7 "errors"
8 "net/http/httptest"
9 "slices"
10 "testing"
11 "time"
12
13 "connectrpc.com/connect"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"
17
18 pb "bnema.dev/ermite-api/gen/ermite"
19 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
20 "bnema.dev/ermite/internal/domain"
21 "bnema.dev/ermite/internal/ports/mocks"
22)
23
24type statusCase struct {
25 domain domain.SignatureStatus
26 pb pb.SignatureStatus
27}
28
29type formatCase struct {
30 domain domain.SignatureFormat
31 pb pb.SignatureFormat
32}
33
34var (
35 statusCases = []statusCase{
36 {domain.StatusUnsigned, pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED},
37 {domain.StatusVerified, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED},
38 {domain.StatusUnknownKey, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY},
39 {domain.StatusBadSignature, pb.SignatureStatus_SIGNATURE_STATUS_BAD_SIGNATURE},
40 {domain.StatusEmailMismatch, pb.SignatureStatus_SIGNATURE_STATUS_EMAIL_MISMATCH},
41 {domain.StatusExpired, pb.SignatureStatus_SIGNATURE_STATUS_EXPIRED},
42 {domain.StatusRevoked, pb.SignatureStatus_SIGNATURE_STATUS_REVOKED},
43 {domain.StatusUnsupported, pb.SignatureStatus_SIGNATURE_STATUS_UNSUPPORTED},
44 }
45 formatCases = []formatCase{
46 {domain.FormatSSH, pb.SignatureFormat_SIGNATURE_FORMAT_SSH},
47 {domain.FormatGPG, pb.SignatureFormat_SIGNATURE_FORMAT_GPG},
48 {domain.FormatX509, pb.SignatureFormat_SIGNATURE_FORMAT_X509},
49 }
50)
51
52func TestSignatureEnumMappers(t *testing.T) {
53 t.Parallel()
54 for _, c := range statusCases {
55 assert.Equal(t, c.pb, signatureStatusToPB(c.domain), c.domain)
56 }
57 assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNSPECIFIED, signatureStatusToPB(domain.StatusUnchecked))
58 for _, c := range formatCases {
59 assert.Equal(t, c.pb, signatureFormatToPB(c.domain), c.domain)
60 }
61 assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, signatureFormatToPB(domain.FormatNone))
62
63 // Nothing in the contract is unreachable.
64 for v, n := range pb.SignatureStatus_name {
65 if v != 0 {
66 assert.True(t, slices.ContainsFunc(statusCases, func(c statusCase) bool {
67 return c.pb == pb.SignatureStatus(v)
68 }), "%s is never produced", n)
69 }
70 }
71 for v, n := range pb.SignatureFormat_name {
72 if v != 0 {
73 assert.True(t, slices.ContainsFunc(formatCases, func(c formatCase) bool {
74 return c.pb == pb.SignatureFormat(v)
75 }), "%s is never produced", n)
76 }
77 }
78}
79
80func TestSignatureFormatFromPB(t *testing.T) {
81 t.Parallel()
82 f, err := signatureFormatFromPB(pb.SignatureFormat_SIGNATURE_FORMAT_SSH)
83 require.NoError(t, err)
84 assert.Equal(t, domain.FormatSSH, f)
85 f, err = signatureFormatFromPB(pb.SignatureFormat_SIGNATURE_FORMAT_GPG)
86 require.NoError(t, err)
87 assert.Equal(t, domain.FormatGPG, f)
88 for _, bad := range []pb.SignatureFormat{pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, pb.SignatureFormat_SIGNATURE_FORMAT_X509, pb.SignatureFormat(99)} {
89 _, err = signatureFormatFromPB(bad)
90 assert.ErrorIs(t, err, domain.ErrInvalid, bad)
91 }
92}
93
94func TestCommitToPBVerification(t *testing.T) {
95 t.Parallel()
96 t.Run("unchecked sets nothing", func(t *testing.T) {
97 t.Parallel()
98 assert.Nil(t, commitToPB(domain.Commit{}).GetVerification())
99 })
100 t.Run("unsigned", func(t *testing.T) {
101 t.Parallel()
102 v := commitToPB(domain.Commit{Verification: domain.CommitVerification{Status: domain.StatusUnsigned}}).GetVerification()
103 require.NotNil(t, v)
104 assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED, v.GetStatus())
105 assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, v.GetFormat())
106 })
107 t.Run("verified", func(t *testing.T) {
108 t.Parallel()
109 v := commitToPB(domain.Commit{Verification: domain.CommitVerification{
110 Format: domain.FormatSSH, Status: domain.StatusVerified, Fingerprint: "SHA256:abc", KeyID: "SHA256:abc", KeyTitle: "laptop",
111 }}).GetVerification()
112 require.NotNil(t, v)
113 assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_SSH, v.GetFormat())
114 assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, v.GetStatus())
115 assert.Equal(t, "SHA256:abc", v.GetFingerprint())
116 assert.Equal(t, "SHA256:abc", v.GetKeyId())
117 assert.Equal(t, "laptop", v.GetKeyTitle())
118 })
119 t.Run("every status", func(t *testing.T) {
120 t.Parallel()
121 for _, c := range statusCases {
122 v := commitToPB(domain.Commit{Verification: domain.CommitVerification{Format: domain.FormatGPG, Status: c.domain}}).GetVerification()
123 require.NotNil(t, v, c.domain)
124 assert.Equal(t, c.pb, v.GetStatus())
125 }
126 })
127}
128
129func TestTrustedKeyServiceList(t *testing.T) {
130 t.Parallel()
131 created := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
132 keys := []domain.TrustedKey{
133 {ID: "SHA256:builtin", Format: domain.FormatSSH, Title: "instance", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"ermite@example.com"}, Builtin: true},
134 {ID: "ABCD", Format: domain.FormatGPG, Title: "me", PublicKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----", Emails: []string{"a@b.c", "d@e.f"}, CreatedAt: created},
135 }
136 tests := []struct {
137 name string
138 list []domain.TrustedKey
139 usecase error
140 wantCode connect.Code
141 }{
142 {name: "ok", list: keys},
143 {name: "empty"},
144 {name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
145 {name: "unknown error", usecase: errors.New("disk on fire"), wantCode: connect.CodeInternal},
146 }
147 for _, tt := range tests {
148 t.Run(tt.name, func(t *testing.T) {
149 t.Parallel()
150 svc := mocks.NewTrustedKeyService(t)
151 svc.EXPECT().ListTrustedKeys(mock.Anything, domain.Owner()).Return(tt.list, tt.usecase).Once()
152 resp, err := (&trustedKeyService{svc}).ListTrustedKeys(ownerCtx(), connect.NewRequest(&pb.ListTrustedKeysRequest{}))
153 if tt.wantCode != 0 {
154 requireCode(t, tt.wantCode, err)
155 return
156 }
157 require.NoError(t, err)
158 require.Len(t, resp.Msg.GetKeys(), len(tt.list))
159 for i, want := range tt.list {
160 got := resp.Msg.GetKeys()[i]
161 assert.Equal(t, want.ID, got.GetId())
162 assert.Equal(t, signatureFormatToPB(want.Format), got.GetFormat())
163 assert.Equal(t, want.Title, got.GetTitle())
164 assert.Equal(t, want.PublicKey, got.GetPublicKey())
165 assert.Equal(t, want.Emails, got.GetEmails())
166 assert.Equal(t, want.Builtin, got.GetBuiltin())
167 assert.Equal(t, want.CreatedAt.IsZero(), got.GetCreatedAt() == nil)
168 if !want.CreatedAt.IsZero() {
169 assert.True(t, created.Equal(got.GetCreatedAt().AsTime()))
170 }
171 }
172 })
173 }
174}
175
176func TestTrustedKeyServiceAdd(t *testing.T) {
177 t.Parallel()
178 stored := domain.TrustedKey{ID: "SHA256:k", Format: domain.FormatSSH, Title: "laptop", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"a@b.c"}}
179 tests := []struct {
180 name string
181 format pb.SignatureFormat
182 usecase error
183 callsUC bool
184 wantCode connect.Code
185 }{
186 {name: "ssh", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true},
187 {name: "unspecified format", format: pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, wantCode: connect.CodeInvalidArgument},
188 {name: "x509 format", format: pb.SignatureFormat_SIGNATURE_FORMAT_X509, wantCode: connect.CodeInvalidArgument},
189 {name: "not the owner", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
190 {name: "invalid key", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrInvalid, wantCode: connect.CodeInvalidArgument},
191 {name: "already listed", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists},
192 {name: "too many", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrResourceExhausted, wantCode: connect.CodeResourceExhausted},
193 }
194 for _, tt := range tests {
195 t.Run(tt.name, func(t *testing.T) {
196 t.Parallel()
197 svc := mocks.NewTrustedKeyService(t)
198 if tt.callsUC {
199 ret := stored
200 if tt.usecase != nil {
201 ret = domain.TrustedKey{}
202 }
203 svc.EXPECT().AddTrustedKey(mock.Anything, domain.Owner(), domain.FormatSSH, "laptop", "ssh-ed25519 AAAA", []string{"a@b.c"}).Return(ret, tt.usecase).Once()
204 }
205 resp, err := (&trustedKeyService{svc}).AddTrustedKey(ownerCtx(), connect.NewRequest(&pb.AddTrustedKeyRequest{
206 Format: tt.format, Title: "laptop", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"a@b.c"},
207 }))
208 if tt.wantCode != 0 {
209 requireCode(t, tt.wantCode, err)
210 return
211 }
212 require.NoError(t, err)
213 assert.Equal(t, "SHA256:k", resp.Msg.GetKey().GetId())
214 assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_SSH, resp.Msg.GetKey().GetFormat())
215 })
216 }
217}
218
219func TestTrustedKeyServiceRemove(t *testing.T) {
220 t.Parallel()
221 tests := []struct {
222 name string
223 usecase error
224 wantCode connect.Code
225 }{
226 {name: "ok"},
227 {name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
228 {name: "unknown id", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound},
229 {name: "builtin", usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition},
230 }
231 for _, tt := range tests {
232 t.Run(tt.name, func(t *testing.T) {
233 t.Parallel()
234 svc := mocks.NewTrustedKeyService(t)
235 svc.EXPECT().RemoveTrustedKey(mock.Anything, domain.Owner(), "SHA256:k").Return(tt.usecase).Once()
236 _, err := (&trustedKeyService{svc}).RemoveTrustedKey(ownerCtx(), connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: "SHA256:k"}))
237 if tt.wantCode != 0 {
238 requireCode(t, tt.wantCode, err)
239 return
240 }
241 require.NoError(t, err)
242 })
243 }
244}
245
246// GetVersion lists "commit_signatures", and the service is mounted, only when
247// the API is built with trusted keys.
248func TestAPICommitSignaturesFeature(t *testing.T) {
249 t.Parallel()
250 serve := func(cfg Config) (features []string, keys ermiteconnect.TrustedKeyServiceClient) {
251 cfg.SigningKey = mocks.NewSigningKeyService(t)
252 srv := httptest.NewServer(New(cfg).Handler())
253 t.Cleanup(srv.Close)
254 c := ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL)
255 resp, err := c.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
256 require.NoError(t, err)
257 return resp.Msg.GetFeatures(), ermiteconnect.NewTrustedKeyServiceClient(srv.Client(), srv.URL)
258 }
259
260 features, keys := serve(Config{})
261 assert.NotContains(t, features, "commit_signatures")
262 assert.NotContains(t, Features, "commit_signatures", "the shared list is not modified")
263 _, err := keys.ListTrustedKeys(context.Background(), connect.NewRequest(&pb.ListTrustedKeysRequest{}))
264 assert.Equal(t, connect.CodeUnimplemented, connect.CodeOf(err))
265
266 svc := mocks.NewTrustedKeyService(t)
267 features, keys = serve(Config{TrustedKeys: svc, Auth: mocks.NewAuthService(t)})
268 assert.Contains(t, features, "commit_signatures")
269 // An anonymous caller reaches the use case, which refuses it.
270 svc.EXPECT().ListTrustedKeys(mock.Anything, domain.Anonymous()).Return(nil, domain.ErrUnauthenticated).Once()
271 _, err = keys.ListTrustedKeys(context.Background(), connect.NewRequest(&pb.ListTrustedKeysRequest{}))
272 assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err))
273}