1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"
19 "bnema.dev/ermite/internal/ports/mocks"
20 "bnema.dev/ermite/internal/usecase"
21)
22
23const (
24 sshArmor = "-----BEGIN SSH SIGNATURE-----\n AAAA\n -----END SSH SIGNATURE-----\n"
25 gpgArmor = "-----BEGIN PGP SIGNATURE-----\n\n iQ==\n -----END PGP SIGNATURE-----\n"
26 x509Armor = "-----BEGIN SIGNED MESSAGE-----\n MIIB\n -----END SIGNED MESSAGE-----\n"
27)
28
29var sigNow = time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
30
31type sigFixture struct {
32 store *mocks.TrustedKeyStore
33 inspector *mocks.KeyInspector
34 ssh, gpg *mocks.SignatureVerifier
35 objects *mocks.CommitObjectReader
36 clock *mocks.Clock
37 sigs *usecase.Signatures
38}
39
40var builtinKey = domain.TrustedKey{
41 ID: "SHA256:builtin", Format: domain.FormatSSH, Title: "instance", Emails: []string{"bot@example.org"}, Builtin: true,
42}
43
44func newSigFixture(t *testing.T, stored ...domain.TrustedKey) *sigFixture {
45 t.Helper()
46 f := &sigFixture{
47 store: mocks.NewTrustedKeyStore(t), inspector: mocks.NewKeyInspector(t),
48 ssh: mocks.NewSignatureVerifier(t), gpg: mocks.NewSignatureVerifier(t),
49 objects: mocks.NewCommitObjectReader(t), clock: mocks.NewClock(t),
50 }
51 f.clock.EXPECT().Now().Return(sigNow).Maybe()
52 f.store.EXPECT().ListTrustedKeys(mock.Anything).Return(stored, nil)
53 var err error
54 f.sigs, err = usecase.NewSignatures(f.store, f.inspector, map[domain.SignatureFormat]ports.SignatureVerifier{
55 domain.FormatSSH: f.ssh, domain.FormatGPG: f.gpg,
56 }, f.objects, builtinKey, f.clock)
57 require.NoError(t, err)
58 return f
59}
60
61func sigToken(scopes []domain.Scope, repos ...domain.RepoName) domain.Principal {
62 return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, Repos: repos})
63}
64
65func TestNewSignaturesLoadError(t *testing.T) {
66 t.Parallel()
67 store := mocks.NewTrustedKeyStore(t)
68 store.EXPECT().ListTrustedKeys(mock.Anything).Return(nil, errors.New("boom"))
69 _, err := usecase.NewSignatures(store, nil, nil, nil, domain.TrustedKey{}, nil)
70 require.Error(t, err)
71}
72
73func TestTrustedKeyAccess(t *testing.T) {
74 t.Parallel()
75 ctx := context.Background()
76 reader := sigToken([]domain.Scope{domain.ScopeRepoRead})
77 owner := sigToken(domain.AllScopes())
78
79 t.Run("list", func(t *testing.T) {
80 t.Parallel()
81 f := newSigFixture(t)
82 _, err := f.sigs.ListTrustedKeys(ctx, domain.Anonymous())
83 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
84 for _, p := range []domain.Principal{reader, owner, domain.Root(), domain.Owner()} {
85 _, err := f.sigs.ListTrustedKeys(ctx, p)
86 assert.NoError(t, err)
87 }
88 })
89
90 t.Run("add and remove", func(t *testing.T) {
91 t.Parallel()
92 f := newSigFixture(t)
93 _, err := f.sigs.AddTrustedKey(ctx, domain.Anonymous(), domain.FormatSSH, "t", "k", []string{"a@b.c"})
94 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
95 err = f.sigs.RemoveTrustedKey(ctx, domain.Anonymous(), "x")
96 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
97
98 _, err = f.sigs.AddTrustedKey(ctx, reader, domain.FormatSSH, "t", "k", []string{"a@b.c"})
99 assert.ErrorIs(t, err, domain.ErrForbidden)
100 assert.Contains(t, err.Error(), "managing trusted keys needs")
101 err = f.sigs.RemoveTrustedKey(ctx, reader, "x")
102 assert.ErrorIs(t, err, domain.ErrForbidden)
103
104 limited := sigToken(domain.AllScopes(), name(t, "r"))
105 _, err = f.sigs.AddTrustedKey(ctx, limited, domain.FormatSSH, "t", "k", []string{"a@b.c"})
106 assert.ErrorIs(t, err, domain.ErrForbidden)
107
108 for _, p := range []domain.Principal{owner, domain.Root(), domain.Owner()} {
109 id := "SHA256:" + p.Label()
110 f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return(id, "ssh-ed25519 K", nil).Once()
111 f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(nil).Once()
112 _, err := f.sigs.AddTrustedKey(ctx, p, domain.FormatSSH, "t", "k", []string{"a@b.c"})
113 assert.NoError(t, err)
114 f.store.EXPECT().DeleteTrustedKey(ctx, id).Return(nil).Once()
115 assert.NoError(t, f.sigs.RemoveTrustedKey(ctx, p, id))
116 }
117 })
118}
119
120func TestAddTrustedKey(t *testing.T) {
121 t.Parallel()
122 ctx := context.Background()
123 owner := domain.Root()
124
125 t.Run("stores the canonical key and lists it after the builtin", func(t *testing.T) {
126 t.Parallel()
127 f := newSigFixture(t)
128 f.inspector.EXPECT().Inspect(domain.FormatSSH, "ssh-ed25519 K comment").Return("SHA256:k1", "ssh-ed25519 K", nil)
129 want := domain.TrustedKey{
130 ID: "SHA256:k1", Format: domain.FormatSSH, Title: "laptop", PublicKey: "ssh-ed25519 K",
131 Emails: []string{"a@b.c", "d@e.f"}, CreatedAt: sigNow,
132 }
133 f.store.EXPECT().CreateTrustedKey(ctx, want).Return(nil)
134 got, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, " laptop ", "ssh-ed25519 K comment", []string{"A@B.c", " d@e.f", "a@b.c"})
135 require.NoError(t, err)
136 assert.Equal(t, want, got)
137
138 keys, err := f.sigs.ListTrustedKeys(ctx, owner)
139 require.NoError(t, err)
140 require.Len(t, keys, 2)
141 assert.Equal(t, builtinKey, keys[0])
142 assert.Equal(t, want, keys[1])
143 })
144
145 t.Run("validation", func(t *testing.T) {
146 t.Parallel()
147 f := newSigFixture(t)
148 for name, call := range map[string]func() error{
149 "empty title": func() error {
150 _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, " ", "k", []string{"a@b.c"})
151 return err
152 },
153 "no email": func() error {
154 _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", nil)
155 return err
156 },
157 "bad email": func() error {
158 _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", []string{"not an email"})
159 return err
160 },
161 "x509": func() error {
162 _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatX509, "t", "k", []string{"a@b.c"})
163 return err
164 },
165 "no format": func() error {
166 _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatNone, "t", "k", []string{"a@b.c"})
167 return err
168 },
169 "key too big": func() error {
170 _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatGPG, "t", strings.Repeat("x", domain.MaxTrustedKeyBytes+1), []string{"a@b.c"})
171 return err
172 },
173 } {
174 assert.ErrorIs(t, call(), domain.ErrInvalid, name)
175 }
176 })
177
178 t.Run("inspector error is returned", func(t *testing.T) {
179 t.Parallel()
180 f := newSigFixture(t)
181 f.inspector.EXPECT().Inspect(domain.FormatGPG, "bad").Return("", "", fmt.Errorf("%w: nope", domain.ErrInvalid))
182 _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatGPG, "t", "bad", []string{"a@b.c"})
183 assert.ErrorIs(t, err, domain.ErrInvalid)
184 })
185
186 t.Run("builtin and duplicates are refused", func(t *testing.T) {
187 t.Parallel()
188 f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:old", Format: domain.FormatSSH})
189 f.inspector.EXPECT().Inspect(domain.FormatSSH, "b").Return(builtinKey.ID, "ssh-ed25519 B", nil)
190 _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "b", []string{"a@b.c"})
191 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
192
193 f.inspector.EXPECT().Inspect(domain.FormatSSH, "o").Return("SHA256:old", "ssh-ed25519 O", nil)
194 _, err = f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "o", []string{"a@b.c"})
195 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
196 })
197
198 t.Run("limit", func(t *testing.T) {
199 t.Parallel()
200 var stored []domain.TrustedKey
201 for i := range domain.MaxTrustedKeys {
202 stored = append(stored, domain.TrustedKey{ID: fmt.Sprintf("SHA256:%d", i), Format: domain.FormatSSH})
203 }
204 f := newSigFixture(t, stored...)
205 f.inspector.EXPECT().Inspect(domain.FormatSSH, "n").Return("SHA256:new", "ssh-ed25519 N", nil)
206 _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "n", []string{"a@b.c"})
207 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
208 })
209
210 t.Run("store error leaves the list unchanged", func(t *testing.T) {
211 t.Parallel()
212 f := newSigFixture(t)
213 f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return("SHA256:k", "ssh-ed25519 K", nil)
214 f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(errors.New("down"))
215 _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", []string{"a@b.c"})
216 require.Error(t, err)
217 keys, _ := f.sigs.ListTrustedKeys(ctx, owner)
218 assert.Len(t, keys, 1)
219 })
220}
221
222func TestRemoveTrustedKey(t *testing.T) {
223 t.Parallel()
224 ctx := context.Background()
225
226 t.Run("builtin is refused without touching the store", func(t *testing.T) {
227 t.Parallel()
228 f := newSigFixture(t)
229 err := f.sigs.RemoveTrustedKey(ctx, domain.Root(), builtinKey.ID)
230 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
231 })
232
233 t.Run("removes from the store and the list", func(t *testing.T) {
234 t.Parallel()
235 f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:a"}, domain.TrustedKey{ID: "SHA256:b"})
236 f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:a").Return(nil)
237 require.NoError(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:a"))
238 keys, _ := f.sigs.ListTrustedKeys(ctx, domain.Root())
239 require.Len(t, keys, 2)
240 assert.Equal(t, "SHA256:b", keys[1].ID)
241 })
242
243 t.Run("store errors are returned and the key stays", func(t *testing.T) {
244 t.Parallel()
245 f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:a"})
246 f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:a").Return(domain.ErrNotFound)
247 assert.ErrorIs(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:a"), domain.ErrNotFound)
248 keys, _ := f.sigs.ListTrustedKeys(ctx, domain.Root())
249 assert.Len(t, keys, 2)
250 })
251}
252
253func TestListWithoutBuiltin(t *testing.T) {
254 t.Parallel()
255 store := mocks.NewTrustedKeyStore(t)
256 store.EXPECT().ListTrustedKeys(mock.Anything).Return([]domain.TrustedKey{{ID: "SHA256:a"}}, nil)
257 s, err := usecase.NewSignatures(store, nil, nil, nil, domain.TrustedKey{}, nil)
258 require.NoError(t, err)
259 keys, err := s.ListTrustedKeys(context.Background(), domain.Root())
260 require.NoError(t, err)
261 require.Len(t, keys, 1)
262 assert.Equal(t, "SHA256:a", keys[0].ID)
263}
264
265// rawCommit builds a commit object with the signature armor in a gpgsig header.
266func rawCommit(armor string) []byte {
267 var b strings.Builder
268 b.WriteString("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@b.c> 1 +0000\ncommitter A <a@b.c> 1 +0000\n")
269 if armor != "" {
270 lines := strings.Split(strings.TrimSuffix(armor, "\n"), "\n")
271 b.WriteString("gpgsig " + strings.TrimPrefix(lines[0], " ") + "\n")
272 for _, l := range lines[1:] {
273 b.WriteString(" " + strings.TrimPrefix(l, " ") + "\n")
274 }
275 }
276 b.WriteString("\nmessage\n")
277 return []byte(b.String())
278}
279
280func sigCommit(c string) domain.Commit {
281 return domain.Commit{ID: oid(c), Committer: domain.Signature{Email: "a@b.c", Time: time.Unix(1700000000, 0)}}
282}
283
284func TestVerifyCommits(t *testing.T) {
285 t.Parallel()
286 ctx := context.Background()
287 repo := name(t, "r")
288 stored := []domain.TrustedKey{
289 {ID: "SHA256:s1", Format: domain.FormatSSH, Emails: []string{"a@b.c"}},
290 {ID: "ABCD", Format: domain.FormatGPG, Emails: []string{"a@b.c"}},
291 }
292
293 t.Run("unsigned, x509 and unknown armor need no verifier", func(t *testing.T) {
294 t.Parallel()
295 f := newSigFixture(t, stored...)
296 cs := []domain.Commit{sigCommit("1"), sigCommit("2"), sigCommit("3")}
297 f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1"), oid("2"), oid("3")}).
298 Return(map[domain.ObjectID][]byte{
299 oid("1"): rawCommit(""),
300 oid("2"): rawCommit(x509Armor),
301 oid("3"): rawCommit("-----BEGIN WHAT-----\n x\n -----END WHAT-----\n"),
302 }, nil).Once()
303 got := f.sigs.VerifyCommits(ctx, repo, cs)
304 assert.Equal(t, map[domain.ObjectID]domain.CommitVerification{
305 oid("1"): {Status: domain.StatusUnsigned},
306 oid("2"): {Format: domain.FormatX509, Status: domain.StatusUnsupported},
307 oid("3"): {Format: domain.FormatNone, Status: domain.StatusBadSignature},
308 }, got)
309 })
310
311 t.Run("ssh gets the builtin and the ssh keys only", func(t *testing.T) {
312 t.Parallel()
313 f := newSigFixture(t, stored...)
314 c := sigCommit("1")
315 raw := rawCommit(sshArmor)
316 f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).Return(map[domain.ObjectID][]byte{c.ID: raw}, nil)
317 want := domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusVerified, Fingerprint: "SHA256:s1", KeyID: "SHA256:s1"}
318 payload, sig, ok := domain.SplitCommitSignature(raw)
319 require.True(t, ok)
320 f.ssh.EXPECT().Verify(payload, sig, "a@b.c", int64(1700000000), []domain.TrustedKey{builtinKey, stored[0]}).Return(want)
321 assert.Equal(t, want, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})[c.ID])
322 })
323
324 t.Run("gpg gets the gpg keys only", func(t *testing.T) {
325 t.Parallel()
326 f := newSigFixture(t, stored...)
327 c := sigCommit("1")
328 raw := rawCommit(gpgArmor)
329 f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).Return(map[domain.ObjectID][]byte{c.ID: raw}, nil)
330 want := domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusExpired}
331 payload, sig, _ := domain.SplitCommitSignature(raw)
332 f.gpg.EXPECT().Verify(payload, sig, "a@b.c", int64(1700000000), []domain.TrustedKey{stored[1]}).Return(want)
333 assert.Equal(t, want, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})[c.ID])
334 })
335
336 t.Run("results are cached per repository", func(t *testing.T) {
337 t.Parallel()
338 f := newSigFixture(t, stored...)
339 c := sigCommit("1")
340 f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).
341 Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Once()
342 first := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
343 second := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
344 assert.Equal(t, first, second)
345
346 // Another repository is another entry.
347 other := name(t, "other")
348 f.objects.EXPECT().ReadCommitObjects(ctx, other, []domain.ObjectID{c.ID}).
349 Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Once()
350 f.sigs.VerifyCommits(ctx, other, []domain.Commit{c})
351 })
352
353 t.Run("only the misses are read", func(t *testing.T) {
354 t.Parallel()
355 f := newSigFixture(t, stored...)
356 f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).
357 Return(map[domain.ObjectID][]byte{oid("1"): rawCommit("")}, nil).Once()
358 f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")})
359 f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).
360 Return(map[domain.ObjectID][]byte{oid("2"): rawCommit("")}, nil).Once()
361 got := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1"), sigCommit("2")})
362 assert.Len(t, got, 2)
363 })
364
365 t.Run("adding or removing a key invalidates the cache", func(t *testing.T) {
366 t.Parallel()
367 f := newSigFixture(t, stored...)
368 c := sigCommit("1")
369 f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).
370 Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Times(3)
371 f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
372
373 f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return("SHA256:new", "ssh-ed25519 N", nil)
374 f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(nil)
375 _, err := f.sigs.AddTrustedKey(ctx, domain.Root(), domain.FormatSSH, "t", "k", []string{"a@b.c"})
376 require.NoError(t, err)
377 f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // read 2
378 f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // cached
379
380 f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:new").Return(nil)
381 require.NoError(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:new"))
382 f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // read 3
383 })
384
385 t.Run("a read error returns the cached results only", func(t *testing.T) {
386 t.Parallel()
387 f := newSigFixture(t, stored...)
388 f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).
389 Return(map[domain.ObjectID][]byte{oid("1"): rawCommit("")}, nil).Once()
390 f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")})
391
392 f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).Return(nil, errors.New("disk")).Once()
393 got := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1"), sigCommit("2")})
394 assert.Equal(t, map[domain.ObjectID]domain.CommitVerification{oid("1"): {Status: domain.StatusUnsigned}}, got)
395
396 // A failure is not cached.
397 f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).
398 Return(map[domain.ObjectID][]byte{oid("2"): rawCommit("")}, nil).Once()
399 assert.Len(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("2")}), 1)
400 })
401
402 t.Run("a commit missing from the raw map is left out and not cached", func(t *testing.T) {
403 t.Parallel()
404 f := newSigFixture(t, stored...)
405 f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).Return(map[domain.ObjectID][]byte{}, nil).Twice()
406 assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")}))
407 assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")}))
408 })
409
410 t.Run("no commits, no read", func(t *testing.T) {
411 t.Parallel()
412 f := newSigFixture(t)
413 assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, nil))
414 })
415
416 t.Run("the cache stays bounded", func(t *testing.T) {
417 t.Parallel()
418 f := newSigFixture(t)
419 read := 0 // ids asked to the reader
420 f.objects.EXPECT().ReadCommitObjects(ctx, repo, mock.Anything).
421 RunAndReturn(func(_ context.Context, _ domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
422 read += len(ids)
423 m := make(map[domain.ObjectID][]byte, len(ids))
424 for _, id := range ids {
425 m[id] = rawCommit("")
426 }
427 return m, nil
428 })
429 mk := func(from, n int) []domain.Commit {
430 cs := make([]domain.Commit, n)
431 for i := range cs {
432 cs[i] = sigCommit("a")
433 cs[i].ID = numID(from + i)
434 }
435 return cs
436 }
437 const total = 2 * usecase.MaxSignatureCacheEntries
438 for from := 0; from < total; from += 1000 {
439 assert.Len(t, f.sigs.VerifyCommits(ctx, repo, mk(from, 1000)), 1000)
440 }
441 assert.Equal(t, total, read)
442 // The latest page is still cached.
443 f.sigs.VerifyCommits(ctx, repo, mk(total-1000, 1000))
444 assert.Equal(t, total, read)
445 // At most MaxSignatureCacheEntries of the total are kept.
446 read = 0
447 assert.Len(t, f.sigs.VerifyCommits(ctx, repo, mk(0, total)), total)
448 assert.GreaterOrEqual(t, read, total-usecase.MaxSignatureCacheEntries)
449 })
450}
451
452func numID(n int) domain.ObjectID {
453 id, err := domain.ParseObjectID(fmt.Sprintf("%040x", n+1))
454 if err != nil {
455 panic(err)
456 }
457 return id
458}