Files
browser/platform/service/plugin_operations_test.go

126 lines
6.5 KiB
Go

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 TestAIConfigRecommendationDispatchesConfigWriteImmediately(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.ConfigExecution == nil || response.ConfigExecution.Job.ID == "" {
t.Fatalf("expected direct config write dispatch, got %+v", response)
}
if response.ConfigExecution.Job.Capability != domain.JobCapabilityConfigWrite || response.ConfigExecution.Status != "queued" {
t.Fatalf("unexpected AI config execution: %+v", response.ConfigExecution)
}
jobs, _ := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if len(jobs) != 1 || jobs[0].ID != response.ConfigExecution.Job.ID {
t.Fatalf("AI recommendation must dispatch exactly one config write job: %+v", jobs)
}
repeated, 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 || repeated.ConfigExecution == nil || repeated.ConfigExecution.Job.ID != response.ConfigExecution.Job.ID {
t.Fatalf("repeat AI request must reuse the idempotent job: %+v err=%v", repeated, 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
}