Rebuild SCUM plugin-owned data flow
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
||||
bridge := domain.GameClientBridgeManifest{
|
||||
Commands: []domain.GameClientBridgeCommandDeclaration{{
|
||||
Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone,
|
||||
PayloadSchemaRef: "schemas/bridge/announcement.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096,
|
||||
}},
|
||||
LogProjections: []domain.GameClientBridgeLogProjectionDeclaration{{
|
||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 16,
|
||||
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
||||
{Pattern: `Player "(?<name>[^"]+)" reported as player (?<slot>\d+)`},
|
||||
{Pattern: `Player (?<slot>\d+) SteamID \(assumed\): (?<steamId>\d+)`},
|
||||
},
|
||||
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId", "name": "name"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
||||
Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
||||
TimestampField: "lastLoginAt", ActiveWindowSeconds: 600,
|
||||
ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, ObservedAtField: "observedAt"},
|
||||
Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{ProfileKey: "scum-client", CommandType: "announcement.send", TextField: "message", NewTextTemplate: "welcome {{name}}", ReturningTextTemplate: "welcome back {{name}}"},
|
||||
},
|
||||
}},
|
||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||
}
|
||||
profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}}}}
|
||||
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, []string{"server.game-client.command"}, nil, profiles); len(violations) != 0 {
|
||||
t.Fatalf("expected repeated named captures across steps to validate, got %v", violations)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
mutate func(*domain.GameClientBridgeManifest, *domain.GamePluginRuntimeProfiles)
|
||||
}{
|
||||
{name: "invalid regex", expected: "valid regular expression", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Steps[0].Pattern = "("
|
||||
}},
|
||||
{name: "missing capture", expected: "references undeclared capture missing", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Target.CaptureMappings["steamId"] = "missing"
|
||||
}},
|
||||
{name: "missing profile", expected: "must reference a declared game-client bridge profile", mutate: func(_ *domain.GameClientBridgeManifest, value *domain.GamePluginRuntimeProfiles) {
|
||||
value.ClientManagers = nil
|
||||
}},
|
||||
{name: "missing command", expected: "must reference a declared command", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Presence.Announcement.CommandType = "missing.command"
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := domain.CopyGameClientBridgeManifest(bridge)
|
||||
candidateProfiles := profiles
|
||||
candidateProfiles.ClientManagers = append([]domain.RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
|
||||
test.mutate(&candidate, &candidateProfiles)
|
||||
violations := validateGameClientBridgeManifest("gameClientBridge", candidate, []string{"server.game-client.command"}, nil, candidateProfiles)
|
||||
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
|
||||
t.Fatalf("expected %q violation, got %v", test.expected, violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -24,6 +25,12 @@ const (
|
||||
maxProductionMessageLength = 320
|
||||
)
|
||||
|
||||
var (
|
||||
gameClientBridgeCollectionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,119}$`)
|
||||
gameClientBridgeFieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,79}$`)
|
||||
gameClientBridgeCaptureNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,79}$`)
|
||||
)
|
||||
|
||||
type ValidationError struct {
|
||||
Violations []string
|
||||
}
|
||||
@@ -427,7 +434,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
|
||||
|
||||
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
|
||||
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
|
||||
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.DataPacks) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
|
||||
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LogProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
|
||||
return nil
|
||||
}
|
||||
var violations []string
|
||||
@@ -566,6 +573,9 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 {
|
||||
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
||||
}
|
||||
if template.PollIntervalSeconds < 0 || template.PollIntervalSeconds > 86400 {
|
||||
violations = append(violations, prefix+".pollIntervalSeconds is invalid")
|
||||
}
|
||||
projectsRows := template.SQLRef != "" || template.RowTarget != nil
|
||||
if projectsRows {
|
||||
if !safeRelativeSQLRef(template.SQLRef) {
|
||||
@@ -578,6 +588,9 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if !clientManagerIdentifierPattern.MatchString(target.Collection) || len(target.UpsertKeys) == 0 || len(target.ColumnMappings) == 0 {
|
||||
violations = append(violations, prefix+".rowTarget must declare a collection, upsert keys, and column mappings")
|
||||
}
|
||||
if target.WriteMode != "" && target.WriteMode != domain.PluginDataRowWriteModeMerge && target.WriteMode != domain.PluginDataRowWriteModeReplace {
|
||||
violations = append(violations, prefix+".rowTarget.writeMode must be merge or replace")
|
||||
}
|
||||
for _, key := range target.UpsertKeys {
|
||||
if !clientManagerIdentifierPattern.MatchString(key) {
|
||||
violations = append(violations, prefix+".rowTarget upsert key is invalid")
|
||||
@@ -602,6 +615,18 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability")
|
||||
}
|
||||
}
|
||||
logProjectionKeys := map[string]struct{}{}
|
||||
for index, projection := range bridge.LogProjections {
|
||||
prefix := fmt.Sprintf("%s.logProjections[%d]", field, index)
|
||||
if !clientManagerIdentifierPattern.MatchString(projection.Key) {
|
||||
violations = append(violations, prefix+".key is invalid")
|
||||
}
|
||||
if _, exists := logProjectionKeys[projection.Key]; exists {
|
||||
violations = append(violations, prefix+".key is duplicated")
|
||||
}
|
||||
logProjectionKeys[projection.Key] = struct{}{}
|
||||
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection, bridge.Commands, runtimeProfiles.ClientManagers)...)
|
||||
}
|
||||
dataPackKeys := map[string]struct{}{}
|
||||
for index, dataPack := range bridge.DataPacks {
|
||||
prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index)
|
||||
@@ -795,6 +820,176 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration, commands []domain.GameClientBridgeCommandDeclaration, clientManagers []domain.RuntimeClientManagerProfile) []string {
|
||||
var violations []string
|
||||
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
|
||||
violations = append(violations, prefix+".streamKeys must contain between 1 and 64 streams")
|
||||
}
|
||||
for _, streamKey := range projection.StreamKeys {
|
||||
if !clientManagerIdentifierPattern.MatchString(streamKey) {
|
||||
violations = append(violations, prefix+".streamKeys contains an invalid stream key")
|
||||
}
|
||||
}
|
||||
violations = append(violations, duplicateViolations(prefix+".streamKeys", projection.StreamKeys)...)
|
||||
|
||||
captures := map[string]struct{}{}
|
||||
if len(projection.Steps) < 1 || len(projection.Steps) > 64 {
|
||||
violations = append(violations, prefix+".steps must contain between 1 and 64 patterns")
|
||||
}
|
||||
for index, step := range projection.Steps {
|
||||
stepPrefix := fmt.Sprintf("%s.steps[%d].pattern", prefix, index)
|
||||
if strings.TrimSpace(step.Pattern) == "" || len([]rune(step.Pattern)) > 16384 {
|
||||
violations = append(violations, stepPrefix+" is empty or too large")
|
||||
continue
|
||||
}
|
||||
compiled, err := regexp.Compile(step.Pattern)
|
||||
if err != nil {
|
||||
violations = append(violations, stepPrefix+" must be a valid regular expression")
|
||||
continue
|
||||
}
|
||||
for _, capture := range compiled.SubexpNames() {
|
||||
if capture != "" {
|
||||
captures[capture] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(projection.CorrelationFields) < 1 || len(projection.CorrelationFields) > 64 {
|
||||
violations = append(violations, prefix+".correlationFields must contain between 1 and 64 captures")
|
||||
}
|
||||
for _, field := range projection.CorrelationFields {
|
||||
if !gameClientBridgeCaptureNamePattern.MatchString(field) {
|
||||
violations = append(violations, prefix+".correlationFields contains an invalid capture name")
|
||||
continue
|
||||
}
|
||||
if _, exists := captures[field]; !exists {
|
||||
violations = append(violations, prefix+".correlationFields references undeclared capture "+field)
|
||||
}
|
||||
}
|
||||
violations = append(violations, duplicateViolations(prefix+".correlationFields", projection.CorrelationFields)...)
|
||||
if projection.MaxInterveningLines < 0 || projection.MaxInterveningLines > 100000 {
|
||||
violations = append(violations, prefix+".maxInterveningLines is invalid")
|
||||
}
|
||||
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".target", projection.Target, captures)...)
|
||||
|
||||
if projection.Presence == nil {
|
||||
return violations
|
||||
}
|
||||
presence := projection.Presence
|
||||
if !gameClientBridgeFieldPattern.MatchString(presence.TimestampField) || !gameClientBridgeLogProjectionTargetDeclaresField(projection.Target, presence.TimestampField) {
|
||||
violations = append(violations, prefix+".presence.timestampField must reference a projected target field")
|
||||
}
|
||||
if presence.ActiveWindowSeconds < 1 || presence.ActiveWindowSeconds > 31536000 {
|
||||
violations = append(violations, prefix+".presence.activeWindowSeconds is invalid")
|
||||
}
|
||||
if presence.ActivityTarget != nil {
|
||||
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".presence.activityTarget", *presence.ActivityTarget, captures)...)
|
||||
}
|
||||
|
||||
announcement := presence.Announcement
|
||||
if !clientManagerIdentifierPattern.MatchString(announcement.ProfileKey) {
|
||||
violations = append(violations, prefix+".presence.announcement.profileKey is invalid")
|
||||
} else {
|
||||
profileFound := false
|
||||
for _, profile := range clientManagers {
|
||||
if profile.Key == announcement.ProfileKey && containsString(profile.Health.RequiredCapabilities, "game-client.bridge") {
|
||||
profileFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !profileFound {
|
||||
violations = append(violations, prefix+".presence.announcement.profileKey must reference a declared game-client bridge profile")
|
||||
}
|
||||
}
|
||||
var command *domain.GameClientBridgeCommandDeclaration
|
||||
for index := range commands {
|
||||
if commands[index].Type == announcement.CommandType {
|
||||
command = &commands[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if command == nil {
|
||||
violations = append(violations, prefix+".presence.announcement.commandType must reference a declared command")
|
||||
}
|
||||
if !gameClientBridgeFieldPattern.MatchString(announcement.TextField) {
|
||||
violations = append(violations, prefix+".presence.announcement.textField is invalid")
|
||||
} else if command != nil && command.ProtectedRequest != nil && command.ProtectedRequest.TextField != announcement.TextField {
|
||||
violations = append(violations, prefix+".presence.announcement.textField must match the command protected request")
|
||||
}
|
||||
if strings.TrimSpace(announcement.NewTextTemplate) == "" || len([]rune(announcement.NewTextTemplate)) > 4096 {
|
||||
violations = append(violations, prefix+".presence.announcement.newTextTemplate is empty or too large")
|
||||
}
|
||||
if strings.TrimSpace(announcement.ReturningTextTemplate) == "" || len([]rune(announcement.ReturningTextTemplate)) > 4096 {
|
||||
violations = append(violations, prefix+".presence.announcement.returningTextTemplate is empty or too large")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateGameClientBridgeLogProjectionTarget(prefix string, target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]struct{}) []string {
|
||||
var violations []string
|
||||
if !gameClientBridgeCollectionPattern.MatchString(target.Collection) {
|
||||
violations = append(violations, prefix+".collection is invalid")
|
||||
}
|
||||
if len(target.UpsertKeys) < 1 || len(target.UpsertKeys) > 8 {
|
||||
violations = append(violations, prefix+".upsertKeys must contain between 1 and 8 fields")
|
||||
}
|
||||
for _, key := range target.UpsertKeys {
|
||||
if !gameClientBridgeFieldPattern.MatchString(key) {
|
||||
violations = append(violations, prefix+".upsertKeys contains an invalid field")
|
||||
}
|
||||
if !gameClientBridgeLogProjectionTargetDeclaresField(target, key) {
|
||||
violations = append(violations, prefix+".upsertKeys field "+key+" is not projected")
|
||||
}
|
||||
}
|
||||
violations = append(violations, duplicateViolations(prefix+".upsertKeys", target.UpsertKeys)...)
|
||||
|
||||
if len(target.CaptureMappings) < 1 || len(target.CaptureMappings) > 64 {
|
||||
violations = append(violations, prefix+".captureMappings must contain between 1 and 64 mappings")
|
||||
}
|
||||
projectedFields := map[string]struct{}{}
|
||||
for destination, capture := range target.CaptureMappings {
|
||||
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeCaptureNamePattern.MatchString(capture) {
|
||||
violations = append(violations, prefix+".captureMappings contains an invalid field or capture")
|
||||
}
|
||||
if _, exists := captures[capture]; !exists {
|
||||
violations = append(violations, prefix+".captureMappings references undeclared capture "+capture)
|
||||
}
|
||||
projectedFields[destination] = struct{}{}
|
||||
}
|
||||
if len(target.FixedValues) > 64 {
|
||||
violations = append(violations, prefix+".fixedValues contains too many fields")
|
||||
}
|
||||
for destination, value := range target.FixedValues {
|
||||
if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 {
|
||||
violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value")
|
||||
}
|
||||
if _, exists := projectedFields[destination]; exists {
|
||||
violations = append(violations, prefix+" declares field "+destination+" more than once")
|
||||
}
|
||||
projectedFields[destination] = struct{}{}
|
||||
}
|
||||
if target.ObservedAtField != "" {
|
||||
if !gameClientBridgeFieldPattern.MatchString(target.ObservedAtField) {
|
||||
violations = append(violations, prefix+".observedAtField is invalid")
|
||||
}
|
||||
if _, exists := projectedFields[target.ObservedAtField]; exists {
|
||||
violations = append(violations, prefix+" declares field "+target.ObservedAtField+" more than once")
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetDeclaresField(target domain.GameClientBridgeLogProjectionTargetDeclaration, field string) bool {
|
||||
if target.ObservedAtField == field {
|
||||
return true
|
||||
}
|
||||
if _, exists := target.CaptureMappings[field]; exists {
|
||||
return true
|
||||
}
|
||||
_, exists := target.FixedValues[field]
|
||||
return exists
|
||||
}
|
||||
|
||||
func validCompanionProofEnvironment(value string) bool {
|
||||
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
||||
return false
|
||||
|
||||
@@ -204,6 +204,13 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(
|
||||
{name: "timeout bound", expected: "timeoutSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].TimeoutSeconds = 61
|
||||
}},
|
||||
{name: "poll interval bound", expected: "pollIntervalSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 86401
|
||||
}},
|
||||
{name: "write mode", expected: "writeMode must be merge or replace", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].SQLRef = "sql/player-lookup.sql"
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}, WriteMode: "append"}
|
||||
}},
|
||||
{name: "unknown transport", expected: "transportKey must reference", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].TransportKey = "missing"
|
||||
}},
|
||||
|
||||
Reference in New Issue
Block a user