2286 lines
100 KiB
Go
2286 lines
100 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
var fixedTime = time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
|
|
|
func TestCoreServiceCreateListGetWorkflows(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
|
|
user, err := svc.CreateUser(domain.User{
|
|
ID: "user-1",
|
|
DisplayName: "Operator",
|
|
Roles: []string{"admin"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
if user.Status != domain.UserStatusActive || !user.CreatedAt.Equal(fixedTime) {
|
|
t.Fatalf("expected user defaults, got %+v", user)
|
|
}
|
|
if _, err := svc.GetUser(user.ID); err != nil {
|
|
t.Fatalf("get user: %v", err)
|
|
}
|
|
users, err := svc.ListUsers(domain.UserFilter{Status: domain.UserStatusActive})
|
|
if err != nil || len(users) != 1 {
|
|
t.Fatalf("list users: len=%d err=%v", len(users), err)
|
|
}
|
|
|
|
generatedUser, err := svc.CreateUser(domain.User{
|
|
DisplayName: "Generated User",
|
|
Email: "generated@example.test",
|
|
Roles: []string{"server-admin"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create generated user: %v", err)
|
|
}
|
|
if generatedUser.ID != "user-generated-example-test" {
|
|
t.Fatalf("expected generated user id from email, got %q", generatedUser.ID)
|
|
}
|
|
|
|
provider, err := svc.CreateAIProvider(validProvider())
|
|
if err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
if provider.APIKeyRef != "secret://providers/openai" {
|
|
t.Fatalf("expected provider key reference only, got %+v", provider)
|
|
}
|
|
if _, err := svc.GetAIProvider(provider.ID); err != nil {
|
|
t.Fatalf("get provider: %v", err)
|
|
}
|
|
providers, err := svc.ListAIProviders(domain.AIProviderFilter{Status: domain.AIProviderStatusActive})
|
|
if err != nil || len(providers) != 1 {
|
|
t.Fatalf("list providers: len=%d err=%v", len(providers), err)
|
|
}
|
|
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
if _, err := svc.GetGamePlugin(plugin.ID); err != nil {
|
|
t.Fatalf("get plugin: %v", err)
|
|
}
|
|
plugins, err := svc.ListGamePlugins(domain.GamePluginFilter{Status: domain.GamePluginStatusInstalled})
|
|
if err != nil || len(plugins) != 1 {
|
|
t.Fatalf("list plugins: len=%d err=%v", len(plugins), err)
|
|
}
|
|
if _, err := svc.GetRunEndpoint(endpoint.ID); err != nil {
|
|
t.Fatalf("get endpoint: %v", err)
|
|
}
|
|
endpoints, err := svc.ListRunEndpoints(domain.RunEndpointFilter{Status: domain.RunEndpointStatusOnline})
|
|
if err != nil || len(endpoints) != 1 {
|
|
t.Fatalf("list endpoints: len=%d err=%v", len(endpoints), err)
|
|
}
|
|
|
|
instance, err := svc.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-1",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "SCUM #1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server instance: %v", err)
|
|
}
|
|
if instance.PluginVersion != plugin.Version || instance.ConfigVersion != 1 || instance.State != domain.ServerInstanceStateDraft {
|
|
t.Fatalf("expected server defaults, got %+v", instance)
|
|
}
|
|
if _, err := svc.GetServerInstance(instance.ID); err != nil {
|
|
t.Fatalf("get server instance: %v", err)
|
|
}
|
|
instances, err := svc.ListServerInstances(domain.ServerInstanceFilter{PluginID: plugin.ID})
|
|
if err != nil || len(instances) != 1 {
|
|
t.Fatalf("list server instances: len=%d err=%v", len(instances), err)
|
|
}
|
|
|
|
job, err := svc.CreateJob(domain.Job{
|
|
ID: "job-1",
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Capability: "process.start",
|
|
IdempotencyKey: "idem-start",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create job: %v", err)
|
|
}
|
|
if job.State != domain.JobStateQueued || !job.CreatedAt.Equal(fixedTime) {
|
|
t.Fatalf("expected job defaults, got %+v", job)
|
|
}
|
|
if _, err := svc.GetJob(job.ID); err != nil {
|
|
t.Fatalf("get job: %v", err)
|
|
}
|
|
streams, err := svc.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
|
if err != nil || len(streams) != 2 {
|
|
t.Fatalf("expected default job stdout/stderr streams, len=%d err=%v streams=%+v", len(streams), err, streams)
|
|
}
|
|
jobs, err := svc.ListJobs(domain.JobFilter{RunEndpointID: endpoint.ID})
|
|
if err != nil || len(jobs) != 1 {
|
|
t.Fatalf("list jobs: len=%d err=%v", len(jobs), err)
|
|
}
|
|
|
|
artifact, err := svc.CreateArtifact(domain.Artifact{
|
|
ID: "artifact-1",
|
|
OwnerKind: domain.ArtifactOwnerKindJob,
|
|
OwnerID: job.ID,
|
|
SizeBytes: 128,
|
|
Checksum: "sha256:abc",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create artifact: %v", err)
|
|
}
|
|
if artifact.State != domain.ArtifactStateUploading {
|
|
t.Fatalf("expected artifact default state, got %+v", artifact)
|
|
}
|
|
if _, err := svc.GetArtifact(artifact.ID); err != nil {
|
|
t.Fatalf("get artifact: %v", err)
|
|
}
|
|
artifacts, err := svc.ListArtifacts(domain.ArtifactFilter{OwnerID: job.ID})
|
|
if err != nil || len(artifacts) != 1 {
|
|
t.Fatalf("list artifacts: len=%d err=%v", len(artifacts), err)
|
|
}
|
|
|
|
stream, err := svc.CreateLogStream(domain.LogStream{
|
|
ID: "log-1",
|
|
ServerInstanceID: instance.ID,
|
|
Source: domain.LogStreamSourceProcess,
|
|
StreamKey: "stdout",
|
|
StorageBackend: domain.LogStorageBackendLocalSegments,
|
|
RetentionPolicy: "default",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create log stream: %v", err)
|
|
}
|
|
if _, err := svc.GetLogStream(stream.ID); err != nil {
|
|
t.Fatalf("get log stream: %v", err)
|
|
}
|
|
streams, err = svc.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
|
if err != nil || len(streams) != 3 {
|
|
t.Fatalf("list log streams: len=%d err=%v", len(streams), err)
|
|
}
|
|
|
|
}
|
|
|
|
func TestCoreServiceRejectsServerCreationOnStaleRunEndpoint(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
svc.now = func() time.Time { return fixedTime.Add(runHeartbeatStaleAfter + time.Second) }
|
|
|
|
_, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-stale-run", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Stale Run Server"})
|
|
if err == nil || !strings.Contains(err.Error(), "get run endpoint dependency") || !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("expected stale Run endpoint to be rejected before binding, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceCreateRemoteProgramJobCreatesManagementLogStreams(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunProgram)
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("update plugin capabilities: %v", err)
|
|
}
|
|
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunProgram)
|
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
|
t.Fatalf("update endpoint capabilities: %v", err)
|
|
}
|
|
instance, err := svc.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-terminal",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "SCUM Terminal",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server instance: %v", err)
|
|
}
|
|
job, err := svc.CreateJob(domain.Job{
|
|
ID: "job-terminal",
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Capability: domain.JobCapabilityRemoteRunProgram,
|
|
TargetKey: "protected-program",
|
|
InputRef: "input://protected-program/job-terminal",
|
|
IdempotencyKey: "idem-terminal",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create remote program job: %v", err)
|
|
}
|
|
streams, err := svc.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
|
if err != nil {
|
|
t.Fatalf("list log streams: %v", err)
|
|
}
|
|
if len(streams) != 4 {
|
|
t.Fatalf("expected stdout/stderr plus management program streams, got %+v", streams)
|
|
}
|
|
want := map[string]domain.LogStreamSource{
|
|
"stdout": domain.LogStreamSourceProcess,
|
|
"stderr": domain.LogStreamSourceProcess,
|
|
"management-program.stdout": domain.LogStreamSourceManagementProgram,
|
|
"management-program.stderr": domain.LogStreamSourceManagementProgram,
|
|
}
|
|
for _, stream := range streams {
|
|
source, ok := want[stream.StreamKey]
|
|
if !ok {
|
|
t.Fatalf("unexpected stream key: %+v", stream)
|
|
}
|
|
if stream.Source != source || stream.ID != jobLogStreamID(job.ID, stream.StreamKey) {
|
|
t.Fatalf("unexpected stream metadata: %+v", stream)
|
|
}
|
|
delete(want, stream.StreamKey)
|
|
}
|
|
if len(want) != 0 {
|
|
t.Fatalf("missing streams: %+v", want)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceStartupRecoversLegacyJobLogStreams(t *testing.T) {
|
|
store := repo.NewMemoryStore()
|
|
seed := newCoreService(store, func() time.Time { return fixedTime })
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, seed)
|
|
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunProgram)
|
|
if err := seed.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("update plugin capabilities: %v", err)
|
|
}
|
|
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunProgram)
|
|
if err := seed.store.RunEndpoints().Update(endpoint); err != nil {
|
|
t.Fatalf("update endpoint capabilities: %v", err)
|
|
}
|
|
if _, err := seed.CreateServerInstance(domain.ServerInstance{ID: "legacy-terminal-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Legacy Terminal"}); err != nil {
|
|
t.Fatalf("create server instance: %v", err)
|
|
}
|
|
if err := store.Jobs().Create(domain.Job{
|
|
ID: "legacy-terminal-job",
|
|
ServerInstanceID: "legacy-terminal-server",
|
|
RunEndpointID: endpoint.ID,
|
|
Capability: domain.JobCapabilityRemoteRunProgram,
|
|
TargetKey: "protected-program",
|
|
InputRef: "input://protected-program/legacy-terminal-job",
|
|
IdempotencyKey: "legacy-terminal",
|
|
State: domain.JobStateQueued,
|
|
CreatedAt: fixedTime,
|
|
UpdatedAt: fixedTime,
|
|
}); err != nil {
|
|
t.Fatalf("seed legacy job: %v", err)
|
|
}
|
|
before, err := seed.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: "legacy-terminal-server"})
|
|
if err != nil || len(before) != 0 {
|
|
t.Fatalf("expected no seeded streams before recovery, len=%d err=%v streams=%+v", len(before), err, before)
|
|
}
|
|
recovered, err := NewCoreServiceWithDurableStores(store, NewMemoryLogBodyStore(), NewMemoryArtifactBodyStore())
|
|
if err != nil {
|
|
t.Fatalf("recover durable service: %v", err)
|
|
}
|
|
after, err := recovered.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: "legacy-terminal-server"})
|
|
if err != nil || len(after) != 4 {
|
|
t.Fatalf("expected recovered job log streams, len=%d err=%v streams=%+v", len(after), err, after)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRejectsInvalidServerDependencies(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
|
|
disabledPlugin := plugin
|
|
disabledPlugin.ID = "server.disabled"
|
|
disabledPlugin.Status = domain.GamePluginStatusDisabled
|
|
if _, err := svc.CreateGamePlugin(disabledPlugin); err != nil {
|
|
t.Fatalf("create disabled plugin fixture: %v", err)
|
|
}
|
|
|
|
_, err := svc.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-disabled",
|
|
PluginID: disabledPlugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Disabled Plugin Server",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "plugin must be installed") {
|
|
t.Fatalf("expected disabled plugin rejection, got %v", err)
|
|
}
|
|
|
|
weakEndpoint := endpoint
|
|
weakEndpoint.ID = "run-weak"
|
|
weakEndpoint.Capabilities = []string{"process.start"}
|
|
if _, err := svc.CreateRunEndpoint(weakEndpoint); err != nil {
|
|
t.Fatalf("create weak endpoint fixture: %v", err)
|
|
}
|
|
|
|
_, err = svc.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-weak",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: weakEndpoint.ID,
|
|
Name: "Weak Endpoint Server",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "logs.read") {
|
|
t.Fatalf("expected missing capability rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRejectsRawAIProviderSecret(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
provider := validProvider()
|
|
provider.APIKeyRef = "sk-raw-secret"
|
|
|
|
_, err := svc.CreateAIProvider(provider)
|
|
if err == nil || !strings.Contains(err.Error(), "apiKeyRef must reference secret storage") {
|
|
t.Fatalf("expected raw secret rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceAuthenticatesActiveUsers(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
created, err := svc.CreateUser(domain.User{
|
|
ID: "user-auth",
|
|
DisplayName: "Auth User",
|
|
Email: "auth@example.test",
|
|
Roles: []string{"platform-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create auth user: %v", err)
|
|
}
|
|
if created.PasswordHash == "secret-password" || created.PasswordHash == "" {
|
|
t.Fatalf("expected password to be hashed, got %q", created.PasswordHash)
|
|
}
|
|
|
|
session, err := svc.LoginUser(domain.UserLogin{Account: "auth@example.test", Password: "secret-password"})
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
if session.SessionID == "" || session.Status != "authenticated" || session.User.ID != created.ID {
|
|
t.Fatalf("unexpected auth session: %+v", session)
|
|
}
|
|
|
|
current, err := svc.GetCurrentUser(session.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("current user: %v", err)
|
|
}
|
|
if current.ID != created.ID {
|
|
t.Fatalf("expected current user %q, got %+v", created.ID, current)
|
|
}
|
|
|
|
if err := svc.LogoutUser(session.SessionID); err != nil {
|
|
t.Fatalf("logout: %v", err)
|
|
}
|
|
if _, err := svc.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) {
|
|
t.Fatalf("expected logged out session to be unauthorized, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceFirstRegistrationBootstrapsPlatformAdmin(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
session, err := svc.RegisterUser(domain.UserRegistration{
|
|
DisplayName: "Bootstrap Admin",
|
|
Email: "bootstrap@example.test",
|
|
Password: "secret-password",
|
|
Profile: domain.UserProfile{Phone: "13800000000", QQ: "10001"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
if session.Status != "authenticated" || session.SessionID == "" {
|
|
t.Fatalf("expected authenticated bootstrap registration, got %+v", session)
|
|
}
|
|
if session.User.Status != domain.UserStatusActive || len(session.User.Roles) != 1 || session.User.Roles[0] != "platform-admin" {
|
|
t.Fatalf("expected active platform admin user, got %+v", session.User)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRegistersLaterUsersAsPendingLowPrivilege(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
if _, err := svc.CreateUser(domain.User{ID: "user-existing", DisplayName: "Existing Admin", Roles: []string{"platform-admin"}}); err != nil {
|
|
t.Fatalf("create existing user: %v", err)
|
|
}
|
|
session, err := svc.RegisterUser(domain.UserRegistration{
|
|
DisplayName: "Pending Player",
|
|
Email: "pending@example.test",
|
|
Password: "secret-password",
|
|
Profile: domain.UserProfile{Phone: "13800000000", QQ: "10001"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
if session.Status != "pending" || session.SessionID != "" {
|
|
t.Fatalf("expected pending registration without session token, got %+v", session)
|
|
}
|
|
if session.User.Status != domain.UserStatusPending || len(session.User.Roles) != 1 || session.User.Roles[0] != "server-admin" {
|
|
t.Fatalf("expected low-privilege pending user, got %+v", session.User)
|
|
}
|
|
if _, err := svc.LoginUser(domain.UserLogin{Account: "pending@example.test", Password: "secret-password"}); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected pending login to be forbidden, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceScopesServerAccessAndMembership(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-owner",
|
|
DisplayName: "Server Owner",
|
|
Email: "owner@example.test",
|
|
Roles: []string{"server-owner"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
helperSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-helper",
|
|
DisplayName: "Server Helper",
|
|
Email: "helper@example.test",
|
|
Roles: []string{"server-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
adminSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-platform",
|
|
DisplayName: "Platform Admin",
|
|
Email: "platform@example.test",
|
|
Roles: []string{"platform-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-owned",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Owned Server",
|
|
State: domain.ServerInstanceStateReady,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create owned server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, instance, "local")
|
|
if instance.OwnerUserID != "user-owner" {
|
|
t.Fatalf("expected owner to be recorded, got %+v", instance)
|
|
}
|
|
ownerServers, err := svc.ListServerInstancesForSession(ownerSession, domain.ServerInstanceFilter{})
|
|
if err != nil || len(ownerServers) != 1 {
|
|
t.Fatalf("expected owner server visibility, len=%d err=%v", len(ownerServers), err)
|
|
}
|
|
helperServers, err := svc.ListServerInstancesForSession(helperSession, domain.ServerInstanceFilter{})
|
|
if err != nil || len(helperServers) != 0 {
|
|
t.Fatalf("expected helper to see no servers before invite, len=%d err=%v", len(helperServers), err)
|
|
}
|
|
adminServers, err := svc.ListServerInstancesForSession(adminSession, domain.ServerInstanceFilter{})
|
|
if err != nil || len(adminServers) != 1 {
|
|
t.Fatalf("expected platform admin to see all servers, len=%d err=%v", len(adminServers), err)
|
|
}
|
|
|
|
candidates, err := svc.ListServerAdministratorCandidates(ownerSession, instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("list candidates: %v", err)
|
|
}
|
|
if len(candidates) != 1 || candidates[0].ID != "user-helper" {
|
|
t.Fatalf("expected only helper candidate, got %+v", candidates)
|
|
}
|
|
if _, err := svc.AddServerAdministrator(helperSession, instance.ID, "user-owner"); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected non-owner add to be forbidden, got %v", err)
|
|
}
|
|
if _, err := svc.AddServerAdministrator(ownerSession, instance.ID, "user-platform"); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected platform admin invite to be forbidden, got %v", err)
|
|
}
|
|
|
|
updated, err := svc.AddServerAdministrator(ownerSession, instance.ID, "user-helper")
|
|
if err != nil {
|
|
t.Fatalf("add helper admin: %v", err)
|
|
}
|
|
if len(updated.AdminUserIDs) != 1 || updated.AdminUserIDs[0] != "user-helper" {
|
|
t.Fatalf("expected helper membership, got %+v", updated)
|
|
}
|
|
helperServers, err = svc.ListServerInstancesForSession(helperSession, domain.ServerInstanceFilter{})
|
|
if err != nil || len(helperServers) != 1 {
|
|
t.Fatalf("expected helper to see invited server, len=%d err=%v", len(helperServers), err)
|
|
}
|
|
if _, err := svc.StartServerInstanceForSession(helperSession, domain.ServerLifecycleCommand{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion,
|
|
IdempotencyKey: "idem-helper-start",
|
|
}); err != nil {
|
|
t.Fatalf("expected helper lifecycle access: %v", err)
|
|
}
|
|
|
|
removed, err := svc.RemoveServerAdministrator(ownerSession, instance.ID, "user-helper")
|
|
if err != nil {
|
|
t.Fatalf("remove helper admin: %v", err)
|
|
}
|
|
if len(removed.AdminUserIDs) != 0 {
|
|
t.Fatalf("expected helper membership removed, got %+v", removed)
|
|
}
|
|
if _, err := svc.GetServerInstanceForSession(helperSession, instance.ID); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected helper access to be revoked, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceDeletesServerInstancesWithPasswordConfirmation(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-delete-owner",
|
|
DisplayName: "Delete Owner",
|
|
Email: "delete-owner@example.test",
|
|
Roles: []string{"server-owner"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
adminSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-delete-admin",
|
|
DisplayName: "Delete Admin",
|
|
Email: "delete-admin@example.test",
|
|
Roles: []string{"platform-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
otherSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-delete-other",
|
|
DisplayName: "Delete Other",
|
|
Email: "delete-other@example.test",
|
|
Roles: []string{"server-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
|
|
ownerInstance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-delete-owner",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Delete Owner Server",
|
|
State: domain.ServerInstanceStateReady,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create owner instance: %v", err)
|
|
}
|
|
adminTarget, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-delete-admin",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Delete Admin Target",
|
|
State: domain.ServerInstanceStateReady,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create admin target: %v", err)
|
|
}
|
|
runningTarget, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-delete-running",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Delete Running Target",
|
|
State: domain.ServerInstanceStateReady,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create running target: %v", err)
|
|
}
|
|
runningTarget.State = domain.ServerInstanceStateRunning
|
|
if err := svc.store.ServerInstances().Update(runningTarget); err != nil {
|
|
t.Fatalf("set running target state: %v", err)
|
|
}
|
|
installingTarget, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-delete-installing",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Delete Installing Target",
|
|
State: domain.ServerInstanceStateInstalling,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create installing target: %v", err)
|
|
}
|
|
draftTarget, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-delete-draft",
|
|
PluginID: plugin.ID,
|
|
Name: "Delete Draft Target",
|
|
State: domain.ServerInstanceStateDraft,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create draft target: %v", err)
|
|
}
|
|
|
|
if _, err := svc.DeleteServerInstanceForSession(otherSession, ownerInstance.ID, domain.ServerDeletionRequest{Password: "secret-password"}); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected non-owner delete to be forbidden, got %v", err)
|
|
}
|
|
if _, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, domain.ServerDeletionRequest{}); err == nil {
|
|
t.Fatalf("expected missing password to fail")
|
|
} else {
|
|
var validationErr validator.ValidationError
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("expected missing password to be validation error, got %v", err)
|
|
}
|
|
}
|
|
if _, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, domain.ServerDeletionRequest{Password: "wrong-password"}); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected wrong password to be forbidden, got %v", err)
|
|
}
|
|
|
|
deletedOwner, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, domain.ServerDeletionRequest{Password: "secret-password"})
|
|
if err != nil {
|
|
t.Fatalf("delete owner instance: %v", err)
|
|
}
|
|
if deletedOwner.State != domain.ServerInstanceStateDeleted {
|
|
t.Fatalf("expected deleted owner state, got %+v", deletedOwner)
|
|
}
|
|
deletedAdmin, err := svc.DeleteServerInstanceForSession(adminSession, adminTarget.ID, domain.ServerDeletionRequest{Password: "secret-password"})
|
|
if err != nil {
|
|
t.Fatalf("delete admin target: %v", err)
|
|
}
|
|
if deletedAdmin.State != domain.ServerInstanceStateDeleted {
|
|
t.Fatalf("expected deleted admin state, got %+v", deletedAdmin)
|
|
}
|
|
deletedDraft, err := svc.DeleteServerInstanceForSession(ownerSession, draftTarget.ID, domain.ServerDeletionRequest{Password: "secret-password"})
|
|
if err != nil {
|
|
t.Fatalf("delete draft target without run endpoint: %v", err)
|
|
}
|
|
if deletedDraft.State != domain.ServerInstanceStateDeleted || deletedDraft.RunEndpointID != "" {
|
|
t.Fatalf("expected deleted draft target without run endpoint, got %+v", deletedDraft)
|
|
}
|
|
if _, err := svc.DeleteServerInstanceForSession(ownerSession, runningTarget.ID, domain.ServerDeletionRequest{Password: "secret-password"}); err == nil {
|
|
t.Fatalf("expected running instance delete to fail")
|
|
} else {
|
|
var validationErr validator.ValidationError
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("expected running delete to be validation error, got %v", err)
|
|
}
|
|
}
|
|
if _, err := svc.DeleteServerInstanceForSession(ownerSession, installingTarget.ID, domain.ServerDeletionRequest{Password: "secret-password", Force: true, Confirmation: "not enough"}); err == nil {
|
|
t.Fatalf("expected installing force delete without exact confirmation to fail")
|
|
}
|
|
forcedRunning, err := svc.DeleteServerInstanceForSession(ownerSession, runningTarget.ID, domain.ServerDeletionRequest{Password: "secret-password", Force: true, Confirmation: ServerDeletionForceConfirmation})
|
|
if err != nil {
|
|
t.Fatalf("force delete running target: %v", err)
|
|
}
|
|
if forcedRunning.State != domain.ServerInstanceStateDeleted {
|
|
t.Fatalf("expected forced running target deleted, got %+v", forcedRunning)
|
|
}
|
|
forcedInstalling, err := svc.DeleteServerInstanceForSession(ownerSession, installingTarget.ID, domain.ServerDeletionRequest{Password: "secret-password", Force: true, Confirmation: ServerDeletionForceConfirmation})
|
|
if err != nil {
|
|
t.Fatalf("force delete installing target: %v", err)
|
|
}
|
|
if forcedInstalling.State != domain.ServerInstanceStateDeleted {
|
|
t.Fatalf("expected forced installing target deleted, got %+v", forcedInstalling)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceMetricsAndConfigReadAreRoleScoped(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-owner-metrics",
|
|
DisplayName: "Metrics Owner",
|
|
Email: "owner-metrics@example.test",
|
|
Roles: []string{"server-owner"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
otherSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-other-metrics",
|
|
DisplayName: "Metrics Other",
|
|
Email: "other-metrics@example.test",
|
|
Roles: []string{"server-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-metrics",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Metrics Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
if _, err := svc.CreateJob(domain.Job{ID: "job-metrics", ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: "process.start", IdempotencyKey: "idem-metrics"}); err != nil {
|
|
t.Fatalf("create job: %v", err)
|
|
}
|
|
|
|
usage, err := svc.GetPlatformResourceUsage()
|
|
if err != nil {
|
|
t.Fatalf("get platform usage: %v", err)
|
|
}
|
|
if usage.Source != "platform-derived" || usage.CollectedAt.IsZero() || usage.CPUPercent < 0 || usage.CPUPercent > 100 {
|
|
t.Fatalf("unexpected platform usage: %+v", usage)
|
|
}
|
|
|
|
ownerMetrics, err := svc.ListServerMetricsForSession(ownerSession)
|
|
if err != nil {
|
|
t.Fatalf("list owner metrics: %v", err)
|
|
}
|
|
if len(ownerMetrics) != 1 || ownerMetrics[0].ServerInstanceID != instance.ID || ownerMetrics[0].Online || ownerMetrics[0].CPUPercent != nil || ownerMetrics[0].Source != "run-metrics-pending" {
|
|
t.Fatalf("expected pending metrics without fabricated resource values, got %+v", ownerMetrics)
|
|
}
|
|
|
|
runHello := validRunControlHello()
|
|
runHello.RunEndpointID = endpoint.ID
|
|
registered, err := svc.RegisterRunHello(runHello)
|
|
if err != nil {
|
|
t.Fatalf("register run: %v", err)
|
|
}
|
|
oldCPU := 28.0
|
|
latestCPU := 64.0
|
|
latestMemory := 52.0
|
|
latestDisk := 31.0
|
|
players := 11
|
|
maxPlayers := 40
|
|
tps := 19.7
|
|
latency := 48.0
|
|
if _, err := svc.IngestMetricBatch(domain.MetricBatchIngest{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, Samples: []domain.MetricSample{
|
|
{ServerInstanceID: instance.ID, Online: true, CPUPercent: &oldCPU, Source: "run", CollectedAt: fixedTime.Add(-time.Minute)},
|
|
{ServerInstanceID: instance.ID, Online: true, PlayerCount: &players, MaxPlayers: &maxPlayers, TPS: &tps, LatencyMS: &latency, CPUPercent: &latestCPU, MemoryPercent: &latestMemory, DiskPercent: &latestDisk, Source: "run", CollectedAt: fixedTime.Add(time.Minute)},
|
|
}}); err != nil {
|
|
t.Fatalf("ingest metrics: %v", err)
|
|
}
|
|
ownerMetrics, err = svc.ListServerMetricsForSession(ownerSession)
|
|
if err != nil {
|
|
t.Fatalf("list owner metrics after ingest: %v", err)
|
|
}
|
|
if len(ownerMetrics) != 1 || ownerMetrics[0].CPUPercent == nil || *ownerMetrics[0].CPUPercent != latestCPU || ownerMetrics[0].PlayerCount == nil || *ownerMetrics[0].PlayerCount != players || ownerMetrics[0].CollectedAt != fixedTime.Add(time.Minute) {
|
|
t.Fatalf("expected latest persisted sample metrics, got %+v", ownerMetrics)
|
|
}
|
|
otherMetrics, err := svc.ListServerMetricsForSession(otherSession)
|
|
if err != nil {
|
|
t.Fatalf("list other metrics: %v", err)
|
|
}
|
|
if len(otherMetrics) != 0 {
|
|
t.Fatalf("expected other user to see no metrics, got %+v", otherMetrics)
|
|
}
|
|
|
|
config, err := svc.GetServerConfigForSession(ownerSession, instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("get config: %v", err)
|
|
}
|
|
if config.ServerInstanceID != instance.ID || config.ConfigVersion != instance.ConfigVersion || !strings.Contains(config.Content, "server.name=Metrics Server") {
|
|
t.Fatalf("unexpected config: %+v", config)
|
|
}
|
|
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} {
|
|
if strings.Contains(config.Content, forbidden) {
|
|
t.Fatalf("config content exposed forbidden fragment %q: %s", forbidden, config.Content)
|
|
}
|
|
}
|
|
if _, err := svc.GetServerConfigForSession(otherSession, instance.ID); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected other config access to be forbidden, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceMergesRecentPartialMetricSamples(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-partial-metrics", DisplayName: "Partial Metrics Owner", Email: "partial-metrics@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-partial-metrics", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Partial Metrics", State: domain.ServerInstanceStateRunning})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
runHello := validRunControlHello()
|
|
runHello.RunEndpointID = endpoint.ID
|
|
registered, err := svc.RegisterRunHello(runHello)
|
|
if err != nil {
|
|
t.Fatalf("register run: %v", err)
|
|
}
|
|
cpu := 37.0
|
|
memory := 49.0
|
|
players := 8
|
|
disk := 78.0
|
|
staleMemory := 12.0
|
|
if _, err := svc.IngestMetricBatch(domain.MetricBatchIngest{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, Samples: []domain.MetricSample{
|
|
{ServerInstanceID: instance.ID, Online: true, CPUPercent: &cpu, MemoryPercent: &memory, PlayerCount: &players, Source: "run", CollectedAt: fixedTime.Add(-15 * time.Second)},
|
|
{ServerInstanceID: instance.ID, Online: true, MemoryPercent: &staleMemory, Source: "run", CollectedAt: fixedTime.Add(-3 * time.Minute)},
|
|
{ServerInstanceID: instance.ID, Online: true, DiskPercent: &disk, Source: "run", CollectedAt: fixedTime},
|
|
}}); err != nil {
|
|
t.Fatalf("ingest partial metrics: %v", err)
|
|
}
|
|
metrics, err := svc.ListServerMetricsForSession(ownerSession)
|
|
if err != nil {
|
|
t.Fatalf("list merged metrics: %v", err)
|
|
}
|
|
if len(metrics) != 1 || metrics[0].CPUPercent == nil || *metrics[0].CPUPercent != cpu || metrics[0].MemoryPercent == nil || *metrics[0].MemoryPercent != memory || metrics[0].PlayerCount == nil || *metrics[0].PlayerCount != players || metrics[0].DiskPercent == nil || *metrics[0].DiskPercent != disk || metrics[0].CollectedAt != fixedTime {
|
|
t.Fatalf("expected recent partial metrics to merge, got %+v", metrics)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-owner-config",
|
|
DisplayName: "Config Owner",
|
|
Email: "owner-config@example.test",
|
|
Roles: []string{"server-owner"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
otherSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-other-config",
|
|
DisplayName: "Config Other",
|
|
Email: "other-config@example.test",
|
|
Roles: []string{"server-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-config",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Config Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, instance, "local")
|
|
current, err := svc.GetServerConfigForSession(ownerSession, instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("get config: %v", err)
|
|
}
|
|
proposed := strings.Replace(current.Content, "state=running", "state=running\nmotd=Approved", 1)
|
|
|
|
preview, err := svc.PreviewServerConfigWriteForSession(ownerSession, domain.ServerConfigDiffRequest{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion,
|
|
Key: current.Key,
|
|
ProposedContent: proposed,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("preview config write: %v", err)
|
|
}
|
|
if !preview.HasChanges || preview.Source != "platform-review" || preview.ProposedContent != proposed {
|
|
t.Fatalf("unexpected preview: %+v", preview)
|
|
}
|
|
jobs, err := svc.ListJobs(domain.JobFilter{})
|
|
if err != nil || len(jobs) != 0 {
|
|
t.Fatalf("preview must not create jobs, jobs=%+v err=%v", jobs, err)
|
|
}
|
|
|
|
dispatch, err := svc.ApproveServerConfigWriteForSession(ownerSession, domain.ServerConfigWriteApproval{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion,
|
|
Key: current.Key,
|
|
ProposedContent: proposed,
|
|
IdempotencyKey: "idem-config-approve",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("approve config write: %v", err)
|
|
}
|
|
if dispatch.Status != "queued" || dispatch.Job.Capability != domain.JobCapabilityConfigWrite || dispatch.Job.TargetKey != current.Key || !strings.HasPrefix(dispatch.Job.InputRef, "input://server-config/") {
|
|
t.Fatalf("unexpected config dispatch: %+v", dispatch)
|
|
}
|
|
|
|
if _, err := svc.PreviewServerConfigWriteForSession(ownerSession, domain.ServerConfigDiffRequest{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion + 1,
|
|
Key: current.Key,
|
|
ProposedContent: proposed,
|
|
}); err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") {
|
|
t.Fatalf("expected stale config version rejection, got %v", err)
|
|
}
|
|
if _, err := svc.ApproveServerConfigWriteForSession(otherSession, domain.ServerConfigWriteApproval{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion,
|
|
Key: current.Key,
|
|
ProposedContent: proposed,
|
|
IdempotencyKey: "idem-config-forbidden",
|
|
}); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected unauthorized approval rejection, got %v", err)
|
|
}
|
|
if _, err := svc.PreviewServerConfigWriteForSession(ownerSession, domain.ServerConfigDiffRequest{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion,
|
|
Key: "/Users/tasia/secret.properties",
|
|
ProposedContent: proposed,
|
|
}); err == nil || !strings.Contains(err.Error(), "key") {
|
|
t.Fatalf("expected unsafe key rejection, got %v", err)
|
|
}
|
|
if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
|
|
ServerInstanceID: instance.ID,
|
|
Operation: domain.FileOperationRead,
|
|
Key: "../secrets.env",
|
|
IdempotencyKey: "idem-file-unsafe",
|
|
}); err == nil || !strings.Contains(err.Error(), "key") {
|
|
t.Fatalf("expected unsafe file key rejection, got %v", err)
|
|
}
|
|
|
|
fileDispatch, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
|
|
ServerInstanceID: instance.ID,
|
|
PluginID: plugin.ID,
|
|
Operation: domain.FileOperationRead,
|
|
Key: "logs/latest.log",
|
|
IdempotencyKey: "idem-file-read",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dispatch file read: %v", err)
|
|
}
|
|
if fileDispatch.Job.Capability != domain.JobCapabilityFilesRead || fileDispatch.Job.TargetKey != "logs/latest.log" {
|
|
t.Fatalf("unexpected file dispatch: %+v", fileDispatch)
|
|
}
|
|
jobs, err = svc.ListJobs(domain.JobFilter{})
|
|
if err != nil || len(jobs) != 2 {
|
|
t.Fatalf("expected only approved config and file jobs, jobs=%+v err=%v", jobs, err)
|
|
}
|
|
}
|
|
|
|
func TestPluginFileWorkspaceDoesNotConstrainServerFileDispatch(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
plugin.FileWorkspace = scumTestFileWorkspace()
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("update plugin workspace: %v", err)
|
|
}
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-owner-file-workspace",
|
|
DisplayName: "File Workspace Owner",
|
|
Email: "file-workspace-owner@example.test",
|
|
Roles: []string{"server-owner"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-file-workspace",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "File Workspace Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: `C:\scumserver`},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, instance, "local")
|
|
|
|
allowed, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
|
|
ServerInstanceID: instance.ID,
|
|
PluginID: plugin.ID,
|
|
Operation: domain.FileOperationRead,
|
|
Key: "scum-server-settings",
|
|
IdempotencyKey: "idem-file-workspace-read",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dispatch declared file read: %v", err)
|
|
}
|
|
if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead || allowed.Job.ExecutionInput.Deployment == nil || allowed.Job.ExecutionInput.Deployment.ServerRoot != `C:\scumserver` {
|
|
t.Fatalf("unexpected declared file dispatch: %+v", allowed)
|
|
}
|
|
unknown, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
|
|
ServerInstanceID: instance.ID,
|
|
PluginID: plugin.ID,
|
|
Operation: domain.FileOperationRead,
|
|
Key: "logs/latest.log",
|
|
IdempotencyKey: "idem-file-workspace-unknown",
|
|
})
|
|
if err != nil || unknown.Job.TargetKey != "logs/latest.log" || unknown.Job.Capability != domain.JobCapabilityFilesRead {
|
|
t.Fatalf("expected undeclared file read to queue, dispatch=%+v err=%v", unknown, err)
|
|
}
|
|
written, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
|
|
ServerInstanceID: instance.ID,
|
|
PluginID: plugin.ID,
|
|
Operation: domain.FileOperationWrite,
|
|
Key: "scum-chat-log",
|
|
InputRef: "input://file-workspace/update",
|
|
Content: "line",
|
|
IdempotencyKey: "idem-file-workspace-log-write",
|
|
})
|
|
if err != nil || written.Job.TargetKey != "scum-chat-log" || written.Job.Capability != domain.JobCapabilityFilesWrite || written.Job.ExecutionInput.Deployment == nil || written.Job.ExecutionInput.Deployment.ServerRoot != `C:\scumserver` {
|
|
t.Fatalf("expected declared log write to queue, dispatch=%+v err=%v", written, err)
|
|
}
|
|
}
|
|
|
|
func TestReadRunFileInputChunkReadsRequestedRange(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
plugin.FileWorkspace = scumTestFileWorkspace()
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("update plugin workspace: %v", err)
|
|
}
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-chunk", DisplayName: "File Chunk Owner", Email: "file-chunk@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-chunk", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Chunk Server", State: domain.ServerInstanceStateRunning})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, instance, "local")
|
|
payload := []byte("0123456789abcdef")
|
|
upload, err := svc.UploadServerFileForSession(ownerSession, domain.ServerFileUploadRequest{ServerInstanceID: instance.ID, DirectoryKey: "scum-logs", Filename: "chunked.log", Payload: payload, Checksum: validator.BytesChecksum(payload), IdempotencyKey: "file-chunk-upload"})
|
|
if err != nil {
|
|
t.Fatalf("upload server file: %v", err)
|
|
}
|
|
hello := validRunControlHello()
|
|
hello.RunEndpointID = endpoint.ID
|
|
hello.CapabilityReport.Capabilities = []string{domain.JobCapabilityFilesWrite}
|
|
registered, err := svc.RegisterRunHello(hello)
|
|
if err != nil {
|
|
t.Fatalf("register run: %v", err)
|
|
}
|
|
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, Capabilities: []string{domain.JobCapabilityFilesWrite}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
|
if err != nil || !claim.HasJob || claim.Job.JobID != upload.Job.ID {
|
|
t.Fatalf("claim file write: claim=%+v err=%v", claim, err)
|
|
}
|
|
chunk, err := svc.ReadRunFileInputChunk(domain.RunFileInputChunkRequest{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 4, Length: 6})
|
|
if err != nil || string(chunk.Payload) != string(payload[4:10]) || chunk.Offset != 4 || chunk.Complete {
|
|
t.Fatalf("read offset file input chunk: chunk=%+v err=%v", chunk, err)
|
|
}
|
|
finalChunk, err := svc.ReadRunFileInputChunk(domain.RunFileInputChunkRequest{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 14, Length: 8})
|
|
if err != nil || string(finalChunk.Payload) != string(payload[14:]) || !finalChunk.Complete {
|
|
t.Fatalf("read final file input chunk: chunk=%+v err=%v", finalChunk, err)
|
|
}
|
|
}
|
|
|
|
func TestServerFileListReportsFailedRuntimeRefresh(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityFilesList)
|
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
|
t.Fatalf("update file list capability: %v", err)
|
|
}
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-list-failure", DisplayName: "File List Failure", Email: "file-list-failure@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-list-failure", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File List Failure Server", State: domain.ServerInstanceStateRunning, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: `C:\scumserver`}})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, instance, "local")
|
|
|
|
refresh, err := svc.RefreshServerFileListForSession(ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "server-root", IdempotencyKey: "idem-file-list-failure"})
|
|
if err != nil || refresh.State != "pending" || refresh.Job.ID == "" || refresh.Job.ExecutionInput.Deployment == nil || refresh.Job.ExecutionInput.Deployment.ServerRoot != `C:\scumserver` {
|
|
t.Fatalf("refresh file list: result=%+v err=%v", refresh, err)
|
|
}
|
|
failedJob := refresh.Job
|
|
failedJob.State = domain.JobStateFailed
|
|
failedJob.Progress = domain.JobProgress{Percent: 100, Message: "executor does not support files.list"}
|
|
failedJob.TerminalAt = fixedTime.Add(2 * time.Minute)
|
|
failedJob.UpdatedAt = failedJob.TerminalAt
|
|
if err := svc.store.Jobs().Update(failedJob); err != nil {
|
|
t.Fatalf("update failed file list job: %v", err)
|
|
}
|
|
|
|
list, err := svc.ListServerFilesForSession(ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "server-root"})
|
|
if err != nil || list.State != "failed" || list.Job.ID != failedJob.ID || !strings.Contains(list.Reason, "executor does not support files.list") {
|
|
t.Fatalf("expected failed file list state, list=%+v err=%v", list, err)
|
|
}
|
|
}
|
|
|
|
func TestServerFileManagerUsesDefaultProfileWithoutRuntimeBinding(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityFilesList)
|
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
|
t.Fatalf("update file list capability: %v", err)
|
|
}
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-default-profile", DisplayName: "File Default Profile", Email: "file-default-profile@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-default-profile", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Default Profile Server", State: domain.ServerInstanceStateRunning, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: `C:\scumserver`}})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
if _, err := svc.runtimeBindingForServer(instance.ID); !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("file manager must not require a runtime binding: %v", err)
|
|
}
|
|
|
|
list, err := svc.RefreshServerFileListForSession(ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "server-root", IdempotencyKey: "file-default-profile-list"})
|
|
if err != nil || list.State != "pending" || list.Job.ExecutionInput.WorkspaceScope != "local" {
|
|
t.Fatalf("expected default-profile file list job, result=%+v err=%v", list, err)
|
|
}
|
|
read, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ServerInstanceID: instance.ID, PluginID: plugin.ID, Operation: domain.FileOperationRead, Key: "logs/latest.log", IdempotencyKey: "file-default-profile-read"})
|
|
if err != nil || read.Job.ExecutionInput.WorkspaceScope != "local" {
|
|
t.Fatalf("expected default-profile file read job, result=%+v err=%v", read, err)
|
|
}
|
|
}
|
|
|
|
func TestServerFileBrowseWaitsForFreshRunResultWithoutCachedList(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityFilesList)
|
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
|
t.Fatalf("update file list capability: %v", err)
|
|
}
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-browse", DisplayName: "File Browse", Email: "file-browse@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-browse", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Browse Server", State: domain.ServerInstanceStateRunning, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: `C:\scumserver`}})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, instance, "local")
|
|
oldJob, err := svc.CreateJob(domain.Job{ID: "job-file-list-old", ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: domain.JobCapabilityFilesList, TargetKey: "server-root", IdempotencyKey: "idem-file-list-old"})
|
|
if err != nil {
|
|
t.Fatalf("create old list job: %v", err)
|
|
}
|
|
oldJob.State = domain.JobStateSucceeded
|
|
oldJob.ExecutionResult = domain.JobExecutionResult{Kind: "file.list", Content: runFileListFixture("server-root", "", ".platform")}
|
|
oldJob.TerminalAt = fixedTime.Add(10 * time.Minute)
|
|
oldJob.UpdatedAt = oldJob.TerminalAt
|
|
if err := svc.store.Jobs().Update(oldJob); err != nil {
|
|
t.Fatalf("store old list result: %v", err)
|
|
}
|
|
helloRequest := validRunControlHello()
|
|
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityFilesList)
|
|
helloRequest.CapabilityReport.Fingerprint = "cap-file-browse"
|
|
hello, err := svc.RegisterRunHello(helloRequest)
|
|
if err != nil {
|
|
t.Fatalf("register run hello: %v", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
resultCh := make(chan domain.ServerFileListResult, 1)
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
result, err := svc.BrowseServerFilesForSession(ctx, ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "server-root", IdempotencyKey: "idem-file-browse-fresh"})
|
|
if err != nil {
|
|
errCh <- err
|
|
return
|
|
}
|
|
resultCh <- result
|
|
}()
|
|
job := waitForServerFileListJob(t, svc, instance.ID, "job-file-list-old")
|
|
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityFilesList}, Capacity: domain.RunCapacity{MaxJobs: 4}})
|
|
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID {
|
|
t.Fatalf("claim fresh file list job: claim=%+v err=%v", claim, err)
|
|
}
|
|
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "listed"}, ExecutionResult: domain.JobExecutionResult{Kind: "file.list", Content: runFileListFixture("server-root", "", "SCUM")}})
|
|
if err != nil {
|
|
t.Fatalf("complete fresh file list job: %v", err)
|
|
}
|
|
select {
|
|
case err := <-errCh:
|
|
t.Fatalf("browse failed: %v", err)
|
|
case result := <-resultCh:
|
|
if result.State != "ready" || len(result.Entries) != 1 || result.Entries[0].Name != "SCUM" || result.Job.ID != job.ID {
|
|
t.Fatalf("expected fresh browse result, got %+v", result)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatalf("browse timed out: %v", ctx.Err())
|
|
}
|
|
}
|
|
|
|
func TestServerFileListFallsBackToPluginWorkspaceWithoutRunListCapability(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
plugin.FileWorkspace = scumTestFileWorkspace()
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("update plugin workspace: %v", err)
|
|
}
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-list-fallback", DisplayName: "File List Fallback", Email: "file-list-fallback@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-list-fallback", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File List Fallback Server", State: domain.ServerInstanceStateRunning})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, instance, "local")
|
|
|
|
refresh, err := svc.RefreshServerFileListForSession(ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "scum-config", IdempotencyKey: "idem-file-list-fallback"})
|
|
if err != nil {
|
|
t.Fatalf("refresh file list fallback: %v", err)
|
|
}
|
|
if refresh.State != "ready" || refresh.Job.ID != "" || len(refresh.Entries) != 2 || refresh.Entries[1].LogicalKey != "scum-server-settings" || !strings.Contains(refresh.Reason, "未声明 files.list") {
|
|
t.Fatalf("expected plugin workspace fallback, got %+v", refresh)
|
|
}
|
|
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
|
if err != nil {
|
|
t.Fatalf("list fallback jobs: %v", err)
|
|
}
|
|
if len(jobs) != 0 {
|
|
t.Fatalf("unsupported Run capability must not create a failed refresh job, got %+v", jobs)
|
|
}
|
|
}
|
|
|
|
func TestDeclaredFileReadSnapshotProjectionStatesAndPassThroughContent(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
plugin.FileWorkspace = scumTestFileWorkspace()
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("update plugin workspace: %v", err)
|
|
}
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-snapshot-owner", DisplayName: "File Snapshot Owner", Email: "file-snapshot-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-snapshot-other", DisplayName: "File Snapshot Other", Email: "file-snapshot-other@example.test", Roles: []string{"server-admin"}, PasswordHash: "secret-password"})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-snapshot", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Snapshot Server", State: domain.ServerInstanceStateRunning})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
|
|
snapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
|
if err != nil || snapshot.State != "not-read" {
|
|
t.Fatalf("expected not-read without jobs, snapshot=%+v err=%v", snapshot, err)
|
|
}
|
|
queued := createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-queued", domain.JobStateQueued, 1, "")
|
|
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
|
if err != nil || snapshot.State != "pending" || snapshot.JobID != queued.ID {
|
|
t.Fatalf("expected pending queued job, snapshot=%+v err=%v", snapshot, err)
|
|
}
|
|
queued.State = domain.JobStateFailed
|
|
queued.UpdatedAt = fixedTime.Add(2 * time.Minute)
|
|
queued.TerminalAt = fixedTime.Add(2 * time.Minute)
|
|
if err := svc.store.Jobs().Update(queued); err != nil {
|
|
t.Fatalf("update failed read job: %v", err)
|
|
}
|
|
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-cancelled", domain.JobStateCancelled, 3, "")
|
|
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
|
if err != nil || snapshot.State != "not-read" {
|
|
t.Fatalf("failed/cancelled reads must not mask not-read, snapshot=%+v err=%v", snapshot, err)
|
|
}
|
|
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-old", domain.JobStateSucceeded, 4, "ServerName=Old\nRconPassword=secret\n")
|
|
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-failed-newer", domain.JobStateFailed, 5, "")
|
|
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
|
if err != nil || snapshot.State != "ready" || snapshot.JobID != "job-file-snapshot-success-old" || !strings.Contains(snapshot.Content, "RconPassword=secret") {
|
|
t.Fatalf("expected older successful pass-through result, snapshot=%+v err=%v", snapshot, err)
|
|
}
|
|
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-new", domain.JobStateSucceeded, 6, "ServerName=New\nApiToken=secret\n")
|
|
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
|
if err != nil || snapshot.JobID != "job-file-snapshot-success-new" || snapshot.Content != "ServerName=New\nApiToken=secret\n" {
|
|
t.Fatalf("expected newest successful pass-through result, snapshot=%+v err=%v", snapshot, err)
|
|
}
|
|
unknownSnapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log")
|
|
if err != nil || unknownSnapshot.State != "not-read" {
|
|
t.Fatalf("expected unknown logical key to report not-read, snapshot=%+v err=%v", unknownSnapshot, err)
|
|
}
|
|
if _, err := svc.GetDeclaredFileReadSnapshotForSession(otherSession, instance.ID, "scum-server-settings"); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected unrelated session forbidden, got %v", err)
|
|
}
|
|
}
|
|
|
|
func scumTestFileWorkspace() domain.PluginFileWorkspace {
|
|
return domain.PluginFileWorkspace{
|
|
DefaultDirectoryKey: "scum-config",
|
|
Directories: []domain.PluginLogicalDirectory{
|
|
{Key: "scum-config", Label: "服务器配置", Scope: "config"},
|
|
{Key: "scum-logs", Label: "日志文件", Scope: "logs"},
|
|
},
|
|
Files: []domain.PluginLogicalFile{
|
|
{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true},
|
|
{Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"},
|
|
},
|
|
ConfigFields: []domain.PluginConfigField{
|
|
{Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func createDeclaredFileReadJob(t *testing.T, svc *CoreService, instance domain.ServerInstance, endpoint domain.RunEndpoint, id string, state domain.JobState, minuteOffset int, content string) domain.Job {
|
|
t.Helper()
|
|
job, err := svc.CreateJob(domain.Job{
|
|
ID: id,
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Capability: domain.JobCapabilityFilesRead,
|
|
TargetKey: "scum-server-settings",
|
|
IdempotencyKey: id,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create declared file read job: %v", err)
|
|
}
|
|
stamp := fixedTime.Add(time.Duration(minuteOffset) * time.Minute)
|
|
job.State = state
|
|
job.UpdatedAt = stamp
|
|
if state == domain.JobStateSucceeded || state == domain.JobStateFailed || state == domain.JobStateCancelled {
|
|
job.TerminalAt = stamp
|
|
}
|
|
if state == domain.JobStateSucceeded {
|
|
job.ExecutionResult = domain.JobExecutionResult{Kind: "file.read", Version: minuteOffset, Checksum: validator.BytesChecksum([]byte(content)), SizeBytes: int64(len(content)), Content: content}
|
|
}
|
|
if err := svc.store.Jobs().Update(job); err != nil {
|
|
t.Fatalf("update declared file read job: %v", err)
|
|
}
|
|
return job
|
|
}
|
|
|
|
func TestConfigWriteTerminalResultAppliesDurableTypedProjection(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "typed-config-owner", DisplayName: "Typed Config Owner", Email: "typed-config-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "typed-config-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Typed Config", State: domain.ServerInstanceStateRunning})
|
|
if err != nil {
|
|
t.Fatalf("create typed config server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, instance, "local")
|
|
current, err := svc.GetServerConfigForSession(ownerSession, instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("read typed config: %v", err)
|
|
}
|
|
proposed := current.Content + "motd=typed\n"
|
|
dispatch, err := svc.ApproveServerConfigWriteForSession(ownerSession, domain.ServerConfigWriteApproval{ServerInstanceID: instance.ID, ExpectedConfigVersion: current.ConfigVersion, ExpectedChecksum: current.Checksum, Key: current.Key, ProposedContent: proposed, IdempotencyKey: "typed-config-write"})
|
|
if err != nil {
|
|
t.Fatalf("queue typed config write: %v", err)
|
|
}
|
|
helloRequest := validRunControlHello()
|
|
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityConfigWrite)
|
|
hello, err := svc.RegisterRunHello(helloRequest)
|
|
if err != nil {
|
|
t.Fatalf("register typed config Run: %v", err)
|
|
}
|
|
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityConfigWrite}, Capacity: domain.RunCapacity{MaxJobs: 2}})
|
|
if err != nil || !claim.HasJob {
|
|
t.Fatalf("claim typed config job: claim=%+v err=%v", claim, err)
|
|
}
|
|
checksum := validator.BytesChecksum([]byte(proposed))
|
|
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "config write completed"}, Message: "config write completed", ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: dispatch.Job.ExecutionInput.ExpectedVersion + 1, Checksum: checksum, SizeBytes: int64(len(proposed)), Summary: "atomic compare-and-swap file write"}}); err != nil {
|
|
t.Fatalf("complete typed config job: %v", err)
|
|
}
|
|
updated, err := svc.GetServerConfigForSession(ownerSession, instance.ID)
|
|
if err != nil || updated.Content != proposed || updated.ConfigVersion != current.ConfigVersion+1 || updated.Checksum != checksum {
|
|
t.Fatalf("expected durable typed config projection, config=%+v err=%v", updated, err)
|
|
}
|
|
stored, err := svc.GetJobForSession(ownerSession, dispatch.Job.ID)
|
|
if err != nil {
|
|
t.Fatalf("read typed config job: %v", err)
|
|
}
|
|
if stored.ExecutionResult.Content != "" || stored.ExecutionResult.Checksum != checksum || stored.ExecutionResult.Version != current.ConfigVersion+1 {
|
|
t.Fatalf("unexpected safe/private job result projection: %+v", stored.ExecutionResult)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceUpdatesUsersProfileAndTheme(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
if _, err := svc.CreateUser(domain.User{
|
|
ID: "user-profile",
|
|
DisplayName: "Profile User",
|
|
Email: "profile@example.test",
|
|
Roles: []string{"server-admin"},
|
|
PasswordHash: "secret-password",
|
|
}); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
session, err := svc.LoginUser(domain.UserLogin{Account: "profile@example.test", Password: "secret-password"})
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
|
|
updated, err := svc.UpdateCurrentUserProfile(session.SessionID, domain.UserProfile{AvatarURL: "avatar://profile", Phone: "13900000000", ContactNote: "primary contact"})
|
|
if err != nil {
|
|
t.Fatalf("update profile: %v", err)
|
|
}
|
|
if updated.Profile.Phone != "13900000000" || updated.Profile.ContactNote != "primary contact" {
|
|
t.Fatalf("unexpected profile: %+v", updated.Profile)
|
|
}
|
|
|
|
theme, err := svc.UpdateCurrentUserTheme(session.SessionID, domain.UserThemePreference{PaletteID: "crystal-moonlight", BackgroundPresetID: "moon"})
|
|
if err != nil {
|
|
t.Fatalf("update theme: %v", err)
|
|
}
|
|
if theme.UserID != "user-profile" || theme.Persistence != "api" || !theme.UpdatedAt.Equal(fixedTime) {
|
|
t.Fatalf("unexpected theme preference: %+v", theme)
|
|
}
|
|
|
|
adminUpdate := updated
|
|
adminUpdate.Status = domain.UserStatusDisabled
|
|
adminUpdate.Roles = []string{"server-owner"}
|
|
adminUpdate.DisplayName = "Profile User Updated"
|
|
saved, err := svc.UpdateUser(updated.ID, adminUpdate)
|
|
if err != nil {
|
|
t.Fatalf("admin update user: %v", err)
|
|
}
|
|
if saved.Status != domain.UserStatusDisabled || saved.Roles[0] != "server-owner" || saved.DisplayName != "Profile User Updated" {
|
|
t.Fatalf("unexpected updated user: %+v", saved)
|
|
}
|
|
}
|
|
|
|
func createServiceUserAndLogin(t *testing.T, svc *CoreService, user domain.User) string {
|
|
t.Helper()
|
|
if _, err := svc.CreateUser(user); err != nil {
|
|
t.Fatalf("create %s: %v", user.ID, err)
|
|
}
|
|
session, err := svc.LoginUser(domain.UserLogin{Account: user.Email, Password: "secret-password"})
|
|
if err != nil {
|
|
t.Fatalf("login %s: %v", user.ID, err)
|
|
}
|
|
return session.SessionID
|
|
}
|
|
|
|
func TestCoreServiceManagesAIProviderMetadata(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
created, err := svc.CreateAIProvider(validProvider())
|
|
if err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
|
|
updated := created
|
|
updated.Name = "OpenAI Primary"
|
|
updated.BaseURL = "https://relay.example.test/v1"
|
|
updated.Models = []string{"gpt-4.1-mini"}
|
|
updated.DefaultModel = "gpt-4.1-mini"
|
|
updated.RelayMode = domain.AIRelayModeRelay
|
|
updated.APIKeyRef = "vault://providers/openai-primary"
|
|
got, err := svc.UpdateAIProvider(created.ID, updated)
|
|
if err != nil {
|
|
t.Fatalf("update provider: %v", err)
|
|
}
|
|
if got.Name != "OpenAI Primary" || got.Status != domain.AIProviderStatusActive || got.APIKeyRef != "vault://providers/openai-primary" {
|
|
t.Fatalf("unexpected updated provider: %+v", got)
|
|
}
|
|
|
|
disabled, err := svc.SetAIProviderStatus(created.ID, domain.AIProviderStatusDisabled)
|
|
if err != nil {
|
|
t.Fatalf("disable provider: %v", err)
|
|
}
|
|
if disabled.Status != domain.AIProviderStatusDisabled {
|
|
t.Fatalf("expected disabled provider, got %+v", disabled)
|
|
}
|
|
|
|
testResult, err := svc.TestAIProvider(created.ID)
|
|
if err != nil {
|
|
t.Fatalf("test provider: %v", err)
|
|
}
|
|
if testResult.Success || !strings.Contains(strings.Join(testResult.Violations, ","), "provider must be active") {
|
|
t.Fatalf("expected disabled provider test failure, got %+v", testResult)
|
|
}
|
|
|
|
enabled, err := svc.SetAIProviderStatus(created.ID, domain.AIProviderStatusActive)
|
|
if err != nil {
|
|
t.Fatalf("enable provider: %v", err)
|
|
}
|
|
if enabled.Status != domain.AIProviderStatusActive {
|
|
t.Fatalf("expected active provider, got %+v", enabled)
|
|
}
|
|
|
|
testResult, err = svc.TestAIProvider(created.ID)
|
|
if err != nil {
|
|
t.Fatalf("test enabled provider: %v", err)
|
|
}
|
|
if !testResult.Success || testResult.Mode != "provider" {
|
|
t.Fatalf("expected metadata test success, got %+v", testResult)
|
|
}
|
|
|
|
models, err := svc.ListAIProviderModels(created.ID)
|
|
if err != nil {
|
|
t.Fatalf("list provider models: %v", err)
|
|
}
|
|
if models.ProviderID != created.ID || models.DefaultModel != "gpt-4.1-mini" || len(models.Models) != 1 || models.Models[0] != "gpt-4.1-mini" {
|
|
t.Fatalf("unexpected provider models: %+v", models)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRegistersGamePluginManifest(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
|
|
registration := validPluginManifestRegistration()
|
|
registration.Manifest.AssetFiles = []domain.PluginAssetFile{{Path: "actions/install.json", Mode: 0o600}, {Path: "bin/install-server", Mode: 0o700}, {Path: "assets/map.bin", Mode: 0o600}}
|
|
registration.AssetFiles = []domain.PluginAssetFile{{Path: "actions/install.json", Content: "{}", Mode: 0o600}, {Path: "bin/install-server", Content: "#!/usr/bin/env sh\n"}, {Path: "assets/map.bin", Content: base64.StdEncoding.EncodeToString([]byte{0xff, 0x00, 0x7f}), Encoding: "base64"}}
|
|
plugin, err := svc.RegisterGamePluginManifest(registration)
|
|
if err != nil {
|
|
t.Fatalf("register manifest: %v", err)
|
|
}
|
|
if plugin.ID != "game.example" || plugin.ServerType != "example" || plugin.ServerDisplayName != "Example Server" {
|
|
t.Fatalf("unexpected registered plugin metadata: %+v", plugin)
|
|
}
|
|
if plugin.Status != domain.GamePluginStatusInstalled {
|
|
t.Fatalf("expected installed status, got %+v", plugin)
|
|
}
|
|
if !plugin.Permissions.AI || !plugin.Permissions.Logs || !plugin.Permissions.Files || !plugin.Permissions.Artifacts || !plugin.Permissions.Jobs {
|
|
t.Fatalf("expected aggregate permissions from manifest, got %+v", plugin.Permissions)
|
|
}
|
|
if len(plugin.Pages) != 1 || plugin.Pages[0].Permissions[0] != "server.logs.read" {
|
|
t.Fatalf("expected page metadata, got %+v", plugin.Pages)
|
|
}
|
|
if len(plugin.AIPurposes) != 1 || plugin.AIPurposes[0] != "logs.diagnose" {
|
|
t.Fatalf("expected AI purposes, got %+v", plugin.AIPurposes)
|
|
}
|
|
if len(plugin.BridgeActions) != 4 || plugin.BridgeActions[0] != string(domain.PluginBridgeActionServerInstancesRead) {
|
|
t.Fatalf("expected bridge actions, got %+v", plugin.BridgeActions)
|
|
}
|
|
if len(plugin.LifecycleAssets) != 3 || plugin.LifecycleAssets[1].Path != "bin/install-server" || plugin.LifecycleAssets[1].Mode != 0o700 || plugin.LifecycleAssets[2].Encoding != "base64" {
|
|
t.Fatalf("expected declared lifecycle assets with manifest mode defaults, got %+v", plugin.LifecycleAssets)
|
|
}
|
|
|
|
listed, err := svc.ListGamePlugins(domain.GamePluginFilter{ServerType: "example", Status: domain.GamePluginStatusInstalled})
|
|
if err != nil || len(listed) != 1 {
|
|
t.Fatalf("list registered plugins: len=%d err=%v", len(listed), err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceMarketplacePluginsAreFilteredSafeAndStateful(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
if _, err := svc.RegisterGamePluginManifest(validPluginManifestRegistration()); err != nil {
|
|
t.Fatalf("register manifest: %v", err)
|
|
}
|
|
|
|
listed, err := svc.ListMarketplacePlugins(domain.PluginMarketplaceFilter{ServerType: "example", Status: domain.GamePluginStatusInstalled, Capability: "logs.read", Keyword: "development"})
|
|
if err != nil {
|
|
t.Fatalf("list marketplace plugins: %v", err)
|
|
}
|
|
if len(listed) != 1 || listed[0].ID != "game.example" || listed[0].Source != "platform-registry" {
|
|
t.Fatalf("unexpected marketplace list: %+v", listed)
|
|
}
|
|
if len(listed[0].Capabilities) == 0 || listed[0].Capabilities[0] != "process.install" || len(listed[0].Pages) != 1 || listed[0].AIPurposes[0] != "logs.diagnose" {
|
|
t.Fatalf("expected manifest-backed projection, got %+v", listed[0])
|
|
}
|
|
|
|
detail, err := svc.GetMarketplacePlugin("game.example")
|
|
if err != nil {
|
|
t.Fatalf("get marketplace plugin: %v", err)
|
|
}
|
|
if detail.ManifestRef != "artifact://manifests/game.example/0.1.0" || detail.CreateFormSchemaRef != "schemas/create-form.schema.json" {
|
|
t.Fatalf("unexpected marketplace detail refs: %+v", detail)
|
|
}
|
|
|
|
disabled, err := svc.SetMarketplacePluginState("game.example", domain.PluginMarketplaceStateActionDisable)
|
|
if err != nil {
|
|
t.Fatalf("disable marketplace plugin: %v", err)
|
|
}
|
|
if disabled.Status != domain.GamePluginStatusDisabled {
|
|
t.Fatalf("expected disabled status, got %+v", disabled)
|
|
}
|
|
enabled, err := svc.SetMarketplacePluginState("game.example", domain.PluginMarketplaceStateActionEnable)
|
|
if err != nil {
|
|
t.Fatalf("enable marketplace plugin: %v", err)
|
|
}
|
|
if enabled.Status != domain.GamePluginStatusInstalled {
|
|
t.Fatalf("expected installed status after enable, got %+v", enabled)
|
|
}
|
|
|
|
missing, err := svc.ListMarketplacePlugins(domain.PluginMarketplaceFilter{Keyword: "missing"})
|
|
if err != nil || len(missing) != 0 {
|
|
t.Fatalf("expected empty keyword result, len=%d err=%v", len(missing), err)
|
|
}
|
|
if _, err := svc.GetMarketplacePlugin("missing"); !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("expected missing plugin error, got %v", err)
|
|
}
|
|
if _, err := svc.SetMarketplacePluginState("game.example", domain.PluginMarketplaceStateAction("download")); err == nil || !strings.Contains(err.Error(), "action is not supported") {
|
|
t.Fatalf("expected unsupported action validation, got %v", err)
|
|
}
|
|
if _, err := svc.ListMarketplacePlugins(domain.PluginMarketplaceFilter{Keyword: "sk-raw-secret"}); err == nil || !strings.Contains(err.Error(), "raw credential") {
|
|
t.Fatalf("expected unsafe keyword validation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceMarketplaceListSkipsHistoricalInvalidCapabilities(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
if _, err := svc.RegisterGamePluginManifest(validPluginManifestRegistration()); err != nil {
|
|
t.Fatalf("register current manifest: %v", err)
|
|
}
|
|
|
|
legacy := gamePluginFromManifestRegistration(validPluginManifestRegistration())
|
|
legacy.ID = "game.legacy"
|
|
legacy.Name = "Legacy Server"
|
|
legacy.RequiredRunCapabilities = append(legacy.RequiredRunCapabilities, "remote.run.protected.sql", "remote.run.protected.rcon")
|
|
legacy.RemoteAccess.RunCapabilities = append(legacy.RemoteAccess.RunCapabilities, "remote.run.protected.sql", "remote.run.protected.rcon")
|
|
if err := svc.store.GamePlugins().Create(legacy); err != nil {
|
|
t.Fatalf("seed historical plugin: %v", err)
|
|
}
|
|
|
|
listed, err := svc.ListMarketplacePlugins(domain.PluginMarketplaceFilter{})
|
|
if err != nil {
|
|
t.Fatalf("list marketplace plugins with historical record: %v", err)
|
|
}
|
|
if len(listed) != 1 || listed[0].ID != "game.example" {
|
|
t.Fatalf("expected only current marketplace plugin, got %+v", listed)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceAuthorizesPluginBridgeActions(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
if _, err := svc.RegisterGamePluginManifest(validPluginManifestRegistration()); err != nil {
|
|
t.Fatalf("register manifest: %v", err)
|
|
}
|
|
|
|
allowed, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: domain.PluginBridgeActionLogsQuery,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("authorize logs query: %v", err)
|
|
}
|
|
if !allowed.Allowed || allowed.RequiredPermissions[0] != "server.logs.read" {
|
|
t.Fatalf("expected allowed logs bridge action, got %+v", allowed)
|
|
}
|
|
|
|
missingPermission, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: domain.PluginBridgeActionFilesRequest,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("authorize files request: %v", err)
|
|
}
|
|
if missingPermission.Allowed || !strings.Contains(missingPermission.Reason, "required permission") {
|
|
t.Fatalf("expected missing permission denial, got %+v", missingPermission)
|
|
}
|
|
|
|
aiAllowed, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: domain.PluginBridgeActionAIInvoke,
|
|
AIPurpose: "logs.diagnose",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("authorize AI request: %v", err)
|
|
}
|
|
if !aiAllowed.Allowed {
|
|
t.Fatalf("expected allowed AI bridge action, got %+v", aiAllowed)
|
|
}
|
|
|
|
aiDenied, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: domain.PluginBridgeActionAIInvoke,
|
|
AIPurpose: "config.suggest",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("authorize undeclared AI request: %v", err)
|
|
}
|
|
if aiDenied.Allowed || !strings.Contains(aiDenied.Reason, "ai purpose") {
|
|
t.Fatalf("expected undeclared AI purpose denial, got %+v", aiDenied)
|
|
}
|
|
|
|
_, err = svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: domain.PluginBridgeAction("direct.run.socket"),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "action is not supported") {
|
|
t.Fatalf("expected unsupported action validation error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRemoteAccessRequiresPluginDeclaration(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
registration := validPluginManifestRegistration()
|
|
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities,
|
|
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
|
domain.JobCapabilityRemoteRunLogsTransfer,
|
|
domain.JobCapabilityRemoteRunRCONCommand,
|
|
)
|
|
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.remote.access")
|
|
registration.Manifest.Bridge.Actions = append(registration.Manifest.Bridge.Actions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
|
registration.Manifest.Pages = append(registration.Manifest.Pages, domain.GamePluginPage{
|
|
Key: "remote",
|
|
Title: "Remote",
|
|
Path: "/remote",
|
|
Permissions: []string{"server.remote.access"},
|
|
BridgeActions: []string{string(domain.PluginBridgeActionRemoteAccessRequest)},
|
|
})
|
|
registration.Manifest.RemoteAccess = domain.GamePluginRemoteAccess{
|
|
Methods: []string{"run"},
|
|
RunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand},
|
|
DatabaseEngines: []string{"sqlite"},
|
|
RCON: true,
|
|
LogTransfer: true,
|
|
}
|
|
plugin, err := svc.RegisterGamePluginManifest(registration)
|
|
if err != nil {
|
|
t.Fatalf("register remote manifest: %v", err)
|
|
}
|
|
if !plugin.Permissions.RemoteAccess || !plugin.RemoteAccess.RCON || plugin.RemoteAccess.DatabaseEngines[0] != "sqlite" {
|
|
t.Fatalf("expected remote access metadata from manifest, got %+v", plugin)
|
|
}
|
|
marketplace, err := svc.GetMarketplacePlugin(plugin.ID)
|
|
if err != nil || !marketplace.RemoteAccess.LogTransfer || marketplace.RemoteAccess.Methods[0] != "run" {
|
|
t.Fatalf("expected marketplace remote access projection, got %+v err=%v", marketplace, err)
|
|
}
|
|
|
|
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{
|
|
ID: "run-remote",
|
|
DisplayName: "Remote Run",
|
|
Version: "0.1.0",
|
|
Capabilities: append([]string{"process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"}, plugin.RemoteAccess.RunCapabilities...),
|
|
Capacity: domain.RunCapacity{MaxJobs: 2},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create remote endpoint: %v", err)
|
|
}
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-remote-owner",
|
|
DisplayName: "Remote Owner",
|
|
Email: "remote-owner@example.test",
|
|
Roles: []string{"server-owner"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-remote",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Remote Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create remote server: %v", err)
|
|
}
|
|
|
|
queued, err := svc.ExecutePluginBridgeAction(ownerSession, domain.PluginBridgeExecuteRequest{
|
|
RequestID: "remote-rcon-1",
|
|
PluginID: plugin.ID,
|
|
RouteKey: "remote",
|
|
ServerInstanceID: instance.ID,
|
|
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
|
Payload: map[string]string{
|
|
"capability": domain.JobCapabilityRemoteRunRCONCommand,
|
|
"targetKey": "rcon/command",
|
|
"inputRef": "input://server-remote/rcon/command/1",
|
|
"idempotencyKey": "idem-remote-rcon",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute remote bridge action: %v", err)
|
|
}
|
|
if queued.Status != "queued" || queued.Result["capability"] != domain.JobCapabilityRemoteRunRCONCommand {
|
|
t.Fatalf("expected queued remote bridge job, got %+v", queued)
|
|
}
|
|
|
|
plainPlugin, plainEndpoint := createPluginAndRunEndpoint(t, svc)
|
|
plainEndpoint.Capabilities = append(plainEndpoint.Capabilities, domain.JobCapabilityRemoteRunRCONCommand)
|
|
if err := svc.store.RunEndpoints().Update(plainEndpoint); err != nil {
|
|
t.Fatalf("extend plain endpoint: %v", err)
|
|
}
|
|
plainInstance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-plain", PluginID: plainPlugin.ID, RunEndpointID: plainEndpoint.ID, Name: "Plain Server"})
|
|
if err != nil {
|
|
t.Fatalf("create plain server: %v", err)
|
|
}
|
|
_, err = svc.CreateJob(domain.Job{ID: "job-remote-denied", ServerInstanceID: plainInstance.ID, RunEndpointID: plainEndpoint.ID, Capability: domain.JobCapabilityRemoteRunRCONCommand, TargetKey: "rcon/command", InputRef: "input://plain/rcon/command/1", IdempotencyKey: "idem-denied"})
|
|
if err == nil || !strings.Contains(err.Error(), "plugin missing required capability") {
|
|
t.Fatalf("expected undeclared remote job denial, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceDispatchesDeclaredSQLiteQueryTemplate(t *testing.T) {
|
|
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
|
|
|
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
|
RequestID: "query-template-dispatch-1",
|
|
PluginID: plugin.ID,
|
|
RouteKey: "remote",
|
|
ServerInstanceID: instance.ID,
|
|
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
|
Payload: map[string]string{
|
|
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
|
"declarationKey": "scum-db-read",
|
|
"targetKey": "scum-db.player-lookup",
|
|
"idempotencyKey": "query-template-dispatch-1",
|
|
"input.templateKey": "players.by-id",
|
|
"input.playerId": "steam-123",
|
|
"input.maxRows": "100",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute declared sqlite query template: %v", err)
|
|
}
|
|
if queued.Status != "queued" || queued.Result["jobId"] == "" {
|
|
t.Fatalf("expected queued query template job, got %+v", queued)
|
|
}
|
|
job, err := svc.store.Jobs().Get(queued.Result["jobId"])
|
|
if err != nil {
|
|
t.Fatalf("get query template job: %v", err)
|
|
}
|
|
if job.ExecutionInput.TimeoutSeconds != 20 {
|
|
t.Fatalf("expected template timeout 20, got %+v", job.ExecutionInput)
|
|
}
|
|
if job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || job.ExecutionInput.Inputs["sqlRef"] != "sql/players.by-id.sql" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["maxRows"] != "25" {
|
|
t.Fatalf("expected typed bounded query template inputs, got %#v", job.ExecutionInput.Inputs)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceDeniesUndeclaredOrMismatchedSQLiteQueryTemplateBeforeJob(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
templateKey string
|
|
declarationKey string
|
|
targetKey string
|
|
}{
|
|
{name: "undeclared template", templateKey: "players.unknown", declarationKey: "scum-db-read", targetKey: "scum-db.player-lookup"},
|
|
{name: "mismatched transport", templateKey: "players.by-id", declarationKey: "other-transport", targetKey: "scum-db.player-lookup"},
|
|
{name: "mismatched target", templateKey: "players.by-id", declarationKey: "scum-db-read", targetKey: "scum-db.other"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
|
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
|
RequestID: "query-template-denied-1",
|
|
PluginID: plugin.ID,
|
|
RouteKey: "remote",
|
|
ServerInstanceID: instance.ID,
|
|
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
|
Payload: map[string]string{
|
|
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
|
"declarationKey": test.declarationKey,
|
|
"targetKey": test.targetKey,
|
|
"idempotencyKey": "query-template-denied-1",
|
|
"input.templateKey": test.templateKey,
|
|
"input.playerId": "steam-123",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute denied sqlite query template: %v", err)
|
|
}
|
|
if result.Status != "denied" || result.Error == nil || result.Error.Code != "query_template_denied" {
|
|
t.Fatalf("expected query template denial, got %+v", result)
|
|
}
|
|
jobs, err := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
|
|
if err != nil {
|
|
t.Fatalf("list jobs after denial: %v", err)
|
|
}
|
|
if len(jobs) != 0 {
|
|
t.Fatalf("query template denial created jobs: %+v", jobs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindBridgeQueryTemplateRequiresPagePermissionAndRemoteAction(t *testing.T) {
|
|
_, plugin, _, _, _ := createSQLiteQueryBridgeFixture(t)
|
|
plugin.GameClientBridge.QueryTemplates[0].Permission = "server.game-client.read"
|
|
|
|
for index := range plugin.Pages {
|
|
if plugin.Pages[index].Key == "remote" {
|
|
plugin.Pages[index].Permissions = []string{"server.remote.access"}
|
|
}
|
|
}
|
|
if _, reason := findBridgeQueryTemplate(plugin, "remote", "players.by-id"); !strings.Contains(reason, "permission") {
|
|
t.Fatalf("expected query template permission denial, got %q", reason)
|
|
}
|
|
|
|
for index := range plugin.Pages {
|
|
if plugin.Pages[index].Key == "remote" {
|
|
plugin.Pages[index].Permissions = []string{"server.remote.access"}
|
|
plugin.Pages[index].BridgeActions = nil
|
|
}
|
|
}
|
|
plugin.GameClientBridge.QueryTemplates[0].Permission = "server.remote.access"
|
|
if _, reason := findBridgeQueryTemplate(plugin, "remote", "players.by-id"); !strings.Contains(reason, "remote access") {
|
|
t.Fatalf("expected query template remote access denial, got %q", reason)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceDispatchesSQLiteExecuteSQLText(t *testing.T) {
|
|
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
|
|
|
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
|
RequestID: "sqlite-execute-1",
|
|
PluginID: plugin.ID,
|
|
RouteKey: "remote",
|
|
ServerInstanceID: instance.ID,
|
|
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
|
Payload: map[string]string{
|
|
"capability": domain.JobCapabilityRemoteRunDBSQLiteExecute,
|
|
"declarationKey": "scum-db-read",
|
|
"targetKey": "scum-db.player-lookup",
|
|
"idempotencyKey": "sqlite-execute-1",
|
|
"input.mode": "execute",
|
|
"input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute sqlite SQL bridge input: %v", err)
|
|
}
|
|
if result.Status != "queued" || result.Result["jobId"] == "" {
|
|
t.Fatalf("expected queued sqlite execute job, got %+v", result)
|
|
}
|
|
job, getErr := svc.store.Jobs().Get(result.Result["jobId"])
|
|
if getErr != nil {
|
|
t.Fatalf("get sqlite execute job: %v", getErr)
|
|
}
|
|
if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteExecute || job.ExecutionInput.Inputs["sqlText"] == "" || job.ExecutionInput.Inputs["mode"] != "execute" {
|
|
t.Fatalf("expected sqlite execute inputs, got %#v", job)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceUpsertsDuplicateGamePluginManifest(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
registration := validPluginManifestRegistration()
|
|
if _, err := svc.RegisterGamePluginManifest(registration); err != nil {
|
|
t.Fatalf("register first manifest: %v", err)
|
|
}
|
|
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "manifest-refresh-server", PluginID: registration.Manifest.ID, PluginVersion: registration.Manifest.Version, Name: "Manifest refresh server"}); err != nil {
|
|
t.Fatalf("create server for manifest refresh: %v", err)
|
|
}
|
|
if err := svc.store.RuntimeBindings().Create(domain.RuntimeBinding{ID: "runtime-binding-manifest-refresh-server", ServerInstanceID: "manifest-refresh-server", PluginID: registration.Manifest.ID, PluginVersion: registration.Manifest.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: fixedTime, UpdatedAt: fixedTime}); err != nil {
|
|
t.Fatalf("create runtime binding for manifest refresh: %v", err)
|
|
}
|
|
|
|
registration.Manifest.Version = "0.1.1"
|
|
registration.Manifest.Description = "Development plugin refreshed"
|
|
registration.ManifestRef = "artifact://manifests/game.example/0.1.1"
|
|
updated, err := svc.RegisterGamePluginManifest(registration)
|
|
if err != nil {
|
|
t.Fatalf("upsert manifest: %v", err)
|
|
}
|
|
if updated.ID != "game.example" || updated.Version != "0.1.1" || updated.ManifestRef != "artifact://manifests/game.example/0.1.1" {
|
|
t.Fatalf("expected existing plugin to update in place, got %+v", updated)
|
|
}
|
|
listed, err := svc.ListGamePlugins(domain.GamePluginFilter{ServerType: "example", Status: domain.GamePluginStatusInstalled})
|
|
if err != nil || len(listed) != 1 || listed[0].ID != "game.example" || listed[0].Version != "0.1.1" {
|
|
t.Fatalf("expected one refreshed plugin after upsert, listed=%+v err=%v", listed, err)
|
|
}
|
|
server, err := svc.GetServerInstance("manifest-refresh-server")
|
|
if err != nil || server.PluginVersion != "0.1.1" {
|
|
t.Fatalf("expected existing server to follow refreshed plugin version, server=%+v err=%v", server, err)
|
|
}
|
|
binding, err := svc.store.RuntimeBindings().Get("runtime-binding-manifest-refresh-server")
|
|
if err != nil || binding.PluginVersion != "0.1.1" {
|
|
t.Fatalf("expected runtime binding to follow refreshed plugin version, binding=%+v err=%v", binding, err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRejectsUnsafeGamePluginManifest(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
registration := validPluginManifestRegistration()
|
|
registration.Manifest.Description = "requires direct run socket and raw AI key"
|
|
|
|
_, err := svc.RegisterGamePluginManifest(registration)
|
|
if err == nil || !strings.Contains(err.Error(), "direct run access") || !strings.Contains(err.Error(), "raw credential") {
|
|
t.Fatalf("expected unsafe manifest rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRejectsInvalidAIProviderManagement(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
provider := validProvider()
|
|
if _, err := svc.CreateAIProvider(provider); err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
|
|
provider.APIKeyRef = "sk-raw-secret"
|
|
_, err := svc.UpdateAIProvider(provider.ID, provider)
|
|
if err == nil || !strings.Contains(err.Error(), "apiKeyRef must reference secret storage") {
|
|
t.Fatalf("expected raw secret rejection, got %v", err)
|
|
}
|
|
|
|
_, err = svc.SetAIProviderStatus(provider.ID, domain.AIProviderStatusError)
|
|
if err == nil || !strings.Contains(err.Error(), "status must be active or disabled") {
|
|
t.Fatalf("expected invalid status rejection, got %v", err)
|
|
}
|
|
|
|
_, err = svc.UpdateAIProvider("missing", provider)
|
|
if !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("expected missing update target, got %v", err)
|
|
}
|
|
_, err = svc.TestAIProvider("missing")
|
|
if !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("expected missing test target, got %v", err)
|
|
}
|
|
_, err = svc.ListAIProviderModels("missing")
|
|
if !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("expected missing models target, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceReturnsExistingJobForDuplicateIdempotencyKey(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
instance, err := svc.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-1",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "SCUM #1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server instance: %v", err)
|
|
}
|
|
|
|
first, err := svc.CreateJob(domain.Job{
|
|
ID: "job-1",
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Capability: "process.start",
|
|
IdempotencyKey: "idem-start",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create first job: %v", err)
|
|
}
|
|
second, err := svc.CreateJob(domain.Job{
|
|
ID: "job-2",
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Capability: "process.start",
|
|
IdempotencyKey: "idem-start",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create second job: %v", err)
|
|
}
|
|
if second.ID != first.ID {
|
|
t.Fatalf("expected idempotent job %q, got %q", first.ID, second.ID)
|
|
}
|
|
|
|
jobs, err := svc.ListJobs(domain.JobFilter{RunEndpointID: endpoint.ID})
|
|
if err != nil {
|
|
t.Fatalf("list jobs: %v", err)
|
|
}
|
|
if len(jobs) != 1 {
|
|
t.Fatalf("expected one stored job, got %+v", jobs)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRejectsJobTargetMismatch(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
otherEndpoint := endpoint
|
|
otherEndpoint.ID = "run-other"
|
|
if _, err := svc.CreateRunEndpoint(otherEndpoint); err != nil {
|
|
t.Fatalf("create other endpoint: %v", err)
|
|
}
|
|
instance, err := svc.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-1",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "SCUM #1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server instance: %v", err)
|
|
}
|
|
|
|
_, err = svc.CreateJob(domain.Job{
|
|
ID: "job-1",
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: otherEndpoint.ID,
|
|
Capability: "process.start",
|
|
IdempotencyKey: "idem-start",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "job runEndpointId must match server instance") {
|
|
t.Fatalf("expected target mismatch rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServicePropagatesDuplicateErrors(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
user := domain.User{ID: "user-1", DisplayName: "Operator", Status: domain.UserStatusActive}
|
|
if _, err := svc.CreateUser(user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
_, err := svc.CreateUser(user)
|
|
if !errors.Is(err, repo.ErrDuplicate) {
|
|
t.Fatalf("expected duplicate error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func newTestCoreService() *CoreService {
|
|
svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime })
|
|
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: []byte("platform-built-distribution")})
|
|
return svc
|
|
}
|
|
|
|
type staticDistributionBuilder struct {
|
|
payload []byte
|
|
err error
|
|
}
|
|
|
|
func (builder staticDistributionBuilder) Readiness() (bool, string) {
|
|
if builder.err != nil {
|
|
return false, builder.err.Error()
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
func (builder staticDistributionBuilder) Build(domain.DistributionBuildInput) ([]byte, error) {
|
|
if builder.err != nil {
|
|
return nil, builder.err
|
|
}
|
|
return domain.CopyBytes(builder.payload), nil
|
|
}
|
|
|
|
func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlugin, domain.RunEndpoint) {
|
|
t.Helper()
|
|
|
|
plugin, err := svc.CreateGamePlugin(domain.GamePlugin{
|
|
ID: "server.scum",
|
|
Name: "SCUM",
|
|
Version: "1.0.0",
|
|
ServerType: "scum",
|
|
ManifestRef: "artifact://manifests/server.scum/1.0.0",
|
|
CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0",
|
|
RequiredRunCapabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "config.write", "files.read", "files.write"},
|
|
DeclaredPermissions: []string{"server.files.read", "server.files.write"},
|
|
LifecycleActions: domain.PluginLifecycleActions{
|
|
Install: "actions/install.json",
|
|
Start: "actions/start.json",
|
|
Stop: "actions/stop.json",
|
|
},
|
|
Permissions: domain.PluginPermissions{
|
|
Logs: true,
|
|
Files: true,
|
|
Jobs: true,
|
|
},
|
|
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create plugin fixture: %v", err)
|
|
}
|
|
|
|
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{
|
|
ID: "run-local",
|
|
DisplayName: "Local Run",
|
|
Version: "0.1.0",
|
|
Capabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "config.write", "files.read", "files.write"},
|
|
Capacity: domain.RunCapacity{MaxJobs: 4},
|
|
LastHeartbeatAt: svc.now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create run endpoint fixture: %v", err)
|
|
}
|
|
|
|
return plugin, endpoint
|
|
}
|
|
|
|
func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlugin, domain.RunEndpoint, string, domain.ServerInstance) {
|
|
t.Helper()
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
|
|
executeCapability := domain.JobCapabilityRemoteRunDBSQLiteExecute
|
|
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability, executeCapability)
|
|
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
|
|
plugin.Permissions.RemoteAccess = true
|
|
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
|
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{
|
|
Key: "remote",
|
|
Title: "Remote",
|
|
Path: "/remote",
|
|
Permissions: []string{"server.remote.access"},
|
|
BridgeActions: []string{string(domain.PluginBridgeActionRemoteAccessRequest)},
|
|
})
|
|
plugin.RemoteAccess = domain.GamePluginRemoteAccess{
|
|
Methods: []string{"run"},
|
|
RunCapabilities: []string{capability, executeCapability},
|
|
DatabaseEngines: []string{"sqlite"},
|
|
}
|
|
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{
|
|
Key: "scum-db-read",
|
|
Kind: "sqlite",
|
|
TargetKey: "scum-db.player-lookup",
|
|
Capabilities: []string{capability, executeCapability},
|
|
})
|
|
plugin.GameClientBridge = domain.GameClientBridgeManifest{
|
|
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{
|
|
{
|
|
Key: "players.by-id",
|
|
Title: "Player lookup",
|
|
Permission: "server.remote.access",
|
|
Engine: "sqlite",
|
|
TransportKey: "scum-db-read",
|
|
TargetKey: "scum-db.player-lookup",
|
|
ParameterSchemaRef: "schemas/queries/players.by-id.parameters.schema.json",
|
|
ResultSchemaRef: "schemas/queries/players.by-id.result.schema.json",
|
|
SQLRef: "sql/players.by-id.sql",
|
|
MaxRows: 25,
|
|
TimeoutSeconds: 20,
|
|
},
|
|
},
|
|
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100},
|
|
Pages: []domain.GameClientBridgePageContract{
|
|
{PageKey: "remote", QueryTemplateKeys: []string{"players.by-id"}},
|
|
},
|
|
}
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("update sqlite query plugin fixture: %v", err)
|
|
}
|
|
endpoint.Capabilities = append(endpoint.Capabilities, capability, executeCapability)
|
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
|
t.Fatalf("update sqlite query endpoint fixture: %v", err)
|
|
}
|
|
session := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-query-owner",
|
|
DisplayName: "Query Owner",
|
|
Email: "query-owner@example.test",
|
|
Roles: []string{"server-owner"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{
|
|
ID: "server-query-template",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Query Template Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create sqlite query server fixture: %v", err)
|
|
}
|
|
return svc, plugin, endpoint, session, instance
|
|
}
|
|
|
|
func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domain.ServerInstance, profileKey string) domain.RuntimeBinding {
|
|
t.Helper()
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
t.Fatalf("get plugin for runtime binding: %v", err)
|
|
}
|
|
profile, ok := runtimeLifecycleProfile(plugin.RuntimeProfiles, profileKey)
|
|
if !ok {
|
|
t.Fatalf("runtime profile %s missing", profileKey)
|
|
}
|
|
required, _ := runtimeBindingKeys(plugin.RuntimeProfiles, profile)
|
|
bindings := map[string]string{}
|
|
for _, key := range required {
|
|
if runtimeBindingTestKeyIsSensitive(key) {
|
|
bindings[key] = "secret://" + instance.ID + "/" + strings.ReplaceAll(key, "/", "-")
|
|
} else {
|
|
bindings[key] = "runtime." + strings.ReplaceAll(key, "/", ".")
|
|
}
|
|
}
|
|
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: profileKey, Bindings: bindings}, true)
|
|
if err != nil {
|
|
t.Fatalf("build runtime binding: %v", err)
|
|
}
|
|
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
|
|
t.Fatalf("create runtime binding: %v", err)
|
|
}
|
|
return binding
|
|
}
|
|
|
|
func waitForServerFileListJob(t *testing.T, svc *CoreService, serverInstanceID string, excludedJobID string) domain.Job {
|
|
t.Helper()
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID})
|
|
if err != nil {
|
|
t.Fatalf("list server jobs: %v", err)
|
|
}
|
|
for _, job := range jobs {
|
|
if job.ID != excludedJobID && job.Capability == domain.JobCapabilityFilesList {
|
|
return job
|
|
}
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
t.Fatalf("fresh file list job was not created")
|
|
return domain.Job{}
|
|
}
|
|
|
|
func runFileListFixture(directoryKey string, relativePath string, name string) string {
|
|
return fmt.Sprintf(`{"directoryKey":%q,"path":%q,"entries":[{"name":%q,"kind":"directory","directoryKey":%q,"relativePath":%q}]}`, directoryKey, relativePath, name, directoryKey, name)
|
|
}
|
|
|
|
func runtimeBindingTestKeyIsSensitive(key string) bool {
|
|
normalized := strings.ToLower(key)
|
|
return strings.Contains(normalized, "password") || strings.Contains(normalized, "credential") || strings.Contains(normalized, "secret") || strings.Contains(normalized, "token") || strings.Contains(normalized, "dsn")
|
|
}
|
|
|
|
func validPluginManifestRegistration() domain.GamePluginManifestRegistration {
|
|
return domain.GamePluginManifestRegistration{
|
|
ManifestRef: "artifact://manifests/game.example/0.1.0",
|
|
Manifest: domain.GamePluginManifest{
|
|
ID: "game.example",
|
|
Name: "Example Server",
|
|
Description: "Development plugin",
|
|
Version: "0.1.0",
|
|
Kind: "game-plugin",
|
|
Tags: []string{"example", "development"},
|
|
Server: domain.GamePluginManifestServer{
|
|
Type: "example",
|
|
DisplayName: "Example Server",
|
|
SupportedOS: []string{"linux", "darwin"},
|
|
CreateFormSchema: "schemas/create-form.schema.json",
|
|
},
|
|
Bridge: domain.GamePluginBridge{
|
|
Actions: []string{
|
|
string(domain.PluginBridgeActionServerInstancesRead),
|
|
string(domain.PluginBridgeActionLogsQuery),
|
|
string(domain.PluginBridgeActionFilesRequest),
|
|
string(domain.PluginBridgeActionAIInvoke),
|
|
},
|
|
},
|
|
Capabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"},
|
|
Permissions: []string{"server.read", "server.lifecycle", "server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"},
|
|
Actions: domain.PluginLifecycleActions{
|
|
Install: "actions/install.json",
|
|
Start: "actions/start.json",
|
|
Stop: "actions/stop.json",
|
|
Restart: "actions/restart.json",
|
|
},
|
|
Pages: []domain.GamePluginPage{
|
|
{
|
|
Key: "logs",
|
|
Title: "Logs",
|
|
Path: "/logs",
|
|
Permissions: []string{"server.logs.read", "ai.invoke"},
|
|
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
|
},
|
|
},
|
|
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
|
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"},
|
|
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func validProvider() domain.AIProvider {
|
|
return domain.AIProvider{
|
|
ID: "ai.openai",
|
|
Name: "OpenAI",
|
|
Kind: domain.AIProviderKindOpenAI,
|
|
BaseURL: "https://api.openai.com/v1",
|
|
APIKeyRef: "secret://providers/openai",
|
|
Models: []string{"gpt-4.1", "gpt-4.1-mini"},
|
|
DefaultModel: "gpt-4.1",
|
|
RelayMode: domain.AIRelayModeDirect,
|
|
TimeoutMS: 30000,
|
|
RedactionPolicy: "default",
|
|
}
|
|
}
|