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 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) } if _, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: forged.ID, ExpectedConfigVersion: forged.ConfigVersion, IdempotencyKey: "start-forged-complete"}); err == nil || !strings.Contains(err.Error(), "rcon.password") { t.Fatalf("expected derived missing keys to override stored complete status, got %v", 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) } if _, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-without-binding"}); err == nil || !strings.Contains(err.Error(), "runtime profile is not configured") { t.Fatalf("expected missing binding to block start, got %v", 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) } if _, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-incomplete-binding"}); err == nil || !strings.Contains(err.Error(), "rcon.password") { t.Fatalf("expected missing logical key to block start, got %v", 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"}}}, } }