package service import ( "errors" "strings" "testing" "browser.local/platform/domain" "browser.local/platform/repo" ) func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) { svc, session, instance := newPluginOperationsFixture(t) request := domain.PluginLifecycleRequest{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Operation: domain.PluginLifecycleOperationInstall, TargetVersion: "1.0.0", IdempotencyKey: "plugin-install-v1"} first, err := svc.RunPluginLifecycleForSession(session, request) if err != nil { t.Fatalf("dispatch plugin install: %v", err) } second, err := svc.RunPluginLifecycleForSession(session, request) if err != nil { t.Fatalf("repeat plugin install: %v", err) } if first.Job.ID == "" || second.Job.ID != first.Job.ID { t.Fatalf("expected one idempotent job, got first=%+v second=%+v", first.Job, second.Job) } drift := request drift.TargetVersion = "1.1.0" if _, err := svc.RunPluginLifecycleForSession(session, drift); err == nil || !strings.Contains(err.Error(), "idempotencyKey") { t.Fatalf("expected immutable input conflict, got %v", err) } jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) if err != nil || len(jobs) != 1 { t.Fatalf("expected exactly one lifecycle job, got %+v err=%v", jobs, err) } } func TestPluginLifecycleBridgeDispatchesBoundedJob(t *testing.T) { svc, session, instance := newPluginOperationsFixture(t) plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { t.Fatalf("get plugin: %v", err) } plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.lifecycle") plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionPluginLifecycle)) plugin.Pages = append(plugin.Pages, domain.GamePluginPage{ Key: "operations", Title: "Operations", Path: "/operations", Permissions: []string{"server.lifecycle"}, BridgeActions: []string{string(domain.PluginBridgeActionPluginLifecycle)}, }) if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("update plugin bridge declaration: %v", err) } response, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{ RequestID: "bridge-plugin-install", PluginID: plugin.ID, RouteKey: "operations", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionPluginLifecycle, Payload: map[string]string{"operation": "install", "targetVersion": plugin.Version, "idempotencyKey": "bridge-plugin-install-v1"}, }) if err != nil { t.Fatalf("execute lifecycle bridge: %v", err) } if response.Status != "queued" || response.Result["jobId"] == "" || response.Result["installationId"] == "" { t.Fatalf("expected plugin lifecycle job, got %+v", response) } serialized := strings.ToLower(strings.Join([]string{ response.Result["jobId"], response.Result["installationId"], response.Result["currentState"], response.Result["desiredState"], }, " ")) for _, forbidden := range []string{"password", "apikey", "token", "secret://", "baseurl", "hostpath", "socket", "pid", "dsn", "rcon", "runendpoint"} { if strings.Contains(serialized, forbidden) { t.Fatalf("bridge lifecycle result exposed forbidden fragment %q: %s", forbidden, serialized) } } } func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testing.T) { svc, session, instance := newPluginOperationsFixture(t) provider, err := svc.CreateAIProvider(domain.AIProvider{ID: "ai-local", Name: "Local AI", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:11434/v1", Models: []string{"test-model"}, DefaultModel: "test-model", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, Status: domain.AIProviderStatusActive, RedactionPolicy: "strict"}) if err != nil { t.Fatalf("create provider: %v", err) } response, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-1", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"}) if err != nil { t.Fatalf("invoke AI: %v", err) } if response.ConfigRecommendation == nil || response.ConfigRecommendation.DiffID == "" { t.Fatalf("expected persisted config recommendation, got %+v", response) } jobs, _ := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) if len(jobs) != 0 { t.Fatalf("AI recommendation must not dispatch before approval: %+v", jobs) } approved, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"}) if err != nil { t.Fatalf("approve AI diff: %v", err) } if approved.Preview.State != domain.AIConfigDiffStateApproved || approved.Dispatch.Job.ID == "" { t.Fatalf("expected approved diff and queued job, got %+v", approved) } repeated, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"}) if err != nil || repeated.Dispatch.Job.ID != approved.Dispatch.Job.ID { t.Fatalf("repeat approval must return original job: %+v err=%v", repeated, err) } jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) if len(jobs) != 1 { t.Fatalf("approval must dispatch exactly one job, got %+v", jobs) } staleResponse, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-stale", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"}) if err != nil { t.Fatalf("invoke stale AI candidate: %v", err) } stored, err := svc.store.ServerInstances().Get(instance.ID) if err != nil { t.Fatalf("get server: %v", err) } stored.ConfigVersion++ if err := svc.store.ServerInstances().Update(stored); err != nil { t.Fatalf("advance config revision: %v", err) } if _, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: staleResponse.ConfigRecommendation.DiffID, IdempotencyKey: "approve-stale"}); err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") { t.Fatalf("expected stale revision rejection, got %v", err) } } func newPluginOperationsFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) { t.Helper() svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) plugin.SupportedOS = []string{"linux"} if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("update plugin platform: %v", err) } endpoint.Platform = "linux" endpoint.Architecture = "amd64" endpoint.LastHeartbeatAt = fixedTime if err := svc.store.RunEndpoints().Update(endpoint); err != nil { t.Fatalf("update endpoint metadata: %v", err) } session := createServiceUserAndLogin(t, svc, domain.User{ID: "production-owner", DisplayName: "Production Owner", Email: "production-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "production-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Production Server", State: domain.ServerInstanceStateReady}) if err != nil { t.Fatalf("create server: %v", err) } binding := domain.RuntimeBinding{ID: "runtime-binding-" + instance.ID, ServerInstanceID: instance.ID, PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: fixedTime, UpdatedAt: fixedTime} if err := svc.store.RuntimeBindings().Create(binding); err != nil && !errors.Is(err, repo.ErrDuplicate) { t.Fatalf("create runtime binding: %v", err) } return svc, session, instance }