216 lines
13 KiB
Go
216 lines
13 KiB
Go
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: "SCUM"}); err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
stored, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "scum_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: "scum_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: "scum_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: "scum_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: "scum_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 TestDeclaredSQLiteQueryProjectsRowsIntoPluginCollection(t *testing.T) {
|
|
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
|
|
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{
|
|
Collection: "users",
|
|
UpsertKeys: []string{"userId"},
|
|
ColumnMappings: map[string]string{
|
|
"userId": "user_id",
|
|
"displayName": "display_name",
|
|
},
|
|
}
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("update plugin row target: %v", err)
|
|
}
|
|
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-project-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-project-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"
|
|
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: "users"})
|
|
if err != nil || len(items) != 2 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" {
|
|
t.Fatalf("projected plugin rows=%+v err=%v", items, err)
|
|
}
|
|
}
|
|
|
|
func TestDeclaredSQLiteQueryProjectionRejectsInvalidBatchAtomically(t *testing.T) {
|
|
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
|
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{
|
|
Collection: "members",
|
|
UpsertKeys: []string{"accountId"},
|
|
ColumnMappings: map[string]string{
|
|
"accountId": "account_id",
|
|
"displayName": "display_name",
|
|
},
|
|
}
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("update plugin row target: %v", err)
|
|
}
|
|
job := domain.Job{
|
|
ServerInstanceID: instance.ID,
|
|
Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
|
State: domain.JobStateSucceeded,
|
|
ExecutionInput: domain.JobExecutionInput{Inputs: map[string]string{"templateKey": plugin.GameClientBridge.QueryTemplates[0].Key}},
|
|
ExecutionResult: domain.JobExecutionResult{Content: `{"rows":[{"account_id":"one","display_name":"Ada","ignored":"value"},{"display_name":"Missing key"}]}`},
|
|
}
|
|
if err := svc.projectPluginDataJobResult(job); err == nil {
|
|
t.Fatal("expected missing upsert key error")
|
|
}
|
|
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "members"})
|
|
if err != nil || len(items) != 0 {
|
|
t.Fatalf("invalid projection batch partially applied values=%+v err=%v", items, err)
|
|
}
|
|
}
|
|
|
|
func TestDeclaredSQLiteQueryProjectionFailureKeepsJobRetryable(t *testing.T) {
|
|
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
|
|
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{
|
|
Collection: "members",
|
|
UpsertKeys: []string{"accountId"},
|
|
ColumnMappings: map[string]string{
|
|
"accountId": "account_id",
|
|
"displayName": "display_name",
|
|
},
|
|
}
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("update plugin row target: %v", err)
|
|
}
|
|
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-invalid-projection", 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-invalid-projection", "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-invalid-query"
|
|
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)
|
|
}
|
|
result := 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":[{"display_name":"Missing key"}]}`}}
|
|
if _, err := svc.CompleteRunJob(result); err == nil {
|
|
t.Fatal("expected projection failure")
|
|
}
|
|
job, err := svc.store.Jobs().Get(claim.Job.JobID)
|
|
if err != nil || isTerminalJobState(job.State) {
|
|
t.Fatalf("projection failure persisted terminal job=%+v err=%v", job, err)
|
|
}
|
|
if _, err := svc.CompleteRunJob(result); err == nil {
|
|
t.Fatal("expected projection retry to re-run and fail")
|
|
}
|
|
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "members"})
|
|
if err != nil || len(items) != 0 {
|
|
t.Fatalf("invalid retry projected records=%+v err=%v", items, err)
|
|
}
|
|
}
|