Feat v0.9.4 package adoption roles (#78)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m55s
CI/CD / test-sqlite (push) Successful in 3m7s
CI/CD / build-and-deploy (push) Successful in 1m19s
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m55s
CI/CD / test-sqlite (push) Successful in 3m7s
CI/CD / build-and-deploy (push) Successful in 1m19s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #78.
This commit is contained in:
284
server/handlers/package_adopt.go
Normal file
284
server/handlers/package_adopt.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"armature/store"
|
||||
)
|
||||
|
||||
// PackageAdoptHandler manages team package adoption endpoints.
|
||||
type PackageAdoptHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
func NewPackageAdoptHandler(s store.Stores) *PackageAdoptHandler {
|
||||
return &PackageAdoptHandler{stores: s}
|
||||
}
|
||||
|
||||
// AdoptPackage clones a global adoptable package into the team.
|
||||
// POST /api/v1/teams/:teamId/packages/:id/adopt
|
||||
func (h *PackageAdoptHandler) AdoptPackage(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
teamID := c.Param("teamId")
|
||||
sourceID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
// Load source package
|
||||
src, err := h.stores.Packages.Get(ctx, sourceID)
|
||||
if err != nil || src == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify adoptable + global
|
||||
if !src.Adoptable {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "package is not adoptable"})
|
||||
return
|
||||
}
|
||||
if src.Scope != "global" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only global packages can be adopted"})
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotency: check if already adopted
|
||||
existing, err := h.stores.Packages.GetByAdoptedFrom(ctx, sourceID, teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check adoption"})
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
// Already adopted — return the existing record
|
||||
roles, _ := h.stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"package": existing,
|
||||
"roles_added": filterRolesBySource(roles, existing.ID),
|
||||
"already_adopted": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate adopted package ID: {sourceID}--{teamID[:8]}
|
||||
shortTeam := teamID
|
||||
if len(shortTeam) > 8 {
|
||||
shortTeam = shortTeam[:8]
|
||||
}
|
||||
adoptedID := sourceID + "--" + shortTeam
|
||||
|
||||
// Create team-scoped package row referencing the original
|
||||
adoptedPkg := &store.PackageRegistration{
|
||||
ID: adoptedID,
|
||||
Title: src.Title,
|
||||
Type: src.Type,
|
||||
Version: src.Version,
|
||||
Description: src.Description,
|
||||
Author: src.Author,
|
||||
Tier: src.Tier,
|
||||
IsSystem: false,
|
||||
Scope: "team",
|
||||
TeamID: &teamID,
|
||||
InstalledBy: &userID,
|
||||
Manifest: src.Manifest,
|
||||
Enabled: true,
|
||||
Status: "active",
|
||||
SchemaVersion: src.SchemaVersion,
|
||||
PackageSettings: src.PackageSettings,
|
||||
Source: "extension",
|
||||
Adoptable: false,
|
||||
AdoptedFrom: &sourceID,
|
||||
}
|
||||
|
||||
if err := h.stores.Packages.Create(ctx, adoptedPkg); err != nil {
|
||||
log.Printf("[packages] adopt: failed to create adopted package %s: %v", adoptedID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt package"})
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-populate role catalog from requires_roles
|
||||
var rolesAdded []string
|
||||
if rr, ok := src.Manifest["requires_roles"].([]any); ok {
|
||||
for _, v := range rr {
|
||||
if role, ok := v.(string); ok && role != "" {
|
||||
if err := h.stores.Teams.AddRoleToCatalog(ctx, teamID, role, adoptedID); err != nil {
|
||||
log.Printf("[packages] adopt: failed to add role %q to catalog: %v", role, err)
|
||||
continue
|
||||
}
|
||||
rolesAdded = append(rolesAdded, role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If source is a workflow package, clone the workflow + stages
|
||||
if src.Type == "workflow" {
|
||||
h.cloneWorkflowForAdoption(c, src, adoptedPkg)
|
||||
}
|
||||
|
||||
log.Printf("[packages] adopted %s → %s for team %s (roles: %v)", sourceID, adoptedID, teamID, rolesAdded)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"package": adoptedPkg,
|
||||
"roles_added": rolesAdded,
|
||||
})
|
||||
}
|
||||
|
||||
// cloneWorkflowForAdoption copies the workflow definition from the source
|
||||
// package into a team-scoped workflow. Best-effort — logs errors but does
|
||||
// not abort the adoption.
|
||||
func (h *PackageAdoptHandler) cloneWorkflowForAdoption(c *gin.Context, src, adopted *store.PackageRegistration) {
|
||||
ctx := c.Request.Context()
|
||||
teamID := *adopted.TeamID
|
||||
|
||||
// Extract workflow_definition from manifest
|
||||
wfDef, ok := src.Manifest["workflow_definition"].(map[string]any)
|
||||
if !ok {
|
||||
log.Printf("[packages] adopt: no workflow_definition in manifest for %s", src.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// Use the InstallWorkflowFromManifest path if available, or
|
||||
// fall back to looking up the workflow by package ID
|
||||
wfSlug, _ := wfDef["slug"].(string)
|
||||
if wfSlug == "" {
|
||||
wfSlug = src.ID
|
||||
}
|
||||
|
||||
// Find the global workflow installed from this package
|
||||
wf, err := h.stores.Workflows.GetBySlug(ctx, nil, wfSlug)
|
||||
if err != nil || wf == nil {
|
||||
log.Printf("[packages] adopt: source workflow %q not found, skipping clone", wfSlug)
|
||||
return
|
||||
}
|
||||
|
||||
stages, err := h.stores.Workflows.ListStages(ctx, wf.ID)
|
||||
if err != nil {
|
||||
log.Printf("[packages] adopt: failed to load stages for %s: %v", wf.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Clone workflow into team scope
|
||||
clone := *wf
|
||||
clone.ID = ""
|
||||
clone.TeamID = &teamID
|
||||
clone.IsActive = false
|
||||
clone.Version = 0
|
||||
clone.CreatedBy = c.GetString("user_id")
|
||||
|
||||
if err := h.stores.Workflows.Create(ctx, &clone); err != nil {
|
||||
// Slug conflict — append short ID
|
||||
clone.Slug = wfSlug + "-" + store.NewID()[:6]
|
||||
if err2 := h.stores.Workflows.Create(ctx, &clone); err2 != nil {
|
||||
log.Printf("[packages] adopt: failed to clone workflow: %v", err2)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Clone stages
|
||||
for _, st := range stages {
|
||||
cloneSt := st
|
||||
cloneSt.ID = ""
|
||||
cloneSt.WorkflowID = clone.ID
|
||||
if err := h.stores.Workflows.CreateStage(ctx, &cloneSt); err != nil {
|
||||
log.Printf("[packages] adopt: failed to clone stage %s: %v", st.Name, err)
|
||||
}
|
||||
}
|
||||
log.Printf("[packages] adopt: cloned workflow %s → %s for team %s", wf.ID, clone.ID, teamID)
|
||||
}
|
||||
|
||||
// ListAdoptablePackages returns global adoptable packages, annotating which
|
||||
// ones have already been adopted by this team.
|
||||
// GET /api/v1/teams/:teamId/packages/adoptable
|
||||
func (h *PackageAdoptHandler) ListAdoptablePackages(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
teamID := c.Param("teamId")
|
||||
|
||||
pkgs, err := h.stores.Packages.ListAdoptable(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
|
||||
return
|
||||
}
|
||||
|
||||
type adoptableItem struct {
|
||||
store.PackageRegistration
|
||||
Adopted bool `json:"adopted"`
|
||||
}
|
||||
|
||||
var result []adoptableItem
|
||||
for _, pkg := range pkgs {
|
||||
item := adoptableItem{PackageRegistration: pkg}
|
||||
existing, _ := h.stores.Packages.GetByAdoptedFrom(ctx, pkg.ID, teamID)
|
||||
item.Adopted = existing != nil
|
||||
result = append(result, item)
|
||||
}
|
||||
if result == nil {
|
||||
result = []adoptableItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
// UnadoptPackage removes a team's adopted package and cleans up role catalog.
|
||||
// DELETE /api/v1/teams/:teamId/packages/:id/unadopt
|
||||
func (h *PackageAdoptHandler) UnadoptPackage(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
teamID := c.Param("teamId")
|
||||
pkgID := c.Param("id")
|
||||
|
||||
pkg, err := h.stores.Packages.Get(ctx, pkgID)
|
||||
if err != nil || pkg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify it's an adopted package belonging to this team
|
||||
if pkg.AdoptedFrom == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "package was not adopted"})
|
||||
return
|
||||
}
|
||||
if pkg.TeamID == nil || *pkg.TeamID != teamID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "package does not belong to this team"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up role catalog entries sourced from this package
|
||||
if err := h.stores.Teams.RemoveRoleCatalogBySource(ctx, teamID, pkgID); err != nil {
|
||||
log.Printf("[packages] unadopt: failed to clean role catalog: %v", err)
|
||||
}
|
||||
|
||||
// Delete the adopted package row
|
||||
if err := h.stores.Packages.Delete(ctx, pkgID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove package"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[packages] unadopted %s from team %s", pkgID, teamID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "package unadopted"})
|
||||
}
|
||||
|
||||
// ListRoleCatalog returns all known roles for a team.
|
||||
// GET /api/v1/teams/:teamId/roles/catalog
|
||||
func (h *PackageAdoptHandler) ListRoleCatalog(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
teamID := c.Param("teamId")
|
||||
|
||||
roles, err := h.stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list role catalog"})
|
||||
return
|
||||
}
|
||||
if roles == nil {
|
||||
roles = []store.TeamRoleCatalogEntry{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": roles})
|
||||
}
|
||||
|
||||
// filterRolesBySource returns only roles from a specific package.
|
||||
func filterRolesBySource(roles []store.TeamRoleCatalogEntry, pkgID string) []string {
|
||||
var result []string
|
||||
for _, r := range roles {
|
||||
if r.SourcePackageID == pkgID {
|
||||
result = append(result, r.Role)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
353
server/handlers/package_adopt_test.go
Normal file
353
server/handlers/package_adopt_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"armature/database"
|
||||
"armature/store"
|
||||
)
|
||||
|
||||
// ── Manifest: adoptable parsing ──────────────
|
||||
|
||||
func TestValidateManifest_Adoptable(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"id": "adopt-wf",
|
||||
"title": "Adoptable Workflow",
|
||||
"type": "workflow",
|
||||
"adoptable": true,
|
||||
"workflow_definition": map[string]any{
|
||||
"slug": "adopt-wf",
|
||||
},
|
||||
}
|
||||
info, err := ValidateManifest(m)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !info.Adoptable {
|
||||
t.Error("expected Adoptable to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifest_Adoptable_Default(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"id": "plain-surface",
|
||||
"title": "Plain Surface",
|
||||
"type": "surface",
|
||||
}
|
||||
info, err := ValidateManifest(m)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if info.Adoptable {
|
||||
t.Error("expected Adoptable to be false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifest_Adoptable_LibraryRejected(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"id": "adopt-lib",
|
||||
"title": "Adoptable Library",
|
||||
"type": "library",
|
||||
"adoptable": true,
|
||||
"exports": []any{"module_a"},
|
||||
}
|
||||
_, err := ValidateManifest(m)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for adoptable library")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifest_Adoptable_TestRunnerRejected(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"id": "adopt-runner",
|
||||
"title": "Adoptable Runner",
|
||||
"type": "test-runner",
|
||||
"adoptable": true,
|
||||
}
|
||||
_, err := ValidateManifest(m)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for adoptable test-runner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifest_Adoptable_SurfaceAllowed(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"id": "adopt-surface",
|
||||
"title": "Adoptable Surface",
|
||||
"type": "surface",
|
||||
"adoptable": true,
|
||||
}
|
||||
info, err := ValidateManifest(m)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !info.Adoptable {
|
||||
t.Error("expected Adoptable to be true for surface")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Store: role catalog CRUD ──────────────
|
||||
|
||||
// seedMinimalPackage creates a minimal package for FK references in tests.
|
||||
func seedMinimalPackage(t *testing.T, stores store.Stores, id string) {
|
||||
t.Helper()
|
||||
pkg := &store.PackageRegistration{
|
||||
ID: id, Title: id, Type: "surface", Version: "1.0.0",
|
||||
Scope: "global", Tier: "browser", Manifest: map[string]any{"id": id, "title": id},
|
||||
Enabled: true, Status: "active", Source: "extension",
|
||||
}
|
||||
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
|
||||
t.Fatalf("seed minimal package %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleCatalog_AddAndList(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
stores := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
teamID, _, _ := seedTeamAndMember(t, stores)
|
||||
pkgID := "rc-pkg-" + store.NewID()[:6]
|
||||
seedMinimalPackage(t, stores, pkgID)
|
||||
|
||||
// Add two roles
|
||||
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgID); err != nil {
|
||||
t.Fatalf("AddRoleToCatalog: %v", err)
|
||||
}
|
||||
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "reviewer", pkgID); err != nil {
|
||||
t.Fatalf("AddRoleToCatalog: %v", err)
|
||||
}
|
||||
|
||||
roles, err := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRoleCatalog: %v", err)
|
||||
}
|
||||
if len(roles) != 2 {
|
||||
t.Fatalf("expected 2 roles, got %d: %v", len(roles), roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleCatalog_AddIdempotent(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
stores := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
teamID, _, _ := seedTeamAndMember(t, stores)
|
||||
pkgA := "rc-idem-a-" + store.NewID()[:6]
|
||||
pkgB := "rc-idem-b-" + store.NewID()[:6]
|
||||
seedMinimalPackage(t, stores, pkgA)
|
||||
seedMinimalPackage(t, stores, pkgB)
|
||||
|
||||
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgA); err != nil {
|
||||
t.Fatalf("first add: %v", err)
|
||||
}
|
||||
// Same role from different source — should be idempotent (unique on team_id, role)
|
||||
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgB); err != nil {
|
||||
t.Fatalf("idempotent add should not error: %v", err)
|
||||
}
|
||||
|
||||
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||
if len(roles) != 1 {
|
||||
t.Errorf("expected 1 role after idempotent add, got %d", len(roles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleCatalog_RemoveBySource(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
stores := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
teamID, _, _ := seedTeamAndMember(t, stores)
|
||||
pkgRm := "rc-rm-" + store.NewID()[:6]
|
||||
pkgKeep := "rc-keep-" + store.NewID()[:6]
|
||||
seedMinimalPackage(t, stores, pkgRm)
|
||||
seedMinimalPackage(t, stores, pkgKeep)
|
||||
|
||||
stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgRm)
|
||||
stores.Teams.AddRoleToCatalog(ctx, teamID, "reviewer", pkgRm)
|
||||
stores.Teams.AddRoleToCatalog(ctx, teamID, "admin-role", pkgKeep)
|
||||
|
||||
if err := stores.Teams.RemoveRoleCatalogBySource(ctx, teamID, pkgRm); err != nil {
|
||||
t.Fatalf("RemoveRoleCatalogBySource: %v", err)
|
||||
}
|
||||
|
||||
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||
if len(roles) != 1 {
|
||||
t.Fatalf("expected 1 role remaining, got %d: %v", len(roles), roles)
|
||||
}
|
||||
if roles[0].Role != "admin-role" {
|
||||
t.Errorf("expected 'admin-role' remaining, got %q", roles[0].Role)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Store: package adoption ──────────────
|
||||
|
||||
func seedAdoptablePackage(t *testing.T, stores store.Stores, id string, roles []string) {
|
||||
t.Helper()
|
||||
manifest := map[string]any{
|
||||
"id": id,
|
||||
"title": "Adoptable " + id,
|
||||
"type": "surface",
|
||||
}
|
||||
if len(roles) > 0 {
|
||||
rr := make([]any, len(roles))
|
||||
for i, r := range roles {
|
||||
rr[i] = r
|
||||
}
|
||||
manifest["requires_roles"] = rr
|
||||
}
|
||||
pkg := &store.PackageRegistration{
|
||||
ID: id,
|
||||
Title: "Adoptable " + id,
|
||||
Type: "surface",
|
||||
Version: "1.0.0",
|
||||
Tier: "browser",
|
||||
Scope: "global",
|
||||
Manifest: manifest,
|
||||
Enabled: true,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Adoptable: true,
|
||||
}
|
||||
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
|
||||
t.Fatalf("seed adoptable package: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdoptPackage_Success(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
stores := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||
pkgID := "adopt-test-" + store.NewID()[:6]
|
||||
seedAdoptablePackage(t, stores, pkgID, []string{"approver", "reviewer"})
|
||||
|
||||
// Set up handler
|
||||
h := NewPackageAdoptHandler(stores)
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, r := gin.CreateTestContext(w)
|
||||
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
|
||||
c.Set("user_id", userID)
|
||||
h.AdoptPackage(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkgID+"/adopt", nil)
|
||||
c.Request = req
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
// Verify roles_added
|
||||
rolesAdded, ok := resp["roles_added"].([]any)
|
||||
if !ok || len(rolesAdded) != 2 {
|
||||
t.Errorf("expected 2 roles_added, got %v", resp["roles_added"])
|
||||
}
|
||||
|
||||
// Verify team package was created
|
||||
shortTeam := teamID
|
||||
if len(shortTeam) > 8 {
|
||||
shortTeam = shortTeam[:8]
|
||||
}
|
||||
adopted, _ := stores.Packages.Get(ctx, pkgID+"--"+shortTeam)
|
||||
if adopted == nil {
|
||||
t.Fatal("adopted package not found in DB")
|
||||
}
|
||||
if adopted.Scope != "team" {
|
||||
t.Errorf("expected scope 'team', got %q", adopted.Scope)
|
||||
}
|
||||
if adopted.AdoptedFrom == nil || *adopted.AdoptedFrom != pkgID {
|
||||
t.Errorf("expected adopted_from=%q, got %v", pkgID, adopted.AdoptedFrom)
|
||||
}
|
||||
|
||||
// Verify role catalog populated
|
||||
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||
found := 0
|
||||
for _, r := range roles {
|
||||
if r.SourcePackageID == pkgID+"--"+shortTeam {
|
||||
found++
|
||||
}
|
||||
}
|
||||
if found != 2 {
|
||||
t.Errorf("expected 2 catalog roles from adoption, got %d", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdoptPackage_Idempotent(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
stores := testStores(t)
|
||||
|
||||
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||
pkgID := "adopt-idem-" + store.NewID()[:6]
|
||||
seedAdoptablePackage(t, stores, pkgID, nil)
|
||||
|
||||
h := NewPackageAdoptHandler(stores)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
doAdopt := func() int {
|
||||
w := httptest.NewRecorder()
|
||||
_, r := gin.CreateTestContext(w)
|
||||
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
|
||||
c.Set("user_id", userID)
|
||||
h.AdoptPackage(c)
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkgID+"/adopt", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
code1 := doAdopt()
|
||||
if code1 != http.StatusCreated {
|
||||
t.Fatalf("first adopt: expected 201, got %d", code1)
|
||||
}
|
||||
code2 := doAdopt()
|
||||
if code2 != http.StatusOK {
|
||||
t.Fatalf("second adopt: expected 200, got %d", code2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdoptPackage_NotAdoptable(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
stores := testStores(t)
|
||||
|
||||
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||
|
||||
// Seed a non-adoptable package
|
||||
pkg := &store.PackageRegistration{
|
||||
ID: "no-adopt-" + store.NewID()[:6], Title: "Not Adoptable",
|
||||
Type: "surface", Version: "1.0.0", Tier: "browser", Scope: "global",
|
||||
Manifest: map[string]any{"id": "no-adopt", "title": "Not Adoptable"},
|
||||
Enabled: true, Status: "active", Source: "extension",
|
||||
Adoptable: false,
|
||||
}
|
||||
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
|
||||
t.Fatalf("seed non-adoptable: %v", err)
|
||||
}
|
||||
|
||||
h := NewPackageAdoptHandler(stores)
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
_, r := gin.CreateTestContext(w)
|
||||
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
|
||||
c.Set("user_id", userID)
|
||||
h.AdoptPackage(c)
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkg.ID+"/adopt", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for non-adoptable, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ type ManifestInfo struct {
|
||||
UserPermissions []string // user-facing permissions declared by the extension
|
||||
GatePermission string // if set, checks this user permission before calling on_request
|
||||
RequiresRoles []string // team roles needed to access this package (advisory, OR semantics)
|
||||
Adoptable bool // if true, teams can adopt this package to get a team-scoped copy
|
||||
}
|
||||
|
||||
// ValidateManifest parses a manifest map and validates all required fields,
|
||||
@@ -178,6 +179,11 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.9.4: adoptable — teams can adopt this package to get a team-scoped copy
|
||||
if v, ok := manifest["adoptable"].(bool); ok {
|
||||
info.Adoptable = v
|
||||
}
|
||||
|
||||
info.SchemaVersion = ParseSchemaVersion(manifest)
|
||||
|
||||
// ── Type-specific constraints ────────────────────────────────
|
||||
@@ -216,10 +222,16 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
||||
if info.HasRoute {
|
||||
return nil, fmt.Errorf("library packages cannot have a route")
|
||||
}
|
||||
if info.Adoptable {
|
||||
return nil, fmt.Errorf("library packages cannot be adoptable")
|
||||
}
|
||||
case "test-runner":
|
||||
// Test runners are surface-like packages discovered by type.
|
||||
// They are not shown in navigation (extensionNavItems filters for surface/full).
|
||||
// They may have a route but it's optional — the registry surface provides access.
|
||||
if info.Adoptable {
|
||||
return nil, fmt.Errorf("test-runner packages cannot be adoptable")
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
|
||||
@@ -591,6 +591,18 @@ func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *Ma
|
||||
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
||||
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
|
||||
}
|
||||
|
||||
// v0.9.4: persist adoptable flag (Update doesn't cover it — separate column)
|
||||
if mInfo.Adoptable {
|
||||
q := `UPDATE packages SET adoptable = true WHERE id = $1`
|
||||
if !database.IsPostgres() {
|
||||
q = `UPDATE packages SET adoptable = 1 WHERE id = ?`
|
||||
}
|
||||
if _, err := database.DB.ExecContext(c.Request.Context(), q, pkgID); err != nil {
|
||||
log.Printf("[packages] Failed to set adoptable for %s: %v", pkgID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return preservedEnabled, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,13 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
|
||||
// AdoptTeamWorkflow clones a global (team_id=NULL) workflow into this team.
|
||||
// The global original is left untouched so other teams can also adopt it.
|
||||
// POST /api/v1/teams/:teamId/workflows/:id/adopt
|
||||
//
|
||||
// Deprecated: Use POST /api/v1/teams/:teamId/packages/:id/adopt instead.
|
||||
// This endpoint will be removed in a future version.
|
||||
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
|
||||
c.Header("X-Deprecated", "Use POST /api/v1/teams/:teamId/packages/:id/adopt instead")
|
||||
log.Printf("[workflows] DEPRECATED: AdoptTeamWorkflow called for team %s — use POST /teams/:teamId/packages/:id/adopt", c.Param("teamId"))
|
||||
|
||||
ctx := c.Request.Context()
|
||||
teamID := c.Param("teamId")
|
||||
srcID := c.Param("id")
|
||||
|
||||
Reference in New Issue
Block a user