1292 lines
50 KiB
Go
1292 lines
50 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
var fixedTime = time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
|
|
|
func TestCoreServiceCreateListGetWorkflows(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
|
|
user, err := svc.CreateUser(domain.User{
|
|
ID: "user-1",
|
|
DisplayName: "Operator",
|
|
Roles: []string{"admin"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
if user.Status != domain.UserStatusActive || !user.CreatedAt.Equal(fixedTime) {
|
|
t.Fatalf("expected user defaults, got %+v", user)
|
|
}
|
|
if _, err := svc.GetUser(user.ID); err != nil {
|
|
t.Fatalf("get user: %v", err)
|
|
}
|
|
users, err := svc.ListUsers(domain.UserFilter{Status: domain.UserStatusActive})
|
|
if err != nil || len(users) != 1 {
|
|
t.Fatalf("list users: len=%d err=%v", len(users), err)
|
|
}
|
|
|
|
generatedUser, err := svc.CreateUser(domain.User{
|
|
DisplayName: "Generated User",
|
|
Email: "generated@example.test",
|
|
Roles: []string{"server-admin"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create generated user: %v", err)
|
|
}
|
|
if generatedUser.ID != "user-generated-example-test" {
|
|
t.Fatalf("expected generated user id from email, got %q", generatedUser.ID)
|
|
}
|
|
|
|
provider, err := svc.CreateAIProvider(validProvider())
|
|
if err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
if provider.APIKeyRef != "secret://providers/openai" {
|
|
t.Fatalf("expected provider key reference only, got %+v", provider)
|
|
}
|
|
if _, err := svc.GetAIProvider(provider.ID); err != nil {
|
|
t.Fatalf("get provider: %v", err)
|
|
}
|
|
providers, err := svc.ListAIProviders(domain.AIProviderFilter{Status: domain.AIProviderStatusActive})
|
|
if err != nil || len(providers) != 1 {
|
|
t.Fatalf("list providers: len=%d err=%v", len(providers), err)
|
|
}
|
|
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
if _, err := svc.GetGamePlugin(plugin.ID); err != nil {
|
|
t.Fatalf("get plugin: %v", err)
|
|
}
|
|
plugins, err := svc.ListGamePlugins(domain.GamePluginFilter{Status: domain.GamePluginStatusInstalled})
|
|
if err != nil || len(plugins) != 1 {
|
|
t.Fatalf("list plugins: len=%d err=%v", len(plugins), err)
|
|
}
|
|
if _, err := svc.GetRunEndpoint(endpoint.ID); err != nil {
|
|
t.Fatalf("get endpoint: %v", err)
|
|
}
|
|
endpoints, err := svc.ListRunEndpoints(domain.RunEndpointFilter{Status: domain.RunEndpointStatusOnline})
|
|
if err != nil || len(endpoints) != 1 {
|
|
t.Fatalf("list endpoints: len=%d err=%v", len(endpoints), err)
|
|
}
|
|
|
|
instance, err := svc.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-1",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "SCUM #1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server instance: %v", err)
|
|
}
|
|
if instance.PluginVersion != plugin.Version || instance.ConfigVersion != 1 || instance.State != domain.ServerInstanceStateDraft {
|
|
t.Fatalf("expected server defaults, got %+v", instance)
|
|
}
|
|
if _, err := svc.GetServerInstance(instance.ID); err != nil {
|
|
t.Fatalf("get server instance: %v", err)
|
|
}
|
|
instances, err := svc.ListServerInstances(domain.ServerInstanceFilter{PluginID: plugin.ID})
|
|
if err != nil || len(instances) != 1 {
|
|
t.Fatalf("list server instances: len=%d err=%v", len(instances), err)
|
|
}
|
|
|
|
job, err := svc.CreateJob(domain.Job{
|
|
ID: "job-1",
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Capability: "process.start",
|
|
IdempotencyKey: "idem-start",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create job: %v", err)
|
|
}
|
|
if job.State != domain.JobStateQueued || !job.CreatedAt.Equal(fixedTime) {
|
|
t.Fatalf("expected job defaults, got %+v", job)
|
|
}
|
|
if _, err := svc.GetJob(job.ID); err != nil {
|
|
t.Fatalf("get job: %v", err)
|
|
}
|
|
jobs, err := svc.ListJobs(domain.JobFilter{RunEndpointID: endpoint.ID})
|
|
if err != nil || len(jobs) != 1 {
|
|
t.Fatalf("list jobs: len=%d err=%v", len(jobs), err)
|
|
}
|
|
|
|
artifact, err := svc.CreateArtifact(domain.Artifact{
|
|
ID: "artifact-1",
|
|
OwnerKind: domain.ArtifactOwnerKindJob,
|
|
OwnerID: job.ID,
|
|
SizeBytes: 128,
|
|
Checksum: "sha256:abc",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create artifact: %v", err)
|
|
}
|
|
if artifact.State != domain.ArtifactStateUploading {
|
|
t.Fatalf("expected artifact default state, got %+v", artifact)
|
|
}
|
|
if _, err := svc.GetArtifact(artifact.ID); err != nil {
|
|
t.Fatalf("get artifact: %v", err)
|
|
}
|
|
artifacts, err := svc.ListArtifacts(domain.ArtifactFilter{OwnerID: job.ID})
|
|
if err != nil || len(artifacts) != 1 {
|
|
t.Fatalf("list artifacts: len=%d err=%v", len(artifacts), err)
|
|
}
|
|
|
|
stream, err := svc.CreateLogStream(domain.LogStream{
|
|
ID: "log-1",
|
|
ServerInstanceID: instance.ID,
|
|
Source: domain.LogStreamSourceProcess,
|
|
StreamKey: "stdout",
|
|
StorageBackend: domain.LogStorageBackendLocalSegments,
|
|
RetentionPolicy: "default",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create log stream: %v", err)
|
|
}
|
|
if _, err := svc.GetLogStream(stream.ID); err != nil {
|
|
t.Fatalf("get log stream: %v", err)
|
|
}
|
|
streams, err := svc.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
|
if err != nil || len(streams) != 1 {
|
|
t.Fatalf("list log streams: len=%d err=%v", len(streams), err)
|
|
}
|
|
|
|
audit, err := svc.CreateAuditEvent(domain.AuditEvent{
|
|
ID: "audit-1",
|
|
ActorID: user.ID,
|
|
Action: "server.create",
|
|
ResourceKind: "server-instance",
|
|
ResourceID: instance.ID,
|
|
Result: domain.AuditResultSuccess,
|
|
Summary: "created server instance",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create audit event: %v", err)
|
|
}
|
|
if !audit.CreatedAt.Equal(fixedTime) {
|
|
t.Fatalf("expected audit timestamp default, got %+v", audit)
|
|
}
|
|
if _, err := svc.GetAuditEvent(audit.ID); err != nil {
|
|
t.Fatalf("get audit event: %v", err)
|
|
}
|
|
auditEvents, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID})
|
|
if err != nil || len(auditEvents) != 1 {
|
|
t.Fatalf("list audit events: len=%d err=%v", len(auditEvents), err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRejectsInvalidServerDependencies(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
|
|
disabledPlugin := plugin
|
|
disabledPlugin.ID = "server.disabled"
|
|
disabledPlugin.Status = domain.GamePluginStatusDisabled
|
|
if _, err := svc.CreateGamePlugin(disabledPlugin); err != nil {
|
|
t.Fatalf("create disabled plugin fixture: %v", err)
|
|
}
|
|
|
|
_, err := svc.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-disabled",
|
|
PluginID: disabledPlugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Disabled Plugin Server",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "plugin must be installed") {
|
|
t.Fatalf("expected disabled plugin rejection, got %v", err)
|
|
}
|
|
|
|
weakEndpoint := endpoint
|
|
weakEndpoint.ID = "run-weak"
|
|
weakEndpoint.Capabilities = []string{"process.start"}
|
|
if _, err := svc.CreateRunEndpoint(weakEndpoint); err != nil {
|
|
t.Fatalf("create weak endpoint fixture: %v", err)
|
|
}
|
|
|
|
_, err = svc.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-weak",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: weakEndpoint.ID,
|
|
Name: "Weak Endpoint Server",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "logs.read") {
|
|
t.Fatalf("expected missing capability rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRejectsRawAIProviderSecret(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
provider := validProvider()
|
|
provider.APIKeyRef = "sk-raw-secret"
|
|
|
|
_, err := svc.CreateAIProvider(provider)
|
|
if err == nil || !strings.Contains(err.Error(), "apiKeyRef must reference secret storage") {
|
|
t.Fatalf("expected raw secret rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceAuthenticatesActiveUsers(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
created, err := svc.CreateUser(domain.User{
|
|
ID: "user-auth",
|
|
DisplayName: "Auth User",
|
|
Email: "auth@example.test",
|
|
Roles: []string{"platform-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create auth user: %v", err)
|
|
}
|
|
if created.PasswordHash == "secret-password" || created.PasswordHash == "" {
|
|
t.Fatalf("expected password to be hashed, got %q", created.PasswordHash)
|
|
}
|
|
|
|
session, err := svc.LoginUser(domain.UserLogin{Account: "auth@example.test", Password: "secret-password"})
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
if session.SessionID == "" || session.Status != "authenticated" || session.User.ID != created.ID {
|
|
t.Fatalf("unexpected auth session: %+v", session)
|
|
}
|
|
|
|
current, err := svc.GetCurrentUser(session.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("current user: %v", err)
|
|
}
|
|
if current.ID != created.ID {
|
|
t.Fatalf("expected current user %q, got %+v", created.ID, current)
|
|
}
|
|
|
|
if err := svc.LogoutUser(session.SessionID); err != nil {
|
|
t.Fatalf("logout: %v", err)
|
|
}
|
|
if _, err := svc.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) {
|
|
t.Fatalf("expected logged out session to be unauthorized, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceFirstRegistrationBootstrapsPlatformAdmin(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
session, err := svc.RegisterUser(domain.UserRegistration{
|
|
DisplayName: "Bootstrap Admin",
|
|
Email: "bootstrap@example.test",
|
|
Password: "secret-password",
|
|
Profile: domain.UserProfile{Phone: "13800000000", QQ: "10001"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
if session.Status != "authenticated" || session.SessionID == "" {
|
|
t.Fatalf("expected authenticated bootstrap registration, got %+v", session)
|
|
}
|
|
if session.User.Status != domain.UserStatusActive || len(session.User.Roles) != 1 || session.User.Roles[0] != "platform-admin" {
|
|
t.Fatalf("expected active platform admin user, got %+v", session.User)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRegistersLaterUsersAsPendingLowPrivilege(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
if _, err := svc.CreateUser(domain.User{ID: "user-existing", DisplayName: "Existing Admin", Roles: []string{"platform-admin"}}); err != nil {
|
|
t.Fatalf("create existing user: %v", err)
|
|
}
|
|
session, err := svc.RegisterUser(domain.UserRegistration{
|
|
DisplayName: "Pending Player",
|
|
Email: "pending@example.test",
|
|
Password: "secret-password",
|
|
Profile: domain.UserProfile{Phone: "13800000000", QQ: "10001"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
if session.Status != "pending" || session.SessionID != "" {
|
|
t.Fatalf("expected pending registration without session token, got %+v", session)
|
|
}
|
|
if session.User.Status != domain.UserStatusPending || len(session.User.Roles) != 1 || session.User.Roles[0] != "server-admin" {
|
|
t.Fatalf("expected low-privilege pending user, got %+v", session.User)
|
|
}
|
|
if _, err := svc.LoginUser(domain.UserLogin{Account: "pending@example.test", Password: "secret-password"}); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected pending login to be forbidden, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceScopesServerAccessAndMembership(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-owner",
|
|
DisplayName: "Server Owner",
|
|
Email: "owner@example.test",
|
|
Roles: []string{"server-owner"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
helperSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-helper",
|
|
DisplayName: "Server Helper",
|
|
Email: "helper@example.test",
|
|
Roles: []string{"server-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
adminSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-platform",
|
|
DisplayName: "Platform Admin",
|
|
Email: "platform@example.test",
|
|
Roles: []string{"platform-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-owned",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Owned Server",
|
|
State: domain.ServerInstanceStateReady,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create owned server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, instance, "local")
|
|
if instance.OwnerUserID != "user-owner" {
|
|
t.Fatalf("expected owner to be recorded, got %+v", instance)
|
|
}
|
|
ownerServers, err := svc.ListServerInstancesForSession(ownerSession, domain.ServerInstanceFilter{})
|
|
if err != nil || len(ownerServers) != 1 {
|
|
t.Fatalf("expected owner server visibility, len=%d err=%v", len(ownerServers), err)
|
|
}
|
|
helperServers, err := svc.ListServerInstancesForSession(helperSession, domain.ServerInstanceFilter{})
|
|
if err != nil || len(helperServers) != 0 {
|
|
t.Fatalf("expected helper to see no servers before invite, len=%d err=%v", len(helperServers), err)
|
|
}
|
|
adminServers, err := svc.ListServerInstancesForSession(adminSession, domain.ServerInstanceFilter{})
|
|
if err != nil || len(adminServers) != 1 {
|
|
t.Fatalf("expected platform admin to see all servers, len=%d err=%v", len(adminServers), err)
|
|
}
|
|
|
|
candidates, err := svc.ListServerAdministratorCandidates(ownerSession, instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("list candidates: %v", err)
|
|
}
|
|
if len(candidates) != 1 || candidates[0].ID != "user-helper" {
|
|
t.Fatalf("expected only helper candidate, got %+v", candidates)
|
|
}
|
|
if _, err := svc.AddServerAdministrator(helperSession, instance.ID, "user-owner"); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected non-owner add to be forbidden, got %v", err)
|
|
}
|
|
if _, err := svc.AddServerAdministrator(ownerSession, instance.ID, "user-platform"); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected platform admin invite to be forbidden, got %v", err)
|
|
}
|
|
|
|
updated, err := svc.AddServerAdministrator(ownerSession, instance.ID, "user-helper")
|
|
if err != nil {
|
|
t.Fatalf("add helper admin: %v", err)
|
|
}
|
|
if len(updated.AdminUserIDs) != 1 || updated.AdminUserIDs[0] != "user-helper" {
|
|
t.Fatalf("expected helper membership, got %+v", updated)
|
|
}
|
|
helperServers, err = svc.ListServerInstancesForSession(helperSession, domain.ServerInstanceFilter{})
|
|
if err != nil || len(helperServers) != 1 {
|
|
t.Fatalf("expected helper to see invited server, len=%d err=%v", len(helperServers), err)
|
|
}
|
|
if _, err := svc.StartServerInstanceForSession(helperSession, domain.ServerLifecycleCommand{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion,
|
|
IdempotencyKey: "idem-helper-start",
|
|
}); err != nil {
|
|
t.Fatalf("expected helper lifecycle access: %v", err)
|
|
}
|
|
|
|
removed, err := svc.RemoveServerAdministrator(ownerSession, instance.ID, "user-helper")
|
|
if err != nil {
|
|
t.Fatalf("remove helper admin: %v", err)
|
|
}
|
|
if len(removed.AdminUserIDs) != 0 {
|
|
t.Fatalf("expected helper membership removed, got %+v", removed)
|
|
}
|
|
if _, err := svc.GetServerInstanceForSession(helperSession, instance.ID); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected helper access to be revoked, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceMetricsAndConfigReadAreRoleScoped(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-owner-metrics",
|
|
DisplayName: "Metrics Owner",
|
|
Email: "owner-metrics@example.test",
|
|
Roles: []string{"server-owner"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
otherSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-other-metrics",
|
|
DisplayName: "Metrics Other",
|
|
Email: "other-metrics@example.test",
|
|
Roles: []string{"server-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-metrics",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Metrics Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
if _, err := svc.CreateJob(domain.Job{ID: "job-metrics", ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: "process.start", IdempotencyKey: "idem-metrics"}); err != nil {
|
|
t.Fatalf("create job: %v", err)
|
|
}
|
|
|
|
usage, err := svc.GetPlatformResourceUsage()
|
|
if err != nil {
|
|
t.Fatalf("get platform usage: %v", err)
|
|
}
|
|
if usage.Source != "platform-derived" || usage.CollectedAt.IsZero() || usage.CPUPercent < 0 || usage.CPUPercent > 100 {
|
|
t.Fatalf("unexpected platform usage: %+v", usage)
|
|
}
|
|
|
|
ownerMetrics, err := svc.ListServerMetricsForSession(ownerSession)
|
|
if err != nil {
|
|
t.Fatalf("list owner metrics: %v", err)
|
|
}
|
|
if len(ownerMetrics) != 1 || ownerMetrics[0].ServerInstanceID != instance.ID || !ownerMetrics[0].Online || ownerMetrics[0].CPUPercent == nil {
|
|
t.Fatalf("unexpected owner metrics: %+v", ownerMetrics)
|
|
}
|
|
otherMetrics, err := svc.ListServerMetricsForSession(otherSession)
|
|
if err != nil {
|
|
t.Fatalf("list other metrics: %v", err)
|
|
}
|
|
if len(otherMetrics) != 0 {
|
|
t.Fatalf("expected other user to see no metrics, got %+v", otherMetrics)
|
|
}
|
|
|
|
config, err := svc.GetServerConfigForSession(ownerSession, instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("get config: %v", err)
|
|
}
|
|
if config.ServerInstanceID != instance.ID || config.ConfigVersion != instance.ConfigVersion || !strings.Contains(config.Content, "server.name=Metrics Server") {
|
|
t.Fatalf("unexpected config: %+v", config)
|
|
}
|
|
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} {
|
|
if strings.Contains(config.Content, forbidden) {
|
|
t.Fatalf("config content exposed forbidden fragment %q: %s", forbidden, config.Content)
|
|
}
|
|
}
|
|
if _, err := svc.GetServerConfigForSession(otherSession, instance.ID); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected other config access to be forbidden, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-owner-config",
|
|
DisplayName: "Config Owner",
|
|
Email: "owner-config@example.test",
|
|
Roles: []string{"server-owner"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
otherSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-other-config",
|
|
DisplayName: "Config Other",
|
|
Email: "other-config@example.test",
|
|
Roles: []string{"server-admin"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-config",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Config Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, instance, "local")
|
|
current, err := svc.GetServerConfigForSession(ownerSession, instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("get config: %v", err)
|
|
}
|
|
proposed := strings.Replace(current.Content, "state=running", "state=running\nmotd=Approved", 1)
|
|
|
|
preview, err := svc.PreviewServerConfigWriteForSession(ownerSession, domain.ServerConfigDiffRequest{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion,
|
|
Key: current.Key,
|
|
ProposedContent: proposed,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("preview config write: %v", err)
|
|
}
|
|
if !preview.HasChanges || preview.Source != "platform-review" || preview.ProposedContent != proposed {
|
|
t.Fatalf("unexpected preview: %+v", preview)
|
|
}
|
|
jobs, err := svc.ListJobs(domain.JobFilter{})
|
|
if err != nil || len(jobs) != 0 {
|
|
t.Fatalf("preview must not create jobs, jobs=%+v err=%v", jobs, err)
|
|
}
|
|
|
|
dispatch, err := svc.ApproveServerConfigWriteForSession(ownerSession, domain.ServerConfigWriteApproval{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion,
|
|
Key: current.Key,
|
|
ProposedContent: proposed,
|
|
IdempotencyKey: "idem-config-approve",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("approve config write: %v", err)
|
|
}
|
|
if dispatch.Status != "queued" || dispatch.Job.Capability != domain.JobCapabilityConfigWrite || dispatch.Job.TargetKey != current.Key || !strings.HasPrefix(dispatch.Job.InputRef, "input://server-config/") {
|
|
t.Fatalf("unexpected config dispatch: %+v", dispatch)
|
|
}
|
|
|
|
if _, err := svc.PreviewServerConfigWriteForSession(ownerSession, domain.ServerConfigDiffRequest{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion + 1,
|
|
Key: current.Key,
|
|
ProposedContent: proposed,
|
|
}); err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") {
|
|
t.Fatalf("expected stale config version rejection, got %v", err)
|
|
}
|
|
if _, err := svc.ApproveServerConfigWriteForSession(otherSession, domain.ServerConfigWriteApproval{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion,
|
|
Key: current.Key,
|
|
ProposedContent: proposed,
|
|
IdempotencyKey: "idem-config-forbidden",
|
|
}); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected unauthorized approval rejection, got %v", err)
|
|
}
|
|
if _, err := svc.PreviewServerConfigWriteForSession(ownerSession, domain.ServerConfigDiffRequest{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion,
|
|
Key: "/Users/tasia/secret.properties",
|
|
ProposedContent: proposed,
|
|
}); err == nil || !strings.Contains(err.Error(), "key") {
|
|
t.Fatalf("expected unsafe key rejection, got %v", err)
|
|
}
|
|
if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
|
|
ServerInstanceID: instance.ID,
|
|
Operation: domain.FileOperationRead,
|
|
Key: "../secrets.env",
|
|
IdempotencyKey: "idem-file-unsafe",
|
|
}); err == nil || !strings.Contains(err.Error(), "key") {
|
|
t.Fatalf("expected unsafe file key rejection, got %v", err)
|
|
}
|
|
|
|
fileDispatch, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
|
|
ServerInstanceID: instance.ID,
|
|
PluginID: plugin.ID,
|
|
Operation: domain.FileOperationRead,
|
|
Key: "logs/latest.log",
|
|
IdempotencyKey: "idem-file-read",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dispatch file read: %v", err)
|
|
}
|
|
if fileDispatch.Job.Capability != domain.JobCapabilityFilesRead || fileDispatch.Job.TargetKey != "logs/latest.log" {
|
|
t.Fatalf("unexpected file dispatch: %+v", fileDispatch)
|
|
}
|
|
jobs, err = svc.ListJobs(domain.JobFilter{})
|
|
if err != nil || len(jobs) != 2 {
|
|
t.Fatalf("expected only approved config and file jobs, jobs=%+v err=%v", jobs, err)
|
|
}
|
|
}
|
|
|
|
func TestConfigWriteTerminalResultAppliesDurableTypedProjection(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "typed-config-owner", DisplayName: "Typed Config Owner", Email: "typed-config-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "typed-config-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Typed Config", State: domain.ServerInstanceStateRunning})
|
|
if err != nil {
|
|
t.Fatalf("create typed config server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, instance, "local")
|
|
current, err := svc.GetServerConfigForSession(ownerSession, instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("read typed config: %v", err)
|
|
}
|
|
proposed := current.Content + "motd=typed\n"
|
|
dispatch, err := svc.ApproveServerConfigWriteForSession(ownerSession, domain.ServerConfigWriteApproval{ServerInstanceID: instance.ID, ExpectedConfigVersion: current.ConfigVersion, ExpectedChecksum: current.Checksum, Key: current.Key, ProposedContent: proposed, IdempotencyKey: "typed-config-write"})
|
|
if err != nil {
|
|
t.Fatalf("queue typed config write: %v", err)
|
|
}
|
|
helloRequest := validRunControlHello()
|
|
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityConfigWrite)
|
|
hello, err := svc.RegisterRunHello(helloRequest)
|
|
if err != nil {
|
|
t.Fatalf("register typed config Run: %v", err)
|
|
}
|
|
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityConfigWrite}, Capacity: domain.RunCapacity{MaxJobs: 2}})
|
|
if err != nil || !claim.HasJob {
|
|
t.Fatalf("claim typed config job: claim=%+v err=%v", claim, err)
|
|
}
|
|
checksum := validator.BytesChecksum([]byte(proposed))
|
|
if _, 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, Message: "config write completed"}, Message: "config write completed", ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: dispatch.Job.ExecutionInput.ExpectedVersion + 1, Checksum: checksum, SizeBytes: int64(len(proposed)), AuditSummary: "atomic compare-and-swap file write"}}); err != nil {
|
|
t.Fatalf("complete typed config job: %v", err)
|
|
}
|
|
updated, err := svc.GetServerConfigForSession(ownerSession, instance.ID)
|
|
if err != nil || updated.Content != proposed || updated.ConfigVersion != current.ConfigVersion+1 || updated.Checksum != checksum {
|
|
t.Fatalf("expected durable typed config projection, config=%+v err=%v", updated, err)
|
|
}
|
|
stored, err := svc.GetJobForSession(ownerSession, dispatch.Job.ID)
|
|
if err != nil {
|
|
t.Fatalf("read typed config job: %v", err)
|
|
}
|
|
if stored.ExecutionResult.Content != "" || stored.ExecutionResult.Checksum != checksum || stored.ExecutionResult.Version != current.ConfigVersion+1 {
|
|
t.Fatalf("unexpected safe/private job result projection: %+v", stored.ExecutionResult)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceUpdatesUsersProfileAndTheme(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
if _, err := svc.CreateUser(domain.User{
|
|
ID: "user-profile",
|
|
DisplayName: "Profile User",
|
|
Email: "profile@example.test",
|
|
Roles: []string{"server-admin"},
|
|
PasswordHash: "secret-password",
|
|
}); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
session, err := svc.LoginUser(domain.UserLogin{Account: "profile@example.test", Password: "secret-password"})
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
|
|
updated, err := svc.UpdateCurrentUserProfile(session.SessionID, domain.UserProfile{AvatarURL: "avatar://profile", Phone: "13900000000", ContactNote: "primary contact"})
|
|
if err != nil {
|
|
t.Fatalf("update profile: %v", err)
|
|
}
|
|
if updated.Profile.Phone != "13900000000" || updated.Profile.ContactNote != "primary contact" {
|
|
t.Fatalf("unexpected profile: %+v", updated.Profile)
|
|
}
|
|
|
|
theme, err := svc.UpdateCurrentUserTheme(session.SessionID, domain.UserThemePreference{PaletteID: "crystal-moonlight", BackgroundPresetID: "moon"})
|
|
if err != nil {
|
|
t.Fatalf("update theme: %v", err)
|
|
}
|
|
if theme.UserID != "user-profile" || theme.Persistence != "api" || !theme.UpdatedAt.Equal(fixedTime) {
|
|
t.Fatalf("unexpected theme preference: %+v", theme)
|
|
}
|
|
|
|
adminUpdate := updated
|
|
adminUpdate.Status = domain.UserStatusDisabled
|
|
adminUpdate.Roles = []string{"server-owner"}
|
|
adminUpdate.DisplayName = "Profile User Updated"
|
|
saved, err := svc.UpdateUser(updated.ID, adminUpdate)
|
|
if err != nil {
|
|
t.Fatalf("admin update user: %v", err)
|
|
}
|
|
if saved.Status != domain.UserStatusDisabled || saved.Roles[0] != "server-owner" || saved.DisplayName != "Profile User Updated" {
|
|
t.Fatalf("unexpected updated user: %+v", saved)
|
|
}
|
|
}
|
|
|
|
func createServiceUserAndLogin(t *testing.T, svc *CoreService, user domain.User) string {
|
|
t.Helper()
|
|
if _, err := svc.CreateUser(user); err != nil {
|
|
t.Fatalf("create %s: %v", user.ID, err)
|
|
}
|
|
session, err := svc.LoginUser(domain.UserLogin{Account: user.Email, Password: "secret-password"})
|
|
if err != nil {
|
|
t.Fatalf("login %s: %v", user.ID, err)
|
|
}
|
|
return session.SessionID
|
|
}
|
|
|
|
func TestCoreServiceManagesAIProviderMetadata(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
created, err := svc.CreateAIProvider(validProvider())
|
|
if err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
|
|
updated := created
|
|
updated.Name = "OpenAI Primary"
|
|
updated.BaseURL = "https://relay.example.test/v1"
|
|
updated.Models = []string{"gpt-4.1-mini"}
|
|
updated.DefaultModel = "gpt-4.1-mini"
|
|
updated.RelayMode = domain.AIRelayModeRelay
|
|
updated.APIKeyRef = "vault://providers/openai-primary"
|
|
got, err := svc.UpdateAIProvider(created.ID, updated)
|
|
if err != nil {
|
|
t.Fatalf("update provider: %v", err)
|
|
}
|
|
if got.Name != "OpenAI Primary" || got.Status != domain.AIProviderStatusActive || got.APIKeyRef != "vault://providers/openai-primary" {
|
|
t.Fatalf("unexpected updated provider: %+v", got)
|
|
}
|
|
|
|
disabled, err := svc.SetAIProviderStatus(created.ID, domain.AIProviderStatusDisabled)
|
|
if err != nil {
|
|
t.Fatalf("disable provider: %v", err)
|
|
}
|
|
if disabled.Status != domain.AIProviderStatusDisabled {
|
|
t.Fatalf("expected disabled provider, got %+v", disabled)
|
|
}
|
|
|
|
testResult, err := svc.TestAIProvider(created.ID)
|
|
if err != nil {
|
|
t.Fatalf("test provider: %v", err)
|
|
}
|
|
if testResult.Success || !strings.Contains(strings.Join(testResult.Violations, ","), "provider must be active") {
|
|
t.Fatalf("expected disabled provider test failure, got %+v", testResult)
|
|
}
|
|
|
|
enabled, err := svc.SetAIProviderStatus(created.ID, domain.AIProviderStatusActive)
|
|
if err != nil {
|
|
t.Fatalf("enable provider: %v", err)
|
|
}
|
|
if enabled.Status != domain.AIProviderStatusActive {
|
|
t.Fatalf("expected active provider, got %+v", enabled)
|
|
}
|
|
|
|
testResult, err = svc.TestAIProvider(created.ID)
|
|
if err != nil {
|
|
t.Fatalf("test enabled provider: %v", err)
|
|
}
|
|
if !testResult.Success || testResult.Mode != "metadata" {
|
|
t.Fatalf("expected metadata test success, got %+v", testResult)
|
|
}
|
|
|
|
models, err := svc.ListAIProviderModels(created.ID)
|
|
if err != nil {
|
|
t.Fatalf("list provider models: %v", err)
|
|
}
|
|
if models.ProviderID != created.ID || models.DefaultModel != "gpt-4.1-mini" || len(models.Models) != 1 || models.Models[0] != "gpt-4.1-mini" {
|
|
t.Fatalf("unexpected provider models: %+v", models)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRegistersGamePluginManifest(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
|
|
plugin, err := svc.RegisterGamePluginManifest(validPluginManifestRegistration())
|
|
if err != nil {
|
|
t.Fatalf("register manifest: %v", err)
|
|
}
|
|
if plugin.ID != "game.example" || plugin.ServerType != "example" || plugin.ServerDisplayName != "Example Server" {
|
|
t.Fatalf("unexpected registered plugin metadata: %+v", plugin)
|
|
}
|
|
if plugin.Status != domain.GamePluginStatusInstalled {
|
|
t.Fatalf("expected installed status, got %+v", plugin)
|
|
}
|
|
if !plugin.Permissions.AI || !plugin.Permissions.Logs || !plugin.Permissions.Files || !plugin.Permissions.Artifacts || !plugin.Permissions.Jobs {
|
|
t.Fatalf("expected aggregate permissions from manifest, got %+v", plugin.Permissions)
|
|
}
|
|
if len(plugin.Pages) != 1 || plugin.Pages[0].Permissions[0] != "server.logs.read" {
|
|
t.Fatalf("expected page metadata, got %+v", plugin.Pages)
|
|
}
|
|
if len(plugin.AIPurposes) != 1 || plugin.AIPurposes[0] != "logs.diagnose" {
|
|
t.Fatalf("expected AI purposes, got %+v", plugin.AIPurposes)
|
|
}
|
|
if len(plugin.BridgeActions) != 4 || plugin.BridgeActions[0] != string(domain.PluginBridgeActionServerInstancesRead) {
|
|
t.Fatalf("expected bridge actions, got %+v", plugin.BridgeActions)
|
|
}
|
|
|
|
listed, err := svc.ListGamePlugins(domain.GamePluginFilter{ServerType: "example", Status: domain.GamePluginStatusInstalled})
|
|
if err != nil || len(listed) != 1 {
|
|
t.Fatalf("list registered plugins: len=%d err=%v", len(listed), err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceMarketplacePluginsAreFilteredSafeAndStateful(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
if _, err := svc.RegisterGamePluginManifest(validPluginManifestRegistration()); err != nil {
|
|
t.Fatalf("register manifest: %v", err)
|
|
}
|
|
|
|
listed, err := svc.ListMarketplacePlugins(domain.PluginMarketplaceFilter{ServerType: "example", Status: domain.GamePluginStatusInstalled, Capability: "logs.read", Keyword: "development"})
|
|
if err != nil {
|
|
t.Fatalf("list marketplace plugins: %v", err)
|
|
}
|
|
if len(listed) != 1 || listed[0].ID != "game.example" || listed[0].Source != "platform-registry" {
|
|
t.Fatalf("unexpected marketplace list: %+v", listed)
|
|
}
|
|
if len(listed[0].Capabilities) == 0 || listed[0].Capabilities[0] != "process.install" || len(listed[0].Pages) != 1 || listed[0].AIPurposes[0] != "logs.diagnose" {
|
|
t.Fatalf("expected manifest-backed projection, got %+v", listed[0])
|
|
}
|
|
|
|
detail, err := svc.GetMarketplacePlugin("game.example")
|
|
if err != nil {
|
|
t.Fatalf("get marketplace plugin: %v", err)
|
|
}
|
|
if detail.ManifestRef != "artifact://manifests/game.example/0.1.0" || detail.CreateFormSchemaRef != "schemas/create-form.schema.json" {
|
|
t.Fatalf("unexpected marketplace detail refs: %+v", detail)
|
|
}
|
|
|
|
disabled, err := svc.SetMarketplacePluginState("game.example", domain.PluginMarketplaceStateActionDisable)
|
|
if err != nil {
|
|
t.Fatalf("disable marketplace plugin: %v", err)
|
|
}
|
|
if disabled.Status != domain.GamePluginStatusDisabled {
|
|
t.Fatalf("expected disabled status, got %+v", disabled)
|
|
}
|
|
enabled, err := svc.SetMarketplacePluginState("game.example", domain.PluginMarketplaceStateActionEnable)
|
|
if err != nil {
|
|
t.Fatalf("enable marketplace plugin: %v", err)
|
|
}
|
|
if enabled.Status != domain.GamePluginStatusInstalled {
|
|
t.Fatalf("expected installed status after enable, got %+v", enabled)
|
|
}
|
|
|
|
missing, err := svc.ListMarketplacePlugins(domain.PluginMarketplaceFilter{Keyword: "missing"})
|
|
if err != nil || len(missing) != 0 {
|
|
t.Fatalf("expected empty keyword result, len=%d err=%v", len(missing), err)
|
|
}
|
|
if _, err := svc.GetMarketplacePlugin("missing"); !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("expected missing plugin error, got %v", err)
|
|
}
|
|
if _, err := svc.SetMarketplacePluginState("game.example", domain.PluginMarketplaceStateAction("download")); err == nil || !strings.Contains(err.Error(), "action is not supported") {
|
|
t.Fatalf("expected unsupported action validation, got %v", err)
|
|
}
|
|
if _, err := svc.ListMarketplacePlugins(domain.PluginMarketplaceFilter{Keyword: "sk-raw-secret"}); err == nil || !strings.Contains(err.Error(), "raw credential") {
|
|
t.Fatalf("expected unsafe keyword validation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceAuthorizesPluginBridgeActions(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
if _, err := svc.RegisterGamePluginManifest(validPluginManifestRegistration()); err != nil {
|
|
t.Fatalf("register manifest: %v", err)
|
|
}
|
|
|
|
allowed, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: domain.PluginBridgeActionLogsQuery,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("authorize logs query: %v", err)
|
|
}
|
|
if !allowed.Allowed || allowed.RequiredPermissions[0] != "server.logs.read" {
|
|
t.Fatalf("expected allowed logs bridge action, got %+v", allowed)
|
|
}
|
|
|
|
missingPermission, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: domain.PluginBridgeActionFilesRequest,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("authorize files request: %v", err)
|
|
}
|
|
if missingPermission.Allowed || !strings.Contains(missingPermission.Reason, "required permission") {
|
|
t.Fatalf("expected missing permission denial, got %+v", missingPermission)
|
|
}
|
|
|
|
aiAllowed, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: domain.PluginBridgeActionAIInvoke,
|
|
AIPurpose: "logs.diagnose",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("authorize AI request: %v", err)
|
|
}
|
|
if !aiAllowed.Allowed {
|
|
t.Fatalf("expected allowed AI bridge action, got %+v", aiAllowed)
|
|
}
|
|
|
|
aiDenied, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: domain.PluginBridgeActionAIInvoke,
|
|
AIPurpose: "config.suggest",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("authorize undeclared AI request: %v", err)
|
|
}
|
|
if aiDenied.Allowed || !strings.Contains(aiDenied.Reason, "ai purpose") {
|
|
t.Fatalf("expected undeclared AI purpose denial, got %+v", aiDenied)
|
|
}
|
|
|
|
_, err = svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: domain.PluginBridgeAction("direct.run.socket"),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "action is not supported") {
|
|
t.Fatalf("expected unsupported action validation error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRemoteAccessRequiresPluginDeclaration(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
registration := validPluginManifestRegistration()
|
|
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities,
|
|
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
|
domain.JobCapabilityRemoteRunLogsTransfer,
|
|
domain.JobCapabilityRemoteRunRCONCommand,
|
|
)
|
|
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.remote.access")
|
|
registration.Manifest.Bridge.Actions = append(registration.Manifest.Bridge.Actions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
|
registration.Manifest.Pages = append(registration.Manifest.Pages, domain.GamePluginPage{
|
|
Key: "remote",
|
|
Title: "Remote",
|
|
Path: "/remote",
|
|
Permissions: []string{"server.remote.access"},
|
|
BridgeActions: []string{string(domain.PluginBridgeActionRemoteAccessRequest)},
|
|
})
|
|
registration.Manifest.RemoteAccess = domain.GamePluginRemoteAccess{
|
|
Methods: []string{"run"},
|
|
RunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand},
|
|
DatabaseEngines: []string{"sqlite"},
|
|
RCON: true,
|
|
LogTransfer: true,
|
|
}
|
|
plugin, err := svc.RegisterGamePluginManifest(registration)
|
|
if err != nil {
|
|
t.Fatalf("register remote manifest: %v", err)
|
|
}
|
|
if !plugin.Permissions.RemoteAccess || !plugin.RemoteAccess.RCON || plugin.RemoteAccess.DatabaseEngines[0] != "sqlite" {
|
|
t.Fatalf("expected remote access metadata from manifest, got %+v", plugin)
|
|
}
|
|
marketplace, err := svc.GetMarketplacePlugin(plugin.ID)
|
|
if err != nil || !marketplace.RemoteAccess.LogTransfer || marketplace.RemoteAccess.Methods[0] != "run" {
|
|
t.Fatalf("expected marketplace remote access projection, got %+v err=%v", marketplace, err)
|
|
}
|
|
|
|
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{
|
|
ID: "run-remote",
|
|
DisplayName: "Remote Run",
|
|
Version: "0.1.0",
|
|
Capabilities: append([]string{"process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"}, plugin.RemoteAccess.RunCapabilities...),
|
|
Capacity: domain.RunCapacity{MaxJobs: 2},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create remote endpoint: %v", err)
|
|
}
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
|
ID: "user-remote-owner",
|
|
DisplayName: "Remote Owner",
|
|
Email: "remote-owner@example.test",
|
|
Roles: []string{"server-owner"},
|
|
PasswordHash: "secret-password",
|
|
})
|
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
|
ID: "server-remote",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "Remote Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create remote server: %v", err)
|
|
}
|
|
|
|
queued, err := svc.ExecutePluginBridgeAction(ownerSession, domain.PluginBridgeExecuteRequest{
|
|
RequestID: "remote-rcon-1",
|
|
PluginID: plugin.ID,
|
|
RouteKey: "remote",
|
|
ServerInstanceID: instance.ID,
|
|
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
|
Payload: map[string]string{
|
|
"capability": domain.JobCapabilityRemoteRunRCONCommand,
|
|
"targetKey": "rcon/command",
|
|
"inputRef": "input://server-remote/rcon/command/1",
|
|
"idempotencyKey": "idem-remote-rcon",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute remote bridge action: %v", err)
|
|
}
|
|
if queued.Status != "queued" || queued.Result["capability"] != domain.JobCapabilityRemoteRunRCONCommand {
|
|
t.Fatalf("expected queued remote bridge job, got %+v", queued)
|
|
}
|
|
|
|
plainPlugin, plainEndpoint := createPluginAndRunEndpoint(t, svc)
|
|
plainEndpoint.Capabilities = append(plainEndpoint.Capabilities, domain.JobCapabilityRemoteRunRCONCommand)
|
|
if err := svc.store.RunEndpoints().Update(plainEndpoint); err != nil {
|
|
t.Fatalf("extend plain endpoint: %v", err)
|
|
}
|
|
plainInstance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-plain", PluginID: plainPlugin.ID, RunEndpointID: plainEndpoint.ID, Name: "Plain Server"})
|
|
if err != nil {
|
|
t.Fatalf("create plain server: %v", err)
|
|
}
|
|
_, err = svc.CreateJob(domain.Job{ID: "job-remote-denied", ServerInstanceID: plainInstance.ID, RunEndpointID: plainEndpoint.ID, Capability: domain.JobCapabilityRemoteRunRCONCommand, TargetKey: "rcon/command", InputRef: "input://plain/rcon/command/1", IdempotencyKey: "idem-denied"})
|
|
if err == nil || !strings.Contains(err.Error(), "plugin missing required capability") {
|
|
t.Fatalf("expected undeclared remote job denial, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRejectsDuplicateGamePluginManifest(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
registration := validPluginManifestRegistration()
|
|
if _, err := svc.RegisterGamePluginManifest(registration); err != nil {
|
|
t.Fatalf("register first manifest: %v", err)
|
|
}
|
|
|
|
_, err := svc.RegisterGamePluginManifest(registration)
|
|
if !errors.Is(err, repo.ErrDuplicate) {
|
|
t.Fatalf("expected duplicate plugin registration, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRejectsUnsafeGamePluginManifest(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
registration := validPluginManifestRegistration()
|
|
registration.Manifest.Description = "requires direct run socket and raw AI key"
|
|
|
|
_, err := svc.RegisterGamePluginManifest(registration)
|
|
if err == nil || !strings.Contains(err.Error(), "direct run access") || !strings.Contains(err.Error(), "raw credential") {
|
|
t.Fatalf("expected unsafe manifest rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRejectsInvalidAIProviderManagement(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
provider := validProvider()
|
|
if _, err := svc.CreateAIProvider(provider); err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
|
|
provider.APIKeyRef = "sk-raw-secret"
|
|
_, err := svc.UpdateAIProvider(provider.ID, provider)
|
|
if err == nil || !strings.Contains(err.Error(), "apiKeyRef must reference secret storage") {
|
|
t.Fatalf("expected raw secret rejection, got %v", err)
|
|
}
|
|
|
|
_, err = svc.SetAIProviderStatus(provider.ID, domain.AIProviderStatusError)
|
|
if err == nil || !strings.Contains(err.Error(), "status must be active or disabled") {
|
|
t.Fatalf("expected invalid status rejection, got %v", err)
|
|
}
|
|
|
|
_, err = svc.UpdateAIProvider("missing", provider)
|
|
if !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("expected missing update target, got %v", err)
|
|
}
|
|
_, err = svc.TestAIProvider("missing")
|
|
if !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("expected missing test target, got %v", err)
|
|
}
|
|
_, err = svc.ListAIProviderModels("missing")
|
|
if !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("expected missing models target, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceReturnsExistingJobForDuplicateIdempotencyKey(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
instance, err := svc.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-1",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "SCUM #1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server instance: %v", err)
|
|
}
|
|
|
|
first, err := svc.CreateJob(domain.Job{
|
|
ID: "job-1",
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Capability: "process.start",
|
|
IdempotencyKey: "idem-start",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create first job: %v", err)
|
|
}
|
|
second, err := svc.CreateJob(domain.Job{
|
|
ID: "job-2",
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Capability: "process.start",
|
|
IdempotencyKey: "idem-start",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create second job: %v", err)
|
|
}
|
|
if second.ID != first.ID {
|
|
t.Fatalf("expected idempotent job %q, got %q", first.ID, second.ID)
|
|
}
|
|
|
|
jobs, err := svc.ListJobs(domain.JobFilter{RunEndpointID: endpoint.ID})
|
|
if err != nil {
|
|
t.Fatalf("list jobs: %v", err)
|
|
}
|
|
if len(jobs) != 1 {
|
|
t.Fatalf("expected one stored job, got %+v", jobs)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceRejectsJobTargetMismatch(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
otherEndpoint := endpoint
|
|
otherEndpoint.ID = "run-other"
|
|
if _, err := svc.CreateRunEndpoint(otherEndpoint); err != nil {
|
|
t.Fatalf("create other endpoint: %v", err)
|
|
}
|
|
instance, err := svc.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-1",
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
Name: "SCUM #1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create server instance: %v", err)
|
|
}
|
|
|
|
_, err = svc.CreateJob(domain.Job{
|
|
ID: "job-1",
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: otherEndpoint.ID,
|
|
Capability: "process.start",
|
|
IdempotencyKey: "idem-start",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "job runEndpointId must match server instance") {
|
|
t.Fatalf("expected target mismatch rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServicePropagatesDuplicateErrors(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
user := domain.User{ID: "user-1", DisplayName: "Operator", Status: domain.UserStatusActive}
|
|
if _, err := svc.CreateUser(user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
_, err := svc.CreateUser(user)
|
|
if !errors.Is(err, repo.ErrDuplicate) {
|
|
t.Fatalf("expected duplicate error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func newTestCoreService() *CoreService {
|
|
return newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime })
|
|
}
|
|
|
|
func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlugin, domain.RunEndpoint) {
|
|
t.Helper()
|
|
|
|
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{"process.install", "process.start", "process.stop", "logs.read", "config.write", "files.read", "files.write"},
|
|
DeclaredPermissions: []string{"server.files.read", "server.files.write"},
|
|
LifecycleActions: domain.PluginLifecycleActions{
|
|
Install: "actions/install.json",
|
|
Start: "actions/start.json",
|
|
Stop: "actions/stop.json",
|
|
},
|
|
Permissions: domain.PluginPermissions{
|
|
Logs: true,
|
|
Files: true,
|
|
Jobs: true,
|
|
},
|
|
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create plugin fixture: %v", err)
|
|
}
|
|
|
|
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{
|
|
ID: "run-local",
|
|
DisplayName: "Local Run",
|
|
Version: "0.1.0",
|
|
Capabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "config.write", "files.read", "files.write"},
|
|
Capacity: domain.RunCapacity{MaxJobs: 4},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create run endpoint fixture: %v", err)
|
|
}
|
|
|
|
return plugin, endpoint
|
|
}
|
|
|
|
func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domain.ServerInstance, profileKey string) domain.RuntimeBinding {
|
|
t.Helper()
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
t.Fatalf("get plugin for runtime binding: %v", err)
|
|
}
|
|
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: profileKey, Bindings: map[string]string{}}, true)
|
|
if err != nil {
|
|
t.Fatalf("build runtime binding: %v", err)
|
|
}
|
|
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
|
|
t.Fatalf("create runtime binding: %v", err)
|
|
}
|
|
return binding
|
|
}
|
|
|
|
func validPluginManifestRegistration() 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",
|
|
},
|
|
Bridge: domain.GamePluginBridge{
|
|
Actions: []string{
|
|
string(domain.PluginBridgeActionServerInstancesRead),
|
|
string(domain.PluginBridgeActionLogsQuery),
|
|
string(domain.PluginBridgeActionFilesRequest),
|
|
string(domain.PluginBridgeActionAIInvoke),
|
|
},
|
|
},
|
|
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.invoke"},
|
|
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
|
},
|
|
},
|
|
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}},
|
|
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func validProvider() 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,
|
|
RedactionPolicy: "default",
|
|
}
|
|
}
|