v0.28.1 cc7ba64

trusted_keys_test.go RawHistoryFolder

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}

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 45

↑↓ moveenter openesc close