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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user