Files
browser/platform/service/dependency_updates_test.go
T
2026-07-20 16:42:33 +08:00

314 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)
}
}
audits, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID})
if err != nil {
t.Fatalf("list audits: %v", err)
}
foundDenied := false
for _, audit := range audits {
foundDenied = foundDenied || audit.Action == "dependency.install.denied"
}
if !foundDenied {
t.Fatalf("expected stale digest audit, got %+v", audits)
}
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, AuditSummary: "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)
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)
}
payload := []byte("compiled target-matched run archive")
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)), AuditSummary: "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},
}
}