package system import ( "context" "os" "os/exec" "path/filepath" "slices" "strings" "testing" _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gcfg" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rolepolicy" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rolesync" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/agent" _ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/memorydb" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" ) func TestRoleMenuTemplateValidationIsolation(t *testing.T) { if os.Getenv("ROLE_TEMPLATE_TEST_CHILD") != "1" { configPath := filepath.Join(t.TempDir(), "config.yaml") if err := os.WriteFile(configPath, []byte("database:\n default:\n type: sqlite\n link: sqlite::@file(:memory:)\n memorydb:\n link: \"\"\n"), 0600); err != nil { t.Fatal(err) } executable, err := os.Executable() if err != nil { t.Fatal(err) } command := exec.Command(executable, "-test.run=^TestRoleMenuTemplateValidationIsolation$", "-test.v") command.Env = append(os.Environ(), "ROLE_TEMPLATE_TEST_CHILD=1", "GF_GCFG_FILE="+configPath) output, err := command.CombinedOutput() if err != nil { t.Fatalf("isolated role tests failed: %v\n%s", err, output) } t.Log(string(output)) return } originalAdapter := g.Cfg().GetAdapter() adapter, err := gcfg.NewAdapterContent("database:\n default:\n type: sqlite\n link: sqlite::@file(:memory:)\n memorydb:\n link: \"\"\n", "yaml") if err != nil { t.Fatal(err) } g.Cfg().SetAdapter(adapter) t.Cleanup(func() { g.Cfg().SetAdapter(originalAdapter) }) if err := gdb.SetConfig(gdb.Config{"default": {{Type: "sqlite", Link: "sqlite::@file(:memory:)"}}}); err != nil { t.Fatal(err) } ctx := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 1, TenantId: "000000", DeptId: 100, Roles: []model.Role{{RoleId: consts.SuperAdminRoleId}}}}}) t.Cleanup(func() { _ = g.DB().Close(ctx) }) for _, statement := range []string{ "CREATE TABLE sys_config (tenant_id TEXT, config_key TEXT, config_value TEXT)", "CREATE TABLE sys_role (role_id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER, created_dept INTEGER, status TEXT, is_pass INTEGER, data_scope TEXT, role_name TEXT, role_sort INTEGER, menu_check_strictly INTEGER, dept_check_strictly INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, deleted_by INTEGER, remark TEXT, deleted_at DATETIME)", "CREATE TABLE sys_role_menu (tenant_id TEXT, role_id INTEGER, menu_id INTEGER, data_scope TEXT)", "CREATE TABLE sys_user_role (tenant_id TEXT, user_id INTEGER, role_id INTEGER)", "CREATE TABLE sys_role_dept (tenant_id TEXT, role_id INTEGER, dept_id INTEGER)", "INSERT INTO sys_config VALUES ('000000','agent.role.template.role_id','82')", "INSERT INTO sys_role (role_id,tenant_id,dept_id,created_dept,status,is_pass,data_scope,deleted_at) VALUES (82,'000000',0,100,'0',0,'4',NULL)", } { if _, err := g.DB().Exec(ctx, statement); err != nil { t.Fatal(err) } } service := NewSysRole() t.Run("普通角色新增菜单不受无效模板影响", func(t *testing.T) { if err := service.RoleMenu(ctx, 200, []int64{10, 11}, nil); err != nil { t.Fatal(err) } count, err := g.DB().Model("sys_role_menu").Where("role_id", 200).Count() if err != nil || count != 2 { t.Fatalf("menu count %d: %v", count, err) } }) t.Run("普通角色修改菜单不受无效模板影响", func(t *testing.T) { if err := service.RoleMenu(ctx, 200, []int64{11, 12}, nil); err != nil { t.Fatal(err) } count, err := g.DB().Model("sys_role_menu").Where("role_id", 200).Where("menu_id", 10).Count() if err != nil || count != 0 { t.Fatalf("removed menu count %d: %v", count, err) } }) t.Run("模板仍禁止全部按钮数据权限", func(t *testing.T) { err := service.RoleMenu(ctx, 82, []int64{10}, map[int64]string{10: consts.SysRoleDataScopeAll}) if err == nil || !strings.Contains(err.Error(), "模板按钮") { t.Fatalf("expected template restriction: %v", err) } }) t.Run("无效模板仍禁止删除", func(t *testing.T) { _, err := service.Delete(ctx, &model.SysRoleDeleteParam{RoleId: 82}) if err == nil || !strings.Contains(err.Error(), "模板不能删除") { t.Fatalf("expected template deletion rejection: %v", err) } }) t.Run("批量删除不能包含模板", func(t *testing.T) { _, err := service.Delete(ctx, &model.SysRoleDeleteParam{RoleIds: []int64{200, 82}}) if err == nil || !strings.Contains(err.Error(), "模板不能删除") { t.Fatalf("expected batch deletion rejection: %v", err) } }) t.Run("非管理员不能绕过无效模板授权", func(t *testing.T) { limited := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175}}}) if err := service.RoleMenu(limited, 201, []int64{10}, nil); err == nil { t.Fatal("invalid template must fail closed") } count, err := g.DB().Model("sys_role_menu").Where("role_id", 201).Count() if err != nil || count != 0 { t.Fatalf("unauthorized menu count %d: %v", count, err) } }) t.Run("运行时模板校验仍然严格", func(t *testing.T) { if _, err := rolepolicy.Template(ctx); err == nil { t.Fatal("invalid template was accepted") } }) t.Run("无关角色不读取模板角色实体", func(t *testing.T) { template, err := rolepolicy.TemplateForRoles(ctx, 200) if err != nil || template != nil { t.Fatalf("unexpected template: %+v %v", template, err) } }) t.Run("已删除模板仍保留身份保护", func(t *testing.T) { if _, err := g.DB().Exec(ctx, "DELETE FROM sys_role WHERE role_id=82"); err != nil { t.Fatal(err) } template, err := rolepolicy.TemplateForRoles(ctx, 82) if err != nil || template == nil || template.RoleId != 82 { t.Fatalf("template identity lost: %+v %v", template, err) } }) t.Run("错误参数不阻断无关角色但不能用于授权", func(t *testing.T) { if _, err := g.DB().Exec(ctx, "UPDATE sys_config SET config_value='invalid'"); err != nil { t.Fatal(err) } if err := service.RoleMenu(ctx, 202, []int64{10}, nil); err != nil { t.Fatal(err) } if _, err := rolepolicy.Template(ctx); err == nil { t.Fatal("invalid configuration must fail strict validation") } }) t.Run("平台管理员权限加载不依赖无效模板", func(t *testing.T) { for _, statement := range []string{ "UPDATE sys_config SET config_value='82'", "ALTER TABLE sys_role ADD COLUMN role_key TEXT", "INSERT INTO sys_role (role_id,tenant_id,dept_id,status,role_key,data_scope) VALUES (1,'000000',100,'0','admin','1'),(102,'000000',175,'0','agent','1')", "CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_role INTEGER, status TEXT, ancestors TEXT, dept_type INTEGER, order_num INTEGER DEFAULT 0, deleted_at DATETIME)", "INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_role,status,deleted_at) VALUES (100,'000000',0,1,'0',NULL),(175,'000000',100,102,'0',NULL)", "CREATE TABLE sys_menu (menu_id INTEGER PRIMARY KEY, menu_name TEXT, parent_id INTEGER, path TEXT, menu_type TEXT, order_num INTEGER, perms TEXT, status TEXT, deleted_at DATETIME)", "INSERT INTO sys_menu VALUES (10,'授权功能',0,'allowed','C',1,'system:test:allowed','0',NULL),(11,'受限功能',0,'restricted','C',2,'system:test:restricted','0',NULL)", "CREATE TABLE sys_user (user_id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER, user_name TEXT, status TEXT, deleted_at DATETIME)", "INSERT INTO sys_user VALUES (1,'000000',100,'admin','0',NULL),(2,'000000',175,'agent','0',NULL)", "CREATE TABLE sys_user_post (tenant_id TEXT, user_id INTEGER, post_id INTEGER)", "INSERT INTO sys_user_role VALUES ('000000',1,1),('000000',2,102)", } { if _, err := g.DB().Exec(ctx, statement); err != nil { t.Fatal(err) } } accessCodes, _, err := NewSysAuth().GetUserAccessCodeList(ctx, 1) if err != nil { t.Fatal(err) } if !slices.Contains(accessCodes, "cpm:system:test:allowed") { t.Fatalf("administrator permissions missing: %v", accessCodes) } menus, err := NewSysMenu().GetUserMenu(ctx) if err != nil || len(menus) != 2 { t.Fatalf("administrator menu loading failed: %v %v", menus, err) } }) t.Run("不能仅凭旧令牌声称是平台管理员", func(t *testing.T) { stale := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175, Roles: []model.Role{{RoleId: consts.SuperAdminRoleId}}}}}) if _, _, err := NewSysAuth().GetUserAccessCodeList(stale, 2); err == nil { t.Fatal("stale administrator claim bypassed template validation") } }) t.Run("停用平台管理员角色不能跳过模板校验", func(t *testing.T) { if _, err := g.DB().Exec(ctx, "UPDATE sys_role SET status='1' WHERE role_id=1"); err != nil { t.Fatal(err) } defer func() { if _, err := g.DB().Exec(ctx, "UPDATE sys_role SET status='0' WHERE role_id=1"); err != nil { t.Error(err) } }() if _, _, err := NewSysAuth().GetUserAccessCodeList(ctx, 1); err == nil { t.Fatal("disabled administrator role bypassed validation") } }) t.Run("跨租户管理员标记不能跳过模板校验", func(t *testing.T) { other := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 1, TenantId: "other", DeptId: 100, Roles: []model.Role{{RoleId: consts.SuperAdminRoleId}}}}}) if _, _, err := NewSysAuth().GetUserAccessCodeList(other, 1); err == nil { t.Fatal("cross-tenant administrator claim bypassed validation") } }) t.Run("模板参数格式异常不阻断平台管理员", func(t *testing.T) { if _, err := g.DB().Exec(ctx, "UPDATE sys_config SET config_value='invalid'"); err != nil { t.Fatal(err) } if _, _, err := NewSysAuth().GetUserAccessCodeList(ctx, 1); err != nil { t.Fatal(err) } }) t.Run("平台单独扩大默认角色权限后菜单及鉴权均生效且不扩大数据范围", func(t *testing.T) { for _, statement := range []string{ "UPDATE sys_config SET config_value='82'", "INSERT INTO sys_role (role_id,tenant_id,dept_id,created_dept,status,is_pass,data_scope) VALUES (82,'000000',100,100,'0',1,'4')", "INSERT INTO sys_role_menu VALUES ('000000',82,10,'0'),('000000',102,10,'0'),('000000',102,11,'0')", } { if _, err := g.DB().Exec(ctx, statement); err != nil { t.Fatal(err) } } agent := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175}}}) accessCodes, roleCodes, err := NewSysAuth().GetUserAccessCodeList(agent, 2) if err != nil { t.Fatal(err) } if !slices.Contains(accessCodes, "cpm:system:test:allowed") || !slices.Contains(accessCodes, "cpm:system:test:restricted") { t.Fatalf("agent menu boundary violated: %v", accessCodes) } menus, err := NewSysMenu().GetUserMenu(agent) if err != nil || len(menus) != 2 { t.Fatalf("individual menus missing: %v %v", menus, err) } if len(roleCodes) != 2 || !strings.HasSuffix(roleCodes[0], "|4") || !strings.HasSuffix(roleCodes[1], "|4") { t.Fatalf("agent data scope boundary violated: %v", roleCodes) } }) t.Run("平台从角色管理扩大普通角色权限不受默认角色限制", func(t *testing.T) { for _, statement := range []string{ "DELETE FROM sys_role_menu WHERE role_id=102 AND menu_id=11", "INSERT INTO sys_role(role_id,tenant_id,dept_id,created_dept,status,data_scope,role_key) VALUES(103,'000000',175,175,'0','4','special')", "INSERT INTO sys_user VALUES(3,'000000',175,'special','0',NULL)", "INSERT INTO sys_user_role VALUES('000000',3,103)", } { if _, err := g.DB().Exec(ctx, statement); err != nil { t.Fatal(err) } } if _, err := service.Edit(rolesync.DeferRecording(ctx), &model.SysRoleEditParam{RoleId: 103, MenuIds: []int64{11}}); err != nil { t.Fatal(err) } special := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 3, TenantId: "000000", DeptId: 175}}}) codes, _, err := NewSysAuth().GetUserAccessCodeList(special, 3) if err != nil || !slices.Contains(codes, "cpm:system:test:restricted") { t.Fatalf("saved permission did not reach API authorization: %v %v", codes, err) } menus, err := NewSysMenu().GetUserMenu(special) if err != nil || len(menus) != 1 || menus[0].MenuId != 11 { t.Fatalf("saved permission did not reach navigation: %v %v", menus, err) } if _, err := g.DB().Exec(ctx, "DELETE FROM sys_role_menu WHERE role_id=103"); err != nil { t.Fatal(err) } codes, _, err = NewSysAuth().GetUserAccessCodeList(special, 3) if err != nil || slices.Contains(codes, "cpm:system:test:restricted") { t.Fatalf("revoked role permission remained usable: %v %v", codes, err) } }) t.Run("角色详情说明模板和默认角色身份", func(t *testing.T) { for _, test := range []struct { id int64 template, defaultRole bool }{{82, true, false}, {102, false, true}, {103, false, false}} { role, err := service.View(ctx, &model.SysRoleViewParam{RoleId: test.id}) if err != nil { t.Fatal(err) } if role.IsTemplateRole != test.template || role.IsDeptDefaultRole != test.defaultRole { t.Fatalf("wrong role metadata: %+v", role) } } }) t.Run("非平台管理员不能单独修改模板菜单或数据范围", func(t *testing.T) { operator := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175}}}) if err := service.RoleMenu(operator, 82, []int64{10}, nil); err == nil || !strings.Contains(err.Error(), "仅平台超级管理员") { t.Fatalf("template menu change was not rejected: %v", err) } if err := service.EditRoleDataScope(operator, &model.SysRoleDataScopeEditParam{RoleId: 82, DataScope: consts.SysRoleDataScopeDeptAndBelow}); err == nil || !strings.Contains(err.Error(), "仅平台超级管理员") { t.Fatalf("template data scope change was not rejected: %v", err) } }) t.Run("企业入口允许已有下发角色时新增多个普通角色", func(t *testing.T) { for _, roleName := range []string{"财务", "客服"} { roleID, err := agent.NewAgentRole().RoleSave(ctx, &model.AgentRoleSaveParam{RoleName: roleName, RoleKey: "staff", MenuIds: []int64{10}, DataScope: consts.SysRoleDataScopePersonal}) if err != nil { t.Fatal(err) } record, err := g.DB().Model("sys_role").Where("role_id", roleID).One() if err != nil || record["is_pass"].Int() != 0 || record["data_scope"].String() != consts.SysRoleDataScopePersonal { t.Fatalf("ordinary role was changed: %v %v", record, err) } } }) t.Run("配置平台模板后历史下发标记不限制普通角色维护", func(t *testing.T) { _, err := agent.NewAgentRole().RoleSave(ctx, &model.AgentRoleSaveParam{RoleName: "历史标记角色", RoleKey: "legacy", IsPass: 1}) if err != nil { t.Fatal(err) } }) t.Run("平台模板由参数识别不依赖隐藏下发标记", func(t *testing.T) { _, err := agent.NewAgentRole().RoleSave(rolesync.DeferRecording(ctx), &model.AgentRoleSaveParam{RoleId: 82, RoleName: "模板", RoleKey: "template", MenuIds: []int64{10}, IsPass: 0}) if err != nil { t.Fatal(err) } template, err := rolepolicy.Template(ctx) if err != nil || template == nil || template.RoleId != 82 || template.IsPass != 0 { t.Fatalf("configured template not recognized: %+v %v", template, err) } }) t.Run("企业入口拒绝错误历史传递标记", func(t *testing.T) { _, err := agent.NewAgentRole().RoleSave(ctx, &model.AgentRoleSaveParam{RoleName: "无效标记", RoleKey: "invalid", IsPass: 2}) if err == nil || !strings.Contains(err.Error(), "传递标记无效") { t.Fatalf("invalid legacy marker was accepted: %v", err) } }) t.Run("配置模板后历史下发标记不阻断普通角色删除", func(t *testing.T) { for _, statement := range []string{ "INSERT INTO sys_role(role_id,tenant_id,dept_id,created_dept,status,is_pass,data_scope) VALUES(600,'000000',100,100,'0',1,'4')", "UPDATE sys_role SET created_dept=100 WHERE role_id=102", } { if _, err := g.DB().Exec(ctx, statement); err != nil { t.Fatal(err) } } if _, err := service.Delete(ctx, &model.SysRoleDeleteParam{RoleId: 600}); err != nil { t.Fatal(err) } }) t.Run("授权范围不能超出当前操作员有效角色", func(t *testing.T) { if _, err := g.DB().Exec(ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,created_dept,status,is_pass,data_scope,role_key) VALUES(501,'000000',175,175,'0',0,'4','staff')"); err != nil { t.Fatal(err) } if _, err := g.DB().Exec(ctx, "INSERT INTO sys_user_role VALUES('000000',3,501)"); err != nil { t.Fatal(err) } operator := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 3, TenantId: "000000", DeptId: 175}}}) if err := service.RoleMenu(operator, 501, []int64{10}, nil); err == nil || !strings.Contains(err.Error(), "超出") { t.Fatalf("operator without permission granted it: %v", err) } if _, err := g.DB().Exec(ctx, "INSERT INTO sys_role_menu VALUES('000000',501,10,'0')"); err != nil { t.Fatal(err) } if err := service.RoleMenu(operator, 501, []int64{10}, nil); err != nil { t.Fatal(err) } if _, err := g.DB().Exec(ctx, "UPDATE sys_role SET status='1' WHERE role_id=501"); err != nil { t.Fatal(err) } if err := service.RoleMenu(operator, 501, []int64{10}, nil); err == nil { t.Fatal("disabled operator role granted permission") } }) t.Run("展示祖先兼容旧请求但不写入实际授权", func(t *testing.T) { for _, statement := range []string{ "INSERT INTO sys_menu VALUES (900,'展示目录',0,'display','M',0,'','0',NULL),(901,'未授权父菜单',900,'parent','C',0,'system:parent:list','0',NULL)", "UPDATE sys_menu SET parent_id=901 WHERE menu_id=10", } { if _, err := g.DB().Exec(ctx, statement); err != nil { t.Fatal(err) } } operator := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175}}}) tree, assignable, err := agent.NewAgentMenu().AllowedMenuTree(operator, 175) if err != nil { t.Fatal(err) } if !slices.Contains(assignable, 10) || slices.Contains(assignable, 900) || slices.Contains(assignable, 901) { t.Fatalf("display ancestors leaked into assignable IDs: %v", assignable) } if len(tree) != 3 || !slices.ContainsFunc(tree, func(menu *model.SysMenuTreeModel) bool { return menu.MenuId == 901 && menu.ParentId == 900 }) || !slices.ContainsFunc(tree, func(menu *model.SysMenuTreeModel) bool { return menu.MenuId == 10 && menu.ParentId == 901 }) { t.Fatalf("display hierarchy is incomplete: %+v", tree) } if err := service.RoleMenu(operator, 502, []int64{900, 901, 10}, nil); err != nil { t.Fatal(err) } var stored []int64 values, err := g.DB().Model("sys_role_menu").Where("role_id", 502).Array("menu_id") if err != nil { t.Fatal(err) } for _, value := range values { stored = append(stored, value.Int64()) } if len(stored) != 1 || stored[0] != 10 { t.Fatalf("display-only permissions were persisted: %v", stored) } newRole, err := agent.NewAgentRole().RoleSave(rolesync.DeferRecording(operator), &model.AgentRoleSaveParam{ RoleName: "下级测试角色", RoleKey: "scoped-agent", MenuIds: []int64{900, 901, 10}, }) if err != nil { t.Fatal(err) } storedMenus, err := g.DB().Model("sys_role_menu").Where("role_id", newRole).Array("menu_id") if err != nil || len(storedMenus) != 1 || storedMenus[0].Int64() != 10 { t.Fatalf("agent save did not preserve the same authorization boundary: %v %v", storedMenus, err) } if err := service.RoleMenu(operator, 502, []int64{10, 11}, nil); err == nil { t.Fatal("unrelated permission was accepted") } count, err := g.DB().Model("sys_role_menu").Where("role_id", 502).Count() if err != nil || count != 1 { t.Fatalf("failed validation changed permissions: %d %v", count, err) } }) t.Run("默认角色缺失明确报错而不是静默返回404", func(t *testing.T) { if _, err := g.DB().Exec(ctx, "UPDATE sys_dept SET dept_role=0 WHERE dept_id=175"); err != nil { t.Fatal(err) } defer func() { _, _ = g.DB().Exec(ctx, "UPDATE sys_dept SET dept_role=102 WHERE dept_id=175") }() if _, err := rolepolicy.RestrictMenus(ctx, "000000", 175, []int64{10}); err == nil || !strings.Contains(err.Error(), "未关联默认角色") { t.Fatalf("missing role must be actionable: %v", err) } operator := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175}}}) if _, err := g.DB().Exec(ctx, "UPDATE sys_dept SET dept_type=1 WHERE dept_id=175"); err != nil { t.Fatal(err) } if err := agent.NewAgentDept().EnsureCurrentDeptDefaultRole(operator); err == nil { t.Fatal("missing default role was not reported") } count, err := g.DB().Model("sys_user_role").Where("user_id", 2).Count() if err != nil || count != 1 { t.Fatalf("read request changed user roles: %d %v", count, err) } }) }