Files
browser/platform/service/runtime_bindings_test.go
T

182 lines
11 KiB
Go

package service
import (
"path/filepath"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestRegisteredRuntimeProfilesSurviveFileStoreReload(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("create file store: %v", err)
}
svc := newCoreService(store, func() time.Time { return fixedTime })
registration := validPluginManifestRegistration()
registration.Manifest.RuntimeProfiles = requiredRuntimeProfilesFixture()
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "remote.run.rcon.command")
registered, err := svc.RegisterGamePluginManifest(registration)
if err != nil {
t.Fatalf("register manifest: %v", err)
}
if len(registered.RuntimeProfiles.LifecycleProfiles) != 1 {
t.Fatalf("expected registered profiles, got %+v", registered.RuntimeProfiles)
}
reloaded, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("reload file store: %v", err)
}
plugin, err := reloaded.GamePlugins().Get(registration.Manifest.ID)
if err != nil {
t.Fatalf("get reloaded plugin: %v", err)
}
if plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys[0] != "rcon" || plugin.RuntimeProfiles.TransportProfiles[0].TargetKey != "rcon.password" {
t.Fatalf("runtime profiles were not preserved: %+v", plugin.RuntimeProfiles)
}
}
func TestLegacyLocalRuntimeBindingCanonicalizesToUnambiguousLocalProcessProfile(t *testing.T) {
plugin := domain.GamePlugin{RuntimeProfiles: domain.GamePluginRuntimeProfiles{
LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "run-local", Mode: "local-process"}},
}}
binding, err := normalizeRuntimeBinding(plugin, domain.RuntimeBinding{ID: "binding-legacy", ServerInstanceID: "server-legacy", PluginID: "plugin-legacy", PluginVersion: "1.0.0", ProfileKey: "local", Bindings: map[string]string{}, CreatedAt: fixedTime, UpdatedAt: fixedTime})
if err != nil {
t.Fatalf("normalize legacy runtime binding: %v", err)
}
if binding.ProfileKey != "run-local" || binding.Mode != "local-process" {
t.Fatalf("expected canonical local-process profile, got %+v", binding)
}
}
func TestLegacyLocalRuntimeBindingDoesNotGuessAmbiguousProfiles(t *testing.T) {
profiles := domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{
{Key: "run-local", Mode: "local-process"},
{Key: "run-local-alt", Mode: "local-process"},
}}
if _, ok := runtimeLifecycleProfile(profiles, "local"); ok {
t.Fatal("expected ambiguous legacy local profile lookup to fail")
}
}
func TestRuntimeBindingValidationAndLifecycleGating(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.RuntimeProfiles = requiredRuntimeProfilesFixture()
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin profiles: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "runtime-owner", DisplayName: "Runtime Owner", Email: "runtime-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "runtime-other", DisplayName: "Runtime Other", Email: "runtime-other@example.test", Roles: []string{"server-admin"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "runtime-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Runtime Server", State: domain.ServerInstanceStateReady})
if err != nil {
t.Fatalf("create server: %v", err)
}
forged, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "runtime-forged-complete", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Forged Complete", State: domain.ServerInstanceStateReady})
if err != nil {
t.Fatalf("create forged-status server: %v", err)
}
if err := svc.store.RuntimeBindings().Create(domain.RuntimeBinding{ID: "runtime-binding-" + forged.ID, ServerInstanceID: forged.ID, PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: "local", Mode: "local-process", Bindings: map[string]string{}, Status: domain.RuntimeBindingStatusComplete, CreatedAt: fixedTime, UpdatedAt: fixedTime}); err != nil {
t.Fatalf("store forged complete binding: %v", err)
}
forgedView, err := svc.GetServerRuntimeBindingForSession(ownerSession, forged.ID)
if err != nil || forgedView.Status != domain.RuntimeBindingStatusIncomplete || len(forgedView.MissingKeys) != 2 || !containsString(forgedView.MissingKeys, "server-root") || !containsString(forgedView.MissingKeys, "rcon.password") {
t.Fatalf("expected derived missing keys to override stored complete status in the readiness view, view=%+v err=%v", forgedView, err)
}
view, err := svc.GetServerRuntimeBindingForSession(ownerSession, instance.ID)
if err != nil || view.Configured || view.Reason != "runtime profile is not configured" {
t.Fatalf("unexpected unconfigured view: view=%+v err=%v", view, err)
}
withoutBinding, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-without-binding"})
if err != nil || withoutBinding.Job.TargetKey != "actions/start.json" || withoutBinding.Job.ExecutionInput.WorkspaceScope != "" {
t.Fatalf("expected plugin lifecycle start without manual runtime binding, result=%+v err=%v", withoutBinding, err)
}
if _, err := svc.UpdateServerRuntimeBindingForSession(otherSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local"}); err != ErrForbidden {
t.Fatalf("expected non-owner update forbidden, got %v", err)
}
if _, err := svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "unknown"}); err == nil {
t.Fatal("expected undeclared profile rejection")
}
if _, err := svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "/srv/game"}}); err == nil {
t.Fatal("expected raw host path rejection")
}
view, err = svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}})
if err != nil || view.Status != domain.RuntimeBindingStatusIncomplete || len(view.MissingKeys) != 1 || view.MissingKeys[0] != "rcon.password" {
t.Fatalf("unexpected incomplete binding: view=%+v err=%v", view, err)
}
incompleteStart, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-incomplete-binding"})
if err != nil || incompleteStart.Job.TargetKey != "actions/start.json" || incompleteStart.Job.ExecutionInput.WorkspaceScope != "local" {
t.Fatalf("expected plugin lifecycle start to tolerate incomplete optional bindings, result=%+v err=%v", incompleteStart, err)
}
view, err = svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"rcon.password": "secret://runtime-server/rcon"}})
if err != nil || view.Status != domain.RuntimeBindingStatusComplete || len(view.MissingKeys) != 0 {
t.Fatalf("unexpected complete binding: view=%+v err=%v", view, err)
}
result, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-complete-binding"})
if err != nil || result.Job.TargetKey != "actions/start.json" || result.Job.ExecutionInput.WorkspaceScope != "local" {
t.Fatalf("expected complete binding to permit start, result=%+v err=%v", result, err)
}
view, err = svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root-updated"}})
if err != nil || view.Status != domain.RuntimeBindingStatusComplete {
t.Fatalf("expected active server runtime binding edits to remain available, view=%+v err=%v", view, err)
}
instance.State = domain.ServerInstanceStateDeleted
if err := svc.store.ServerInstances().Update(instance); err != nil {
t.Fatalf("mark deleted: %v", err)
}
if _, err := svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}); err == nil || !strings.Contains(err.Error(), "deleted") {
t.Fatalf("expected deleted server runtime binding edit rejection, got %v", err)
}
}
func TestRuntimeBindingLogSourcesAreConfigurableButNotRequired(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.RuntimeProfiles = requiredRuntimeProfilesFixture()
plugin.RuntimeProfiles.LogSources = []domain.RuntimeLogSource{
{Key: "console-stdout", Kind: "process.stdout", TargetKey: "process/server", StreamKey: "console.stdout", CursorKind: "sequence", RetentionDays: 30},
{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30},
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin profiles: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "runtime-logs-owner", DisplayName: "Runtime Logs Owner", Email: "runtime-logs-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "runtime-log-sources", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Runtime Log Sources", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
view, err := svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-log-sources/rcon"}})
if err != nil || view.Status != domain.RuntimeBindingStatusComplete || len(view.MissingKeys) != 0 {
t.Fatalf("log sources should not block runtime readiness: view=%+v err=%v", view, err)
}
seenLogs := map[string]domain.RuntimeBindingKeyView{}
for _, key := range view.Keys {
if strings.HasPrefix(key.Key, "logs/") || strings.HasPrefix(key.Key, "process/") {
seenLogs[key.Key] = key
}
}
for _, key := range []string{"logs/latest", "process/server"} {
item, ok := seenLogs[key]
if !ok || item.Required || item.Configured {
t.Fatalf("expected optional unconfigured log key %s, seen=%+v view=%+v", key, seenLogs, view)
}
}
}
func requiredRuntimeProfilesFixture() domain.GamePluginRuntimeProfiles {
return domain.GamePluginRuntimeProfiles{
Discovery: []domain.RuntimeDiscoveryProbe{{Key: "server-root-check", Kind: "file.exists", TargetKey: "server-root", Required: true}},
LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}, TransportKeys: []string{"rcon"}}},
TransportProfiles: []domain.RuntimeTransportProfile{{Key: "rcon", Kind: "rcon", TargetKey: "rcon.password", Capabilities: []string{"remote.run.rcon.command"}}},
}
}