303 lines
18 KiB
Go
303 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"browser.local/platform/domain"
|
|
)
|
|
|
|
func TestDependencyCatalogRequiresCurrentReviewedDigest(t *testing.T) {
|
|
svc, session, instance := newDistributionTestFixture(t)
|
|
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "dependency-other-owner", DisplayName: "Other Owner", Email: "dependency-other@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
if _, err := svc.GetDependencyCatalogForSession(otherSession, instance.ID); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("expected cross-owner dependency catalog denial, got %v", err)
|
|
}
|
|
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("get dependency catalog: %v", err)
|
|
}
|
|
if catalog.TargetOS != "linux" || catalog.TargetArch != "amd64" || len(catalog.Probes) != 1 || len(catalog.Plans) != 1 || !strings.HasPrefix(catalog.Plans[0].Digest, "sha256:") {
|
|
t.Fatalf("unexpected dependency catalog: %+v", catalog)
|
|
}
|
|
request := domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, Install: true, InstallPlanKey: catalog.Plans[0].Key, PlanDigest: "sha256:" + strings.Repeat("f", 64), IdempotencyKey: "dependency-stale-digest"}
|
|
if _, err := svc.QueueDependencyJobForSession(session, request); err == nil || !strings.Contains(err.Error(), "planDigest") {
|
|
t.Fatalf("expected stale digest rejection, got %v", err)
|
|
}
|
|
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
|
if err != nil {
|
|
t.Fatalf("list jobs: %v", err)
|
|
}
|
|
for _, job := range jobs {
|
|
if job.IdempotencyKey == request.IdempotencyKey {
|
|
t.Fatalf("stale digest created a job: %+v", job)
|
|
}
|
|
}
|
|
request.PlanDigest = catalog.Plans[0].Digest
|
|
request.IdempotencyKey = "dependency-current-digest"
|
|
job, err := svc.QueueDependencyJobForSession(session, request)
|
|
if err != nil {
|
|
t.Fatalf("queue reviewed dependency plan: %v", err)
|
|
}
|
|
if job.Capability != domain.JobCapabilityDependenciesInstall || job.TargetKey != "dependencies/install/"+catalog.Plans[0].Key {
|
|
t.Fatalf("unexpected dependency install job: %+v", job)
|
|
}
|
|
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
t.Fatalf("get plugin: %v", err)
|
|
}
|
|
plugin.RuntimeProfiles.InstallPlans[0].Steps[0].PackageName = "openjdk-22-jre"
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("mutate plugin declaration: %v", err)
|
|
}
|
|
runSession := registerDependencyUpdateRun(t, svc, instance)
|
|
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
|
if err != nil || !claim.HasJob {
|
|
t.Fatalf("claim dependency install: claim=%+v err=%v", claim, err)
|
|
}
|
|
_, err = svc.GetDependencyExecutionInput(domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
|
|
if err == nil || !strings.Contains(err.Error(), "changed after dispatch") {
|
|
t.Fatalf("expected changed declaration rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDependencyCatalogNamesMissingPluginPermission(t *testing.T) {
|
|
svc, session, instance := newDistributionTestFixture(t)
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
t.Fatalf("get plugin: %v", err)
|
|
}
|
|
permissions := plugin.DeclaredPermissions[:0]
|
|
for _, permission := range plugin.DeclaredPermissions {
|
|
if permission != "server.dependencies.manage" {
|
|
permissions = append(permissions, permission)
|
|
}
|
|
}
|
|
plugin.DeclaredPermissions = permissions
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("update plugin: %v", err)
|
|
}
|
|
|
|
_, err = svc.GetDependencyCatalogForSession(session, instance.ID)
|
|
if !errors.Is(err, ErrForbidden) || !strings.Contains(err.Error(), "server.dependencies.manage") {
|
|
t.Fatalf("expected named dependency permission denial, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDependencyInputFencingCancellationAndTerminalProjection(t *testing.T) {
|
|
svc, session, instance := newDistributionTestFixture(t)
|
|
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("catalog: %v", err)
|
|
}
|
|
job, err := svc.QueueDependencyJobForSession(session, domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, IdempotencyKey: "dependency-check-fencing"})
|
|
if err != nil {
|
|
t.Fatalf("queue dependency check: %v", err)
|
|
}
|
|
runSession := registerDependencyUpdateRun(t, svc, instance)
|
|
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesCheck}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
|
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID {
|
|
t.Fatalf("claim dependency check: claim=%+v err=%v", claim, err)
|
|
}
|
|
base := domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
|
|
input, err := svc.GetDependencyExecutionInput(base)
|
|
if err != nil || input.PlanDigest == "" || input.Bindings["java"] == "" {
|
|
t.Fatalf("get fenced dependency input: input=%+v err=%v", input, err)
|
|
}
|
|
wrongSession := base
|
|
wrongSession.SessionToken = "stale-session"
|
|
if _, err := svc.GetDependencyExecutionInput(wrongSession); err == nil {
|
|
t.Fatal("expected wrong session rejection")
|
|
}
|
|
wrongAttempt := base
|
|
wrongAttempt.Attempt++
|
|
if _, err := svc.GetDependencyExecutionInput(wrongAttempt); err == nil {
|
|
t.Fatal("expected wrong attempt rejection")
|
|
}
|
|
wrongLease := base
|
|
wrongLease.LeaseToken = "stale-lease"
|
|
if _, err := svc.GetDependencyExecutionInput(wrongLease); err == nil {
|
|
t.Fatal("expected wrong lease rejection")
|
|
}
|
|
|
|
evidence, _ := json.Marshal(domain.DependencyExecutionEvidence{ProbeKey: input.Probe.Key, PlanDigest: input.PlanDigest, State: string(domain.DependencyStatePresent), Evidence: "OpenJDK 21"})
|
|
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "dependency probe completed"}, ResultRef: "artifact://jobs/dependency-check/result", Message: "dependency probe completed", ExecutionResult: domain.JobExecutionResult{Kind: "dependency.check", Checksum: input.PlanDigest, Summary: "dependency probe completed", Content: string(evidence)}}); err != nil {
|
|
t.Fatalf("complete dependency result: %v", err)
|
|
}
|
|
projected, err := svc.GetDependencyCatalogForSession(session, instance.ID)
|
|
if err != nil || projected.Probes[0].State != domain.DependencyStatePresent || projected.Probes[0].Evidence != "OpenJDK 21" {
|
|
t.Fatalf("unexpected dependency projection: catalog=%+v err=%v", projected, err)
|
|
}
|
|
|
|
cancelJob, err := svc.QueueDependencyJobForSession(session, domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, IdempotencyKey: "dependency-check-cancel"})
|
|
if err != nil {
|
|
t.Fatalf("queue cancellable dependency check: %v", err)
|
|
}
|
|
claim, err = svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesCheck}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
|
if err != nil || claim.Job.JobID != cancelJob.ID {
|
|
t.Fatalf("claim cancellable dependency check: claim=%+v err=%v", claim, err)
|
|
}
|
|
if _, err := svc.RequestRunJobCancelForSession(session, domain.RunJobCancelRequest{JobID: cancelJob.ID, Reason: "operator cancelled"}); err != nil {
|
|
t.Fatalf("request cancel: %v", err)
|
|
}
|
|
if _, err := svc.GetDependencyExecutionInput(domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}); err == nil || !strings.Contains(err.Error(), "cancelled") {
|
|
t.Fatalf("expected cancelled input rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPluginBridgeDependencyInstallUsesReviewedPlanDigest(t *testing.T) {
|
|
svc, session, instance := newDistributionTestFixture(t)
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
t.Fatalf("get plugin: %v", err)
|
|
}
|
|
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{Key: "runtime", Title: "Runtime", Path: "/runtime", Permissions: []string{"server.dependencies.manage"}, BridgeActions: []string{string(domain.PluginBridgeActionDependenciesRequest)}})
|
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
t.Fatalf("add dependency bridge page: %v", err)
|
|
}
|
|
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("get dependency catalog: %v", err)
|
|
}
|
|
request := domain.PluginBridgeExecuteRequest{RequestID: "bridge-dependency-install", PluginID: plugin.ID, RouteKey: "runtime", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionDependenciesRequest, Payload: map[string]string{"operation": "install", "probeKey": catalog.Probes[0].Key, "planKey": catalog.Plans[0].Key, "idempotencyKey": "bridge-dependency-install"}}
|
|
denied, err := svc.ExecutePluginBridgeAction(session, request)
|
|
if err != nil {
|
|
t.Fatalf("execute bridge without digest: %v", err)
|
|
}
|
|
if denied.Status == "queued" || denied.Error == nil {
|
|
t.Fatalf("bridge install without reviewed digest must be denied: %+v", denied)
|
|
}
|
|
request.Payload["planDigest"] = catalog.Plans[0].Digest
|
|
request.Payload["idempotencyKey"] = "bridge-dependency-install-approved"
|
|
approved, err := svc.ExecutePluginBridgeAction(session, request)
|
|
if err != nil {
|
|
t.Fatalf("execute reviewed bridge install: %v", err)
|
|
}
|
|
if approved.Status != "queued" || approved.Result["capability"] != domain.JobCapabilityDependenciesInstall {
|
|
t.Fatalf("expected reviewed bridge dependency job, got %+v", approved)
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateTargetFencingChunksHealthAndRollbackProjection(t *testing.T) {
|
|
svc, session, instance := newDistributionTestFixture(t)
|
|
payload := []byte("compiled target-matched run archive")
|
|
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
|
|
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "run-update-build"})
|
|
if err != nil {
|
|
t.Fatalf("generate update distribution: %v", err)
|
|
}
|
|
distribution = completeDistributionBuild(t, svc, distribution, payload)
|
|
otherInstance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-update-other", PluginID: instance.PluginID, RunEndpointID: instance.RunEndpointID, Name: "Other Update Server", State: domain.ServerInstanceStateReady})
|
|
if err != nil {
|
|
t.Fatalf("create other update server: %v", err)
|
|
}
|
|
createCompleteRuntimeBinding(t, svc, otherInstance, "local")
|
|
if _, err := svc.PushRunUpdateForSession(session, domain.RunUpdateRequest{ServerInstanceID: otherInstance.ID, ArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, IdempotencyKey: "run-update-cross-server"}); err == nil {
|
|
t.Fatal("expected cross-server update artifact rejection")
|
|
}
|
|
|
|
endpoint, _ := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
|
endpoint.Architecture = "arm64"
|
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
|
t.Fatalf("change endpoint target: %v", err)
|
|
}
|
|
request := domain.RunUpdateRequest{ServerInstanceID: instance.ID, ArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, IdempotencyKey: "run-update-target-check"}
|
|
if _, err := svc.PushRunUpdateForSession(session, request); err == nil || !strings.Contains(err.Error(), "target-matched") {
|
|
t.Fatalf("expected cross-target update rejection, got %v", err)
|
|
}
|
|
endpoint.Architecture = "amd64"
|
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
|
t.Fatalf("restore endpoint target: %v", err)
|
|
}
|
|
request.IdempotencyKey = "run-update-fenced"
|
|
update, err := svc.PushRunUpdateForSession(session, request)
|
|
if err != nil {
|
|
t.Fatalf("push target-matched update: %v", err)
|
|
}
|
|
runSession := registerDependencyUpdateRun(t, svc, instance)
|
|
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRunSelfUpdate}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
|
if err != nil || !claim.HasJob || claim.Job.JobID != update.JobID {
|
|
t.Fatalf("claim Run update: claim=%+v err=%v", claim, err)
|
|
}
|
|
inputRequest := domain.RunUpdateInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
|
|
input, err := svc.GetRunUpdateInput(inputRequest)
|
|
if err != nil || input.TargetRelease != update.TargetRelease || input.Checksum != distribution.Checksum {
|
|
t.Fatalf("get Run update input: input=%+v err=%v", input, err)
|
|
}
|
|
chunk, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 0, Length: 8})
|
|
if err != nil || string(chunk.Payload) != string(payload[:8]) || chunk.Offset != 0 || chunk.TotalBytes != int64(len(payload)) {
|
|
t.Fatalf("read bounded update chunk: chunk=%+v err=%v", chunk, err)
|
|
}
|
|
if _, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt + 1, Offset: 0, Length: 8}); err == nil {
|
|
t.Fatal("expected stale update chunk attempt rejection")
|
|
}
|
|
|
|
evidence, _ := json.Marshal(domain.RunUpdateExecutionEvidence{TargetRelease: update.TargetRelease, Phase: "staged"})
|
|
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "Run update verified and staged"}, ResultRef: "artifact://jobs/run-update/staged", Message: "Run update verified and staged", ExecutionResult: domain.JobExecutionResult{Kind: "run.update.staged", Checksum: update.Checksum, SizeBytes: int64(len(payload)), Summary: "verified update staged", Content: string(evidence)}}); err != nil {
|
|
t.Fatalf("complete staged Run update: %v", err)
|
|
}
|
|
updates, err := svc.ListRunUpdateJobsForSession(session, instance.ID)
|
|
if err != nil || len(updates) != 1 || updates[0].Phase != domain.RunUpdatePhaseRestartRequested {
|
|
t.Fatalf("expected restart-requested projection, updates=%+v err=%v", updates, err)
|
|
}
|
|
|
|
newHello := dependencyUpdateHello(instance)
|
|
newHello.Version = update.TargetRelease
|
|
newRegistration, err := svc.RegisterRunHello(newHello)
|
|
if err != nil {
|
|
t.Fatalf("register updated Run: %v", err)
|
|
}
|
|
health := domain.RunUpdateHealthReport{RunEndpointID: instance.RunEndpointID, SessionToken: newRegistration.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Outcome: "succeeded", Version: update.TargetRelease}
|
|
if _, err := svc.ReportRunUpdateHealth(domain.RunUpdateHealthReport{RunEndpointID: health.RunEndpointID, SessionToken: health.SessionToken, JobID: health.JobID, LeaseToken: "stale-lease", Attempt: health.Attempt, Outcome: health.Outcome, Version: health.Version}); err == nil {
|
|
t.Fatal("expected stale health lease rejection")
|
|
}
|
|
result, err := svc.ReportRunUpdateHealth(health)
|
|
if err != nil || !result.Accepted || result.Phase != domain.RunUpdatePhaseSucceeded {
|
|
t.Fatalf("report updated Run health: result=%+v err=%v", result, err)
|
|
}
|
|
|
|
rollbackHello := dependencyUpdateHello(instance)
|
|
rollbackHello.Version = update.PreviousVersion
|
|
rollbackRegistration, err := svc.RegisterRunHello(rollbackHello)
|
|
if err != nil {
|
|
t.Fatalf("register rolled-back Run: %v", err)
|
|
}
|
|
health.SessionToken = rollbackRegistration.SessionToken
|
|
health.Outcome = "rolled-back"
|
|
health.Version = update.PreviousVersion
|
|
result, err = svc.ReportRunUpdateHealth(health)
|
|
if err != nil || result.Phase != domain.RunUpdatePhaseRolledBack {
|
|
t.Fatalf("report rollback: result=%+v err=%v", result, err)
|
|
}
|
|
updates, _ = svc.ListRunUpdateJobsForSession(session, instance.ID)
|
|
if !updates[0].Rollback || updates[0].Status != domain.DistributionJobStatusFailed || updates[0].Phase != domain.RunUpdatePhaseRolledBack {
|
|
t.Fatalf("unexpected rollback projection: %+v", updates[0])
|
|
}
|
|
}
|
|
|
|
func registerDependencyUpdateRun(t *testing.T, svc *CoreService, instance domain.ServerInstance) string {
|
|
t.Helper()
|
|
result, err := svc.RegisterRunHello(dependencyUpdateHello(instance))
|
|
if err != nil || !result.Accepted {
|
|
t.Fatalf("register dependency/update Run: result=%+v err=%v", result, err)
|
|
}
|
|
return result.SessionToken
|
|
}
|
|
|
|
func dependencyUpdateHello(instance domain.ServerInstance) domain.RunControlHello {
|
|
return domain.RunControlHello{
|
|
RegistrationToken: "registration-token",
|
|
RunEndpointID: instance.RunEndpointID,
|
|
DisplayName: "Dependency Update Run",
|
|
Version: "0.1.0",
|
|
Status: domain.RunEndpointStatusOnline,
|
|
Platform: "linux",
|
|
Architecture: "amd64",
|
|
CapabilityReport: domain.RunCapabilityReport{Capabilities: []string{domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate}, Fingerprint: "dependency-update-v1"},
|
|
Capacity: domain.RunCapacity{MaxJobs: 2},
|
|
}
|
|
}
|