tools_test.go
⎇
Raw
1package tools
2
3import (
4 "archive/zip"
5 "bytes"
6 "context"
7 "crypto/sha256"
8 "encoding/hex"
9 "fmt"
10 "net/http"
11 "net/http/httptest"
12 "os"
13 "path/filepath"
14 "runtime"
15 "strings"
16 "testing"
17
18 "vidarchive/internal/config"
19)
20
21// fakeRelease serves the two GitHub paths the installer uses: the asset itself
22// and the checksum file next to it.
23func fakeRelease(t *testing.T, assets map[string][]byte) string {
24 t.Helper()
25 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26 name := filepath.Base(r.URL.Path)
27 body, ok := assets[name]
28 if !ok {
29 http.NotFound(w, r)
30 return
31 }
32 _, _ = w.Write(body)
33 }))
34 t.Cleanup(srv.Close)
35 return srv.URL
36}
37
38func sumsFile(asset string, body []byte) []byte {
39 sum := sha256.Sum256(body)
40 return []byte(fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), asset))
41}
42
43func testManager(t *testing.T, assets map[string][]byte) *Manager {
44 t.Helper()
45 dir := t.TempDir()
46 cfg := &config.Config{
47 BinDir: dir,
48 YTDLPPath: filepath.Join(dir, "yt-dlp"),
49 YTDLPManaged: true,
50 DenoPath: filepath.Join(dir, "deno"),
51 DenoManaged: true,
52 }
53 return &Manager{cfg: cfg, baseURL: fakeRelease(t, assets)}
54}
55
56// requireArch returns the candidate list for this architecture, skipping when
57// the platform has no mapped release build.
58func requireArch(t *testing.T, assets map[string][]string) []string {
59 t.Helper()
60 candidates, ok := assets[runtime.GOARCH]
61 if !ok {
62 t.Skipf("no release asset mapped for %s", runtime.GOARCH)
63 }
64 return candidates
65}
66
67func requireYTDLPAsset(t *testing.T) string {
68 t.Helper()
69 return requireArch(t, ytdlpAssets)[0]
70}
71
72// script is a tiny executable stand-in for a real release binary.
73func script(body string) []byte {
74 return []byte("#!/bin/sh\n" + body + "\n")
75}
76
77func TestEnsureInstallsYTDLP(t *testing.T) {
78 asset := requireYTDLPAsset(t)
79 body := script("echo 2025.09.05")
80 m := testManager(t, map[string][]byte{
81 asset: body,
82 "SHA2-256SUMS": sumsFile(asset, body),
83 })
84
85 if err := m.Ensure(context.Background(), false); err != nil {
86 t.Fatalf("Ensure: %v", err)
87 }
88
89 got, err := os.ReadFile(m.cfg.YTDLPPath)
90 if err != nil {
91 t.Fatalf("read installed binary: %v", err)
92 }
93 if !bytes.Equal(got, body) {
94 t.Errorf("installed content = %q, want %q", got, body)
95 }
96 info, err := os.Stat(m.cfg.YTDLPPath)
97 if err != nil {
98 t.Fatal(err)
99 }
100 if info.Mode().Perm()&0o111 == 0 {
101 t.Errorf("installed binary is not executable, mode %v", info.Mode())
102 }
103}
104
105func TestEnsureRejectsBadChecksum(t *testing.T) {
106 asset := requireYTDLPAsset(t)
107 m := testManager(t, map[string][]byte{
108 asset: []byte("tampered"),
109 "SHA2-256SUMS": sumsFile(asset, []byte("original")),
110 })
111
112 err := m.Ensure(context.Background(), false)
113 if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
114 t.Fatalf("Ensure err = %v, want a checksum mismatch", err)
115 }
116 if Installed(m.cfg.YTDLPPath) {
117 t.Error("a binary that failed verification was installed anyway")
118 }
119 // A failed download must not leave scratch files in the managed directory.
120 entries, err := os.ReadDir(m.cfg.BinDir)
121 if err != nil {
122 t.Fatal(err)
123 }
124 if len(entries) != 0 {
125 t.Errorf("bin dir not clean after failure: %v", entries)
126 }
127}
128
129func TestEnsureInstallsDenoFromZip(t *testing.T) {
130 ytdlpAsset := requireYTDLPAsset(t)
131 denoAsset := requireArch(t, denoAssets)[0]
132
133 body := script("echo deno 2.1.4")
134 var archive bytes.Buffer
135 zw := zip.NewWriter(&archive)
136 f, err := zw.Create("deno")
137 if err != nil {
138 t.Fatal(err)
139 }
140 if _, err := f.Write(body); err != nil {
141 t.Fatal(err)
142 }
143 if err := zw.Close(); err != nil {
144 t.Fatal(err)
145 }
146
147 ytdlpBody := script("echo 2025.09.05")
148 m := testManager(t, map[string][]byte{
149 ytdlpAsset: ytdlpBody,
150 "SHA2-256SUMS": sumsFile(ytdlpAsset, ytdlpBody),
151 denoAsset: archive.Bytes(),
152 denoAsset + ".sha256sum": sumsFile(denoAsset, archive.Bytes()),
153 })
154
155 if err := m.Ensure(context.Background(), true); err != nil {
156 t.Fatalf("Ensure: %v", err)
157 }
158 got, err := os.ReadFile(m.cfg.DenoPath)
159 if err != nil {
160 t.Fatalf("read deno: %v", err)
161 }
162 if !bytes.Equal(got, body) {
163 t.Errorf("extracted deno = %q, want %q", got, body)
164 }
165}
166
167// An external tool is the operator's to manage, so nothing is downloaded and
168// nothing is overwritten.
169func TestEnsureSkipsExternalTools(t *testing.T) {
170 dir := t.TempDir()
171 m := &Manager{
172 cfg: &config.Config{
173 BinDir: dir,
174 YTDLPPath: "/usr/bin/yt-dlp",
175 YTDLPManaged: false,
176 DenoPath: "/usr/bin/deno",
177 DenoManaged: false,
178 },
179 // Any request would fail: the installer must not make one.
180 baseURL: "http://127.0.0.1:1",
181 }
182
183 if err := m.Ensure(context.Background(), true); err != nil {
184 t.Fatalf("Ensure: %v", err)
185 }
186 summary, err := m.Update(context.Background())
187 if err != nil {
188 t.Fatalf("Update: %v", err)
189 }
190 if !strings.Contains(summary, "Nothing to update") {
191 t.Errorf("Update summary = %q, want a note that nothing is under control", summary)
192 }
193}
194
195func TestParseChecksum(t *testing.T) {
196 multi := []byte("aa yt-dlp\nbb yt-dlp_linux\n")
197 if got, ok := parseChecksum(multi, "yt-dlp_linux"); !ok || got != "bb" {
198 t.Errorf("multi-line = %q,%v, want bb,true", got, ok)
199 }
200 if _, ok := parseChecksum(multi, "yt-dlp_macos"); ok {
201 t.Error("missing asset reported as found")
202 }
203
204 // A hex with no filename is not sha256sum format and must not be trusted
205 // for an arbitrary asset.
206 bare := []byte(strings.Repeat("a", 64) + "\n")
207 if _, ok := parseChecksum(bare, "deno.zip"); ok {
208 t.Error("a checksum line with no filename was accepted")
209 }
210}
211
212func TestLastLineStripsColor(t *testing.T) {
213 out := "Looking up stable version\nLocal deno version \x1b[32m2.9.7\x1b[39m is the most recent release\n"
214 if got, want := lastLine(out), "Local deno version 2.9.7 is the most recent release"; got != want {
215 t.Errorf("lastLine = %q, want %q", got, want)
216 }
217}
218
219// A build for the wrong C library or architecture downloads fine and then fails
220// to exec. The installer must fall through to the next candidate instead of
221// leaving a binary that cannot run.
222func TestEnsureFallsBackWhenCandidateDoesNotRun(t *testing.T) {
223 candidates := requireArch(t, ytdlpAssets)
224 if len(candidates) < 2 {
225 t.Skipf("only one yt-dlp candidate for %s", runtime.GOARCH)
226 }
227 broken, working := candidates[0], candidates[1]
228
229 brokenBody := script("exit 1")
230 workingBody := script("echo 2025.09.05")
231 sums := append(sumsFile(broken, brokenBody), sumsFile(working, workingBody)...)
232
233 m := testManager(t, map[string][]byte{
234 broken: brokenBody,
235 working: workingBody,
236 "SHA2-256SUMS": sums,
237 })
238
239 if err := m.Ensure(context.Background(), false); err != nil {
240 t.Fatalf("Ensure: %v", err)
241 }
242 got, err := os.ReadFile(m.cfg.YTDLPPath)
243 if err != nil {
244 t.Fatalf("read installed binary: %v", err)
245 }
246 if !bytes.Equal(got, workingBody) {
247 t.Errorf("installed the candidate that does not run: %q", got)
248 }
249}
250
251// Every candidate failing must leave nothing behind, not a half-installed
252// binary that fails on the first download instead.
253func TestEnsureInstallsNothingWhenNoCandidateRuns(t *testing.T) {
254 candidates := requireArch(t, ytdlpAssets)
255
256 assets := map[string][]byte{}
257 var sums []byte
258 for _, asset := range candidates {
259 body := script("exit 1")
260 assets[asset] = body
261 sums = append(sums, sumsFile(asset, body)...)
262 }
263 assets["SHA2-256SUMS"] = sums
264
265 m := testManager(t, assets)
266 err := m.Ensure(context.Background(), false)
267 if err == nil || !strings.Contains(err.Error(), "does not run on this system") {
268 t.Fatalf("Ensure err = %v, want a trial-run failure", err)
269 }
270 if Installed(m.cfg.YTDLPPath) {
271 t.Error("a binary that never ran was installed")
272 }
273 entries, err := os.ReadDir(m.cfg.BinDir)
274 if err != nil {
275 t.Fatal(err)
276 }
277 if len(entries) != 0 {
278 t.Errorf("bin dir not clean after failure: %v", entries)
279 }
280}
281
282// VIDARCHIVE_UPDATE_EXTERNAL_TOOLS is the escape hatch for a platform with no
283// mapped release build: the operator installs the binary once, VidArchive runs
284// its updater from then on.
285func TestUpdateRunsUpdaterForOptedInExternalTool(t *testing.T) {
286 dir := t.TempDir()
287 external := filepath.Join(dir, "my-yt-dlp")
288 if err := os.WriteFile(external, script(`echo "yt-dlp is up to date (stable@2026.01.01)"`), 0o755); err != nil {
289 t.Fatal(err)
290 }
291
292 m := &Manager{
293 cfg: &config.Config{
294 BinDir: dir,
295 YTDLPPath: external,
296 YTDLPManaged: false,
297 DenoPath: filepath.Join(dir, "deno"),
298 DenoManaged: false,
299 UpdateExternalTools: true,
300 },
301 // Any release request would fail: an external tool is never downloaded.
302 baseURL: "http://127.0.0.1:1",
303 }
304
305 summary, err := m.Update(context.Background())
306 if err != nil {
307 t.Fatalf("Update: %v", err)
308 }
309 if !strings.Contains(summary, "up to date") {
310 t.Errorf("Update summary = %q, want the updater's own output", summary)
311 }
312}
313
314// The opt-in grants update rights, not install rights. A missing external
315// binary must be reported, never downloaded over the operator's chosen path.
316func TestUpdateDoesNotInstallOverExternalPath(t *testing.T) {
317 dir := t.TempDir()
318 external := filepath.Join(dir, "my-yt-dlp")
319
320 m := &Manager{
321 cfg: &config.Config{
322 BinDir: dir,
323 YTDLPPath: external,
324 YTDLPManaged: false,
325 DenoPath: filepath.Join(dir, "deno"),
326 DenoManaged: false,
327 UpdateExternalTools: true,
328 },
329 baseURL: "http://127.0.0.1:1",
330 }
331
332 _, err := m.Update(context.Background())
333 if err == nil || !strings.Contains(err.Error(), "is not installed at") {
334 t.Fatalf("Update err = %v, want a not-installed report", err)
335 }
336 if Installed(external) {
337 t.Error("an external path was populated by the updater")
338 }
339}
340
341// The checksum is the only integrity control on a binary that gets executed
342// and then left on PATH, so a missing sums file must stop the install.
343func TestEnsureFailsClosedWithoutDenoChecksum(t *testing.T) {
344 ytdlpAsset := requireYTDLPAsset(t)
345 denoAsset := requireArch(t, denoAssets)[0]
346
347 ytdlpBody := script("echo 2025.09.05")
348 m := testManager(t, map[string][]byte{
349 ytdlpAsset: ytdlpBody,
350 "SHA2-256SUMS": sumsFile(ytdlpAsset, ytdlpBody),
351 // The asset is served, its .sha256sum is not.
352 denoAsset: []byte("PK\x03\x04 not a real archive"),
353 })
354
355 err := m.Ensure(context.Background(), true)
356 if err == nil || !strings.Contains(err.Error(), "checksum file") {
357 t.Fatalf("Ensure err = %v, want a missing-checksum failure", err)
358 }
359 if Installed(m.cfg.DenoPath) {
360 t.Error("deno was installed without a verified checksum")
361 }
362}
363
364// A file that cannot be executed is not an install. Treating it as one would
365// block the repair forever and fail every download instead.
366func TestEnsureReplacesUnexecutableBinary(t *testing.T) {
367 asset := requireYTDLPAsset(t)
368 body := script("echo 2025.09.05")
369 m := testManager(t, map[string][]byte{
370 asset: body,
371 "SHA2-256SUMS": sumsFile(asset, body),
372 })
373
374 if err := os.WriteFile(m.cfg.YTDLPPath, []byte("truncated"), 0o644); err != nil {
375 t.Fatal(err)
376 }
377 if Installed(m.cfg.YTDLPPath) {
378 t.Fatal("a 0644 file counted as installed")
379 }
380 if err := m.Ensure(context.Background(), false); err != nil {
381 t.Fatalf("Ensure: %v", err)
382 }
383 got, err := os.ReadFile(m.cfg.YTDLPPath)
384 if err != nil {
385 t.Fatal(err)
386 }
387 if !bytes.Equal(got, body) {
388 t.Errorf("unexecutable file was not replaced: %q", got)
389 }
390}
391
392// A second caller must be told, not queued: queueing spends its context waiting
393// and then reports a failure for an update that actually worked.
394func TestUpdateDoesNotQueueBehindARunningOne(t *testing.T) {
395 m := testManager(t, nil)
396 m.mu.Lock()
397 defer m.mu.Unlock()
398
399 summary, err := m.Update(context.Background())
400 if err != nil {
401 t.Fatalf("Update: %v", err)
402 }
403 if !strings.Contains(summary, "already running") {
404 t.Errorf("Update summary = %q, want a note that one is already running", summary)
405 }
406}
407