package service import ( "testing" "browser.local/platform/domain" ) func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) ownerID := "plugin-data-owner" sessionID := createServiceUserAndLogin(t, svc, domain.User{ID: ownerID, DisplayName: "Plugin data owner", Email: "plugin-data@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: ownerID, Name: "Game Server"}); err != nil { t.Fatalf("create server: %v", err) } stored, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "plugin_users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "futureField": true}}) if err != nil || stored.Value["futureField"] != true { t.Fatalf("put plugin data=%+v err=%v", stored, err) } items, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "plugin_users"}) if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["steamId"] != "steam-1" { t.Fatalf("list plugin data=%+v err=%v", items, err) } if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "other.plugin", ServerInstanceID: "server-1", Collection: "plugin_users"}); err != ErrForbidden { t.Fatalf("expected plugin isolation error, got %v", err) } otherOwner, err := svc.CreateUser(domain.User{ID: "other-plugin-data-owner", DisplayName: "Other plugin data owner", Email: "other-plugin-data@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) if err != nil { t.Fatalf("register other owner: %v", err) } if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-2", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: otherOwner.ID, Name: "Other server"}); err != nil { t.Fatalf("create other server: %v", err) } if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-2", Collection: "plugin_users"}); err != ErrForbidden { t.Fatalf("expected server isolation error, got %v", err) } if _, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "settings", Key: "steam-1", Value: map[string]any{"enabled": true}}); err != nil { t.Fatalf("put second collection: %v", err) } items, err = svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "plugin_users"}) if err != nil || len(items) != 1 || items[0].Value["futureField"] != true { t.Fatalf("collection isolation values=%+v err=%v", items, err) } } func TestPluginDataTransactionAppliesPutAndDeleteTogether(t *testing.T) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Plugin transaction owner", Email: "plugin-transaction@example.test", Password: "secret-password"}) if err != nil { t.Fatalf("register owner: %v", err) } if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-transaction", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "Transaction"}); err != nil { t.Fatalf("create server: %v", err) } if _, err := svc.PutPluginDataForSession(owner.SessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-transaction", Collection: "records", Key: "old", Value: map[string]any{"state": "old"}}); err != nil { t.Fatalf("seed old record: %v", err) } stored, err := svc.ApplyPluginDataTransactionForSession(owner.SessionID, domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: "server-transaction", Collection: "records", Mutations: []domain.PluginDataMutation{ {Operation: domain.PluginDataMutationPut, Key: "one", Value: map[string]any{"state": "ready"}}, {Operation: domain.PluginDataMutationPut, Key: "two", Value: map[string]any{"state": "ready"}}, {Operation: domain.PluginDataMutationDelete, Key: "old"}, }}) if err != nil || len(stored) != 2 { t.Fatalf("apply transaction=%+v err=%v", stored, err) } items, err := svc.ListPluginDataForSession(owner.SessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-transaction", Collection: "records"}) if err != nil || len(items) != 2 || items[0].Key != "one" || items[1].Key != "two" { t.Fatalf("list transaction result=%+v err=%v", items, err) } } func TestPluginDataTransactionValidationFailureDoesNotPartiallyApply(t *testing.T) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Atomic transaction owner", Email: "atomic-transaction@example.test", Password: "secret-password"}) if err != nil { t.Fatalf("register owner: %v", err) } if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-atomic", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "Atomic"}); err != nil { t.Fatalf("create server: %v", err) } if _, err := svc.PutPluginDataForSession(owner.SessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-atomic", Collection: "records", Key: "existing", Value: map[string]any{"state": "before"}}); err != nil { t.Fatalf("seed existing record: %v", err) } _, err = svc.ApplyPluginDataTransactionForSession(owner.SessionID, domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: "server-atomic", Collection: "records", Mutations: []domain.PluginDataMutation{ {Operation: domain.PluginDataMutationPut, Key: "new", Value: map[string]any{"state": "after"}}, {Operation: domain.PluginDataMutationDelete, Key: "existing"}, {Operation: domain.PluginDataMutationPut, Key: "invalid", Value: nil}, }}) if err == nil { t.Fatal("expected transaction validation error") } items, listErr := svc.ListPluginDataForSession(owner.SessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-atomic", Collection: "records"}) if listErr != nil || len(items) != 1 || items[0].Key != "existing" || items[0].Value["state"] != "before" { t.Fatalf("transaction partially applied values=%+v err=%v", items, listErr) } } func TestDeclaredSQLiteQueryDoesNotMutatePluginOrPlatformUserData(t *testing.T) { svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t) if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "plugin_users", Key: "steam-keep", Value: map[string]any{"steamId": "steam-keep", "format": "plugin-local"}}); err != nil { t.Fatalf("seed plugin data: %v", err) } queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-no-projection-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{ "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "game-db-read", "targetKey": "game-db.player-lookup", "idempotencyKey": "query-no-projection-1", "input.templateKey": "players.by-id", }}) if err != nil || queued.Status != "queued" { t.Fatalf("queue declared query=%+v err=%v", queued, err) } helloRequest := validRunControlHello() helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) helloRequest.CapabilityReport.Fingerprint = "cap-plugin-data-query-independent" hello, err := svc.RegisterRunHello(helloRequest) if err != nil { t.Fatalf("register Run: %v", err) } claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) if err != nil || !claim.HasJob || claim.Job == nil { t.Fatalf("claim query job=%+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}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"user_id":"steam-1","display_name":"Ada"},{"user_id":"steam-2","display_name":"Lin"}]}`}}) if err != nil { t.Fatalf("complete query job: %v", err) } items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "plugin_users"}) if err != nil || len(items) != 1 || items[0].Key != "steam-keep" || items[0].Value["format"] != "plugin-local" { t.Fatalf("declared query mutated plugin data=%+v err=%v", items, err) } current, err := svc.GetCurrentUser(session) if err != nil || current.ID != "user-query-owner" || current.Email != "query-owner@example.test" { t.Fatalf("declared query mutated platform user session/user=%+v err=%v", current, err) } } func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) { svc, plugin, endpoint, _, _ := createSQLiteQueryBridgeFixture(t) plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3 if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("enable query refresh hint: %v", err) } helloRequest := validRunControlHello() helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-no-scheduler" hello, err := svc.RegisterRunHello(helloRequest) if err != nil { t.Fatalf("register Run: %v", err) } claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) if err != nil || claim.HasJob { t.Fatalf("automatic projection query was scheduled: %+v err=%v", claim, err) } jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpoint.ID}) if err != nil || len(jobs) != 0 { t.Fatalf("automatic projection query persisted jobs=%+v err=%v", jobs, err) } } func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) { t.Skip("query polling and result projection are plugin-owned") svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t) plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3 plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{ Collection: "plugin_users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", }, { Collection: "scum_trade_goods", RowPath: "rows", MatchField: "kind", MatchValue: "vehicle", UpsertKeys: []string{"code"}, FieldMappings: map[string]string{"className": "displayName"}, FixedValues: map[string]string{"code": "#spawnvehicle {{displayName}}", "spawnCommand": "#spawnvehicle {{displayName}}", "catalogType": "vehicle", "type": "21", "typeName": "其他载具", "imagePath": "/original/{{displayName}}.webp", "source": "sqlite"}, ObservedAtField: "lastSeenAt", MergeExisting: true, }} if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("enable query projection polling: %v", err) } if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods", Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: "#spawnvehicle Truck", Value: map[string]any{"code": "#spawnvehicle Truck", "name": "Named Truck"}}}}); err != nil { t.Fatalf("seed vehicle catalog: %v", err) } helloRequest := validRunControlHello() helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-scheduler" hello, err := svc.RegisterRunHello(helloRequest) if err != nil { t.Fatalf("register Run: %v", err) } claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) if err != nil || !claim.HasJob || claim.Job == nil { t.Fatalf("projection query was not scheduled: %+v err=%v", claim, err) } if claim.Job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || claim.Job.ExecutionInput.Inputs["sqlRef"] != "sql/players.by-id.sql" { t.Fatalf("scheduled projection query lost template inputs: %+v", claim.Job.ExecutionInput.Inputs) } _, 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}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"kind":"player","steamId":"steam-1","displayName":"Ada"},{"kind":"vehicle","steamId":"vehicle-1","displayName":"Truck"}]}`}}) if err != nil { t.Fatalf("complete projection query job: %v", err) } items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "plugin_users"}) if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" || items[0].Value["source"] != "sqlite" || items[0].Value["sampledAt"] == nil { t.Fatalf("declared projection did not write scoped plugin data=%+v err=%v", items, err) } goods, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods"}) if err != nil || len(goods) != 1 || goods[0].Key != "#spawnvehicle Truck" || goods[0].Value["name"] != "Named Truck" || goods[0].Value["className"] != "Truck" || goods[0].Value["type"] != "21" || goods[0].Value["lastSeenAt"] == nil { t.Fatalf("declared vehicle catalog projection did not merge scoped plugin data=%+v err=%v", goods, err) } second, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) if err != nil || second.HasJob { t.Fatalf("fresh projection poll should not reschedule immediately: %+v err=%v", second, err) } }