v0.2.0 80c61e3

catalog_cache_test.go RawHistoryFolder

1package remote
2
3import (
4	"os"
5	"path/filepath"
6	"slices"
7	"strings"
8	"testing"
9	"time"
10
11	"github.com/bnema/vev/internal/ports"
12)
13
14func TestCatalogCachePath(t *testing.T) {
15	stateDir := filepath.Join(t.TempDir(), "state")
16	want := filepath.Join(stateDir, "remote-catalog-cache.json")
17	if got := CatalogCachePath(stateDir); got != want {
18		t.Fatalf("CatalogCachePath(%q) = %q, want %q", stateDir, got, want)
19	}
20}
21
22func TestCatalogCacheStoreLoad(t *testing.T) {
23	t.Parallel()
24
25	path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
26	cache := NewFileCatalogCache(path)
27	fetchedAt := time.Unix(0, 1780000000000000000)
28	entries := []ports.RemoteCatalogCacheEntry{
29		{
30			Host:      "zebra",
31			FetchedAt: fetchedAt,
32			Sessions: []ports.RemoteCatalogSession{
33				{Name: "work", State: "up", Tabs: 2},
34				{Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true},
35			},
36		},
37		{
38			Host:      "arch",
39			FetchedAt: fetchedAt.Add(time.Second),
40			Sessions:  []ports.RemoteCatalogSession{},
41		},
42	}
43
44	if err := cache.Store(entries); err != nil {
45		t.Fatalf("Store() error = %v", err)
46	}
47	raw, err := os.ReadFile(path)
48	if err != nil {
49		t.Fatalf("read cache: %v", err)
50	}
51	arch := strings.Index(string(raw), `"target":"arch"`)
52	zebra := strings.Index(string(raw), `"target":"zebra"`)
53	if arch < 0 || zebra < 0 || arch >= zebra {
54		t.Fatalf("stored hosts are not ordered: %s", raw)
55	}
56	alpha := strings.Index(string(raw), `"name":"alpha"`)
57	work := strings.Index(string(raw), `"name":"work"`)
58	if alpha < 0 || work < 0 || alpha >= work {
59		t.Fatalf("stored sessions are not ordered: %s", raw)
60	}
61	got, err := cache.Load()
62	if err != nil {
63		t.Fatalf("Load() error = %v", err)
64	}
65	want := []ports.RemoteCatalogCacheEntry{
66		{
67			Host:      "arch",
68			FetchedAt: fetchedAt.Add(time.Second),
69			Sessions:  []ports.RemoteCatalogSession{},
70		},
71		{
72			Host:      "zebra",
73			FetchedAt: fetchedAt,
74			Sessions: []ports.RemoteCatalogSession{
75				{Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true},
76				{Name: "work", State: "up", Tabs: 2},
77			},
78		},
79	}
80	if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) {
81		t.Fatalf("Load() = %#v, want %#v", got, want)
82	}
83
84	info, err := os.Stat(path)
85	if err != nil {
86		t.Fatalf("stat cache: %v", err)
87	}
88	if perm := info.Mode().Perm(); perm != 0o600 {
89		t.Fatalf("cache permissions = %04o, want 0600", perm)
90	}
91	dirInfo, err := os.Lstat(filepath.Dir(path))
92	if err != nil {
93		t.Fatalf("stat cache directory: %v", err)
94	}
95	if perm := dirInfo.Mode().Perm(); perm != 0o700 {
96		t.Fatalf("cache directory permissions = %04o, want 0700", perm)
97	}
98	entriesOnDisk, err := os.ReadDir(filepath.Dir(path))
99	if err != nil {
100		t.Fatalf("read cache directory: %v", err)
101	}
102	for _, entry := range entriesOnDisk {
103		if strings.Contains(entry.Name(), ".tmp") {
104			t.Fatalf("temporary file left behind: %q", entry.Name())
105		}
106	}
107}
108
109func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
110	t.Parallel()
111
112	cases := []struct {
113		name string
114		raw  []byte
115	}{
116		{name: "truncated", raw: []byte(`{"version":1,"hosts":[`)},
117		{name: "trailing JSON", raw: []byte(`{"version":1,"hosts":[]} {}`)},
118		{name: "unknown version", raw: []byte(`{"version":3,"hosts":[]}`)},
119		{name: "missing hosts", raw: []byte(`{"version":1}`)},
120		{name: "null hosts", raw: []byte(`{"version":1,"hosts":null}`)},
121		{name: "null sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)},
122		{name: "zero fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)},
123		{name: "negative fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)},
124		{name: "duplicate hosts", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)},
125		{name: "duplicate sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false},{"name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}]}`)},
126		{name: "invalid utf8", raw: []byte("{\"version\":1,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},
127	}
128	for _, tc := range cases {
129		t.Run(tc.name, func(t *testing.T) {
130			t.Parallel()
131			path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
132			if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
133				t.Fatalf("mkdir: %v", err)
134			}
135			if err := os.WriteFile(path, tc.raw, 0o600); err != nil {
136				t.Fatalf("write cache: %v", err)
137			}
138
139			if _, err := NewFileCatalogCache(path).Load(); err == nil {
140				t.Fatal("Load() error = nil, want malformed cache error")
141			}
142			after, err := os.ReadFile(path)
143			if err != nil {
144				t.Fatalf("read cache: %v", err)
145			}
146			if !slices.Equal(after, tc.raw) {
147				t.Fatalf("invalid cache changed: got %q, want %q", after, tc.raw)
148			}
149		})
150	}
151}
152
153func TestCatalogCacheStoreFailurePreservesExistingFile(t *testing.T) {
154	t.Parallel()
155
156	path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
157	cache := NewFileCatalogCache(path)
158	valid := []ports.RemoteCatalogCacheEntry{{
159		Host:      "arch",
160		FetchedAt: time.Unix(0, 1),
161		Sessions:  []ports.RemoteCatalogSession{},
162	}}
163	if err := cache.Store(valid); err != nil {
164		t.Fatalf("initial Store() error = %v", err)
165	}
166	before, err := os.ReadFile(path)
167	if err != nil {
168		t.Fatalf("read initial cache: %v", err)
169	}
170
171	invalid := []ports.RemoteCatalogCacheEntry{{
172		Host:      "arch",
173		FetchedAt: time.Time{},
174		Sessions:  []ports.RemoteCatalogSession{},
175	}}
176	if err := cache.Store(invalid); err == nil {
177		t.Fatal("Store() error = nil, want validation error")
178	}
179	after, err := os.ReadFile(path)
180	if err != nil {
181		t.Fatalf("read cache after failed Store: %v", err)
182	}
183	if !slices.Equal(after, before) {
184		t.Fatalf("failed Store changed cache: got %q, want %q", after, before)
185	}
186}
187
188func TestCatalogCacheStoreRejectsNonPositiveFetchedTimes(t *testing.T) {
189	t.Parallel()
190
191	for _, tc := range []struct {
192		name      string
193		fetchedAt time.Time
194	}{
195		{name: "zero Unix nano", fetchedAt: time.Unix(0, 0)},
196		{name: "negative Unix nano", fetchedAt: time.Unix(0, -1)},
197	} {
198		t.Run(tc.name, func(t *testing.T) {
199			t.Parallel()
200			path := filepath.Join(t.TempDir(), "remote-catalog-cache.json")
201			entries := []ports.RemoteCatalogCacheEntry{{
202				Host:      "arch",
203				FetchedAt: tc.fetchedAt,
204				Sessions:  []ports.RemoteCatalogSession{},
205			}}
206
207			err := NewFileCatalogCache(path).Store(entries)
208
209			if err == nil {
210				t.Fatal("Store() error = nil, want non-positive fetched time error")
211			}
212			if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
213				t.Fatalf("failed Store created cache: stat error = %v", statErr)
214			}
215		})
216	}
217}
218
219func TestCatalogCacheStoreHardensExistingFilePermissions(t *testing.T) {
220	t.Parallel()
221
222	path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
223	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
224		t.Fatalf("mkdir: %v", err)
225	}
226	if err := os.WriteFile(path, []byte(`{"version":1,"hosts":[]}`), 0o644); err != nil {
227		t.Fatalf("seed cache: %v", err)
228	}
229
230	cache := NewFileCatalogCache(path)
231	if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err != nil {
232		t.Fatalf("Store() error = %v", err)
233	}
234	info, err := os.Stat(path)
235	if err != nil {
236		t.Fatalf("stat cache: %v", err)
237	}
238	if perm := info.Mode().Perm(); perm != 0o600 {
239		t.Fatalf("cache permissions = %04o, want 0600", perm)
240	}
241}
242
243func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
244	t.Parallel()
245
246	dir := filepath.Join(t.TempDir(), "vev")
247	if err := os.MkdirAll(dir, 0o700); err != nil {
248		t.Fatalf("mkdir parent: %v", err)
249	}
250	path := filepath.Join(dir, "remote-catalog-cache.json")
251	if err := os.Mkdir(path, 0o700); err != nil {
252		t.Fatalf("mkdir destination: %v", err)
253	}
254
255	cache := NewFileCatalogCache(path)
256	if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err == nil {
257		t.Fatal("Store() error = nil, want rename error")
258	}
259	entries, err := os.ReadDir(dir)
260	if err != nil {
261		t.Fatalf("read cache directory: %v", err)
262	}
263	for _, entry := range entries {
264		if strings.Contains(entry.Name(), ".tmp") {
265			t.Fatalf("temporary file left behind after failed rename: %q", entry.Name())
266		}
267	}
268}
269
270func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool {
271	return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && slices.Equal(a.Sessions, b.Sessions)
272}

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