436 lines
15 KiB
Go
436 lines
15 KiB
Go
package update
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"softbox.local/core/application/install"
|
|
"softbox.local/core/catalog"
|
|
"softbox.local/core/storage"
|
|
)
|
|
|
|
func TestServiceUpdatesAfterNaturalExit(t *testing.T) {
|
|
store, appRoot := seedInstalledApp(t, "1.0.0")
|
|
confirmation := &recordingConfirmation{confirmed: true}
|
|
waiter := &recordingExitWaiter{}
|
|
installer := &recordingInstaller{result: install.InstallResult{AppID: "test-app", Version: "1.1.0"}}
|
|
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
|
return true, nil
|
|
}), confirmation, waiter, installer)
|
|
|
|
result, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
|
if err != nil {
|
|
t.Fatalf("Update() error = %v", err)
|
|
}
|
|
if result != (Result{AppID: "test-app", Version: "1.1.0"}) {
|
|
t.Fatalf("result = %#v", result)
|
|
}
|
|
if confirmation.calls != 1 || waiter.calls != 1 || installer.calls != 1 {
|
|
t.Fatalf("calls confirmation=%d waiter=%d installer=%d, want 1 each", confirmation.calls, waiter.calls, installer.calls)
|
|
}
|
|
if waiter.appID != "test-app" || waiter.entrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") || waiter.timeout != 30*time.Second {
|
|
t.Fatalf("waiter input = %#v", waiter)
|
|
}
|
|
if installer.request.Entry.App.Version != "1.1.0" {
|
|
t.Fatalf("installer request = %#v", installer.request)
|
|
}
|
|
}
|
|
|
|
func TestServiceSkipsCloseFlowWhenTargetAlreadyStopped(t *testing.T) {
|
|
store, _ := seedInstalledApp(t, "1.0.0")
|
|
confirmation := &recordingConfirmation{confirmed: true}
|
|
waiter := &recordingExitWaiter{}
|
|
installer := &recordingInstaller{result: install.InstallResult{AppID: "test-app", Version: "1.1.0"}}
|
|
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
|
return false, nil
|
|
}), confirmation, waiter, installer)
|
|
|
|
if _, err := service.Update(context.Background(), updateRequest("1.1.0")); err != nil {
|
|
t.Fatalf("Update() error = %v", err)
|
|
}
|
|
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 1 {
|
|
t.Fatalf("calls confirmation=%d waiter=%d installer=%d, want 0/0/1", confirmation.calls, waiter.calls, installer.calls)
|
|
}
|
|
}
|
|
|
|
func TestServiceRejectsInvalidOrUnreadyUpdatesBeforeSideEffects(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
prepare func(t *testing.T) (InstalledAppResolver, Request)
|
|
wantErr error
|
|
wantCode FailureCode
|
|
}{
|
|
{
|
|
name: "not installed",
|
|
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
|
return storage.NewInstalledAppStore(filepath.Join(t.TempDir(), "apps")), updateRequest("1.1.0")
|
|
},
|
|
wantErr: ErrAppNotInstalled,
|
|
wantCode: FailureCodeNotInstalled,
|
|
},
|
|
{
|
|
name: "legacy record lacks launch metadata",
|
|
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
|
store, _ := seedInstalledApp(t, "1.0.0")
|
|
record, found, err := store.Read("test-app")
|
|
if err != nil || !found {
|
|
t.Fatalf("Read() found=%t err=%v", found, err)
|
|
}
|
|
record.Entrypoint = ""
|
|
if err := store.Write(record); err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
return store, updateRequest("1.1.0")
|
|
},
|
|
wantErr: ErrUpdateMetadata,
|
|
wantCode: FailureCodeUpdateMetadataInvalid,
|
|
},
|
|
{
|
|
name: "same version",
|
|
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
|
store, _ := seedInstalledApp(t, "1.0.0")
|
|
return store, updateRequest("1.0.0")
|
|
},
|
|
wantErr: ErrUpdateNotAvailable,
|
|
wantCode: FailureCodeUpdateNotAvailable,
|
|
},
|
|
{
|
|
name: "catalog package mismatches selected architecture",
|
|
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
|
store, _ := seedInstalledApp(t, "1.0.0")
|
|
request := updateRequest("1.1.0")
|
|
different := *request.Install.Entry.Package
|
|
different.Size++
|
|
request.Install.Entry.Package = &different
|
|
return store, request
|
|
},
|
|
wantErr: ErrUpdateRequest,
|
|
wantCode: FailureCodeInstallFailed,
|
|
},
|
|
{
|
|
name: "unsafe current layout",
|
|
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
|
return resolverFunc(func(string) (storage.InstalledApp, string, error) {
|
|
return storage.InstalledApp{}, "", storage.ErrStorageLayoutUnsafe
|
|
}), updateRequest("1.1.0")
|
|
},
|
|
wantErr: ErrUpdateTargetUnsafe,
|
|
wantCode: FailureCodeUpdateTargetUnsafe,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
records, request := test.prepare(t)
|
|
confirmation := &recordingConfirmation{confirmed: true}
|
|
waiter := &recordingExitWaiter{}
|
|
installer := &recordingInstaller{}
|
|
service := newService(t, records, targetStateFunc(func(string, string) (bool, error) {
|
|
return true, nil
|
|
}), confirmation, waiter, installer)
|
|
|
|
_, err := service.Update(context.Background(), request)
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("Update() error = %v, want %v", err, test.wantErr)
|
|
}
|
|
if code := updateErrorCode(t, err); code != test.wantCode {
|
|
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
|
}
|
|
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 0 {
|
|
t.Fatalf("side effects confirmation=%d waiter=%d installer=%d, want zero", confirmation.calls, waiter.calls, installer.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceCloseFailuresDoNotInstallOrModifyUserRoots(t *testing.T) {
|
|
errConfirmation := errors.New("confirmation unavailable")
|
|
errWait := errors.New("snapshot unavailable")
|
|
tests := []struct {
|
|
name string
|
|
context func() context.Context
|
|
confirm recordingConfirmation
|
|
wait recordingExitWaiter
|
|
wantErr error
|
|
wantCode FailureCode
|
|
}{
|
|
{
|
|
name: "close declined",
|
|
context: context.Background,
|
|
confirm: recordingConfirmation{confirmed: false},
|
|
wantErr: ErrCloseDeclined,
|
|
wantCode: FailureCodeCloseDeclined,
|
|
},
|
|
{
|
|
name: "confirmation unavailable",
|
|
context: context.Background,
|
|
confirm: recordingConfirmation{err: errConfirmation},
|
|
wantErr: errConfirmation,
|
|
wantCode: FailureCodeCloseConfirmationFailed,
|
|
},
|
|
{
|
|
name: "wait canceled",
|
|
context: context.Background,
|
|
confirm: recordingConfirmation{confirmed: true},
|
|
wait: recordingExitWaiter{err: context.Canceled},
|
|
wantErr: context.Canceled,
|
|
wantCode: FailureCodeExitWaitCanceled,
|
|
},
|
|
{
|
|
name: "wait timed out",
|
|
context: context.Background,
|
|
confirm: recordingConfirmation{confirmed: true},
|
|
wait: recordingExitWaiter{err: context.DeadlineExceeded},
|
|
wantErr: context.DeadlineExceeded,
|
|
wantCode: FailureCodeExitWaitTimedOut,
|
|
},
|
|
{
|
|
name: "wait unavailable",
|
|
context: context.Background,
|
|
confirm: recordingConfirmation{confirmed: true},
|
|
wait: recordingExitWaiter{err: errWait},
|
|
wantErr: errWait,
|
|
wantCode: FailureCodeExitWaitFailed,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
store, appRoot := seedInstalledApp(t, "1.0.0")
|
|
data := filepath.Join(filepath.Dir(appRoot), "..", "data", "test-app", "data.txt")
|
|
license := filepath.Join(filepath.Dir(appRoot), "..", "licenses", "license.txt")
|
|
for _, path := range []string{data, license} {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
t.Fatalf("MkdirAll(%q): %v", path, err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(path), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(%q): %v", path, err)
|
|
}
|
|
}
|
|
installer := &recordingInstaller{}
|
|
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
|
return true, nil
|
|
}), &test.confirm, &test.wait, installer)
|
|
|
|
_, err := service.Update(test.context(), updateRequest("1.1.0"))
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("Update() error = %v, want %v", err, test.wantErr)
|
|
}
|
|
if code := updateErrorCode(t, err); code != test.wantCode {
|
|
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
|
}
|
|
if installer.calls != 0 {
|
|
t.Fatalf("installer calls = %d, want 0", installer.calls)
|
|
}
|
|
for _, path := range []string{data, license} {
|
|
contents, readErr := os.ReadFile(path)
|
|
if readErr != nil || string(contents) != path {
|
|
t.Fatalf("protected file %q = %q, err=%v", path, contents, readErr)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceTargetStateFailureDoesNotRequestCloseOrInstall(t *testing.T) {
|
|
store, _ := seedInstalledApp(t, "1.0.0")
|
|
expected := errors.New("Toolhelp unavailable")
|
|
confirmation := &recordingConfirmation{confirmed: true}
|
|
waiter := &recordingExitWaiter{}
|
|
installer := &recordingInstaller{}
|
|
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
|
return false, expected
|
|
}), confirmation, waiter, installer)
|
|
|
|
_, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
|
if !errors.Is(err, expected) {
|
|
t.Fatalf("Update() error = %v, want %v", err, expected)
|
|
}
|
|
if code := updateErrorCode(t, err); code != FailureCodeTargetStateUnavailable {
|
|
t.Fatalf("code = %q, want %q", code, FailureCodeTargetStateUnavailable)
|
|
}
|
|
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 0 {
|
|
t.Fatalf("side effects confirmation=%d waiter=%d installer=%d, want zero", confirmation.calls, waiter.calls, installer.calls)
|
|
}
|
|
}
|
|
|
|
func TestServicePreservesInstallErrorForRestartRace(t *testing.T) {
|
|
store, _ := seedInstalledApp(t, "1.0.0")
|
|
installErr := &install.InstallError{Stage: install.InstallStagePreflight, Code: install.FailureCodeAppRunning, Err: install.ErrTargetRunning}
|
|
installer := &recordingInstaller{err: installErr}
|
|
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
|
return false, nil
|
|
}), &recordingConfirmation{}, &recordingExitWaiter{}, installer)
|
|
|
|
_, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
|
if !errors.Is(err, install.ErrTargetRunning) {
|
|
t.Fatalf("Update() error = %v, want preserved %v", err, install.ErrTargetRunning)
|
|
}
|
|
if code := updateErrorCode(t, err); code != FailureCodeInstallFailed {
|
|
t.Fatalf("code = %q, want %q", code, FailureCodeInstallFailed)
|
|
}
|
|
}
|
|
|
|
func TestNewServiceRequiresAllDependenciesAndBoundedTimeout(t *testing.T) {
|
|
store, _ := seedInstalledApp(t, "1.0.0")
|
|
config := ServiceConfig{
|
|
Records: store,
|
|
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
|
|
Confirmation: &recordingConfirmation{},
|
|
ExitWaiter: &recordingExitWaiter{},
|
|
Installer: &recordingInstaller{},
|
|
CloseTimeout: 30 * time.Second,
|
|
}
|
|
for _, test := range []struct {
|
|
name string
|
|
mutate func(*ServiceConfig)
|
|
}{
|
|
{"records", func(config *ServiceConfig) { config.Records = nil }},
|
|
{"target state", func(config *ServiceConfig) { config.TargetState = nil }},
|
|
{"confirmation", func(config *ServiceConfig) { config.Confirmation = nil }},
|
|
{"exit waiter", func(config *ServiceConfig) { config.ExitWaiter = nil }},
|
|
{"installer", func(config *ServiceConfig) { config.Installer = nil }},
|
|
{"timeout too short", func(config *ServiceConfig) { config.CloseTimeout = time.Millisecond }},
|
|
{"timeout too long", func(config *ServiceConfig) { config.CloseTimeout = 11 * time.Minute }},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
candidate := config
|
|
test.mutate(&candidate)
|
|
if _, err := NewService(candidate); !errors.Is(err, ErrUpdateConfig) {
|
|
t.Fatalf("NewService() error = %v, want %v", err, ErrUpdateConfig)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func seedInstalledApp(t *testing.T, version string) (*storage.InstalledAppStore, string) {
|
|
t.Helper()
|
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
|
store := storage.NewInstalledAppStore(appsRoot)
|
|
record := storage.InstalledApp{
|
|
SchemaVersion: 1,
|
|
ID: "test-app",
|
|
Version: version,
|
|
Architecture: "amd64",
|
|
Channel: "stable",
|
|
Entrypoint: "bin/App.exe",
|
|
WorkingDirectory: "bin",
|
|
MinOS: "windows-10",
|
|
Files: []storage.InstalledFile{{
|
|
Path: "bin/App.exe",
|
|
Size: 1,
|
|
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
|
}},
|
|
}
|
|
if err := store.Write(record); err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
appRoot := filepath.Join(appsRoot, record.ID)
|
|
entrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
|
|
if err := os.MkdirAll(filepath.Dir(entrypoint), 0o700); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(entrypoint, []byte("old executable"), 0o700); err != nil {
|
|
t.Fatalf("WriteFile() error = %v", err)
|
|
}
|
|
return store, appRoot
|
|
}
|
|
|
|
func updateRequest(version string) Request {
|
|
pkg := catalog.Package{URL: "https://download.invalid/test-app.zip", Size: 1, SHA256: "0000000000000000000000000000000000000000000000000000000000000000", Signature: "placeholder"}
|
|
return Request{Install: install.InstallRequest{
|
|
Entry: catalog.Entry{
|
|
Installable: true,
|
|
Package: &pkg,
|
|
App: catalog.App{
|
|
ID: "test-app",
|
|
Version: version,
|
|
Packages: map[catalog.Architecture]catalog.Package{
|
|
catalog.ArchitectureAMD64: pkg,
|
|
},
|
|
},
|
|
},
|
|
Architecture: catalog.ArchitectureAMD64,
|
|
DownloadPath: "untrusted-candidate.download",
|
|
}}
|
|
}
|
|
|
|
func newService(t *testing.T, records InstalledAppResolver, target TargetStateChecker, confirmation CloseConfirmer, waiter ExitWaiter, installer InstallRunner) *Service {
|
|
t.Helper()
|
|
service, err := NewService(ServiceConfig{
|
|
Records: records,
|
|
TargetState: target,
|
|
Confirmation: confirmation,
|
|
ExitWaiter: waiter,
|
|
Installer: installer,
|
|
CloseTimeout: 30 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewService() error = %v", err)
|
|
}
|
|
return service
|
|
}
|
|
|
|
func updateErrorCode(t *testing.T, err error) FailureCode {
|
|
t.Helper()
|
|
var updateErr *Error
|
|
if !errors.As(err, &updateErr) {
|
|
t.Fatalf("error = %v, want update Error", err)
|
|
}
|
|
return updateErr.Code
|
|
}
|
|
|
|
type resolverFunc func(string) (storage.InstalledApp, string, error)
|
|
|
|
func (resolver resolverFunc) ResolveCurrent(appID string) (storage.InstalledApp, string, error) {
|
|
return resolver(appID)
|
|
}
|
|
|
|
type targetStateFunc func(string, string) (bool, error)
|
|
|
|
func (checker targetStateFunc) IsRunning(appID, entrypoint string) (bool, error) {
|
|
return checker(appID, entrypoint)
|
|
}
|
|
|
|
type recordingConfirmation struct {
|
|
confirmed bool
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (confirmation *recordingConfirmation) ConfirmClose(context.Context, string) (bool, error) {
|
|
confirmation.calls++
|
|
return confirmation.confirmed, confirmation.err
|
|
}
|
|
|
|
type recordingExitWaiter struct {
|
|
err error
|
|
calls int
|
|
appID string
|
|
entrypoint string
|
|
timeout time.Duration
|
|
}
|
|
|
|
func (waiter *recordingExitWaiter) WaitForExit(_ context.Context, appID, entrypoint string, timeout time.Duration) error {
|
|
waiter.calls++
|
|
waiter.appID = appID
|
|
waiter.entrypoint = entrypoint
|
|
waiter.timeout = timeout
|
|
return waiter.err
|
|
}
|
|
|
|
type recordingInstaller struct {
|
|
result install.InstallResult
|
|
err error
|
|
calls int
|
|
request install.InstallRequest
|
|
}
|
|
|
|
func (installer *recordingInstaller) Install(request install.InstallRequest) (install.InstallResult, error) {
|
|
installer.calls++
|
|
installer.request = request
|
|
return installer.result, installer.err
|
|
}
|