150 lines
7.6 KiB
Go
150 lines
7.6 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", "confirmed": "false"},
|
|
})
|
|
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
|
|
}
|