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) } }