detached fda3c2b

store_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"sync"
10	"testing"
11	"time"
12
13	kv "bnema.dev/ermite-kv"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16	"google.golang.org/protobuf/proto"
17
18	storagepb "bnema.dev/ermite/gen/ermite/storage"
19	"bnema.dev/ermite/internal/adapters/kvstore"
20	"bnema.dev/ermite/internal/domain"
21)
22
23func openStore(t *testing.T, dir string) *kvstore.Store {
24	t.Helper()
25	ctx := context.Background()
26	s, err := kvstore.Open(ctx, dir)
27	require.NoError(t, err)
28	t.Cleanup(func() { _ = s.Close(ctx) })
29	return s
30}
31
32func name(t *testing.T, s string) domain.RepoName {
33	t.Helper()
34	n, err := domain.ParseRepoName(s)
35	require.NoError(t, err)
36	return n
37}
38
39var created = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
40
41func TestCreateGetListUpdateDelete(t *testing.T) {
42	ctx := context.Background()
43	s := openStore(t, t.TempDir())
44
45	b := domain.Repo{Name: name(t, "b"), Description: "second", Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
46	a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), ActiveAt: created.Add(2 * time.Hour), Settings: domain.DefaultRepoSettings()}
47	require.NoError(t, s.Create(ctx, b))
48	require.NoError(t, s.Create(ctx, a))
49
50	got, err := s.Get(ctx, b.Name)
51	require.NoError(t, err)
52	assert.Equal(t, b, got)
53	got, err = s.Get(ctx, a.Name)
54	require.NoError(t, err)
55	assert.Equal(t, a, got)
56	assert.True(t, got.PushedAt.Equal(a.PushedAt))
57	assert.True(t, got.ActiveAt.Equal(a.ActiveAt))
58
59	list, err := s.List(ctx)
60	require.NoError(t, err)
61	assert.Equal(t, []domain.Repo{a, b}, list)
62
63	err = s.Create(ctx, b)
64	assert.ErrorIs(t, err, domain.ErrAlreadyExists)
65
66	b.Description = "changed"
67	b.Topics = []string{"forge"}
68	b.Visibility = domain.VisibilityPrivate
69	_, err = s.Mutate(ctx, b.Name, func(r *domain.Repo) (bool, error) { *r = b; return true, nil })
70	require.NoError(t, err)
71	got, err = s.Get(ctx, b.Name)
72	require.NoError(t, err)
73	assert.Equal(t, b, got)
74
75	b.Topics = nil
76	_, err = s.Mutate(ctx, b.Name, func(r *domain.Repo) (bool, error) { *r = b; return true, nil })
77	require.NoError(t, err)
78	got, err = s.Get(ctx, b.Name)
79	require.NoError(t, err)
80	assert.Nil(t, got.Topics, "cleared topics read back as nil")
81
82	require.NoError(t, s.Delete(ctx, b.Name))
83	_, err = s.Get(ctx, b.Name)
84	assert.ErrorIs(t, err, domain.ErrNotFound)
85	require.NoError(t, s.Delete(ctx, b.Name), "deleting a missing repository is not an error")
86	_, err = s.Mutate(ctx, b.Name, func(r *domain.Repo) (bool, error) { return true, nil })
87	assert.ErrorIs(t, err, domain.ErrNotFound)
88
89	_, err = s.Get(ctx, name(t, "never"))
90	assert.ErrorIs(t, err, domain.ErrNotFound)
91}
92
93func TestCreateRejectsInvalidValues(t *testing.T) {
94	ctx := context.Background()
95	s := openStore(t, t.TempDir())
96	n := name(t, "x")
97
98	assert.ErrorIs(t, s.Create(ctx, domain.Repo{Name: n, Visibility: "secret"}), domain.ErrInvalidVisibility)
99	assert.ErrorIs(t, s.Create(ctx, domain.Repo{Visibility: domain.VisibilityPublic}), domain.ErrInvalidName)
100	assert.Error(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "\xff"}))
101	list, err := s.List(ctx)
102	require.NoError(t, err)
103	assert.Empty(t, list)
104}
105
106func TestConcurrentCreateSameName(t *testing.T) {
107	ctx := context.Background()
108	s := openStore(t, t.TempDir())
109	n := name(t, "tools")
110
111	const workers = 16
112	errs := make(chan error, workers)
113	var wg sync.WaitGroup
114	for i := range workers {
115		wg.Add(1)
116		go func() {
117			defer wg.Done()
118			errs <- s.Create(ctx, domain.Repo{Name: n, Description: fmt.Sprint(i), Visibility: domain.VisibilityPrivate, CreatedAt: created})
119		}()
120	}
121	wg.Wait()
122	close(errs)
123	ok, exists := 0, 0
124	for err := range errs {
125		switch {
126		case err == nil:
127			ok++
128		case errors.Is(err, domain.ErrAlreadyExists):
129			exists++
130		default:
131			t.Fatalf("unexpected error: %v", err)
132		}
133	}
134	assert.Equal(t, 1, ok)
135	assert.Equal(t, workers-1, exists)
136}
137
138func TestPersistsAcrossReopen(t *testing.T) {
139	ctx := context.Background()
140	dir := t.TempDir()
141	s, err := kvstore.Open(ctx, dir)
142	require.NoError(t, err)
143	r := domain.Repo{Name: name(t, "keep"), Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
144	require.NoError(t, s.Create(ctx, r))
145	require.NoError(t, s.Close(ctx))
146
147	s2 := openStore(t, dir)
148	got, err := s2.Get(ctx, r.Name)
149	require.NoError(t, err)
150	assert.Equal(t, r, got)
151}
152
153func TestCheck(t *testing.T) {
154	ctx := context.Background()
155	s, err := kvstore.Open(ctx, t.TempDir())
156	require.NoError(t, err)
157	require.NoError(t, s.Check(ctx))
158
159	// The caller's own cancellation is reported, not mistaken for health.
160	cancelled, cancel := context.WithCancel(ctx)
161	cancel()
162	assert.ErrorIs(t, s.Check(cancelled), context.Canceled)
163
164	require.NoError(t, s.Close(ctx))
165	assert.ErrorIs(t, s.Check(ctx), kv.ErrClosed)
166}
167
168func TestOpenLockedDirectory(t *testing.T) {
169	ctx := context.Background()
170	dir := t.TempDir()
171	_ = openStore(t, dir)
172	_, err := kvstore.Open(ctx, dir)
173	assert.ErrorIs(t, err, kv.ErrLocked)
174}
175
176// putRaw writes a value with ermite-kv directly, bypassing the store.
177func putRaw(t *testing.T, dir, key string, val []byte) {
178	t.Helper()
179	ctx := context.Background()
180	db, err := kv.Open(ctx, dir, kv.Options{})
181	require.NoError(t, err)
182	var b kv.Batch
183	b.Put([]byte(key), val)
184	_, err = db.Apply(ctx, &b)
185	require.NoError(t, err)
186	require.NoError(t, db.Close(ctx))
187}
188
189func readSchema(t *testing.T, dir string) uint32 {
190	t.Helper()
191	ctx := context.Background()
192	db, err := kv.Open(ctx, dir, kv.Options{})
193	require.NoError(t, err)
194	defer func() { _ = db.Close(ctx) }()
195	item, ok := db.Get(ctx, []byte("meta/schema"))
196	require.True(t, ok, "meta/schema missing")
197	var sv storagepb.SchemaVersion
198	require.NoError(t, proto.Unmarshal(item.Value, &sv))
199	return sv.GetVersion()
200}
201
202func TestSchema(t *testing.T) {
203	ctx := context.Background()
204
205	t.Run("missing is written as the current version", func(t *testing.T) {
206		dir := t.TempDir()
207		s, err := kvstore.Open(ctx, dir)
208		require.NoError(t, err)
209		require.NoError(t, s.Close(ctx))
210		assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
211	})
212
213	t.Run("current is accepted and kept", func(t *testing.T) {
214		dir := t.TempDir()
215		s, err := kvstore.Open(ctx, dir)
216		require.NoError(t, err)
217		require.NoError(t, s.Close(ctx))
218		s, err = kvstore.Open(ctx, dir)
219		require.NoError(t, err)
220		require.NoError(t, s.Close(ctx))
221		assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
222	})
223
224	t.Run("version 1 is upgraded to the current one, its data kept", func(t *testing.T) {
225		dir := t.TempDir()
226		putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 1}))
227		putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
228		s, err := kvstore.Open(ctx, dir)
229		require.NoError(t, err)
230		got, err := s.Get(ctx, name(t, "old"))
231		require.NoError(t, err)
232		assert.Equal(t, "old", got.Name.String())
233		require.NoError(t, s.Close(ctx))
234		assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
235	})
236
237	t.Run("version 2 is upgraded to the current one, its runs kept", func(t *testing.T) {
238		dir := t.TempDir()
239		putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
240		putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
241		// A run written by a schema 2 build (no head sha), and a retained pull_request
242		// run that has one: both must read back after the upgrade.
243		putRaw(t, dir, "run/old/00000000000000000001", mustProto(t, &storagepb.CIRun{
244			Repo: "old", Id: 1, Number: 1, Attempt: 1, Event: "push", Ref: "refs/heads/main", Sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
245			Status: storagepb.CIStatus_CI_STATUS_COMPLETED,
246		}))
247		putRaw(t, dir, "run/old/00000000000000000002", mustProto(t, &storagepb.CIRun{
248			Repo: "old", Id: 2, Number: 2, Attempt: 1, Event: "pull_request", Ref: "refs/pull/1/merge",
249			Sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", HeadSha: "cccccccccccccccccccccccccccccccccccccccc",
250			Status: storagepb.CIStatus_CI_STATUS_COMPLETED,
251		}))
252		s, err := kvstore.Open(ctx, dir)
253		require.NoError(t, err)
254		got, err := s.Get(ctx, name(t, "old"))
255		require.NoError(t, err)
256		assert.Equal(t, "old", got.Name.String())
257		old, err := s.CI().GetRun(ctx, name(t, "old"), 1)
258		require.NoError(t, err)
259		assert.Equal(t, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", old.Sha)
260		assert.Empty(t, old.HeadSha)
261		assert.Equal(t, old.Sha, old.CheckSha(), "a run without a head sha keeps its checks on its commit")
262		pr, err := s.CI().GetRun(ctx, name(t, "old"), 2)
263		require.NoError(t, err)
264		assert.Equal(t, "pull_request", pr.Event)
265		assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)
266		assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")
267		require.NoError(t, s.Close(ctx))
268		assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
269	})
270
271	t.Run("version 2 is upgraded to the current one, its data kept", func(t *testing.T) {
272		dir := t.TempDir()
273		putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
274		putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
275		s, err := kvstore.Open(ctx, dir)
276		require.NoError(t, err)
277		_, err = s.Get(ctx, name(t, "old"))
278		require.NoError(t, err)
279		require.NoError(t, s.Close(ctx))
280		assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
281	})
282
283	// Downgrade guard: once a build with reusable workflows has opened the data
284	// (and may have stored calls), a build that knows schema 2 only must refuse
285	// it; it does so for any version above its own (ErrSchemaTooNew), which is
286	// the version 3 this build writes.
287	t.Run("data written by this build is above schema 2", func(t *testing.T) {
288		dir := t.TempDir()
289		s, err := kvstore.Open(ctx, dir)
290		require.NoError(t, err)
291		require.NoError(t, s.Close(ctx))
292		assert.Greater(t, readSchema(t, dir), uint32(2), "a build that supports 2 refuses it")
293	})
294
295	t.Run("newer stops startup and releases the lock", func(t *testing.T) {
296		dir := t.TempDir()
297		putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
298		_, err := kvstore.Open(ctx, dir)
299		require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
300		assert.Equal(t, uint32(kvstore.SchemaVersion+1), readSchema(t, dir), "the lock was released and the value kept")
301	})
302
303	t.Run("garbage and zero are errors", func(t *testing.T) {
304		dir := t.TempDir()
305		putRaw(t, dir, "meta/schema", []byte{0xff, 0xff, 0xff})
306		_, err := kvstore.Open(ctx, dir)
307		assert.Error(t, err)
308
309		dir = t.TempDir()
310		putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 0}))
311		_, err = kvstore.Open(ctx, dir)
312		assert.Error(t, err)
313	})
314}
315
316func mustProto(t *testing.T, m proto.Message) []byte {
317	t.Helper()
318	b, err := proto.Marshal(m)
319	require.NoError(t, err)
320	return b
321}
322
323func TestUnspecifiedVisibilityReadsPrivate(t *testing.T) {
324	ctx := context.Background()
325	dir := t.TempDir()
326	putRaw(t, dir, "repo/odd", mustProto(t, &storagepb.Repo{Name: "odd"})) // visibility unset
327	putRaw(t, dir, "repo/future", mustProto(t, &storagepb.Repo{Name: "future", Visibility: storagepb.Visibility(42)}))
328	s := openStore(t, dir)
329
330	for _, n := range []string{"odd", "future"} {
331		got, err := s.Get(ctx, name(t, n))
332		require.NoError(t, err)
333		assert.Equal(t, domain.VisibilityPrivate, got.Visibility, n)
334	}
335}
336
337func TestCorruptRepoValue(t *testing.T) {
338	ctx := context.Background()
339	dir := t.TempDir()
340	putRaw(t, dir, "repo/bad", []byte{0xff, 0xff})
341	putRaw(t, dir, "repo/mismatch", mustProto(t, &storagepb.Repo{Name: "Not Valid"}))
342	s := openStore(t, dir)
343
344	_, err := s.Get(ctx, name(t, "bad"))
345	assert.Error(t, err)
346	_, err = s.Get(ctx, name(t, "mismatch"))
347	assert.ErrorIs(t, err, domain.ErrInvalidName)
348	_, err = s.List(ctx)
349	assert.Error(t, err)
350}
351
352func TestRepoWithoutTopicsReadsNil(t *testing.T) {
353	ctx := context.Background()
354	dir := t.TempDir()
355	putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
356	putRaw(t, dir, "repo/new", mustProto(t, &storagepb.Repo{Name: "new", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC, Topics: []string{"a", "b"}}))
357	s := openStore(t, dir)
358
359	old, err := s.Get(ctx, name(t, "old"))
360	require.NoError(t, err)
361	assert.Nil(t, old.Topics)
362	got, err := s.Get(ctx, name(t, "new"))
363	require.NoError(t, err)
364	assert.Equal(t, []string{"a", "b"}, got.Topics)
365}
366
367func TestSchema6TokenUpgrade(t *testing.T) {
368	ctx := context.Background()
369	for _, from := range []uint32{3, 4, 5} {
370		t.Run(fmt.Sprint(from), func(t *testing.T) {
371			dir := t.TempDir()
372			putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: from}))
373			all := domain.AllScopes()
374			scopes := make([]string, 0, len(all)-1)
375			for _, sc := range all {
376				if sc != domain.ScopeReleaseWrite {
377					scopes = append(scopes, sc.String())
378				}
379			}
380			for _, tc := range []struct {
381				id              string
382				scopes          []string
383				repos, prefixes []string
384				upgraded        bool
385			}{
386				{id: "full", scopes: scopes, upgraded: true},
387				{id: "partial", scopes: []string{"repo:read"}},
388				{id: "missing-review", scopes: func() []string {
389					var out []string
390					for _, s := range scopes {
391						if s != "mr:review" {
392							out = append(out, s)
393						}
394					}
395					return out
396				}(), upgraded: from < 4},
397				{id: "repo", scopes: scopes, repos: []string{"a"}},
398				{id: "branch", scopes: scopes, prefixes: []string{"bot/"}},
399			} {
400				putRaw(t, dir, "token/"+tc.id, mustProto(t, &storagepb.Token{Id: tc.id, Scopes: tc.scopes, Repos: tc.repos, BranchPrefixes: tc.prefixes}))
401			}
402			s, err := kvstore.Open(ctx, dir)
403			require.NoError(t, err)
404			require.NoError(t, s.Close(ctx))
405			assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
406			db, err := kv.Open(ctx, dir, kv.Options{})
407			require.NoError(t, err)
408			defer db.Close(ctx)
409			for _, id := range []string{"full", "partial", "missing-review", "repo", "branch"} {
410				item, ok := db.Get(ctx, []byte("token/"+id))
411				require.True(t, ok)
412				var pb storagepb.Token
413				require.NoError(t, proto.Unmarshal(item.Value, &pb))
414				if id == "full" || (id == "missing-review" && from < 4) {
415					assert.Contains(t, pb.Scopes, "release:write")
416				} else {
417					assert.NotContains(t, pb.Scopes, "release:write")
418				}
419			}
420		})
421	}
422}

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