package releases import ( "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" ) func TestRequestBaseURLUsesTrustedForwardedValues(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://internal:33550/downloads/file.zip", nil) req.Header.Set("X-Forwarded-Proto", "HTTPS, http") req.Header.Set("X-Forwarded-Host", "updates.example.com:8443, internal:33550") if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://updates.example.com:8443" { t.Fatalf("requestBaseURL() = %q", got) } } func TestRequestBaseURLRejectsInvalidForwardedValues(t *testing.T) { tests := []struct { name string proto string host string request string fallback string want string }{ {name: "invalid scheme", proto: "javascript", host: "updates.example.com", request: "https://internal:33550/path", want: "https://updates.example.com"}, {name: "invalid forwarded host", proto: "https", host: "user@evil.example", request: "http://internal:33550/path", want: "https://internal:33550"}, {name: "invalid forwarded port", proto: "https", host: "updates.example.com:99999", request: "http://internal:33550/path", want: "https://internal:33550"}, {name: "invalid request host", proto: "https", host: "evil.example/path", request: "http://internal/path", fallback: "https://fallback.example.com/", want: "https://fallback.example.com"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, test.request, nil) req.Header.Set("X-Forwarded-Proto", test.proto) req.Header.Set("X-Forwarded-Host", test.host) if test.name == "invalid request host" { req.Host = "bad host" } if got := requestBaseURL(req, test.fallback); got != test.want { t.Fatalf("requestBaseURL() = %q, want %q", got, test.want) } }) } } func TestCompareVersion(t *testing.T) { cases := []struct { a string b string want int }{ {"2.0.6.31", "2.0.6.2", 1}, {"2.0.10", "2.0.9", 1}, {"2.0.6.2", "2.0.6.31", -1}, {"2.0.6", "2.0.6.0", 0}, } for _, tc := range cases { if got := compareVersion(tc.a, tc.b); got != tc.want { t.Fatalf("compareVersion(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want) } } } func TestDetectPackageMetadata(t *testing.T) { platform, arch := detectPlatform("YMhutBox_2.0.6.31_x64.msix") if platform != "windows" || arch != "x64" { t.Fatalf("detectPlatform returned %s/%s", platform, arch) } if version := detectVersion("YMhut_Box_WinUI_Setup_2.0.6.31.exe"); version != "2.0.6.31" { t.Fatalf("detectVersion returned %q", version) } } func TestScanPackagesUsesCDNAndCachesHashes(t *testing.T) { dir := t.TempDir() name := "YMhut_Box_WinUI_Setup_2.0.7_x64.exe" if err := os.WriteFile(filepath.Join(dir, name), []byte("stable package"), 0o640); err != nil { t.Fatal(err) } service := NewService(&config.Config{ DownloadsDir: dir, BaseURL: "https://update.ymhut.cn", CDNBaseURL: "https://cdn.ymhut.cn/", }, nil) req := httptest.NewRequest("GET", "https://origin.example/api/client/releases", nil) first := service.ScanPackages(req) second := service.ScanPackages(req) if len(first) != 1 || first[0].URL != "https://cdn.ymhut.cn/downloads/"+name { t.Fatalf("unexpected CDN package: %#v", first) } if len(second) != 1 || first[0].SHA256 == "" || second[0].SHA256 != first[0].SHA256 { t.Fatalf("cached hashes differ: first=%#v second=%#v", first, second) } if len(service.hashes) != 1 { t.Fatalf("expected one cached hash, got %d", len(service.hashes)) } } func TestReconcileListsEverySupportedPackageType(t *testing.T) { dir := t.TempDir() for _, name := range []string{"app.exe", "app.msix", "app.appinstaller", "app.msi", "app.zip", "app.7z"} { if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o640); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignored"), 0o640); err != nil { t.Fatal(err) } service := NewService(&config.Config{DownloadsDir: dir, BaseURL: "https://update.example"}, nil) if got := len(service.ScanPackages(httptest.NewRequest("GET", "https://update.example/api/client/releases", nil))); got != 6 { t.Fatalf("listed %d packages, want 6", got) } } func TestReconcileReusesPersistentHashAfterRestart(t *testing.T) { dir := t.TempDir() cfg := &config.Config{ StorageDir: filepath.Join(dir, "storage"), DownloadsDir: filepath.Join(dir, "downloads"), BaseURL: "https://update.example", Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), HealthIntervalSec: 30, }, } if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil { t.Fatal(err) } name := "YMhut_Box_2.1.0_x64.zip" if err := os.WriteFile(filepath.Join(cfg.DownloadsDir, name), []byte("persistent package hash"), 0o640); err != nil { t.Fatal(err) } store, err := db.Open(cfg) if err != nil { t.Fatal(err) } defer store.Close() first := NewService(cfg, store) if packages := first.ScanPackages(httptest.NewRequest("GET", "https://update.example", nil)); len(packages) != 1 || packages[0].SHA256 == "" { t.Fatalf("initial reconcile did not index the package: %#v", packages) } hashCalls := 0 second := newService(cfg, store, func(path string) string { hashCalls++ return sha256File(path) }) if packages := second.ScanPackages(httptest.NewRequest("GET", "https://update.example", nil)); len(packages) != 1 { t.Fatalf("restart snapshot contains %d packages, want 1", len(packages)) } if hashCalls != 0 { t.Fatalf("restart rehashed %d files, want 0", hashCalls) } } func TestReconcileRehashesChangedFileAndRemovesDeletedIndex(t *testing.T) { service, cfg, cleanup := newPreparedPackageTestService(t) defer cleanup() name := "YMhut_Box_2.2.0_x64.zip" path := filepath.Join(cfg.DownloadsDir, name) if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("first"), 0o640); err != nil { t.Fatal(err) } hashCalls := 0 service.hashFile = func(path string) string { hashCalls++ return sha256File(path) } if err := service.Reconcile(t.Context()); err != nil { t.Fatal(err) } first := service.ScanPackages(nil) if len(first) != 1 || hashCalls != 1 { t.Fatalf("first reconcile packages=%d hashCalls=%d", len(first), hashCalls) } if err := os.WriteFile(path, []byte("changed package bytes"), 0o640); err != nil { t.Fatal(err) } if err := service.Reconcile(t.Context()); err != nil { t.Fatal(err) } second := service.ScanPackages(nil) if hashCalls != 2 || len(second) != 1 || second[0].SHA256 == first[0].SHA256 { t.Fatalf("changed package was not rehashed: first=%#v second=%#v calls=%d", first, second, hashCalls) } if err := os.Remove(path); err != nil { t.Fatal(err) } if err := service.Reconcile(t.Context()); err != nil { t.Fatal(err) } if packages := service.ScanPackages(nil); len(packages) != 0 { t.Fatalf("deleted package remains in snapshot: %#v", packages) } if _, ok, err := service.store.GetReleasePackage(name); err != nil || ok { t.Fatalf("deleted package remains in index: ok=%v err=%v", ok, err) } } func TestScanPackagesNeverHashesDuringRequest(t *testing.T) { dir := t.TempDir() name := "YMhut_Box_2.2.1_x64.7z" if err := os.WriteFile(filepath.Join(dir, name), []byte("large package placeholder"), 0o640); err != nil { t.Fatal(err) } service := NewService(&config.Config{DownloadsDir: dir, BaseURL: "https://update.example"}, nil) service.hashFile = func(string) string { t.Fatal("request-time package scan attempted to hash file content") return "" } for range 10 { if packages := service.ScanPackages(httptest.NewRequest("GET", "https://update.example/api/client/bootstrap", nil)); len(packages) != 1 { t.Fatalf("snapshot contains %d packages, want 1", len(packages)) } } } func TestManifestPrioritizesPublishedVersionOverNumericallyHigherHistoricalPackage(t *testing.T) { dir := t.TempDir() cfg := &config.Config{ DownloadsDir: filepath.Join(dir, "downloads"), UpdatePublicDir: filepath.Join(dir, "update", "public"), BaseURL: "https://update.example", } if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil { t.Fatal(err) } if err := os.MkdirAll(cfg.UpdatePublicDir, 0o750); err != nil { t.Fatal(err) } for _, name := range []string{ "YMhut_Box_WinUI_Setup_2.0.7.111.exe", "YMhut_Box_WinUI_Setup_2.0.7.12.exe", } { if err := os.WriteFile(filepath.Join(cfg.DownloadsDir, name), []byte(name), 0o640); err != nil { t.Fatal(err) } } if err := os.WriteFile( filepath.Join(cfg.UpdatePublicDir, "update-info.json"), []byte(`{"app_version":"2.0.7.12"}`), 0o640); err != nil { t.Fatal(err) } service := NewService(cfg, nil) manifest := service.Manifest(httptest.NewRequest(http.MethodGet, "https://update.example/api/client/releases", nil)) if got := manifestString(manifest, "app_version"); got != "2.0.7.12" { t.Fatalf("manifest app_version = %q, want 2.0.7.12", got) } packages, ok := manifest["packages"].([]Package) if !ok || len(packages) != 2 { t.Fatalf("manifest packages = %#v", manifest["packages"]) } if packages[0].Version != "2.0.7.12" { t.Fatalf("first package version = %q, want published version 2.0.7.12", packages[0].Version) } if got := manifestString(manifest, "download_url"); !strings.Contains(got, "2.0.7.12.exe") { t.Fatalf("manifest download_url = %q, want current package", got) } } func TestManifestFallsBackToHighestPackageWithoutPublishedVersion(t *testing.T) { dir := t.TempDir() for _, name := range []string{"YMhut_Box_2.0.7.12.exe", "YMhut_Box_2.0.7.111.exe"} { if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o640); err != nil { t.Fatal(err) } } service := NewService(&config.Config{ DownloadsDir: dir, UpdatePublicDir: filepath.Join(dir, "missing-public"), BaseURL: "https://update.example", }, nil) manifest := service.Manifest(httptest.NewRequest(http.MethodGet, "https://update.example/api/client/releases", nil)) if got := manifestString(manifest, "app_version"); got != "2.0.7.111" { t.Fatalf("manifest fallback app_version = %q, want highest indexed package 2.0.7.111", got) } } func TestSavePreparedPackageRestoresFileAndManifestWhenIndexFails(t *testing.T) { service, cfg, closeStore := newPreparedPackageTestService(t) name := "YMhut_Box_WinUI_Setup_2.3.0_x64.exe" if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil { t.Fatal(err) } target := filepath.Join(cfg.DownloadsDir, name) if err := os.WriteFile(target, []byte("old package"), 0o640); err != nil { t.Fatal(err) } manifestPath := filepath.Join(cfg.UpdatePublicDir, "update-info.json") if err := os.MkdirAll(cfg.UpdatePublicDir, 0o750); err != nil { t.Fatal(err) } oldManifest := []byte("{\"app_version\":\"1.0.0\"}\n") if err := os.WriteFile(manifestPath, oldManifest, 0o640); err != nil { t.Fatal(err) } temp := filepath.Join(cfg.DownloadsDir, ".upload-new") if err := os.WriteFile(temp, []byte("new package"), 0o640); err != nil { t.Fatal(err) } closeStore() _, err := service.SavePreparedPackage( httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil), UploadedPackageFile{TempPath: temp, Size: int64(len("new package")), SHA256: "abc123"}, UploadOptions{FileName: name, UpdateManifest: true}, "admin") if !errors.Is(err, ErrUploadedPackageIndexFailed) { t.Fatalf("got %v, want index failure", err) } if data, readErr := os.ReadFile(target); readErr != nil || string(data) != "old package" { t.Fatalf("package rollback data=%q err=%v", data, readErr) } if data, readErr := os.ReadFile(manifestPath); readErr != nil || string(data) != string(oldManifest) { t.Fatalf("manifest rollback data=%q err=%v", data, readErr) } } func TestSaveUploadedPackageWritesFileAndUpdatesManifest(t *testing.T) { dir := t.TempDir() cfg := &config.Config{ BaseDir: dir, StorageDir: filepath.Join(dir, "storage"), DataDir: filepath.Join(dir, "data"), UpdatePublicDir: filepath.Join(dir, "data", "update", "public"), DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"), BaseURL: "https://update.ymhut.cn", Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), HealthIntervalSec: 30, }, } store, err := db.Open(cfg) if err != nil { t.Fatal(err) } defer store.Close() service := NewService(cfg, store) req := httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil) pkg, err := service.SaveUploadedPackage(req, strings.NewReader("package bytes"), UploadOptions{ FileName: "YMhut_Box_WinUI_Setup_2.0.6.31.exe", UpdateManifest: true, }, "admin") if err != nil { t.Fatal(err) } if pkg.Version != "2.0.6.31" || pkg.SHA256 == "" || pkg.Size == 0 { t.Fatalf("unexpected package metadata: %#v", pkg) } if _, err := os.Stat(filepath.Join(cfg.DownloadsDir, pkg.FileName)); err != nil { t.Fatal(err) } manifest := readJSON(filepath.Join(cfg.UpdatePublicDir, "update-info.json")) if manifest["download_url"] != pkg.URL || manifest["package_sha256"] != pkg.SHA256 { t.Fatalf("manifest not updated: %#v", manifest) } } func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) { dir := t.TempDir() cfg := &config.Config{ BaseDir: dir, StorageDir: filepath.Join(dir, "storage"), DataDir: filepath.Join(dir, "data"), UpdatePublicDir: filepath.Join(dir, "data", "update", "public"), DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"), Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), HealthIntervalSec: 30, }, } store, err := db.Open(cfg) if err != nil { t.Fatal(err) } defer store.Close() service := NewService(cfg, store) _, err = service.SaveUploadedPackage(httptest.NewRequest("POST", "/", nil), strings.NewReader("x"), UploadOptions{FileName: "../evil.exe"}, "admin") if err == nil { t.Fatal("expected unsafe filename to be rejected") } } func TestSavePreparedPackageReplacesExistingFile(t *testing.T) { service, cfg, cleanup := newPreparedPackageTestService(t) defer cleanup() name := "YMhut_Box_WinUI_Setup_2.0.8_x64.exe" target := filepath.Join(cfg.DownloadsDir, name) if err := os.MkdirAll(cfg.DownloadsDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(target, []byte("old package"), 0o640); err != nil { t.Fatal(err) } temp := filepath.Join(cfg.DownloadsDir, ".upload-new") if err := os.WriteFile(temp, []byte("new package"), 0o640); err != nil { t.Fatal(err) } _, err := service.SavePreparedPackage( httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil), UploadedPackageFile{TempPath: temp, Size: int64(len("new package")), SHA256: "abc123"}, UploadOptions{FileName: name}, "admin") if err != nil { t.Fatal(err) } data, err := os.ReadFile(target) if err != nil { t.Fatal(err) } if string(data) != "new package" { t.Fatalf("target contains %q, want new package", data) } backups, err := filepath.Glob(filepath.Join(cfg.DownloadsDir, "."+name+".backup-*")) if err != nil { t.Fatal(err) } if len(backups) != 0 { t.Fatalf("successful replacement left backups: %v", backups) } } func TestSavePreparedPackageRestoresExistingFileWhenManifestFails(t *testing.T) { service, cfg, cleanup := newPreparedPackageTestService(t) defer cleanup() name := "YMhut_Box_WinUI_Setup_2.0.8_x64.exe" if err := os.MkdirAll(cfg.DownloadsDir, 0o755); err != nil { t.Fatal(err) } target := filepath.Join(cfg.DownloadsDir, name) if err := os.WriteFile(target, []byte("old package"), 0o640); err != nil { t.Fatal(err) } temp := filepath.Join(cfg.DownloadsDir, ".upload-new") if err := os.WriteFile(temp, []byte("new package"), 0o640); err != nil { t.Fatal(err) } blocked := filepath.Join(cfg.BaseDir, "manifest-blocked") if err := os.WriteFile(blocked, []byte("not a directory"), 0o640); err != nil { t.Fatal(err) } cfg.UpdatePublicDir = blocked _, err := service.SavePreparedPackage( httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil), UploadedPackageFile{TempPath: temp, Size: int64(len("new package")), SHA256: "abc123"}, UploadOptions{FileName: name, UpdateManifest: true}, "admin") if !errors.Is(err, ErrUploadedPackageManifestFailed) { t.Fatalf("got %v, want manifest failure", err) } data, readErr := os.ReadFile(target) if readErr != nil { t.Fatal(readErr) } if string(data) != "old package" { t.Fatalf("rollback restored %q, want old package", data) } } func newPreparedPackageTestService(t *testing.T) (*Service, *config.Config, func()) { t.Helper() dir := t.TempDir() cfg := &config.Config{ BaseDir: dir, StorageDir: filepath.Join(dir, "storage"), DataDir: filepath.Join(dir, "data"), UpdatePublicDir: filepath.Join(dir, "data", "update", "public"), DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"), BaseURL: "https://update.ymhut.cn", Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), HealthIntervalSec: 30, }, } store, err := db.Open(cfg) if err != nil { t.Fatal(err) } return NewService(cfg, store), cfg, func() { _ = store.Close() } }