729 lines
23 KiB
Go
729 lines
23 KiB
Go
package install
|
|
|
|
import (
|
|
"archive/zip"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"softbox.local/core/catalog"
|
|
"softbox.local/core/installer"
|
|
"softbox.local/core/storage"
|
|
)
|
|
|
|
func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing.T) {
|
|
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
|
store := storage.NewInstalledAppStore(appsRoot)
|
|
service := newInstallService(t, store, func(currentPath string) error {
|
|
_, err := os.Stat(filepath.Join(currentPath, "bin", "App.exe"))
|
|
return err
|
|
})
|
|
|
|
result, err := service.Install(InstallRequest{
|
|
Entry: installEntry(publishedPackage, "1.2.3"),
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: archivePath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Install() error = %v", err)
|
|
}
|
|
if result.AppID != "test-app" || result.Version != "1.2.3" || result.Recovery.Action != installer.RecoveryNone {
|
|
t.Fatalf("result = %#v", result)
|
|
}
|
|
if got := mustReadFile(t, filepath.Join(appsRoot, "test-app", "current", "bin", "App.exe")); got != "new executable" {
|
|
t.Fatalf("current entrypoint = %q", got)
|
|
}
|
|
record, found, err := store.Read("test-app")
|
|
if err != nil || !found {
|
|
t.Fatalf("Read() found=%t err=%v", found, err)
|
|
}
|
|
if record.Version != "1.2.3" || len(record.Files) != 2 {
|
|
t.Fatalf("record = %#v", record)
|
|
}
|
|
if record.Files[0].Path != "bin/App.exe" || record.Files[0].Size != int64(len("new executable")) {
|
|
t.Fatalf("record first file = %#v", record.Files[0])
|
|
}
|
|
hash := sha256.Sum256([]byte("new executable"))
|
|
if record.Files[0].SHA256 != hex.EncodeToString(hash[:]) {
|
|
t.Fatalf("record first hash = %q", record.Files[0].SHA256)
|
|
}
|
|
}
|
|
|
|
func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T) {
|
|
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
|
tests := []struct {
|
|
name string
|
|
entry catalog.Entry
|
|
wantErr error
|
|
wantCode FailureCode
|
|
}{
|
|
{
|
|
name: "selected package differs from architecture package",
|
|
entry: func() catalog.Entry {
|
|
entry := installEntry(publishedPackage, "1.2.3")
|
|
forged := publishedPackage
|
|
forged.SHA256 = "0000000000000000000000000000000000000000000000000000000000000000"
|
|
entry.Package = &forged
|
|
return entry
|
|
}(),
|
|
wantErr: ErrInstallRequestInvalid,
|
|
wantCode: FailureCodeInstallFailed,
|
|
},
|
|
{
|
|
name: "download hash differs from Catalog",
|
|
entry: func() catalog.Entry {
|
|
entry := installEntry(publishedPackage, "1.2.3")
|
|
entry.App.Packages[catalog.ArchitectureAMD64] = catalog.Package{
|
|
Size: publishedPackage.Size,
|
|
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
|
Signature: publishedPackage.Signature,
|
|
URL: publishedPackage.URL,
|
|
}
|
|
*entry.Package = entry.App.Packages[catalog.ArchitectureAMD64]
|
|
return entry
|
|
}(),
|
|
wantErr: installer.ErrPackageHashMismatch,
|
|
wantCode: FailureCodeHashMismatch,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
|
store := storage.NewInstalledAppStore(appsRoot)
|
|
service := newInstallService(t, store, func(string) error { return nil })
|
|
|
|
_, err := service.Install(InstallRequest{
|
|
Entry: test.entry,
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: archivePath,
|
|
})
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
|
}
|
|
if stage := installErrorStage(t, err); stage != InstallStageVerify {
|
|
t.Fatalf("stage = %q, want %q", stage, InstallStageVerify)
|
|
}
|
|
if code := installErrorCode(t, err); code != test.wantCode {
|
|
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(appsRoot, "test-app", "staging")); !os.IsNotExist(statErr) {
|
|
t.Fatalf("rejected install left staging, stat error = %v", statErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInstallServiceRollsBackHealthAndRecordWriteFailure(t *testing.T) {
|
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
|
store := storage.NewInstalledAppStore(appsRoot)
|
|
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
|
initial := newInstallService(t, store, func(string) error { return nil })
|
|
if _, err := initial.Install(InstallRequest{
|
|
Entry: installEntry(oldPackage, "1.0.0"),
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: oldArchive,
|
|
}); err != nil {
|
|
t.Fatalf("initial Install() error = %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
service func(t *testing.T) *InstallService
|
|
wantStage InstallStage
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "health failure",
|
|
service: func(t *testing.T) *InstallService {
|
|
return newInstallService(t, store, func(string) error { return errors.New("health failed") })
|
|
},
|
|
wantStage: InstallStageHealth,
|
|
wantErr: installer.ErrHealthCheckFailed,
|
|
},
|
|
{
|
|
name: "record write failure",
|
|
service: func(t *testing.T) *InstallService {
|
|
return newInstallService(t, &failingRecordStore{
|
|
InstalledAppStore: store,
|
|
writeErr: errors.New("record disk error"),
|
|
}, func(string) error { return nil })
|
|
},
|
|
wantStage: InstallStageRecord,
|
|
wantErr: ErrInstallRecordWrite,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
|
_, err := test.service(t).Install(InstallRequest{
|
|
Entry: installEntry(publishedPackage, "1.1.0"),
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: archivePath,
|
|
})
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
|
}
|
|
if stage := installErrorStage(t, err); stage != test.wantStage {
|
|
t.Fatalf("stage = %q, want %q", stage, test.wantStage)
|
|
}
|
|
if got := mustReadFile(t, filepath.Join(appsRoot, "test-app", "current", "bin", "App.exe")); got != "old executable" {
|
|
t.Fatalf("current entrypoint after failure = %q", got)
|
|
}
|
|
record, found, readErr := store.Read("test-app")
|
|
if readErr != nil || !found || record.Version != "1.0.0" {
|
|
t.Fatalf("record after failure found=%t record=%#v err=%v", found, record, readErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
|
|
extractor, err := installer.NewExtractor(installTestLimits())
|
|
if err != nil {
|
|
t.Fatalf("NewExtractor() error = %v", err)
|
|
}
|
|
store := storage.NewInstalledAppStore(filepath.Join(t.TempDir(), "apps"))
|
|
config := InstallServiceConfig{
|
|
Extractor: extractor,
|
|
Records: store,
|
|
Health: func(string) error { return nil },
|
|
DiskSpace: diskSpaceCheckerFunc(func(string) (int64, error) {
|
|
return StagingDiskReserveBytes, nil
|
|
}),
|
|
TargetState: targetStateCheckerFunc(func(string, string) (bool, error) {
|
|
return false, nil
|
|
}),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
modify func(*InstallServiceConfig)
|
|
}{
|
|
{
|
|
name: "disk space checker",
|
|
modify: func(config *InstallServiceConfig) {
|
|
config.DiskSpace = nil
|
|
},
|
|
},
|
|
{
|
|
name: "target state checker",
|
|
modify: func(config *InstallServiceConfig) {
|
|
config.TargetState = nil
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
candidate := config
|
|
test.modify(&candidate)
|
|
if _, err := NewInstallService(candidate); !errors.Is(err, ErrInstallServiceConfig) {
|
|
t.Fatalf("NewInstallService() error = %v, want %v", err, ErrInstallServiceConfig)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFailureCodeForPackageFailures(t *testing.T) {
|
|
tests := []struct {
|
|
err error
|
|
code FailureCode
|
|
}{
|
|
{err: installer.ErrPackageHashMismatch, code: FailureCodeHashMismatch},
|
|
{err: installer.ErrPathEscape, code: FailureCodeZIPPathEscape},
|
|
{err: installer.ErrArchiveCorrupt, code: FailureCodeZIPCorrupt},
|
|
{err: installer.ErrAppManifestInvalid, code: FailureCodePackageInvalid},
|
|
{err: ErrInstallRequestInvalid, code: FailureCodeInstallFailed},
|
|
}
|
|
for _, test := range tests {
|
|
if got := failureCodeFor(test.err); got != test.code {
|
|
t.Fatalf("failureCodeFor(%v) = %q, want %q", test.err, got, test.code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInstallServiceAcceptsExactStagingCapacity(t *testing.T) {
|
|
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
|
store := storage.NewInstalledAppStore(appsRoot)
|
|
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
|
|
var diskRoot string
|
|
var targetAppID, targetEntrypoint string
|
|
service := newInstallServiceWithCheckers(
|
|
t,
|
|
store,
|
|
func(string) error { return nil },
|
|
diskSpaceCheckerFunc(func(appRoot string) (int64, error) {
|
|
diskRoot = appRoot
|
|
return required, nil
|
|
}),
|
|
targetStateCheckerFunc(func(appID, entrypointPath string) (bool, error) {
|
|
targetAppID = appID
|
|
targetEntrypoint = entrypointPath
|
|
return false, nil
|
|
}),
|
|
)
|
|
|
|
if _, err := service.Install(InstallRequest{
|
|
Entry: installEntry(publishedPackage, "1.2.3"),
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: archivePath,
|
|
}); err != nil {
|
|
t.Fatalf("Install() error = %v", err)
|
|
}
|
|
appRoot := filepath.Join(appsRoot, "test-app")
|
|
if diskRoot != appRoot {
|
|
t.Fatalf("disk check root = %q, want %q", diskRoot, appRoot)
|
|
}
|
|
if targetAppID != "test-app" || targetEntrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") {
|
|
t.Fatalf("target check = (%q, %q)", targetAppID, targetEntrypoint)
|
|
}
|
|
}
|
|
|
|
func TestInstallServiceInsufficientDiskLeavesNoCurrent(t *testing.T) {
|
|
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
|
store := storage.NewInstalledAppStore(appsRoot)
|
|
service := newInstallServiceWithCheckers(
|
|
t,
|
|
store,
|
|
func(string) error { return nil },
|
|
diskSpaceCheckerFunc(func(string) (int64, error) {
|
|
return 0, nil
|
|
}),
|
|
targetStateCheckerFunc(func(string, string) (bool, error) {
|
|
return false, nil
|
|
}),
|
|
)
|
|
|
|
_, err := service.Install(InstallRequest{
|
|
Entry: installEntry(publishedPackage, "1.2.3"),
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: archivePath,
|
|
})
|
|
if !errors.Is(err, ErrDiskSpaceInsufficient) {
|
|
t.Fatalf("Install() error = %v, want %v", err, ErrDiskSpaceInsufficient)
|
|
}
|
|
if code := installErrorCode(t, err); code != FailureCodeDiskFull {
|
|
t.Fatalf("code = %q, want %q", code, FailureCodeDiskFull)
|
|
}
|
|
appRoot := filepath.Join(appsRoot, "test-app")
|
|
for _, managed := range []string{"staging", "current"} {
|
|
if _, statErr := os.Stat(filepath.Join(appRoot, managed)); !os.IsNotExist(statErr) {
|
|
t.Fatalf("first install left %s, stat error = %v", managed, statErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInstallServicePreflightFailuresPreserveExistingVersion(t *testing.T) {
|
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
|
store := storage.NewInstalledAppStore(appsRoot)
|
|
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
|
initial := newInstallService(t, store, func(string) error { return nil })
|
|
if _, err := initial.Install(InstallRequest{
|
|
Entry: installEntry(oldPackage, "1.0.0"),
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: oldArchive,
|
|
}); err != nil {
|
|
t.Fatalf("initial Install() error = %v", err)
|
|
}
|
|
|
|
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
|
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
|
|
diskProbeErr := errors.New("disk probe unavailable")
|
|
targetProbeErr := errors.New("target state unavailable")
|
|
tests := []struct {
|
|
name string
|
|
disk DiskSpaceChecker
|
|
target TargetStateChecker
|
|
wantErr error
|
|
wantCode FailureCode
|
|
}{
|
|
{
|
|
name: "insufficient disk space",
|
|
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
|
return required - 1, nil
|
|
}),
|
|
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
|
return false, nil
|
|
}),
|
|
wantErr: ErrDiskSpaceInsufficient,
|
|
wantCode: FailureCodeDiskFull,
|
|
},
|
|
{
|
|
name: "disk capacity check fails",
|
|
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
|
return 0, diskProbeErr
|
|
}),
|
|
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
|
return false, nil
|
|
}),
|
|
wantErr: diskProbeErr,
|
|
wantCode: FailureCodeDiskCheckFailed,
|
|
},
|
|
{
|
|
name: "current app is running",
|
|
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
|
return required, nil
|
|
}),
|
|
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
|
return true, nil
|
|
}),
|
|
wantErr: ErrTargetRunning,
|
|
wantCode: FailureCodeAppRunning,
|
|
},
|
|
{
|
|
name: "target state check fails",
|
|
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
|
return required, nil
|
|
}),
|
|
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
|
return false, targetProbeErr
|
|
}),
|
|
wantErr: targetProbeErr,
|
|
wantCode: FailureCodeTargetStateUnavailable,
|
|
},
|
|
}
|
|
|
|
appRoot := filepath.Join(appsRoot, "test-app")
|
|
oldRecord := mustReadFile(t, filepath.Join(appRoot, "installed-app.json"))
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
service := newInstallServiceWithCheckers(t, store, func(string) error { return nil }, test.disk, test.target)
|
|
_, err := service.Install(InstallRequest{
|
|
Entry: installEntry(publishedPackage, "1.1.0"),
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: archivePath,
|
|
})
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
|
}
|
|
if stage := installErrorStage(t, err); stage != InstallStagePreflight {
|
|
t.Fatalf("stage = %q, want %q", stage, InstallStagePreflight)
|
|
}
|
|
if code := installErrorCode(t, err); code != test.wantCode {
|
|
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
|
}
|
|
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
|
|
t.Fatalf("current entrypoint after failure = %q", got)
|
|
}
|
|
if got := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")); got != oldRecord {
|
|
t.Fatal("installed-app record changed after preflight failure")
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
|
|
t.Fatalf("preflight failure left staging, stat error = %v", statErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInstallServiceReportsCorruptPackageWithoutReplacingCurrent(t *testing.T) {
|
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
|
store := storage.NewInstalledAppStore(appsRoot)
|
|
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
|
initial := newInstallService(t, store, func(string) error { return nil })
|
|
if _, err := initial.Install(InstallRequest{
|
|
Entry: installEntry(oldPackage, "1.0.0"),
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: oldArchive,
|
|
}); err != nil {
|
|
t.Fatalf("initial Install() error = %v", err)
|
|
}
|
|
|
|
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
|
corruptInstallPackageEntry(t, archivePath, "payload/bin/App.exe")
|
|
document, err := os.ReadFile(archivePath)
|
|
if err != nil {
|
|
t.Fatalf("read corrupted archive: %v", err)
|
|
}
|
|
hash := sha256.Sum256(document)
|
|
publishedPackage.Size = int64(len(document))
|
|
publishedPackage.SHA256 = hex.EncodeToString(hash[:])
|
|
|
|
service := newInstallService(t, store, func(string) error { return nil })
|
|
_, err = service.Install(InstallRequest{
|
|
Entry: installEntry(publishedPackage, "1.1.0"),
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: archivePath,
|
|
})
|
|
if !errors.Is(err, installer.ErrArchiveCorrupt) {
|
|
t.Fatalf("Install() error = %v, want %v", err, installer.ErrArchiveCorrupt)
|
|
}
|
|
if stage := installErrorStage(t, err); stage != InstallStageExtract {
|
|
t.Fatalf("stage = %q, want %q", stage, InstallStageExtract)
|
|
}
|
|
if code := installErrorCode(t, err); code != FailureCodeZIPCorrupt {
|
|
t.Fatalf("code = %q, want %q", code, FailureCodeZIPCorrupt)
|
|
}
|
|
appRoot := filepath.Join(appsRoot, "test-app")
|
|
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
|
|
t.Fatalf("current entrypoint after corruption = %q", got)
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
|
|
t.Fatalf("corrupt package left staging, stat error = %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestInstallServiceRecoversPreparedTransactionBeforeExtracting(t *testing.T) {
|
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
|
store := storage.NewInstalledAppStore(appsRoot)
|
|
appRoot, err := store.EnsureAppRoot("test-app")
|
|
if err != nil {
|
|
t.Fatalf("EnsureAppRoot() error = %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(appRoot, "current"), 0o700); err != nil {
|
|
t.Fatalf("create current: %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(appRoot, "staging"), 0o700); err != nil {
|
|
t.Fatalf("create stale staging: %v", err)
|
|
}
|
|
if err := os.WriteFile(
|
|
filepath.Join(appRoot, "install-transaction.json"),
|
|
[]byte(`{"schema_version":1,"phase":"prepared","had_current":true}`),
|
|
0o600,
|
|
); err != nil {
|
|
t.Fatalf("write transaction: %v", err)
|
|
}
|
|
|
|
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
|
service := newInstallService(t, store, func(string) error { return nil })
|
|
result, err := service.Install(InstallRequest{
|
|
Entry: installEntry(publishedPackage, "1.2.3"),
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: archivePath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Install() error = %v", err)
|
|
}
|
|
if result.Recovery.Action != installer.RecoveryAborted {
|
|
t.Fatalf("recovery action = %q, want %q", result.Recovery.Action, installer.RecoveryAborted)
|
|
}
|
|
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "new executable" {
|
|
t.Fatalf("current entrypoint = %q", got)
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
|
|
t.Fatalf("staging remains, stat error = %v", statErr)
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(appRoot, "install-transaction.json")); !os.IsNotExist(statErr) {
|
|
t.Fatalf("transaction remains, stat error = %v", statErr)
|
|
}
|
|
}
|
|
|
|
type failingRecordStore struct {
|
|
*storage.InstalledAppStore
|
|
writeErr error
|
|
}
|
|
|
|
func (store *failingRecordStore) Write(storage.InstalledApp) error {
|
|
return store.writeErr
|
|
}
|
|
|
|
func newInstallService(
|
|
t *testing.T,
|
|
store InstallRecordStore,
|
|
health installer.HealthCheck,
|
|
) *InstallService {
|
|
return newInstallServiceWithCheckers(
|
|
t,
|
|
store,
|
|
health,
|
|
diskSpaceCheckerFunc(func(string) (int64, error) {
|
|
return StagingDiskReserveBytes + 64*1024, nil
|
|
}),
|
|
targetStateCheckerFunc(func(string, string) (bool, error) {
|
|
return false, nil
|
|
}),
|
|
)
|
|
}
|
|
|
|
func newInstallServiceWithCheckers(
|
|
t *testing.T,
|
|
store InstallRecordStore,
|
|
health installer.HealthCheck,
|
|
diskSpace DiskSpaceChecker,
|
|
targetState TargetStateChecker,
|
|
) *InstallService {
|
|
t.Helper()
|
|
extractor, err := installer.NewExtractor(installTestLimits())
|
|
if err != nil {
|
|
t.Fatalf("NewExtractor() error = %v", err)
|
|
}
|
|
service, err := NewInstallService(InstallServiceConfig{
|
|
Extractor: extractor,
|
|
Records: store,
|
|
Health: health,
|
|
DiskSpace: diskSpace,
|
|
TargetState: targetState,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewInstallService() error = %v", err)
|
|
}
|
|
return service
|
|
}
|
|
|
|
type diskSpaceCheckerFunc func(string) (int64, error)
|
|
|
|
func (check diskSpaceCheckerFunc) AvailableBytes(appRoot string) (int64, error) {
|
|
return check(appRoot)
|
|
}
|
|
|
|
type targetStateCheckerFunc func(string, string) (bool, error)
|
|
|
|
func (check targetStateCheckerFunc) IsRunning(appID string, entrypointPath string) (bool, error) {
|
|
return check(appID, entrypointPath)
|
|
}
|
|
|
|
func installTestLimits() installer.Limits {
|
|
return installer.Limits{
|
|
MaxEntries: 20,
|
|
MaxArchiveBytes: 64 * 1024,
|
|
MaxCentralDirectoryBytes: 4 * 1024,
|
|
MaxUncompressedBytes: 64 * 1024,
|
|
MaxCompressionRatio: 100,
|
|
}
|
|
}
|
|
|
|
func installEntry(publishedPackage catalog.Package, version string) catalog.Entry {
|
|
selectedPackage := publishedPackage
|
|
return catalog.Entry{
|
|
App: catalog.App{
|
|
ID: "test-app",
|
|
Version: version,
|
|
Channel: catalog.ReleaseStable,
|
|
Status: catalog.CatalogStatusActive,
|
|
MinOS: catalog.Windows10,
|
|
Architectures: []catalog.Architecture{catalog.ArchitectureAMD64},
|
|
EntryEXE: "bin/App.exe",
|
|
Packages: map[catalog.Architecture]catalog.Package{
|
|
catalog.ArchitectureAMD64: publishedPackage,
|
|
},
|
|
},
|
|
Package: &selectedPackage,
|
|
Installable: true,
|
|
}
|
|
}
|
|
|
|
func writeInstallPackage(t *testing.T, version, executable string) (string, catalog.Package) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "package.download")
|
|
file, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatalf("create package: %v", err)
|
|
}
|
|
writer := zip.NewWriter(file)
|
|
entries := []struct {
|
|
name string
|
|
body []byte
|
|
mode os.FileMode
|
|
}{
|
|
{name: "app.json", body: installManifest(version)},
|
|
{name: "payload/bin/App.exe", body: []byte(executable), mode: 0o755},
|
|
{name: "payload/readme.txt", body: []byte("readme")},
|
|
}
|
|
for _, entry := range entries {
|
|
header := &zip.FileHeader{Name: entry.name}
|
|
mode := entry.mode
|
|
if mode == 0 {
|
|
mode = 0o600
|
|
}
|
|
header.SetMode(mode)
|
|
part, err := writer.CreateHeader(header)
|
|
if err != nil {
|
|
t.Fatalf("create ZIP entry: %v", err)
|
|
}
|
|
if _, err := part.Write(entry.body); err != nil {
|
|
t.Fatalf("write ZIP entry: %v", err)
|
|
}
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
file.Close()
|
|
t.Fatalf("close ZIP writer: %v", err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
t.Fatalf("close package: %v", err)
|
|
}
|
|
document, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read package: %v", err)
|
|
}
|
|
hash := sha256.Sum256(document)
|
|
return path, catalog.Package{
|
|
URL: "https://download.invalid/test-app.zip",
|
|
Size: int64(len(document)),
|
|
SHA256: hex.EncodeToString(hash[:]),
|
|
Signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
|
}
|
|
}
|
|
|
|
func installManifest(version string) []byte {
|
|
return []byte(`{"schema_version":1,"id":"test-app","name":"Test App","vendor":"SoftBox","version":"` + version + `","channel":"stable","min_os":"windows-10","architecture":"amd64","entrypoint":"bin/App.exe","working_directory":".","product_id":"test-product","supports_trial":false,"requires_admin":false,"data_policy":"local-app-data","update_policy":"managed-by-softbox"}`)
|
|
}
|
|
|
|
func installErrorStage(t *testing.T, err error) InstallStage {
|
|
t.Helper()
|
|
var installErr *InstallError
|
|
if !errors.As(err, &installErr) {
|
|
t.Fatalf("error %v is not InstallError", err)
|
|
}
|
|
return installErr.Stage
|
|
}
|
|
|
|
func installErrorCode(t *testing.T, err error) FailureCode {
|
|
t.Helper()
|
|
var installErr *InstallError
|
|
if !errors.As(err, &installErr) {
|
|
t.Fatalf("error %v is not InstallError", err)
|
|
}
|
|
return installErr.Code
|
|
}
|
|
|
|
func corruptInstallPackageEntry(t *testing.T, archivePath, entryName string) {
|
|
t.Helper()
|
|
reader, err := zip.OpenReader(archivePath)
|
|
if err != nil {
|
|
t.Fatalf("open ZIP for corruption: %v", err)
|
|
}
|
|
var offset int64 = -1
|
|
for _, file := range reader.File {
|
|
if file.Name != entryName {
|
|
continue
|
|
}
|
|
offset, err = file.DataOffset()
|
|
if err != nil {
|
|
_ = reader.Close()
|
|
t.Fatalf("entry data offset: %v", err)
|
|
}
|
|
break
|
|
}
|
|
if err := reader.Close(); err != nil {
|
|
t.Fatalf("close ZIP reader: %v", err)
|
|
}
|
|
if offset < 0 {
|
|
t.Fatalf("entry %s not found", entryName)
|
|
}
|
|
|
|
document, err := os.ReadFile(archivePath)
|
|
if err != nil {
|
|
t.Fatalf("read ZIP for corruption: %v", err)
|
|
}
|
|
document[offset] ^= 0xff
|
|
if err := os.WriteFile(archivePath, document, 0o600); err != nil {
|
|
t.Fatalf("write corrupted ZIP: %v", err)
|
|
}
|
|
}
|
|
|
|
func mustReadFile(t *testing.T, path string) string {
|
|
t.Helper()
|
|
document, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
return string(document)
|
|
}
|