476 lines
26 KiB
Go
476 lines
26 KiB
Go
package validator
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"browser.local/platform/domain"
|
|
)
|
|
|
|
func TestValidateAIProviderRejectsRawSecret(t *testing.T) {
|
|
provider := validAIProvider()
|
|
provider.APIKeyRef = "sk-test-secret"
|
|
|
|
err := ValidateAIProvider(provider)
|
|
if err == nil || !strings.Contains(err.Error(), "apiKeyRef must reference secret storage") {
|
|
t.Fatalf("expected raw secret rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateAIProviderRequiresDefaultModelInModels(t *testing.T) {
|
|
provider := validAIProvider()
|
|
provider.DefaultModel = "missing-model"
|
|
|
|
err := ValidateAIProvider(provider)
|
|
if err == nil || !strings.Contains(err.Error(), "defaultModel must be included") {
|
|
t.Fatalf("expected default model validation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateGamePluginManifestRegistration(t *testing.T) {
|
|
registration := validGamePluginManifestRegistration()
|
|
|
|
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
|
t.Fatalf("expected manifest registration to validate, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateGamePluginManifestRegistrationValidatesLogicalFileWorkspace(t *testing.T) {
|
|
registration := validGamePluginManifestRegistration()
|
|
registration.Manifest.FileWorkspace = domain.PluginFileWorkspace{
|
|
DefaultDirectoryKey: "config",
|
|
Directories: []domain.PluginLogicalDirectory{{Key: "config", Label: "配置", Scope: "config"}},
|
|
Files: []domain.PluginLogicalFile{{Key: "settings", DirectoryKey: "config", Label: "Settings.ini", Kind: "config", Editable: true}},
|
|
ConfigFields: []domain.PluginConfigField{{Key: "max-players", FileKey: "settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, RestartImpact: "restart-required"}},
|
|
}
|
|
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
|
t.Fatalf("expected safe logical file workspace: %v", err)
|
|
}
|
|
registration.Manifest.FileWorkspace.Files[0].DirectoryKey = "../host"
|
|
if err := ValidateGamePluginManifestRegistration(registration); err == nil {
|
|
t.Fatal("expected unsafe logical directory reference to fail")
|
|
}
|
|
registration.Manifest.FileWorkspace.Files[0].DirectoryKey = "config"
|
|
registration.Manifest.FileWorkspace.Files[0].Editable = false
|
|
if err := ValidateGamePluginManifestRegistration(registration); err == nil {
|
|
t.Fatal("expected a configuration field without an editable owning file to fail")
|
|
}
|
|
}
|
|
|
|
func TestValidateGamePluginManifestRegistrationRejectsUnsafeRequests(t *testing.T) {
|
|
registration := validGamePluginManifestRegistration()
|
|
registration.Manifest.Description = "requires direct run socket and raw AI key material"
|
|
registration.ManifestRef = "file:///etc/plugin.json"
|
|
|
|
err := ValidateGamePluginManifestRegistration(registration)
|
|
if err == nil {
|
|
t.Fatal("expected unsafe manifest registration rejection")
|
|
}
|
|
message := err.Error()
|
|
for _, want := range []string{"manifestRef is unsafe", "direct run access", "raw credential or AI/provider key"} {
|
|
if !strings.Contains(message, want) {
|
|
t.Fatalf("expected validation error to contain %q, got %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateGamePluginManifestRegistrationValidatesAssetFileCoverage(t *testing.T) {
|
|
registration := validGamePluginManifestRegistration()
|
|
registration.Manifest.AssetFiles = []domain.PluginAssetFile{
|
|
{Path: "actions/install.json", Mode: 0o600},
|
|
{Path: "bin/install-server", Mode: 0o700},
|
|
}
|
|
registration.AssetFiles = []domain.PluginAssetFile{
|
|
{Path: "actions/install.json", Content: "{}", Mode: 0o600},
|
|
{Path: "bin/install-server", Content: "#!/usr/bin/env sh\n", Mode: 0o700},
|
|
}
|
|
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
|
t.Fatalf("expected declared asset files with matching content to validate, got %v", err)
|
|
}
|
|
|
|
missing := domain.CopyGamePluginManifestRegistration(registration)
|
|
missing.AssetFiles = missing.AssetFiles[:1]
|
|
if err := ValidateGamePluginManifestRegistration(missing); err == nil || !strings.Contains(err.Error(), "missing declared plugin asset bin/install-server") {
|
|
t.Fatalf("expected missing asset rejection, got %v", err)
|
|
}
|
|
|
|
extra := domain.CopyGamePluginManifestRegistration(registration)
|
|
extra.AssetFiles = append(extra.AssetFiles, domain.PluginAssetFile{Path: "bin/unregistered", Content: "unused"})
|
|
if err := ValidateGamePluginManifestRegistration(extra); err == nil || !strings.Contains(err.Error(), "undeclared plugin asset bin/unregistered") {
|
|
t.Fatalf("expected undeclared asset rejection, got %v", err)
|
|
}
|
|
|
|
inlineContent := domain.CopyGamePluginManifestRegistration(registration)
|
|
inlineContent.Manifest.AssetFiles[0].Content = "{}"
|
|
if err := ValidateGamePluginManifestRegistration(inlineContent); err == nil || !strings.Contains(err.Error(), "content must be supplied only") {
|
|
t.Fatalf("expected manifest inline content rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testing.T) {
|
|
t.Run("unsafe runtime value", func(t *testing.T) {
|
|
registration := validGamePluginManifestRegistration()
|
|
registration.Manifest.RuntimeProfiles = domain.GamePluginRuntimeProfiles{Discovery: []domain.RuntimeDiscoveryProbe{{Key: "server-root-check", Kind: "file.exists", TargetKey: "server-root", Required: true, Expected: "/Users/operator/server"}}}
|
|
err := ValidateGamePluginManifestRegistration(registration)
|
|
if err == nil || !strings.Contains(err.Error(), "unsafe runtime content") {
|
|
t.Fatalf("expected unsafe runtime profile rejection, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("undeclared transport reference", func(t *testing.T) {
|
|
registration := validGamePluginManifestRegistration()
|
|
registration.Manifest.RuntimeProfiles = domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}, TransportKeys: []string{"missing-transport"}}}}
|
|
err := ValidateGamePluginManifestRegistration(registration)
|
|
if err == nil || !strings.Contains(err.Error(), "undeclared transport") {
|
|
t.Fatalf("expected cross-profile reference rejection, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(t *testing.T) {
|
|
registration := validGamePluginManifestRegistration()
|
|
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
|
|
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL)
|
|
registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
|
|
registration.Manifest.Pages[0].BridgeActions = append(registration.Manifest.Pages[0].BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
|
registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{
|
|
{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}},
|
|
{Key: "scum-rcon", Kind: "rcon", TargetKey: "scum-rcon", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedRCON}},
|
|
{Key: "scum-mutation-db", Kind: "sqlite", TargetKey: "scum-mutation-db", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}},
|
|
}
|
|
registration.Manifest.GameClientBridge = domain.GameClientBridgeManifest{
|
|
Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}},
|
|
Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", SchemaRef: "schemas/bridge/players.schema.json", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}},
|
|
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}},
|
|
OperationTemplates: []domain.GameClientBridgeOperationTemplateDeclaration{
|
|
{Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-rcon", TargetKey: "scum-rcon", PayloadSchemaRef: "schemas/bridge/operations/player-fame-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-fame-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-fame-set.confirmation.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
|
|
{Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db", PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "player.lookup", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}},
|
|
},
|
|
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
|
Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set", "player.attribute.855.set"}}},
|
|
}
|
|
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
|
t.Fatalf("expected bridge catalog to validate, got %v", err)
|
|
}
|
|
|
|
for _, commandType := range []string{"sql.execute", "sqlExecute", "database.execute", "database.query", "shell.execute", "powershell.execute", "script.run", "terminal.execute", "command.run"} {
|
|
t.Run("unsafe command type "+commandType, func(t *testing.T) {
|
|
unsafeType := registration
|
|
unsafeType.Manifest.GameClientBridge = domain.CopyGameClientBridgeManifest(registration.Manifest.GameClientBridge)
|
|
unsafeType.Manifest.GameClientBridge.Commands[0].Type = commandType
|
|
err := ValidateGamePluginManifestRegistration(unsafeType)
|
|
if err == nil || !strings.Contains(err.Error(), "type is invalid or unsafe") {
|
|
t.Fatalf("expected %q to be rejected, got %v", commandType, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
unsafe := registration
|
|
unsafe.Manifest.GameClientBridge = domain.CopyGameClientBridgeManifest(registration.Manifest.GameClientBridge)
|
|
unsafe.Manifest.GameClientBridge.Commands[0].Type = "shell.execute"
|
|
unsafe.Manifest.GameClientBridge.Commands[0].ApprovalLevel = ""
|
|
unsafe.Manifest.GameClientBridge.Commands[0].PayloadSchemaRef = "/etc/command.json"
|
|
unsafe.Manifest.GameClientBridge.Pages[0].CommandTypes = []string{"undeclared.command"}
|
|
err := ValidateGamePluginManifestRegistration(unsafe)
|
|
if err == nil {
|
|
t.Fatal("expected unsafe bridge catalog rejection")
|
|
}
|
|
for _, expected := range []string{"type is invalid or unsafe", "approvalLevel is invalid", "schema references", "undeclared command"} {
|
|
if !strings.Contains(err.Error(), expected) {
|
|
t.Fatalf("expected %q in validation error: %v", expected, err)
|
|
}
|
|
}
|
|
|
|
queryTemplateTests := []struct {
|
|
name string
|
|
expected string
|
|
mutate func(*domain.GamePluginManifestRegistration)
|
|
}{
|
|
{name: "duplicate key", expected: "key is duplicated", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.QueryTemplates = append(value.Manifest.GameClientBridge.QueryTemplates, value.Manifest.GameClientBridge.QueryTemplates[0])
|
|
}},
|
|
{name: "unsupported engine", expected: "engine must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.QueryTemplates[0].Engine = "mysql"
|
|
}},
|
|
{name: "undeclared permission", expected: "permission must be declared", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.QueryTemplates[0].Permission = "server.database.admin"
|
|
}},
|
|
{name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.QueryTemplates[0].ParameterSchemaRef = "/etc/query.json"
|
|
}},
|
|
{name: "row bound", expected: "maxRows is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.QueryTemplates[0].MaxRows = 501
|
|
}},
|
|
{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"
|
|
}},
|
|
{name: "target mismatch", expected: "targetKey must match", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.QueryTemplates[0].TargetKey = "db/other"
|
|
}},
|
|
{name: "missing sqlite capability", expected: "transport must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.RuntimeProfiles.TransportProfiles[0].Capabilities = []string{"files.read"}
|
|
}},
|
|
{name: "undeclared page template", expected: "undeclared query template", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.Pages[0].QueryTemplateKeys = []string{"missing.lookup"}
|
|
}},
|
|
{name: "page missing template permission", expected: "must declare query template permission", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.Pages[0].Permissions = []string{"server.logs.read", "server.remote.access"}
|
|
}},
|
|
{name: "page missing remote action", expected: "must declare remote.access.request", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.Pages[0].BridgeActions = nil
|
|
}},
|
|
}
|
|
for _, test := range queryTemplateTests {
|
|
t.Run("query template "+test.name, func(t *testing.T) {
|
|
invalid := domain.CopyGamePluginManifestRegistration(registration)
|
|
test.mutate(&invalid)
|
|
err := ValidateGamePluginManifestRegistration(invalid)
|
|
if err == nil || !strings.Contains(err.Error(), test.expected) {
|
|
t.Fatalf("expected %q rejection, got %v", test.expected, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
operationTemplateTests := []struct {
|
|
name string
|
|
expected string
|
|
mutate func(*domain.GamePluginManifestRegistration)
|
|
}{
|
|
{name: "duplicate key", expected: "key is duplicated", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.OperationTemplates = append(value.Manifest.GameClientBridge.OperationTemplates, value.Manifest.GameClientBridge.OperationTemplates[0])
|
|
}},
|
|
{name: "unsafe key", expected: "key is invalid or unsafe", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.OperationTemplates[0].Key = "raw.sql.execute"
|
|
}},
|
|
{name: "missing approval", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.OperationTemplates[0].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone
|
|
}},
|
|
{name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.OperationTemplates[0].PayloadSchemaRef = "/etc/operation.json"
|
|
}},
|
|
{name: "rcon wrong transport", expected: "transport must be rcon", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.OperationTemplates[0].TransportKey = "sqlite-db"
|
|
value.Manifest.GameClientBridge.OperationTemplates[0].TargetKey = "db/sqlite"
|
|
}},
|
|
{name: "mutation wrong transport", expected: "transport must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.OperationTemplates[1].TransportKey = "scum-rcon"
|
|
value.Manifest.GameClientBridge.OperationTemplates[1].TargetKey = "scum-rcon"
|
|
}},
|
|
{name: "mutation row bound", expected: "maxRowsAffected is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.OperationTemplates[1].MaxRowsAffected = 0
|
|
}},
|
|
{name: "mutation missing safety", expected: "safety must require", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.OperationTemplates[1].Safety.RequiresBeforeValue = false
|
|
}},
|
|
{name: "undeclared page operation", expected: "undeclared operation template", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.GameClientBridge.Pages[0].OperationKeys = []string{"missing.operation"}
|
|
}},
|
|
{name: "page missing operation permission", expected: "must declare operation template permission", mutate: func(value *domain.GamePluginManifestRegistration) {
|
|
value.Manifest.Pages[0].Permissions = []string{"server.game-client.read", "server.remote.access"}
|
|
}},
|
|
}
|
|
for _, test := range operationTemplateTests {
|
|
t.Run("operation template "+test.name, func(t *testing.T) {
|
|
invalid := domain.CopyGamePluginManifestRegistration(registration)
|
|
test.mutate(&invalid)
|
|
err := ValidateGamePluginManifestRegistration(invalid)
|
|
if err == nil || !strings.Contains(err.Error(), test.expected) {
|
|
t.Fatalf("expected %q rejection, got %v", test.expected, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) {
|
|
registration := validGamePluginManifestRegistration()
|
|
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "run.socket")
|
|
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "provider.key.read")
|
|
|
|
err := ValidateGamePluginManifestRegistration(registration)
|
|
if err == nil || !strings.Contains(err.Error(), "manifest.capabilities") || !strings.Contains(err.Error(), "permissions") {
|
|
t.Fatalf("expected unsafe capability and permission rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateServerInstanceDependencies(t *testing.T) {
|
|
instance := domain.ServerInstance{
|
|
ID: "server-1",
|
|
PluginID: "server.scum",
|
|
PluginVersion: "1.0.0",
|
|
RunEndpointID: "run-local",
|
|
Name: "SCUM #1",
|
|
State: domain.ServerInstanceStateDraft,
|
|
ConfigVersion: 1,
|
|
}
|
|
plugin := domain.GamePlugin{
|
|
ID: "server.scum",
|
|
Version: "1.0.0",
|
|
RequiredRunCapabilities: []string{"process.start", "logs.read"},
|
|
Status: domain.GamePluginStatusInstalled,
|
|
}
|
|
endpoint := domain.RunEndpoint{
|
|
ID: "run-local",
|
|
Status: domain.RunEndpointStatusOnline,
|
|
Capabilities: []string{"process.start", "logs.read", "files.read"},
|
|
}
|
|
|
|
if err := ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
|
|
t.Fatalf("expected valid dependencies, got %v", err)
|
|
}
|
|
|
|
endpoint.Capabilities = []string{"process.start"}
|
|
err := ValidateServerInstanceDependencies(instance, plugin, endpoint)
|
|
if err == nil || !strings.Contains(err.Error(), "logs.read") {
|
|
t.Fatalf("expected missing capability error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateServerInstanceRejectsDeletedCreateState(t *testing.T) {
|
|
instance := domain.ServerInstance{
|
|
ID: "server-1",
|
|
PluginID: "server.scum",
|
|
PluginVersion: "1.0.0",
|
|
RunEndpointID: "run-local",
|
|
Name: "SCUM #1",
|
|
State: domain.ServerInstanceStateDeleted,
|
|
}
|
|
|
|
err := ValidateServerInstance(instance)
|
|
if err == nil || !strings.Contains(err.Error(), "state must not be deleted") {
|
|
t.Fatalf("expected deleted state rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateStoredServerInstanceAllowsDeletedWithoutRunEndpoint(t *testing.T) {
|
|
instance := domain.ServerInstance{
|
|
ID: "server-1",
|
|
PluginID: "server.scum",
|
|
PluginVersion: "1.0.0",
|
|
Name: "SCUM #1",
|
|
State: domain.ServerInstanceStateDeleted,
|
|
}
|
|
|
|
if err := ValidateStoredServerInstance(instance); err != nil {
|
|
t.Fatalf("expected stored deleted instance without run endpoint to validate, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateJobBoundsProgress(t *testing.T) {
|
|
job := domain.Job{
|
|
ID: "job-1",
|
|
RunEndpointID: "run-local",
|
|
Capability: "process.start",
|
|
IdempotencyKey: "idem-1",
|
|
State: domain.JobStateQueued,
|
|
Progress: domain.JobProgress{Percent: 101},
|
|
}
|
|
|
|
err := ValidateJob(job)
|
|
if err == nil || !strings.Contains(err.Error(), "progress.percent") {
|
|
t.Fatalf("expected progress bounds error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateArtifactLogAndAudit(t *testing.T) {
|
|
artifact := domain.Artifact{
|
|
ID: "artifact-1",
|
|
OwnerKind: domain.ArtifactOwnerKindJob,
|
|
OwnerID: "job-1",
|
|
SizeBytes: 10,
|
|
Checksum: "sha256:abc",
|
|
State: domain.ArtifactStateAvailable,
|
|
}
|
|
if err := ValidateArtifact(artifact); err != nil {
|
|
t.Fatalf("expected artifact to validate, got %v", err)
|
|
}
|
|
|
|
stream := domain.LogStream{
|
|
ID: "log-1",
|
|
ServerInstanceID: "server-1",
|
|
Source: domain.LogStreamSourceFile,
|
|
StreamKey: "server.log",
|
|
StorageBackend: domain.LogStorageBackendLocalSegments,
|
|
RetentionPolicy: "default",
|
|
}
|
|
if err := ValidateLogStream(stream); err != nil {
|
|
t.Fatalf("expected log stream to validate, got %v", err)
|
|
}
|
|
|
|
audit := domain.AuditEvent{
|
|
ID: "audit-1",
|
|
ActorID: "user-1",
|
|
Action: "server.create",
|
|
ResourceKind: "server-instance",
|
|
ResourceID: "server-1",
|
|
Result: domain.AuditResultSuccess,
|
|
Summary: "created server instance",
|
|
}
|
|
if err := ValidateAuditEvent(audit); err != nil {
|
|
t.Fatalf("expected audit event to validate, got %v", err)
|
|
}
|
|
|
|
audit.Summary = "bearer raw-secret"
|
|
if err := ValidateAuditEvent(audit); err == nil || !strings.Contains(err.Error(), "summary must be redacted") {
|
|
t.Fatalf("expected audit redaction error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func validAIProvider() 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,
|
|
Status: domain.AIProviderStatusActive,
|
|
RedactionPolicy: "default",
|
|
}
|
|
}
|
|
|
|
func validGamePluginManifestRegistration() 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",
|
|
},
|
|
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: 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", ApprovalRequired: []string{"disable", "rollback", "retire"}},
|
|
},
|
|
}
|
|
}
|