Files
YMhut-box-C-/server/unified-management/internal/releases/releases_test.go
T

503 lines
17 KiB
Go

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() }
}