Files

327 lines
17 KiB
Go

package service
import (
"encoding/json"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/repo"
)
func TestSourceRCONDispatchUsesOneTimeOpaqueInput(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
request := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindChat, ChatType: 4, Message: `Bounty "claimed"`, TargetSteamID: "76561198000000001", IdempotencyKey: "rcon-chat-1"}
dispatch, err := svc.DispatchSourceRCONCommandForSession(session, request)
if err != nil {
t.Fatalf("dispatch chat: %v", err)
}
if dispatch.Status != string(domain.JobStateQueued) || dispatch.JobID == "" {
t.Fatalf("unexpected safe dispatch: %+v", dispatch)
}
job, err := svc.store.Jobs().Get(dispatch.JobID)
if err != nil {
t.Fatalf("get RCON job: %v", err)
}
if job.RetryPolicy.MaxAttempts != 1 || job.ExecutionInput.SourceRCON == nil || job.ExecutionInput.SourceRCON.ConfigRef != "ue4ss/Mods/scum_simple_rcon/config.ini" || job.ExecutionInput.SourceRCON.DeploymentStateRef != "runtime/ue4ss-dll/ue4ss/scum-simple-rcon/release.json" || len(job.ExecutionInput.Inputs) != 0 {
t.Fatalf("expected one-attempt frozen RCON plan without inputs, got %+v", job)
}
for _, value := range []string{request.Message, request.TargetSteamID, "password=", "127.0.0.1"} {
body, marshalErr := json.Marshal(job)
if marshalErr != nil {
t.Fatalf("marshal stored job: %v", marshalErr)
}
if strings.Contains(string(body), value) {
t.Fatalf("stored job exposed %q: %s", value, body)
}
}
assignment := dto.RunJobAssignmentFromDomain(domain.RunJobAssignment{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, Capability: job.Capability, TargetKey: job.TargetKey, InputRef: job.InputRef, IdempotencyKey: job.IdempotencyKey, State: job.State, ExecutionInput: job.ExecutionInput})
wire, err := json.Marshal(assignment)
if err != nil {
t.Fatalf("marshal Run assignment: %v", err)
}
if strings.Contains(string(wire), request.Message) || strings.Contains(string(wire), "password=") {
t.Fatalf("Run assignment exposed transient RCON material: %s", wire)
}
duplicate, err := svc.DispatchSourceRCONCommandForSession(session, domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "SetTime 12", IdempotencyKey: request.IdempotencyKey})
if err != nil || duplicate.JobID != dispatch.JobID {
t.Fatalf("expected idempotent dispatch without replacement, duplicate=%+v err=%v", duplicate, err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != job.ID {
t.Fatalf("claim RCON job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"})
if err != nil || !ack.Accepted {
t.Fatalf("ack RCON job: ack=%+v err=%v", ack, err)
}
if _, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: job.ID, LeaseToken: "wrong", Attempt: ack.Job.Attempt}); err == nil {
t.Fatal("expected foreign lease rejection")
}
input, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: job.ID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt})
if err != nil {
t.Fatalf("consume one-time RCON input: %v", err)
}
if input.Command != `SendChat 4 "Bounty \"claimed\"" 76561198000000001` {
t.Fatalf("unexpected formatted RCON chat command: %q", input.Command)
}
if _, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: job.ID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}); err == nil {
t.Fatal("expected repeated one-time input rejection")
}
stored, err := svc.store.Jobs().Get(job.ID)
if err != nil {
t.Fatalf("get stored RCON job after consume: %v", err)
}
storedJSON, _ := json.Marshal(stored)
if strings.Contains(string(storedJSON), input.Command) || strings.Contains(string(storedJSON), request.Message) {
t.Fatalf("consumed command was persisted: %s", storedJSON)
}
}
func TestSourceRCONDispatchRejectsUnsafeOrIncompatibleState(t *testing.T) {
svc, session, _, instance := newSourceRCONFixture(t)
unsafe := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "SetTime 12\nSpawnItem", IdempotencyKey: "rcon-unsafe"}
if _, err := svc.DispatchSourceRCONCommandForSession(session, unsafe); err == nil {
t.Fatal("expected framing control rejection")
}
endpoint, err := svc.store.RunEndpoints().Get("run-local")
if err != nil {
t.Fatal(err)
}
endpoint.Platform = "linux"
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatal(err)
}
_, err = svc.DispatchSourceRCONCommandForSession(session, domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "rcon.status", IdempotencyKey: "rcon-linux"})
if err == nil || !strings.Contains(err.Error(), "unsupported_extension_platform") {
t.Fatalf("expected explicit Linux rejection, got %v", err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 0 {
t.Fatalf("rejected RCON requests must not create jobs, jobs=%+v err=%v", jobs, err)
}
}
func TestSourceRCONDispatchCanonicalizesLegacyLocalBinding(t *testing.T) {
svc, _, _, instance := newSourceRCONFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatal(err)
}
plugin.RuntimeProfiles.LifecycleProfiles[0].Key = "run-local"
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update runtime profile key: %v", err)
}
resolution, err := svc.resolveSourceRCONDispatch(instance)
if err != nil {
t.Fatalf("resolve RCON dispatch with legacy binding: %v", err)
}
if resolution.binding.ProfileKey != "run-local" {
t.Fatalf("expected legacy binding to be canonicalized, got %q", resolution.binding.ProfileKey)
}
}
func TestSourceRCONDispatchSelectsDeclaredProfileWithoutManualBinding(t *testing.T) {
svc, session, _, instance := newSourceRCONFixtureWithRuntimeBinding(t, false)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatal(err)
}
plugin.RuntimeProfiles.LifecycleProfiles[0].Key = "run-local"
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update source RCON profile key: %v", err)
}
dispatch, err := svc.DispatchSourceRCONCommandForSession(session, domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "#ListPlayers", IdempotencyKey: "rcon-without-binding"})
if err != nil {
t.Fatalf("dispatch RCON without manual binding: %v", err)
}
job, err := svc.store.Jobs().Get(dispatch.JobID)
if err != nil {
t.Fatalf("get RCON job: %v", err)
}
if job.ExecutionInput.WorkspaceScope != "run-local" || job.TargetKey != "rcon" || job.ExecutionInput.SourceRCON == nil {
t.Fatalf("expected declared source RCON profile to drive job, got %+v", job)
}
}
func TestBridgeRemoteAccessDispatchesDeclaredSourceRCONCommand(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatal(err)
}
plugin.BridgeActions = []string{string(domain.PluginBridgeActionRemoteAccessRequest)}
plugin.Pages = []domain.GamePluginPage{{Key: "remote", Title: "Remote", Path: "/remote", Permissions: []string{"server.remote.access"}, BridgeActions: []string{string(domain.PluginBridgeActionRemoteAccessRequest)}}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update bridge-capable RCON plugin: %v", err)
}
response, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
RequestID: "bridge-rcon-1",
PluginID: plugin.ID,
RouteKey: "remote",
ServerInstanceID: instance.ID,
Action: domain.PluginBridgeActionRemoteAccessRequest,
Payload: map[string]string{
"capability": domain.JobCapabilityRemoteRunRCONCommand,
"declarationKey": "rcon",
"targetKey": "rcon",
"idempotencyKey": "bridge-rcon-1",
"input.command": "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777",
},
})
if err != nil {
t.Fatalf("execute bridge RCON command: %v", err)
}
if response.Status != "queued" || response.Result["adapterKind"] != string(domain.RemoteAdapterRCON) || response.Result["targetKey"] != "rcon" || response.Result["capability"] != domain.JobCapabilityRemoteRunRCONCommand {
t.Fatalf("expected queued source RCON bridge response, got %+v", response)
}
job, err := svc.store.Jobs().Get(response.Result["jobId"])
if err != nil {
t.Fatalf("get bridge RCON job: %v", err)
}
if job.ExecutionInput.SourceRCON == nil || !strings.HasPrefix(job.InputRef, "input://source-rcon/") || len(job.ExecutionInput.Inputs) != 0 {
t.Fatalf("expected source RCON one-time input job, got %+v", job)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != job.ID {
t.Fatalf("claim bridge RCON job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"})
if err != nil || !ack.Accepted {
t.Fatalf("ack bridge RCON job: ack=%+v err=%v", ack, err)
}
input, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: job.ID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt})
if err != nil {
t.Fatalf("consume bridge RCON input: %v", err)
}
if input.Command != "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777" {
t.Fatalf("expected bridge RCON command to remain verbatim, got %q", input.Command)
}
}
func TestSourceRCONDispatchFallsBackFromIncompatibleLegacyBinding(t *testing.T) {
svc, session, _, instance := newSourceRCONFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatal(err)
}
plugin.RuntimeProfiles.LifecycleProfiles[0].Key = "run-local"
plugin.RuntimeProfiles.LifecycleProfiles = append(plugin.RuntimeProfiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: "legacy-local", Mode: "local-process", Capabilities: []string{"logs.read"}, Platforms: []string{"windows"}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin profiles: %v", err)
}
binding, err := svc.store.RuntimeBindings().Get("runtime-binding-" + instance.ID)
if err != nil {
t.Fatalf("get runtime binding: %v", err)
}
binding.ProfileKey = "legacy-local"
binding.Mode = "local-process"
binding.Bindings = map[string]string{"logs": "runtime-logs"}
if err := svc.store.RuntimeBindings().Update(binding); err != nil {
t.Fatalf("point binding at incompatible profile: %v", err)
}
dispatch, err := svc.DispatchSourceRCONCommandForSession(session, domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "#ListPlayers", IdempotencyKey: "rcon-incompatible-binding"})
if err != nil {
t.Fatalf("dispatch RCON with incompatible binding fallback: %v", err)
}
job, err := svc.store.Jobs().Get(dispatch.JobID)
if err != nil {
t.Fatalf("get RCON job: %v", err)
}
if job.ExecutionInput.WorkspaceScope != "run-local" || job.TargetKey != "rcon" || job.ExecutionInput.RemoteAdapterKey != "rcon" {
t.Fatalf("expected source RCON to use run-local despite incompatible binding, got %+v", job)
}
}
func TestSourceRCONBrokerExpiresWithoutReplay(t *testing.T) {
stamp := fixedTime
broker := newSourceRCONCommandBroker(func() time.Time { return stamp })
if err := broker.Put("job-rcon-expired", "rcon.status"); err != nil {
t.Fatal(err)
}
stamp = stamp.Add(sourceRCONCommandTTL)
if _, err := broker.Consume("job-rcon-expired"); err == nil {
t.Fatal("expected expired command to fail closed")
}
}
func newSourceRCONFixture(t *testing.T) (*CoreService, string, string, domain.ServerInstance) {
return newSourceRCONFixtureWithRuntimeBinding(t, true)
}
func newSourceRCONFixtureWithRuntimeBinding(t *testing.T, createBinding bool) (*CoreService, string, string, domain.ServerInstance) {
t.Helper()
svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime })
capability := domain.JobCapabilityRemoteRunRCONCommand
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{domain.LifecycleCapabilityStart, capability},
DeclaredPermissions: []string{"server.remote.access"},
Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true},
RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{capability}, RCON: true},
LifecycleActions: domain.PluginLifecycleActions{Start: "actions/start.json"},
RuntimeProfiles: domain.GamePluginRuntimeProfiles{
LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityStart, capability}, TransportKeys: []string{"rcon"}, DLLExtensionRefs: []string{"scum-simple-rcon"}, Platforms: []string{"windows"}}},
TransportProfiles: []domain.RuntimeTransportProfile{{Key: "rcon", Kind: "rcon", TargetKey: "rcon", Capabilities: []string{capability}}},
DLLExtensions: []domain.RuntimeDLLExtensionProfile{{
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024,
TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
TargetExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015,
}},
},
})
if err != nil {
t.Fatalf("create SCUM RCON plugin: %v", err)
}
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Capabilities: []string{domain.LifecycleCapabilityStart, capability}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil {
t.Fatalf("create RCON endpoint: %v", err)
}
session := createServiceUserAndLogin(t, svc, domain.User{ID: "user-rcon-owner", DisplayName: "RCON Owner", Email: "rcon-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-rcon", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "RCON Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create RCON server: %v", err)
}
if createBinding {
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"rcon": "runtime-rcon"}}, true)
if err != nil {
t.Fatalf("create RCON binding: %v", err)
}
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
t.Fatalf("store RCON binding: %v", err)
}
}
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = []string{capability}
helloRequest.CapabilityReport.Fingerprint = "cap-source-rcon"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register RCON Run: %v", err)
}
endpoint, err = svc.store.RunEndpoints().Get(endpoint.ID)
if err != nil {
t.Fatal(err)
}
endpoint.Platform = "windows"
endpoint.Architecture = "amd64"
endpoint.Capabilities = []string{domain.LifecycleCapabilityStart, capability}
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update RCON endpoint: %v", err)
}
return svc, session, hello.SessionToken, instance
}