main ece7682

persist_test.go RawHistoryFolder

1package persist
2
3import (
4	"errors"
5	"os"
6	"path/filepath"
7	"testing"
8
9	"github.com/stretchr/testify/mock"
10	"github.com/stretchr/testify/require"
11
12	"github.com/bnema/vev/internal/domain"
13	portsmocks "github.com/bnema/vev/internal/ports/mocks"
14	"github.com/bnema/vev/pkg/safedir"
15)
16
17func privateDir(t *testing.T) string { t.Helper(); return filepath.Join(t.TempDir(), "vev") }
18func validRecord(name string, id byte) domain.CatalogueRecord {
19	return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}}
20}
21
22func TestOpenOrCreate(t *testing.T) {
23	t.Parallel()
24	tests := []struct {
25		name    string
26		write   []byte // nil means "no file at all"
27		wantNew bool
28		wantErr error
29	}{
30		{name: "absent file starts empty", write: nil, wantNew: true},
31		{name: "garbage fails closed", write: []byte("not a vev catalogue"), wantErr: ErrCatalogueUnreadable},
32		{name: "legacy headerless record fails closed", write: []byte{0x08, 0x00, 0x00, 0x00, 0xde, 0xad, 0xbe, 0xef}, wantErr: ErrCatalogueUnreadable},
33	}
34	for _, tt := range tests {
35		t.Run(tt.name, func(t *testing.T) {
36			t.Parallel()
37			dir := privateDir(t)
38			if tt.write != nil {
39				require.NoError(t, safedir.EnsurePrivate(dir))
40				require.NoError(t, os.WriteFile(StorePath(dir), tt.write, 0o600))
41			}
42			got, err := OpenOrCreate(dir)
43			if tt.wantErr != nil {
44				require.ErrorIs(t, err, tt.wantErr)
45				after, readErr := os.ReadFile(StorePath(dir))
46				require.NoError(t, readErr)
47				require.Equal(t, tt.write, after, "unreadable catalogue must remain untouched")
48				return
49			}
50			require.NoError(t, err)
51			t.Cleanup(func() { _ = got.Catalogue.Close() })
52			require.Equal(t, tt.wantNew, got.NewInstall)
53			require.Empty(t, got.Records)
54		})
55	}
56}
57
58func TestOpenOrCreateRejectsNonCurrentDurableStateWithoutMutation(t *testing.T) {
59	t.Parallel()
60	tests := []struct {
61		name  string
62		files map[string][]byte
63	}{
64		{
65			name: "old VEVK version",
66			files: map[string][]byte{
67				filename: {'V', 'E', 'V', 'K', 0, 1},
68			},
69		},
70	}
71	for _, tt := range tests {
72		t.Run(tt.name, func(t *testing.T) {
73			t.Parallel()
74			dir := privateDir(t)
75			require.NoError(t, safedir.EnsurePrivate(dir))
76			for name, data := range tt.files {
77				require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o600))
78			}
79
80			before := directoryBytes(t, dir)
81			_, err := OpenOrCreate(dir)
82			require.ErrorIs(t, err, ErrCatalogueUnreadable)
83			after := directoryBytes(t, dir)
84			for name, data := range before {
85				require.Equal(t, data, after[name], "failure must preserve durable file %q", name)
86			}
87		})
88	}
89}
90
91func directoryBytes(t *testing.T, dir string) map[string][]byte {
92	t.Helper()
93	entries, err := os.ReadDir(dir)
94	require.NoError(t, err)
95	files := make(map[string][]byte, len(entries))
96	for _, entry := range entries {
97		require.False(t, entry.IsDir(), "unexpected directory %q", entry.Name())
98		data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
99		require.NoError(t, err)
100		files[entry.Name()] = data
101	}
102	return files
103}
104
105func TestCatalogueDecodeAllFailsOnMalformedRecord(t *testing.T) {
106	good, err := encodeRecordValue(validRecord("good", 1))
107	require.NoError(t, err)
108	_, err = decodeAll(map[string][]byte{"bad": {0, 1}, "good": good})
109	require.Error(t, err)
110}
111
112func TestCatalogueMutationsUseOneSync(t *testing.T) {
113	store := portsmocks.NewMockStore(t)
114	state := map[string][]byte{}
115	store.EXPECT().Range(mock.Anything).Run(func(fn func([]byte, []byte) bool) {
116		for key, value := range state {
117			if !fn([]byte(key), value) {
118				return
119			}
120		}
121	}).Once()
122	store.EXPECT().Get(mock.Anything).RunAndReturn(func(key []byte) ([]byte, bool) {
123		value, ok := state[string(key)]
124		return append([]byte(nil), value...), ok
125	}).Times(3)
126	store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {
127		state[string(key)] = append([]byte(nil), value...)
128		return nil
129	}).Times(2)
130	store.EXPECT().Delete([]byte("one")).RunAndReturn(func(key []byte) error {
131		delete(state, string(key))
132		return nil
133	}).Once()
134	store.EXPECT().Sync().Return(nil).Twice()
135	p := New(store)
136	one := validRecord("one", 1)
137	require.NoError(t, p.Replace("one", one))
138	renamed := one
139	renamed.Name = "renamed"
140	renamed.Cwd = "/renamed"
141	require.NoError(t, p.Rename("one", renamed))
142	require.NotContains(t, state, "one")
143	encoded, ok := state["renamed"]
144	require.True(t, ok)
145	got, err := decodeRecordValue("renamed", encoded)
146	require.NoError(t, err)
147	require.Equal(t, renamed, got)
148}
149
150func TestCatalogueMutationFailureDoesNotSync(t *testing.T) {
151	store := portsmocks.NewMockStore(t)
152	setErr := errors.New("set failed")
153	store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {})
154	store.EXPECT().Get([]byte("one")).Return(nil, false).Once()
155	store.EXPECT().Set(mock.Anything, mock.Anything).Return(setErr)
156	store.EXPECT().Delete([]byte("one")).Return(nil).Once()
157	p := New(store)
158	err := p.Replace("one", validRecord("one", 1))
159	require.ErrorIs(t, err, setErr)
160}
161
162// TestCatalogueNilPersisterIsNoOp covers the ephemeral/optional-persistence
163// sentinel: a nil *Persister. Every mutating and reading method must be a
164// silent no-op so callers that never enable persistence don't need nil
165// checks of their own.
166func TestCatalogueNilPersisterIsNoOp(t *testing.T) {
167	var p *Persister
168	require.NoError(t, p.Save(validRecord("one", 1)))
169	require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": nil}))
170	require.NoError(t, p.UpdateMetadata(validRecord("one", 1).MetadataUpdate()))
171	require.NoError(t, p.Delete("one"))
172	records, err := p.LoadAll()
173	require.NoError(t, err)
174	require.Empty(t, records)
175	require.NoError(t, p.Close())
176	got, err := p.Records()
177	require.NoError(t, err)
178	require.Empty(t, got)
179}
180
181// TestCatalogueUnavailableStoreErrors covers a non-nil Persister with no
182// backing store (e.g. New(nil)). Unlike the nil-persister sentinel above,
183// this is a Persister that was supposed to have a store: silently no-oping
184// here previously made recovery.Coordinator's checkpoint publish/retry/
185// discard paths believe a durable commit happened when nothing was written.
186// Every mutation and read-with-error must surface errPersistenceUnavailable,
187// matching Create; only Records() (which ports.Catalogue gives no error
188// return for) degrades to an empty slice, with the failure logged instead.
189func TestCatalogueUnavailableStoreErrors(t *testing.T) {
190	p := New(nil)
191	require.ErrorIs(t, p.Save(validRecord("one", 1)), errPersistenceUnavailable)
192	require.ErrorIs(t, p.Apply(map[string]*domain.CatalogueRecord{"one": nil}), errPersistenceUnavailable)
193	require.ErrorIs(t, p.Rename("one", validRecord("two", 1)), errPersistenceUnavailable)
194	require.ErrorIs(t, p.UpdateMetadata(validRecord("one", 1).MetadataUpdate()), errPersistenceUnavailable)
195	require.ErrorIs(t, p.Delete("one"), errPersistenceUnavailable)
196	require.ErrorIs(t, p.Create(validRecord("one", 1)), errPersistenceUnavailable)
197	records, err := p.LoadAll()
198	require.ErrorIs(t, err, errPersistenceUnavailable)
199	require.Empty(t, records)
200	got, err := p.Records()
201	require.ErrorIs(t, err, errPersistenceUnavailable)
202	require.Empty(t, got)
203	require.NoError(t, p.Close())
204}
205
206func TestCatalogueRecordsPropagatesMalformedRecord(t *testing.T) {
207	store := portsmocks.NewMockStore(t)
208	store.EXPECT().Range(mock.Anything).Run(func(fn func([]byte, []byte) bool) {
209		fn([]byte("bad"), []byte{0, 1})
210	})
211	records, err := New(store).Records()
212	require.Error(t, err)
213	require.Empty(t, records)
214}
215
216type failClosedStore struct {
217	data    map[string][]byte
218	durable map[string][]byte
219
220	setFailures           int
221	deleteFailures        int
222	syncFailures          int
223	restoreSetFailures    int
224	restoreDeleteFailures int
225	restoring             bool
226	failure               error
227	restoreFailure        error
228
229	closeFlushed bool
230	closeAborted bool
231}
232
233func newFailClosedStore(data map[string][]byte) *failClosedStore {
234	return &failClosedStore{
235		data:           copyStoreData(data),
236		durable:        copyStoreData(data),
237		failure:        errors.New("store write failed"),
238		restoreFailure: errors.New("store restore failed"),
239	}
240}
241
242func (s *failClosedStore) Get(key []byte) ([]byte, bool) {
243	value, ok := s.data[string(key)]
244	return append([]byte(nil), value...), ok
245}
246
247func (s *failClosedStore) Set(key, value []byte) error {
248	s.data[string(key)] = append([]byte(nil), value...)
249	if s.restoring && s.restoreSetFailures > 0 {
250		s.restoreSetFailures--
251		return s.restoreFailure
252	}
253	if s.setFailures > 0 {
254		s.setFailures--
255		return s.failure
256	}
257	return nil
258}
259
260func (s *failClosedStore) Delete(key []byte) error {
261	delete(s.data, string(key))
262	if s.restoring && s.restoreDeleteFailures > 0 {
263		s.restoreDeleteFailures--
264		return s.restoreFailure
265	}
266	if s.deleteFailures > 0 {
267		s.deleteFailures--
268		return s.failure
269	}
270	return nil
271}
272
273func (s *failClosedStore) Range(fn func([]byte, []byte) bool) {
274	for key, value := range s.data {
275		if !fn([]byte(key), append([]byte(nil), value...)) {
276			return
277		}
278	}
279}
280
281func (s *failClosedStore) Sync() error {
282	if s.syncFailures > 0 {
283		s.syncFailures--
284		s.restoring = true
285		return s.failure
286	}
287	s.durable = copyStoreData(s.data)
288	return nil
289}
290
291func (s *failClosedStore) Close() error {
292	s.closeFlushed = true
293	s.durable = copyStoreData(s.data)
294	return nil
295}
296
297func (s *failClosedStore) CloseWithoutSync() error {
298	s.closeAborted = true
299	return nil
300}
301
302func copyStoreData(data map[string][]byte) map[string][]byte {
303	cloned := make(map[string][]byte, len(data))
304	for key, value := range data {
305		cloned[key] = append([]byte(nil), value...)
306	}
307	return cloned
308}
309
310func TestRejectedIdentityWriteFencesCatalogue(t *testing.T) {
311	t.Parallel()
312	one, err := encodeRecordValue(validRecord("work", 1))
313	require.NoError(t, err)
314
315	tests := []struct {
316		name           string
317		initial        map[string][]byte
318		setFailures    int
319		deleteFailures int
320		syncFailures   int
321		mutate         func(*Persister) error
322	}{
323		{
324			name:        "create set failure",
325			initial:     map[string][]byte{},
326			setFailures: 1,
327			mutate: func(p *Persister) error {
328				return p.Create(validRecord("new", 2))
329			},
330		},
331		{
332			name:         "create sync failure",
333			initial:      map[string][]byte{},
334			syncFailures: 1,
335			mutate: func(p *Persister) error {
336				return p.Create(validRecord("new", 2))
337			},
338		},
339		{
340			name:        "replace set failure",
341			initial:     map[string][]byte{"work": one},
342			setFailures: 1,
343			mutate: func(p *Persister) error {
344				next := validRecord("work", 1)
345				next.Cwd = "/next"
346				return p.Replace("work", next)
347			},
348		},
349		{
350			name:         "replace sync failure",
351			initial:      map[string][]byte{"work": one},
352			syncFailures: 1,
353			mutate: func(p *Persister) error {
354				next := validRecord("work", 1)
355				next.Cwd = "/next"
356				return p.Replace("work", next)
357			},
358		},
359		{
360			name:        "rename set failure",
361			initial:     map[string][]byte{"work": one},
362			setFailures: 1,
363			mutate: func(p *Persister) error {
364				next := validRecord("renamed", 1)
365				return p.Rename("work", next)
366			},
367		},
368		{
369			name:           "rename delete failure",
370			initial:        map[string][]byte{"work": one},
371			deleteFailures: 1,
372			mutate: func(p *Persister) error {
373				next := validRecord("renamed", 1)
374				return p.Rename("work", next)
375			},
376		},
377		{
378			name:         "rename sync failure",
379			initial:      map[string][]byte{"work": one},
380			syncFailures: 1,
381			mutate: func(p *Persister) error {
382				next := validRecord("renamed", 1)
383				return p.Rename("work", next)
384			},
385		},
386		{
387			name:           "delete delete failure",
388			initial:        map[string][]byte{"work": one},
389			deleteFailures: 1,
390			mutate: func(p *Persister) error {
391				return p.Delete("work")
392			},
393		},
394		{
395			name:         "delete sync failure",
396			initial:      map[string][]byte{"work": one},
397			syncFailures: 1,
398			mutate: func(p *Persister) error {
399				return p.Delete("work")
400			},
401		},
402	}
403
404	for _, tt := range tests {
405		t.Run(tt.name, func(t *testing.T) {
406			t.Parallel()
407			store := newFailClosedStore(tt.initial)
408			store.setFailures = tt.setFailures
409			store.deleteFailures = tt.deleteFailures
410			store.syncFailures = tt.syncFailures
411			p := New(store)
412
413			err := tt.mutate(p)
414			require.ErrorIs(t, err, ErrCatalogueDurability)
415			require.ErrorIs(t, err, store.failure)
416			require.Equal(t, tt.initial, store.data, "rejected identity keys must be restored in memory")
417			require.Equal(t, tt.initial, store.durable, "rejected identity write must not reach disk")
418
419			_, _, recordErr := p.Record("work")
420			require.ErrorIs(t, recordErr, ErrCatalogueDurability)
421			require.ErrorIs(t, p.Sync(), ErrCatalogueDurability)
422			require.ErrorIs(t, p.Create(validRecord("later", 3)), ErrCatalogueDurability)
423			require.Equal(t, tt.initial, store.durable, "later Sync must be fenced")
424
425			require.ErrorIs(t, p.Close(), ErrCatalogueDurability)
426			require.True(t, store.closeAborted, "Close must release without flushing rejected state")
427			require.False(t, store.closeFlushed)
428			require.Equal(t, tt.initial, store.durable, "Close must not make rejected state durable")
429		})
430	}
431}
432
433func TestFailedSyncRetainsRepeatedRollbackFailuresAndAborts(t *testing.T) {
434	t.Parallel()
435	encoded, err := encodeRecordValue(validRecord("work", 1))
436	require.NoError(t, err)
437	store := newFailClosedStore(map[string][]byte{"work": encoded})
438	store.syncFailures = 1
439	store.restoreSetFailures = 1
440	store.restoreDeleteFailures = 1
441	p := New(store)
442
443	err = p.Rename("work", validRecord("renamed", 1))
444	require.ErrorIs(t, err, ErrCatalogueDurability)
445	require.ErrorIs(t, err, store.failure)
446	require.ErrorIs(t, err, store.restoreFailure)
447	require.Zero(t, store.restoreSetFailures, "rollback Set failure must be observed")
448	require.Zero(t, store.restoreDeleteFailures, "rollback Delete failure must be observed")
449
450	terminal := p.Sync()
451	require.Same(t, err, terminal, "the fenced terminal error must remain stable")
452	require.ErrorIs(t, terminal, store.failure)
453	require.ErrorIs(t, terminal, store.restoreFailure)
454	require.ErrorIs(t, p.Close(), ErrCatalogueDurability)
455	require.True(t, store.closeAborted)
456	require.False(t, store.closeFlushed)
457	require.Equal(t, map[string][]byte{"work": encoded}, store.durable,
458		"aborting a fenced store must not persist dirty rejected state")
459}
460
461func TestRejectedIdentityWriteRestoresBufferedMetadata(t *testing.T) {
462	t.Parallel()
463	encoded, err := encodeRecordValue(validRecord("work", 1))
464	require.NoError(t, err)
465	store := newFailClosedStore(map[string][]byte{"work": encoded})
466	p := New(store)
467
468	cwd := "/buffered"
469	require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
470		Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: &cwd,
471	}))
472	buffered := copyStoreData(store.data)
473	store.syncFailures = 1
474
475	require.ErrorIs(t, p.Rename("work", validRecord("renamed", 1)), ErrCatalogueDurability)
476	require.Equal(t, buffered, store.data, "rollback must preserve already-buffered metadata on the affected key")
477	require.Equal(t, map[string][]byte{"work": encoded}, store.durable)
478	require.ErrorIs(t, p.Close(), ErrCatalogueDurability)
479	require.Equal(t, map[string][]byte{"work": encoded}, store.durable)
480}

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 12

Tags 11

↑↓ moveenter openesc close