289 lines
8.6 KiB
Go
289 lines
8.6 KiB
Go
package launch
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"softbox.local/core/storage"
|
|
)
|
|
|
|
func TestServiceStartsOnlyVerifiedCurrentEntrypoint(t *testing.T) {
|
|
store, appRoot := seedInstalledApp(t)
|
|
launcher := &recordingLauncher{pid: 42}
|
|
service := newService(t, store, launcher)
|
|
|
|
result, err := service.Start(Request{AppID: "test-app"})
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
if result != (Result{AppID: "test-app", PID: 42}) {
|
|
t.Fatalf("result = %#v", result)
|
|
}
|
|
wantEntrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
|
|
if launcher.command.Entrypoint != wantEntrypoint ||
|
|
launcher.command.WorkingDirectory != filepath.Join(appRoot, "current", "bin") ||
|
|
!launcher.command.RequiresAdmin {
|
|
t.Fatalf("command = %#v", launcher.command)
|
|
}
|
|
}
|
|
|
|
func TestServiceRejectsUnsafeOrUnavailableLaunchStates(t *testing.T) {
|
|
errCompatibility := errors.New("compatibility unavailable")
|
|
errAuthorization := errors.New("authorization unavailable")
|
|
errTargetState := errors.New("target state unavailable")
|
|
errStart := errors.New("start failed")
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*storage.InstalledApp, string)
|
|
service func(*Service)
|
|
wantErr error
|
|
wantCode FailureCode
|
|
}{
|
|
{
|
|
name: "missing legacy metadata",
|
|
mutate: func(record *storage.InstalledApp, _ string) {
|
|
record.Entrypoint = ""
|
|
},
|
|
wantErr: ErrLaunchMetadata,
|
|
wantCode: FailureCodeLaunchMetadataInvalid,
|
|
},
|
|
{
|
|
name: "entrypoint is absent",
|
|
mutate: func(_ *storage.InstalledApp, appRoot string) {
|
|
if err := os.Remove(filepath.Join(appRoot, "current", "bin", "App.exe")); err != nil {
|
|
t.Fatalf("remove entrypoint: %v", err)
|
|
}
|
|
},
|
|
wantErr: ErrEntrypointMissing,
|
|
wantCode: FailureCodeEntrypointMissing,
|
|
},
|
|
{
|
|
name: "unsafe current layout",
|
|
service: func(service *Service) {
|
|
service.records = resolverFunc(func(string) (storage.InstalledApp, string, error) {
|
|
return storage.InstalledApp{}, "", storage.ErrStorageLayoutUnsafe
|
|
})
|
|
},
|
|
wantErr: ErrLaunchTargetUnsafe,
|
|
wantCode: FailureCodeLaunchTargetUnsafe,
|
|
},
|
|
{
|
|
name: "incompatible system",
|
|
service: func(service *Service) {
|
|
service.compatibility = compatibilityFunc(func(string) (bool, error) { return false, nil })
|
|
},
|
|
wantErr: ErrAppIncompatible,
|
|
wantCode: FailureCodeAppIncompatible,
|
|
},
|
|
{
|
|
name: "compatibility failure",
|
|
service: func(service *Service) {
|
|
service.compatibility = compatibilityFunc(func(string) (bool, error) { return false, errCompatibility })
|
|
},
|
|
wantErr: errCompatibility,
|
|
wantCode: FailureCodeCompatibilityUnavailable,
|
|
},
|
|
{
|
|
name: "unauthorized",
|
|
service: func(service *Service) {
|
|
service.authorization = authorizationFunc(func(string) (bool, error) { return false, nil })
|
|
},
|
|
wantErr: ErrLaunchUnauthorized,
|
|
wantCode: FailureCodeLaunchUnauthorized,
|
|
},
|
|
{
|
|
name: "authorization failure",
|
|
service: func(service *Service) {
|
|
service.authorization = authorizationFunc(func(string) (bool, error) { return false, errAuthorization })
|
|
},
|
|
wantErr: errAuthorization,
|
|
wantCode: FailureCodeAuthorizationFailed,
|
|
},
|
|
{
|
|
name: "already running",
|
|
service: func(service *Service) {
|
|
service.targetState = targetStateFunc(func(string, string) (bool, error) { return true, nil })
|
|
},
|
|
wantErr: ErrAppRunning,
|
|
wantCode: FailureCodeAppRunning,
|
|
},
|
|
{
|
|
name: "target state failure",
|
|
service: func(service *Service) {
|
|
service.targetState = targetStateFunc(func(string, string) (bool, error) { return false, errTargetState })
|
|
},
|
|
wantErr: errTargetState,
|
|
wantCode: FailureCodeTargetStateUnavailable,
|
|
},
|
|
{
|
|
name: "launcher failure",
|
|
service: func(service *Service) {
|
|
service.launcher = &recordingLauncher{err: errStart}
|
|
},
|
|
wantErr: errStart,
|
|
wantCode: FailureCodeLaunchFailed,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
store, appRoot := seedInstalledApp(t)
|
|
record, found, err := store.Read("test-app")
|
|
if err != nil || !found {
|
|
t.Fatalf("Read() found=%t err=%v", found, err)
|
|
}
|
|
if test.mutate != nil {
|
|
test.mutate(&record, appRoot)
|
|
if err := store.Write(record); err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
}
|
|
launcher := &recordingLauncher{pid: 42}
|
|
service := newService(t, store, launcher)
|
|
if test.service != nil {
|
|
test.service(service)
|
|
}
|
|
|
|
_, err = service.Start(Request{AppID: "test-app"})
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("Start() error = %v, want %v", err, test.wantErr)
|
|
}
|
|
if code := launchErrorCode(t, err); code != test.wantCode {
|
|
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
|
}
|
|
if launcher.calls != 0 {
|
|
t.Fatalf("launcher calls = %d, want 0", launcher.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewServiceRequiresEveryDependency(t *testing.T) {
|
|
store, _ := seedInstalledApp(t)
|
|
config := ServiceConfig{
|
|
Records: store,
|
|
Compatibility: compatibilityFunc(func(string) (bool, error) { return true, nil }),
|
|
Authorization: authorizationFunc(func(string) (bool, error) { return true, nil }),
|
|
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
|
|
Launcher: &recordingLauncher{pid: 1},
|
|
}
|
|
for _, test := range []struct {
|
|
name string
|
|
mutate func(*ServiceConfig)
|
|
}{
|
|
{"records", func(config *ServiceConfig) { config.Records = nil }},
|
|
{"compatibility", func(config *ServiceConfig) { config.Compatibility = nil }},
|
|
{"authorization", func(config *ServiceConfig) { config.Authorization = nil }},
|
|
{"target state", func(config *ServiceConfig) { config.TargetState = nil }},
|
|
{"launcher", func(config *ServiceConfig) { config.Launcher = nil }},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
candidate := config
|
|
test.mutate(&candidate)
|
|
if _, err := NewService(candidate); !errors.Is(err, ErrLaunchConfig) {
|
|
t.Fatalf("NewService() error = %v, want %v", err, ErrLaunchConfig)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func seedInstalledApp(t *testing.T) (*storage.InstalledAppStore, string) {
|
|
t.Helper()
|
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
|
store := storage.NewInstalledAppStore(appsRoot)
|
|
record := storage.InstalledApp{
|
|
SchemaVersion: 1,
|
|
ID: "test-app",
|
|
Version: "1.2.3",
|
|
Architecture: "amd64",
|
|
Channel: "stable",
|
|
Entrypoint: "bin/App.exe",
|
|
WorkingDirectory: "bin",
|
|
MinOS: "windows-10",
|
|
RequiresAdmin: true,
|
|
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("x"), 0o700); err != nil {
|
|
t.Fatalf("WriteFile() error = %v", err)
|
|
}
|
|
return store, appRoot
|
|
}
|
|
|
|
func newService(t *testing.T, records InstalledAppResolver, launcher ProcessLauncher) *Service {
|
|
t.Helper()
|
|
service, err := NewService(ServiceConfig{
|
|
Records: records,
|
|
Compatibility: compatibilityFunc(func(string) (bool, error) { return true, nil }),
|
|
Authorization: authorizationFunc(func(string) (bool, error) { return true, nil }),
|
|
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
|
|
Launcher: launcher,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewService() error = %v", err)
|
|
}
|
|
return service
|
|
}
|
|
|
|
func launchErrorCode(t *testing.T, err error) FailureCode {
|
|
t.Helper()
|
|
var launchErr *Error
|
|
if !errors.As(err, &launchErr) {
|
|
t.Fatalf("error = %v, want launch Error", err)
|
|
}
|
|
return launchErr.Code
|
|
}
|
|
|
|
type compatibilityFunc func(string) (bool, error)
|
|
|
|
type resolverFunc func(string) (storage.InstalledApp, string, error)
|
|
|
|
func (resolver resolverFunc) ResolveCurrent(appID string) (storage.InstalledApp, string, error) {
|
|
return resolver(appID)
|
|
}
|
|
|
|
func (checker compatibilityFunc) IsCompatible(minOS string) (bool, error) {
|
|
return checker(minOS)
|
|
}
|
|
|
|
type authorizationFunc func(string) (bool, error)
|
|
|
|
func (checker authorizationFunc) IsAuthorized(appID string) (bool, error) {
|
|
return checker(appID)
|
|
}
|
|
|
|
type targetStateFunc func(string, string) (bool, error)
|
|
|
|
func (checker targetStateFunc) IsRunning(appID, entrypoint string) (bool, error) {
|
|
return checker(appID, entrypoint)
|
|
}
|
|
|
|
type recordingLauncher struct {
|
|
command Command
|
|
pid int
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (launcher *recordingLauncher) Start(command Command) (int, error) {
|
|
launcher.calls++
|
|
launcher.command = command
|
|
if launcher.err != nil {
|
|
return 0, launcher.err
|
|
}
|
|
return launcher.pid, nil
|
|
}
|