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}