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}