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 }