package canvas import ( "context" "errors" "sync" "testing" "time" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/db" plugininstances "github.com/kandev/kandev/internal/plugins/instances" "github.com/kandev/kandev/internal/plugins/manifest" "github.com/kandev/kandev/internal/plugins/webapp" ) func TestPublishPackageFirstReleaseRequiresMatchingGrants(t *testing.T) { tests := []struct { name string reads []string wantActivated bool wantStatus string }{ { name: "zero permissions activate", wantActivated: true, wantStatus: plugininstances.ValidationValid, }, { name: "declared permissions require grants", reads: []string{"tasks"}, wantActivated: false, wantStatus: plugininstances.ValidationPendingPermission, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { service, instanceStore, _ := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "First release", }) result := publishTestPackage(t, service, canvas.ID, "first-release", tt.reads) if result.Activated != tt.wantActivated { t.Fatalf("activated = %t, want %t", result.Activated, tt.wantActivated) } if result.Release.ValidationStatus != tt.wantStatus { t.Fatalf("release status = %q, want %q", result.Release.ValidationStatus, tt.wantStatus) } instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } if tt.wantActivated { if instance.ActiveReleaseID != result.Release.ID { t.Fatalf("active release = %q, want %q", instance.ActiveReleaseID, result.Release.ID) } } else if instance.ActiveReleaseID != "" { t.Fatalf("active release = %q, want no active release", instance.ActiveReleaseID) } }) } } func TestCanvasCreationAuthorityFirstPublish(t *testing.T) { service, instanceStore, _ := newCanvasService(t) created := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Owner-authorized canvas", CreatedBySessionID: "session-1", OwnerUserID: "owner-1", }) authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID) if err != nil { t.Fatalf("get creation authority: %v", err) } instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } if instance.DataScopeKind != "" || instance.EffectiveDataScopeKind() != ScopeTask { t.Fatalf("new owner-authorized canvas scope = explicit %q/effective %q, want task until its first publication is consumed", instance.DataScopeKind, instance.EffectiveDataScopeKind()) } pkg := testCanvasPackage("owner-first", []string{"tasks"}) pkg.Manifest.Capabilities.APIWrite = []string{"messages"} pkg.Manifest.Capabilities.Events = []string{"task.updated"} pkg.Manifest.Capabilities.State = true pkg.Manifest.UI.WebApps[0].NetworkOrigins = []string{"https://api.example.test"} result, err := service.PublishPackage(context.Background(), PublishRequest{ CanvasID: created.ID, Package: pkg, Artifact: webapp.Artifact{Digest: "owner-first", RelativePath: "releases/owner-first", Bytes: 1}, ExpectedAuthority: instance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "owner-1", SourceTaskID: "task-1", SourceSessionID: "session-1", }) if err != nil { t.Fatalf("publish owner-authorized canvas: %v", err) } if !result.Activated || result.PermissionRequired { t.Fatalf("publish result = %+v, want activated without review", result) } updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get updated instance: %v", err) } if updated.ActiveReleaseID != result.Release.ID || updated.PluginID != result.Release.PluginID { t.Fatalf("updated instance = %+v, want active release and package identity", updated) } if updated.ScopeKind != ScopeTask || updated.EffectiveDataScopeKind() != ScopeWorkspace { t.Fatalf("updated scopes = placement %q, effective data %q, want task and workspace", updated.ScopeKind, updated.EffectiveDataScopeKind()) } grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("list initial grants: %v", err) } if len(grants) != 5 { t.Fatalf("initial grants = %+v, want five declared grants", grants) } wantGrants := map[string]bool{ "api_read:tasks": false, "api_write:messages": false, "events:task.updated": false, "state:": false, "network:https://api.example.test": false, } for _, grant := range grants { key := grant.PermissionKind + ":" if grant.PermissionKind == "api_read" || grant.PermissionKind == "api_write" || grant.PermissionKind == "events" { key += grant.Resource } if grant.PermissionKind == "network" { key += grant.NetworkOrigin } if _, ok := wantGrants[key]; !ok || grant.ScopeCeiling != ScopeWorkspace || grant.ApprovedBy != "owner-1" { t.Fatalf("initial grant = %+v, want workspace-scoped owner grant", grant) } wantGrants[key] = true } for key, found := range wantGrants { if !found { t.Fatalf("initial grant %q missing from %+v", key, grants) } } consumed, err := service.repo.GetCreationAuthority(context.Background(), created.ID) if err != nil { t.Fatalf("get consumed authority: %v", err) } if consumed.PolicyVersion != authority.PolicyVersion || consumed.ConsumedAt.IsZero() { t.Fatalf("consumed authority = %+v, want policy %d and consumed timestamp", consumed, authority.PolicyVersion) } } func TestCanvasCreationAuthorityVersionOneRetainsTaskDataCeiling(t *testing.T) { service, instanceStore, pool := newCanvasService(t) created := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy task authority", CreatedBySessionID: "session-1", OwnerUserID: "owner-1", }) if _, err := pool.Writer().Exec(`UPDATE canvas_creation_authority SET policy_version = 1 WHERE canvas_id = ?`, created.ID); err != nil { t.Fatalf("set legacy authority version: %v", err) } instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } result, err := service.PublishPackage(context.Background(), PublishRequest{ CanvasID: created.ID, Package: testCanvasPackage("legacy-task-authority", []string{"tasks"}), Artifact: webapp.Artifact{Digest: "legacy-task-authority", RelativePath: "releases/legacy-task-authority", Bytes: 1}, ExpectedAuthority: instance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "owner-1", SourceTaskID: "task-1", SourceSessionID: "session-1", }) if err != nil { t.Fatalf("publish with legacy authority: %v", err) } if !result.Activated { t.Fatalf("legacy authority result = %+v, want active task-scoped first release", result) } updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get published instance: %v", err) } if updated.EffectiveDataScopeKind() != ScopeTask { t.Fatalf("legacy authority effective data scope = %q, want task", updated.EffectiveDataScopeKind()) } grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("list legacy initial grants: %v", err) } if len(grants) != 1 || grants[0].ScopeCeiling != ScopeTask { t.Fatalf("legacy authority grants = %+v, want one task-scoped task-read grant", grants) } } func TestPublishCanvasAuthorityNormalizesLegacyNullDataScope(t *testing.T) { service, instanceStore, _ := newCanvasService(t) created := createCanvas(t, service, CreateCanvasRequest{WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy data scope"}) canvas, err := service.Get(context.Background(), created.ID) if err != nil { t.Fatalf("get canvas: %v", err) } instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } expected := instance.PublishAuthority() expected.DataScopeKind = canvas.DataScopeKind result, err := service.PublishPackage(context.Background(), PublishRequest{ CanvasID: created.ID, Package: testCanvasPackage("legacy-null-data-scope", []string{"tasks"}), Artifact: webapp.Artifact{Digest: "legacy-null-data-scope", RelativePath: "releases/legacy-null-data-scope", Bytes: 1}, ExpectedAuthority: expected, SourceActorKind: "agent", }) if err != nil { t.Fatalf("publish with projected legacy data scope: %v", err) } if result.Activated || !result.PermissionRequired { t.Fatalf("legacy task-scope publication = %+v, want task permission review", result) } } func TestCanvasCreationAuthorityRejectsMismatchedSource(t *testing.T) { service, instanceStore, _ := newCanvasService(t) created := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Mismatched owner", CreatedBySessionID: "session-1", OwnerUserID: "owner-1", }) instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } result, err := service.PublishPackage(context.Background(), PublishRequest{ CanvasID: created.ID, Package: testCanvasPackage("mismatched-owner", []string{"tasks"}), Artifact: webapp.Artifact{Digest: "mismatched-owner", RelativePath: "releases/mismatched-owner", Bytes: 1}, ExpectedAuthority: instance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "foreign-owner", SourceTaskID: "task-1", SourceSessionID: "session-1", }) if err != nil { t.Fatalf("publish mismatched owner: %v", err) } if result.Activated || !result.PermissionRequired { t.Fatalf("mismatched owner result = %+v, want manual review", result) } updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get mismatched-owner instance: %v", err) } if updated.EffectiveDataScopeKind() != ScopeTask { t.Fatalf("mismatched-owner effective data scope = %q, want task", updated.EffectiveDataScopeKind()) } approved, err := service.ApproveRelease(context.Background(), created.ID, result.Release.ID, "member-1") if err != nil { t.Fatalf("member approves task-scoped fallback release: %v", err) } if approved.DataScopeKind != ScopeTask { t.Fatalf("member approval widened effective data scope to %q, want task", approved.DataScopeKind) } grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("list fallback grants: %v", err) } if len(grants) != 1 || grants[0].ScopeCeiling != ScopeTask { t.Fatalf("member approval grants = %+v, want one task-scoped grant", grants) } authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID) if err != nil { t.Fatalf("get unconsumed authority: %v", err) } if !authority.ConsumedAt.IsZero() { t.Fatalf("mismatched owner consumed authority at %s", authority.ConsumedAt) } } func TestCanvasCreationAuthorityDoesNotApproveLaterIncrease(t *testing.T) { service, instanceStore, _ := newCanvasService(t) created := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Later review", CreatedBySessionID: "session-1", OwnerUserID: "owner-1", }) firstInstance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get first instance: %v", err) } first, err := service.PublishPackage(context.Background(), PublishRequest{ CanvasID: created.ID, Package: testCanvasPackage("owner-base", []string{"tasks"}), Artifact: webapp.Artifact{Digest: "owner-base", RelativePath: "releases/owner-base", Bytes: 1}, ExpectedAuthority: firstInstance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "owner-1", SourceTaskID: "task-1", SourceSessionID: "session-1", }) if err != nil { t.Fatalf("publish first owner release: %v", err) } if !first.Activated { t.Fatalf("first owner release = %+v, want active", first) } secondInstance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get second instance: %v", err) } secondPackage := testCanvasPackage("owner-increase", []string{"tasks", "workflows"}) second, err := service.PublishPackage(context.Background(), PublishRequest{ CanvasID: created.ID, Package: secondPackage, Artifact: webapp.Artifact{Digest: "owner-increase", RelativePath: "releases/owner-increase", Bytes: 1}, ExpectedAuthority: secondInstance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "owner-1", SourceTaskID: "task-1", SourceSessionID: "session-1", }) if err != nil { t.Fatalf("publish later owner release: %v", err) } if second.Activated || !second.PermissionRequired { t.Fatalf("later owner release = %+v, want pending review", second) } if _, err := service.ApproveRelease(context.Background(), created.ID, second.Release.ID, "member-1"); !errors.Is(err, plugininstances.ErrWorkspaceOwnerRequired) { t.Fatalf("non-owner approval of workspace-data increase = %v, want ErrWorkspaceOwnerRequired", err) } approved, err := service.ApproveRelease(context.Background(), created.ID, second.Release.ID, "owner-1") if err != nil { t.Fatalf("owner approves workspace-data increase: %v", err) } if approved.ActiveReleaseID != second.Release.ID { t.Fatalf("active release after owner approval = %q, want %q", approved.ActiveReleaseID, second.Release.ID) } current, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get current instance: %v", err) } if current.ActiveReleaseID != second.Release.ID { t.Fatalf("active release after owner-approved increase = %q, want %q", current.ActiveReleaseID, second.Release.ID) } } func TestCanvasCreationAuthorityRejectsUnsupportedCapabilities(t *testing.T) { tests := []struct { name string digest string setup func(*manifest.Manifest) }{ { name: "unknown read", digest: "unsupported-unknown-read", setup: func(m *manifest.Manifest) { m.Capabilities.APIRead = []string{"sessions"} }, }, { name: "wildcard event", digest: "unsupported-wildcard-event", setup: func(m *manifest.Manifest) { m.Capabilities.Events = []string{"task.*"} }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { service, instanceStore, _ := newCanvasService(t) created := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Unsupported capability", CreatedBySessionID: "session-1", OwnerUserID: "owner-1", }) instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } pkg := testCanvasPackage(tt.digest, nil) tt.setup(pkg.Manifest) _, err = service.PublishPackage(context.Background(), PublishRequest{ CanvasID: created.ID, Package: pkg, Artifact: webapp.Artifact{Digest: pkg.Digest, RelativePath: "releases/" + pkg.Digest, Bytes: 1}, ExpectedAuthority: instance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "owner-1", SourceTaskID: "task-1", SourceSessionID: "session-1", }) if !errors.Is(err, ErrInvalidCanvas) { t.Fatalf("publish error = %v, want ErrInvalidCanvas", err) } }) } } func TestCanvasCreationAuthorityRechecksWorkspaceOwner(t *testing.T) { service, instanceStore, pool := newCanvasService(t) created := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Transferred workspace", CreatedBySessionID: "session-1", OwnerUserID: "owner-1", }) instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } if _, err := pool.Writer().Exec(`UPDATE workspaces SET owner_id = 'owner-2' WHERE id = 'workspace-1'`); err != nil { t.Fatalf("transfer workspace owner: %v", err) } _, err = service.PublishPackage(context.Background(), PublishRequest{ CanvasID: created.ID, Package: testCanvasPackage("transferred-workspace", []string{"tasks"}), Artifact: webapp.Artifact{Digest: "transferred-workspace", RelativePath: "releases/transferred-workspace", Bytes: 1}, ExpectedAuthority: instance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "owner-1", SourceTaskID: "task-1", SourceSessionID: "session-1", }) if !errors.Is(err, ErrStaleCanvasPublish) { t.Fatalf("publish after workspace transfer = %v, want ErrStaleCanvasPublish", err) } grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("list grants after workspace transfer: %v", err) } if len(grants) != 0 { t.Fatalf("grants after workspace transfer = %+v, want none", grants) } authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID) if err != nil { t.Fatalf("get authority after workspace transfer: %v", err) } if !authority.ConsumedAt.IsZero() { t.Fatalf("authority after workspace transfer consumed at %s", authority.ConsumedAt) } } func TestCanvasCreationAuthorityRollsBackOnActivationFailure(t *testing.T) { service, instanceStore, _ := newCanvasService(t) created := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Rollback authority", CreatedBySessionID: "session-1", OwnerUserID: "owner-1", }) instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } service.instances = &activationFailureStore{Store: instanceStore} _, err = service.PublishPackage(context.Background(), PublishRequest{ CanvasID: created.ID, Package: testCanvasPackage("rollback-authority", []string{"tasks"}), Artifact: webapp.Artifact{Digest: "rollback-authority", RelativePath: "releases/rollback-authority", Bytes: 1}, ExpectedAuthority: instance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "owner-1", SourceTaskID: "task-1", SourceSessionID: "session-1", }) if err == nil { t.Fatal("publish with activation failure succeeded, want rollback") } releases, err := instanceStore.ListReleases(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("list releases after rollback: %v", err) } if len(releases) != 0 { t.Fatalf("releases after rollback = %+v, want none", releases) } grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("list grants after rollback: %v", err) } if len(grants) != 0 { t.Fatalf("grants after rollback = %+v, want none", grants) } current, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get instance after rollback: %v", err) } if current.ActiveReleaseID != "" || current.PluginID != CanvasPluginID { t.Fatalf("instance after rollback = %+v, want pending synthetic instance", current) } authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID) if err != nil { t.Fatalf("get authority after rollback: %v", err) } if !authority.ConsumedAt.IsZero() { t.Fatalf("authority after rollback consumed at %s", authority.ConsumedAt) } } func TestInstallCanvasPackageCommitsReceiptWithCanvasLifecycle(t *testing.T) { service, instanceStore, _ := newCanvasService(t) ctx := context.Background() result, err := service.InstallCanvasPackage(ctx, InstallCanvasPackageRequest{ WorkspaceID: "workspace-install", Title: "Installed board", Package: testCanvasPackage("atomic-install", nil), Artifact: webapp.Artifact{Digest: "atomic-install", RelativePath: "releases/atomic-install", Bytes: 1}, SourceActorKind: "canvas-install:upload", SourceUserID: "user-1", Approved: true, Receipt: InstallReceipt{ PreparationID: "preparation-atomic", UserID: "user-1", PackageID: "canvas-board", Version: "atomic-install", Digest: "atomic-install", OriginKind: "upload", }, }) if err != nil { t.Fatalf("InstallCanvasPackage() error = %v", err) } if result == nil || result.Canvas == nil || result.Receipt.CanvasID != result.Canvas.ID { t.Fatalf("install result = %+v", result) } receipt, err := service.repo.GetInstallReceipt(ctx, "preparation-atomic", "user-1") if err != nil { t.Fatalf("GetInstallReceipt() error = %v", err) } if receipt.CanvasID != result.Canvas.ID || receipt.WorkspaceID != "workspace-install" { t.Fatalf("receipt = %+v", receipt) } releases, err := instanceStore.ListReleases(ctx, result.Canvas.PluginInstanceID) if err != nil { t.Fatalf("ListReleases() error = %v", err) } if len(releases) != 1 || releases[0].PackageDigest != "atomic-install" { t.Fatalf("releases = %+v", releases) } } func TestFirstTaskReleaseCanBeReviewedAndApproved(t *testing.T) { service, instanceStore, _ := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "First approval", }) published := publishTestPackage(t, service, canvas.ID, "first-approved", []string{"tasks"}) if published.Activated || !published.PermissionRequired { t.Fatalf("first release result = %+v, want pending permission review", published) } before, err := service.Get(context.Background(), canvas.ID) if err != nil { t.Fatalf("get pending canvas: %v", err) } if before.PendingRelease == nil || before.PendingRelease.Permissions == nil || len(before.PendingRelease.Permissions.Reads) != 1 || len(before.PendingRelease.MissingPermissions) != 1 { t.Fatalf("pending release review projection = %+v, want declared and missing tasks permission", before.PendingRelease) } if before.PendingRelease.Permissions.Reads[0] != "tasks" || before.PendingRelease.MissingPermissions[0] != "api_read:tasks" { t.Fatalf("pending permission projection = %+v, want api_read:tasks", before.PendingRelease) } approved, err := service.ApproveRelease(context.Background(), canvas.ID, published.Release.ID, "user-1") if err != nil { t.Fatalf("approve first task release: %v", err) } if approved.ActiveReleaseID != published.Release.ID || approved.ActiveReleaseStatus != plugininstances.ValidationValid { t.Fatalf("approved canvas = %+v, want active valid release", approved) } instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) if err != nil { t.Fatalf("get approved instance: %v", err) } if instance.PluginID != published.Release.PluginID { t.Fatalf("approved plugin id = %q, want release plugin id %q", instance.PluginID, published.Release.PluginID) } grants, err := instanceStore.ListGrants(context.Background(), canvas.PluginInstanceID) if err != nil { t.Fatalf("list approved grants: %v", err) } if len(grants) != 1 || grants[0].PermissionKind != "api_read" || grants[0].Resource != "tasks" { t.Fatalf("approved grants = %+v, want api_read:tasks", grants) } } func TestPublishPackageRejectsStaleEditBaseRelease(t *testing.T) { service, instanceStore, _ := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", Title: "Concurrent edits", }) first := publishTestPackage(t, service, canvas.ID, "edit-base-a", nil) second := publishTestPackage(t, service, canvas.ID, "edit-base-b", nil) _, err := service.PublishPackage(context.Background(), PublishRequest{ CanvasID: canvas.ID, Package: testCanvasPackage("edit-stale", nil), Artifact: webapp.Artifact{Digest: "edit-stale", RelativePath: "releases/edit-stale", Bytes: 1}, SourceActorKind: "agent", ExpectedBaseReleaseID: first.Release.ID, }) if !errors.Is(err, ErrStaleCanvasEdit) { t.Fatalf("stale edit publish error = %v, want ErrStaleCanvasEdit", err) } instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } if instance.ActiveReleaseID != second.Release.ID { t.Fatalf("active release = %q, want newer edit %q", instance.ActiveReleaseID, second.Release.ID) } } func TestPromotionRejectsStaleReleaseReview(t *testing.T) { service, instanceStore, _ := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Review race", }) first := publishTestPackage(t, service, canvas.ID, "review-a", nil) preview, err := service.PromotionPreview(context.Background(), canvas.ID) if err != nil { t.Fatalf("promotion preview: %v", err) } if preview.ActiveReleaseID != first.Release.ID { t.Fatalf("preview release = %q, want %q", preview.ActiveReleaseID, first.Release.ID) } second := publishTestPackage(t, service, canvas.ID, "review-b", nil) _, err = service.PromoteCanvasReviewed(context.Background(), canvas.ID, "user-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration) if !errors.Is(err, ErrStalePromotionReview) { t.Fatalf("stale promotion error = %v, want ErrStalePromotionReview", err) } instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } if instance.ActiveReleaseID != second.Release.ID || instance.ScopeKind != plugininstances.ScopeTask { t.Fatalf("instance after stale promotion = %+v, want task scope and release %q", instance, second.Release.ID) } } func TestPromotionRejectsChangedGrantGeneration(t *testing.T) { service, instanceStore, _ := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Grant review race", }) publishTestPackage(t, service, canvas.ID, "grant-review", nil) preview, err := service.PromotionPreview(context.Background(), canvas.ID) if err != nil { t.Fatalf("promotion preview: %v", err) } if err := instanceStore.AddGrant(context.Background(), plugininstances.Grant{ InstanceID: canvas.PluginInstanceID, PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: plugininstances.ScopeTask, ApprovedBy: "user-1", }); err != nil { t.Fatalf("add grant: %v", err) } _, err = service.PromoteCanvasReviewed(context.Background(), canvas.ID, "user-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration) if !errors.Is(err, ErrStalePromotionReview) { t.Fatalf("changed grant generation error = %v, want ErrStalePromotionReview", err) } } func TestPermissionsFitHonorsInstanceScope(t *testing.T) { permissions := PermissionSummary{Reads: []string{"tasks"}} grants := []plugininstances.Grant{{ PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: plugininstances.ScopeTask, }} if permissionsFit(permissions, plugininstances.ScopeTask, grants) != true { t.Fatal("task grant did not cover task-scoped release") } if permissionsFit(permissions, plugininstances.ScopeWorkspace, grants) { t.Fatal("task grant covered workspace-scoped release") } } func TestCanvasProjectionIncludesEffectiveGrantedPermissions(t *testing.T) { service, instanceStore, _ := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Effective grants", }) if err := instanceStore.AddGrant(context.Background(), plugininstances.Grant{ InstanceID: canvas.PluginInstanceID, PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: plugininstances.ScopeTask, ApprovedBy: "user-1", }); err != nil { t.Fatalf("add grant: %v", err) } publishTestPackage(t, service, canvas.ID, "effective-grants", []string{"tasks"}) got, err := service.Get(context.Background(), canvas.ID) if err != nil { t.Fatalf("get canvas: %v", err) } if len(got.EffectiveGrants) != 1 || got.EffectiveGrants[0].PermissionKind != "api_read" || got.EffectiveGrants[0].Resource != "tasks" { t.Fatalf("effective grants = %+v, want api_read:tasks", got.EffectiveGrants) } } func TestPublishPackagePrunesSupersededPendingReleasesWithoutChangingActive(t *testing.T) { service, instanceStore, pool := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Pending releases", }) setAuthoringTestClock(service) active := publishTestPackage(t, service, canvas.ID, "active-release", nil) pendingOne := publishTestPackage(t, service, canvas.ID, "pending-one", []string{"tasks"}) pendingTwo := publishTestPackage(t, service, canvas.ID, "pending-two", []string{"workflows"}) instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } if instance.ActiveReleaseID != active.Release.ID { t.Fatalf("active release = %q, want %q", instance.ActiveReleaseID, active.Release.ID) } releases, err := instanceStore.ListReleases(context.Background(), canvas.PluginInstanceID) if err != nil { t.Fatalf("list releases: %v", err) } assertReleaseStatus(t, releases, active.Release.ID, plugininstances.ValidationValid, "") assertReleaseAbsent(t, releases, pendingOne.Release.ID) assertReleaseStatus(t, releases, pendingTwo.Release.ID, plugininstances.ValidationPendingPermission, "permission_review_required") if got := countReleaseStatus(releases, plugininstances.ValidationPendingPermission); got != 1 { t.Fatalf("pending releases = %d, want 1", got) } if got := cleanupArtifactPaths(t, pool, canvas.PluginInstanceID); len(got) != 1 || got[0] != "releases/pending-one" { t.Fatalf("cleanup paths = %v, want [releases/pending-one]", got) } } func TestPublishPackageRetainsPriorValidReleaseForRollback(t *testing.T) { service, instanceStore, pool := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Valid releases", }) setAuthoringTestClock(service) active := publishTestPackage(t, service, canvas.ID, "active-release", nil) if err := instanceStore.AddGrant(context.Background(), plugininstances.Grant{ InstanceID: canvas.PluginInstanceID, PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: plugininstances.ScopeTask, ApprovedBy: "user-1", }); err != nil { t.Fatalf("add grant: %v", err) } prior := publishTestPackage(t, service, canvas.ID, "prior-release", []string{"tasks"}) latest := publishTestPackage(t, service, canvas.ID, "latest-release", []string{"tasks"}) instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) if err != nil { t.Fatalf("get instance: %v", err) } if instance.ActiveReleaseID != latest.Release.ID { t.Fatalf("active release = %q, want %q", instance.ActiveReleaseID, latest.Release.ID) } releases, err := instanceStore.ListReleases(context.Background(), canvas.PluginInstanceID) if err != nil { t.Fatalf("list releases: %v", err) } assertReleaseAbsent(t, releases, active.Release.ID) assertReleaseStatus(t, releases, prior.Release.ID, plugininstances.ValidationValid, "") assertReleaseStatus(t, releases, latest.Release.ID, plugininstances.ValidationValid, "") if got := countReleaseStatus(releases, plugininstances.ValidationValid); got != 2 { t.Fatalf("valid releases = %d, want active plus one prior", got) } if got := cleanupArtifactPaths(t, pool, canvas.PluginInstanceID); len(got) != 1 || got[0] != "releases/active-release" { t.Fatalf("cleanup paths = %v, want [releases/active-release]", got) } if err := instanceStore.ActivateRelease(context.Background(), canvas.PluginInstanceID, active.Release.ID); !errors.Is(err, plugininstances.ErrInvalidRelease) { t.Fatalf("activate superseded release = %v, want ErrInvalidRelease", err) } rolledBack, err := service.RollbackRelease(context.Background(), canvas.ID, "") if err != nil { t.Fatalf("rollback: %v", err) } if rolledBack.ActiveReleaseID != prior.Release.ID { t.Fatalf("rollback active release = %q, want %q", rolledBack.ActiveReleaseID, prior.Release.ID) } if rolledBack.PluginInstanceID != canvas.PluginInstanceID || rolledBack.ScopeKind != plugininstances.ScopeTask { t.Fatalf("rollback changed canvas identity or scope: %+v", rolledBack) } } func TestRejectReleasePrunesRejectedArtifact(t *testing.T) { service, instanceStore, pool := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Reject cleanup", }) active := publishTestPackage(t, service, canvas.ID, "reject-active", nil) pending := publishTestPackage(t, service, canvas.ID, "reject-pending", []string{"tasks"}) if _, err := service.RejectRelease(context.Background(), canvas.ID, pending.Release.ID); err != nil { t.Fatalf("reject release: %v", err) } releases, err := instanceStore.ListReleases(context.Background(), canvas.PluginInstanceID) if err != nil { t.Fatalf("list releases: %v", err) } assertReleaseStatus(t, releases, active.Release.ID, plugininstances.ValidationValid, "") assertReleaseAbsent(t, releases, pending.Release.ID) if got := cleanupArtifactPaths(t, pool, canvas.PluginInstanceID); len(got) != 1 || got[0] != "releases/reject-pending" { t.Fatalf("cleanup paths = %v, want [releases/reject-pending]", got) } } func TestReleaseMutationsRequireRestoreAfterArchive(t *testing.T) { service, _, _ := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Archived release mutations", }) prior := publishTestPackage(t, service, canvas.ID, "archived-prior", nil) publishTestPackage(t, service, canvas.ID, "archived-active", nil) pending := publishTestPackage(t, service, canvas.ID, "archived-pending", []string{"tasks"}) if _, err := service.ArchiveCanvas(context.Background(), canvas.ID); err != nil { t.Fatalf("archive canvas: %v", err) } if _, err := service.ApproveRelease(context.Background(), canvas.ID, pending.Release.ID, "user-1"); !errors.Is(err, ErrInvalidCanvasState) { t.Fatalf("approve archived release = %v, want ErrInvalidCanvasState", err) } if _, err := service.RollbackRelease(context.Background(), canvas.ID, prior.Release.ID); !errors.Is(err, ErrInvalidCanvasState) { t.Fatalf("rollback archived release = %v, want ErrInvalidCanvasState", err) } if _, err := service.PromoteCanvas(context.Background(), canvas.ID, "user-1"); !errors.Is(err, ErrInvalidLifecycleState) { t.Fatalf("promote archived canvas = %v, want ErrInvalidLifecycleState", err) } } func TestPublishPackageRejectsAuthorityChangedAfterAuthorization(t *testing.T) { tests := []struct { name string mutate func(*Service, *plugininstances.Store, string, string) error }{ { name: "archive", mutate: func(_ *Service, store *plugininstances.Store, _ string, instanceID string) error { return store.Archive(context.Background(), instanceID) }, }, { name: "promote", mutate: func(service *Service, _ *plugininstances.Store, canvasID, _ string) error { _, err := service.PromoteCanvas(context.Background(), canvasID, "user-1") return err }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { service, instanceStore, _ := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Publish authority race", }) if tt.name == "promote" { publishTestPackage(t, service, canvas.ID, "authority-base", nil) } captured, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) if err != nil { t.Fatalf("capture authority: %v", err) } barrier := &publishTransactionBarrier{ Store: instanceStore, entered: make(chan struct{}), continueFirst: make(chan struct{}), } service.instances = barrier resultCh := make(chan error, 1) go func() { _, publishErr := service.PublishPackage(context.Background(), PublishRequest{ CanvasID: canvas.ID, Package: testCanvasPackage("authority-race", nil), Artifact: webapp.Artifact{Digest: "authority-race", RelativePath: "releases/authority-race", Bytes: 1}, ExpectedAuthority: captured.PublishAuthority(), SourceActorKind: "agent", }) resultCh <- publishErr }() <-barrier.entered if err := tt.mutate(service, instanceStore, canvas.ID, canvas.PluginInstanceID); err != nil { t.Fatalf("%s authority mutation: %v", tt.name, err) } close(barrier.continueFirst) if err := <-resultCh; !errors.Is(err, ErrStaleCanvasPublish) { t.Fatalf("publish after %s = %v, want ErrStaleCanvasPublish", tt.name, err) } }) } } type publishTransactionBarrier struct { *plugininstances.Store entered chan struct{} continueFirst chan struct{} once sync.Once } type activationFailureStore struct { *plugininstances.Store } func (s *activationFailureStore) ActivateReleaseTx(context.Context, *sqlx.Tx, string, string) error { return errors.New("injected activation failure") } func (s *publishTransactionBarrier) WithTransaction(ctx context.Context, fn func(*sqlx.Tx) error) error { first := false s.once.Do(func() { first = true close(s.entered) }) if first { <-s.continueFirst } return s.Store.WithTransaction(ctx, fn) } func publishTestPackage(t *testing.T, service *Service, canvasID, digest string, reads []string) *PublishResult { t.Helper() pkg := testCanvasPackage(digest, reads) result, err := service.PublishPackage(context.Background(), PublishRequest{ CanvasID: canvasID, Package: pkg, Artifact: webapp.Artifact{Digest: digest, RelativePath: "releases/" + digest, Bytes: 1}, SourceActorKind: "agent", }) if err != nil { t.Fatalf("publish %s: %v", digest, err) } return result } func testCanvasPackage(digest string, reads []string) *webapp.Package { return &webapp.Package{ Manifest: &manifest.Manifest{ ID: "canvas-board", APIVersion: manifest.CurrentAPIVersion, Version: digest, UI: manifest.UISection{WebApps: []manifest.WebApp{{ Key: "main", Title: "Board", Entry: "index.html", Placements: []string{manifest.WebAppPlacementTask}, }}}, Capabilities: manifest.Capabilities{APIRead: reads}, }, Digest: digest, } } func setAuthoringTestClock(service *Service) { base := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) tick := 0 service.clock = func() time.Time { tick++ return base.Add(time.Duration(tick) * time.Second) } } func assertReleaseStatus(t *testing.T, releases []plugininstances.Release, id, status, validationError string) { t.Helper() for _, release := range releases { if release.ID != id { continue } if release.ValidationStatus != status || release.ValidationError != validationError { t.Fatalf("release %q = status %q, error %q; want status %q, error %q", id, release.ValidationStatus, release.ValidationError, status, validationError) } return } t.Fatalf("release %q not found in %+v", id, releases) } func assertReleaseAbsent(t *testing.T, releases []plugininstances.Release, id string) { t.Helper() for _, release := range releases { if release.ID == id { t.Fatalf("release %q is still retained: %+v", id, release) } } } func countReleaseStatus(releases []plugininstances.Release, status string) int { count := 0 for _, release := range releases { if release.ValidationStatus == status { count++ } } return count } func cleanupArtifactPaths(t *testing.T, pool *db.Pool, instanceID string) []string { t.Helper() rows, err := pool.Reader().Queryx(pool.Reader().Rebind( "SELECT artifact_path FROM plugin_artifact_cleanup_jobs WHERE instance_id = ? ORDER BY artifact_path", ), instanceID) if err != nil { t.Fatalf("list cleanup jobs: %v", err) } defer func() { _ = rows.Close() }() var paths []string for rows.Next() { var path string if err := rows.Scan(&path); err != nil { t.Fatalf("scan cleanup job: %v", err) } paths = append(paths, path) } if err := rows.Err(); err != nil { t.Fatalf("iterate cleanup jobs: %v", err) } return paths }