package handlers import ( "context" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "armature/database" "armature/middleware" "armature/models" "armature/store" ) // ── Manifest: requires_roles parsing ────────── func TestValidateManifest_RequiresRoles(t *testing.T) { m := map[string]any{ "id": "role-gated", "title": "Role Gated Ext", "type": "surface", "requires_roles": []any{"approver", "reviewer"}, } info, err := ValidateManifest(m) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(info.RequiresRoles) != 2 { t.Fatalf("expected 2 requires_roles, got %d", len(info.RequiresRoles)) } if info.RequiresRoles[0] != "approver" || info.RequiresRoles[1] != "reviewer" { t.Errorf("unexpected roles: %v", info.RequiresRoles) } } func TestValidateManifest_RequiresRoles_Empty(t *testing.T) { m := map[string]any{ "id": "no-roles", "title": "No Roles", "type": "surface", } info, err := ValidateManifest(m) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(info.RequiresRoles) != 0 { t.Errorf("expected empty requires_roles, got %v", info.RequiresRoles) } } func TestValidateManifest_RequiresRoles_SkipsInvalid(t *testing.T) { m := map[string]any{ "id": "bad-roles", "title": "Bad Roles", "type": "surface", "requires_roles": []any{"ok", "", 42, "also-ok"}, } info, err := ValidateManifest(m) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(info.RequiresRoles) != 2 { t.Fatalf("expected 2 valid roles, got %d: %v", len(info.RequiresRoles), info.RequiresRoles) } } // ── Store: team_user_roles CRUD ────────────── func seedTeamAndMember(t *testing.T, stores store.Stores) (teamID, userID, memberID string) { t.Helper() ctx := context.Background() userID = seedRoleUser(t, "roleuser", "roleuser@test.com") creatorID := seedRoleUser(t, "creator", "creator@test.com") team := &models.Team{Name: "role-test-" + store.NewID()[:8], CreatedBy: creatorID, IsActive: true} if err := stores.Teams.Create(ctx, team); err != nil { t.Fatalf("create team: %v", err) } teamID = team.ID mid, err := stores.Teams.AddMemberReturningID(ctx, teamID, userID, "member") if err != nil { t.Fatalf("add member: %v", err) } memberID = mid return } func TestUserRoles_AddAndList(t *testing.T) { database.RequireTestDB(t) stores := testStores(t) ctx := context.Background() teamID, userID, _ := seedTeamAndMember(t, stores) // Initially: only the primary role roles, err := stores.Teams.GetMemberRoles(ctx, teamID, userID) if err != nil { t.Fatalf("GetMemberRoles: %v", err) } if len(roles) != 1 || roles[0] != "member" { t.Errorf("expected [member], got %v", roles) } // Add two additional roles if err := stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID); err != nil { t.Fatalf("AddUserRole: %v", err) } if err := stores.Teams.AddUserRole(ctx, teamID, userID, "approver", userID); err != nil { t.Fatalf("AddUserRole: %v", err) } roles, _ = stores.Teams.GetMemberRoles(ctx, teamID, userID) if len(roles) != 3 { t.Fatalf("expected 3 roles, got %d: %v", len(roles), roles) } // ListUserRoles returns only additional extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID) if len(extra) != 2 { t.Fatalf("expected 2 extra roles, got %d: %v", len(extra), extra) } } func TestUserRoles_AddIdempotent(t *testing.T) { database.RequireTestDB(t) stores := testStores(t) ctx := context.Background() teamID, userID, _ := seedTeamAndMember(t, stores) // Add same role twice — should not error if err := stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID); err != nil { t.Fatalf("first add: %v", err) } if err := stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID); err != nil { t.Fatalf("idempotent add should not error: %v", err) } extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID) if len(extra) != 1 { t.Errorf("expected 1 extra role after idempotent add, got %d", len(extra)) } } func TestUserRoles_HasRole(t *testing.T) { database.RequireTestDB(t) stores := testStores(t) ctx := context.Background() teamID, userID, _ := seedTeamAndMember(t, stores) // Primary role has, _ := stores.Teams.HasRole(ctx, teamID, userID, "member") if !has { t.Error("expected HasRole=true for primary role 'member'") } // Non-existent role has, _ = stores.Teams.HasRole(ctx, teamID, userID, "reviewer") if has { t.Error("expected HasRole=false for unassigned role") } // Add and check stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID) has, _ = stores.Teams.HasRole(ctx, teamID, userID, "reviewer") if !has { t.Error("expected HasRole=true after adding role") } } func TestUserRoles_Remove(t *testing.T) { database.RequireTestDB(t) stores := testStores(t) ctx := context.Background() teamID, userID, _ := seedTeamAndMember(t, stores) stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID) stores.Teams.RemoveUserRole(ctx, teamID, userID, "reviewer") extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID) if len(extra) != 0 { t.Errorf("expected 0 extra roles after remove, got %d", len(extra)) } } func TestUserRoles_RemoveAllOnMemberDelete(t *testing.T) { database.RequireTestDB(t) stores := testStores(t) ctx := context.Background() teamID, userID, _ := seedTeamAndMember(t, stores) stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID) stores.Teams.AddUserRole(ctx, teamID, userID, "approver", userID) // Cleanup (as the handler does) stores.Teams.RemoveAllUserRoles(ctx, teamID, userID) extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID) if len(extra) != 0 { t.Errorf("expected 0 extra roles after RemoveAll, got %d", len(extra)) } } // ── Middleware: RequireRole ─────────────────── func TestRequireRole_Allowed(t *testing.T) { database.RequireTestDB(t) stores := testStores(t) ctx := context.Background() teamID, userID, _ := seedTeamAndMember(t, stores) stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() _, r := gin.CreateTestContext(w) called := false r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() }) r.GET("/teams/:teamId/test", middleware.RequireRole(stores.Teams, []string{"reviewer"}, stores), func(c *gin.Context) { called = true c.Status(http.StatusOK) }, ) req := httptest.NewRequest("GET", "/teams/"+teamID+"/test", nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK || !called { t.Errorf("expected 200 + handler called, got %d called=%v", w.Code, called) } } func TestRequireRole_Denied(t *testing.T) { database.RequireTestDB(t) stores := testStores(t) teamID, userID, _ := seedTeamAndMember(t, stores) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, r := gin.CreateTestContext(w) r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() }) r.GET("/teams/:teamId/test", middleware.RequireRole(stores.Teams, []string{"approver"}, stores), func(c *gin.Context) { c.Status(http.StatusOK) }, ) c.Request = httptest.NewRequest("GET", "/teams/"+teamID+"/test", nil) r.ServeHTTP(w, c.Request) if w.Code != http.StatusForbidden { t.Errorf("expected 403 for missing role, got %d", w.Code) } } // ── Store: HasRoleInAnyTeam ────────────────── func TestHasRoleInAnyTeam_PrimaryRole(t *testing.T) { database.RequireTestDB(t) stores := testStores(t) ctx := context.Background() _, userID, _ := seedTeamAndMember(t, stores) // "member" is the primary role assigned during seedTeamAndMember has, err := stores.Teams.HasRoleInAnyTeam(ctx, userID, "member") if err != nil { t.Fatalf("HasRoleInAnyTeam: %v", err) } if !has { t.Error("expected true for primary role 'member'") } } func TestHasRoleInAnyTeam_AdditionalRole(t *testing.T) { database.RequireTestDB(t) stores := testStores(t) ctx := context.Background() teamID, userID, _ := seedTeamAndMember(t, stores) stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID) has, err := stores.Teams.HasRoleInAnyTeam(ctx, userID, "reviewer") if err != nil { t.Fatalf("HasRoleInAnyTeam: %v", err) } if !has { t.Error("expected true for additional role 'reviewer'") } } func TestHasRoleInAnyTeam_NoMatch(t *testing.T) { database.RequireTestDB(t) stores := testStores(t) ctx := context.Background() _, userID, _ := seedTeamAndMember(t, stores) has, err := stores.Teams.HasRoleInAnyTeam(ctx, userID, "nonexistent") if err != nil { t.Fatalf("HasRoleInAnyTeam: %v", err) } if has { t.Error("expected false for non-existent role") } } // ── Manifest: role access validation ──────── func TestValidateManifest_SurfaceRoleAccess(t *testing.T) { m := map[string]any{ "id": "role-pkg", "title": "Role Gated", "type": "surface", "surfaces": []any{ map[string]any{"path": "/", "access": "role:approver"}, }, } info, err := ValidateManifest(m) if err != nil { t.Fatalf("unexpected error for role:approver access: %v", err) } if !info.HasSurfaces { t.Error("expected HasSurfaces to be true") } } func TestValidateManifest_SurfaceRoleAccessEmpty(t *testing.T) { m := map[string]any{ "id": "role-pkg", "title": "Role Gated", "type": "surface", "surfaces": []any{ map[string]any{"path": "/", "access": "role:"}, }, } _, err := ValidateManifest(m) if err == nil { t.Fatal("expected error for empty role name 'role:'") } } // ── helpers ────────────────────────────────── func seedRoleUser(t *testing.T, username, email string) string { t.Helper() uname := username + store.NewID()[:6] q := `INSERT INTO users (username, email, password_hash, display_name, is_active, auth_source, handle) VALUES ($1, $2, 'hash', $3, true, 'builtin', $4) RETURNING id` return seedInsertReturningID(t, q, uname, email+store.NewID()[:6], uname, uname) }