acl.spec.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. import { resolveScope, scopeFor, scopeOf } from './acl';
  2. import { Me } from './api/models';
  3. /**
  4. * ACL 判级单测(SPEC FR-20 / 《API-契约》§五)
  5. *
  6. * 🔴 判级移植自 v1 `fmode-app/src/app/core/acl.ts:50-69`,重点回归:
  7. * ① **无 me → guest → `'mine'`**(安全默认,最不能错的一条)
  8. * ② 超管优先于公司角色(且不要求有公司)
  9. * ③ **管理员与成员必须区分**(company-admin vs company-member)
  10. * ④ **当前**两者都请求 `'company'`(用户裁决:同公司就看全公司)
  11. *
  12. * 本判定只选「请求哪个 scope」,**不做视图放行** —— 见 acl.ts 注释。
  13. */
  14. describe('acl 判级', () => {
  15. const me = (over: Partial<Me>): Me => ({ id: 'u1', name: 'U', ...over });
  16. describe('🔴 安全默认:无 me 一律 mine', () => {
  17. it('me 为 null → guest → mine', () => {
  18. expect(resolveScope(null).level).toBe('guest');
  19. expect(scopeFor(null)).toBe('mine');
  20. });
  21. it('me 为 undefined → mine', () => {
  22. expect(scopeFor(undefined)).toBe('mine');
  23. });
  24. });
  25. describe('超管(不要求有公司)', () => {
  26. it('roles 含 masters → superadmin → all', () => {
  27. expect(resolveScope(me({ roles: ['masters'] })).level).toBe('superadmin');
  28. expect(scopeFor(me({ roles: ['masters'] }))).toBe('all');
  29. });
  30. it('roles 含 FMODE_AGENT_SUPERADMIN → all', () => {
  31. expect(scopeFor(me({ roles: ['FMODE_AGENT_SUPERADMIN'] }))).toBe('all');
  32. });
  33. it('🔴 超管即使无 company 也是 superadmin → all', () => {
  34. expect(resolveScope(me({ roles: ['masters'] })).level).toBe('superadmin');
  35. expect(scopeFor(me({ roles: ['masters'] }))).toBe('all');
  36. });
  37. it('超管优先于公司管理员', () => {
  38. expect(scopeFor(me({ roles: ['masters', 'C1_Admin'], companyId: 'C1' }))).toBe('all');
  39. });
  40. it('username 白名单 FMODE_AGENT_SUPERADMIN → superadmin', () => {
  41. expect(resolveScope(me({ name: 'FMODE_AGENT_SUPERADMIN' })).level).toBe('superadmin');
  42. });
  43. });
  44. describe('🔥 有公司:管理员 vs 成员(必须区分)', () => {
  45. it('roles 含 <companyId>_Admin → company-admin', () => {
  46. const r = resolveScope(me({ roles: ['C1_Admin'], companyId: 'C1' }));
  47. expect(r.level).toBe('company-admin');
  48. expect(r.companyId).toBe('C1');
  49. expect(scopeOf(r)).toBe('company');
  50. });
  51. it('有公司但无 _Admin 角色 → company-member,请求 company', () => {
  52. const r = resolveScope(me({ roles: [], companyId: 'C1' }));
  53. expect(r.level).toBe('company-member');
  54. expect(r.companyId).toBe('C1');
  55. /*
  56. * 2026-11-04 用户裁决变更:成员也请求 company。
  57. * 通讯录需要按「同事 → 其名下容器」组织,看不到同事的容器就无法交流。
  58. * 跨租户隔离仍由服务端按 company 字段强制过滤。
  59. */
  60. expect(scopeOf(r)).toBe('company');
  61. });
  62. it('🔴 2026-11-04 裁决:成员请求 company(可看到本公司全员容器)', () => {
  63. expect(scopeFor(me({ companyId: 'C1' }))).toBe('company');
  64. expect(scopeFor(me({ roles: ['whatever'], companyId: 'C1' }))).toBe('company');
  65. });
  66. it('管理员仍然请求 company(与成员区分)', () => {
  67. expect(scopeFor(me({ roles: ['C1_Admin'], companyId: 'C1' }))).toBe('company');
  68. });
  69. it('角色前缀与 companyId 不匹配 → company-member(不是管理员)', () => {
  70. const r = resolveScope(me({ roles: ['C2_Admin'], companyId: 'C1' }));
  71. expect(r.level).toBe('company-member');
  72. });
  73. });
  74. describe('无公司 → personal → mine', () => {
  75. it('无 companyId → personal → mine', () => {
  76. const r = resolveScope(me({ roles: ['whatever'] }));
  77. expect(r.level).toBe('personal');
  78. expect(scopeOf(r)).toBe('mine');
  79. });
  80. it('无 companyId 但有无关角色 → mine', () => {
  81. expect(scopeFor(me({ roles: ['some_other_role'] }))).toBe('mine');
  82. });
  83. });
  84. describe('标签(UI 展示用)', () => {
  85. it('各级别都有中文标签', () => {
  86. expect(resolveScope(null).label).toBe('未登录');
  87. expect(resolveScope(me({ roles: ['masters'] })).label).toBe('系统超管');
  88. expect(resolveScope(me({ roles: ['C1_Admin'], companyId: 'C1' })).label).toBe('公司管理员');
  89. expect(resolveScope(me({ companyId: 'C1' })).label).toBe('公司成员');
  90. expect(resolveScope(me({})).label).toBe('个人用户');
  91. });
  92. });
  93. describe('返回类型受约束', () => {
  94. it('scopeFor 只返回 all / company / mine', () => {
  95. const cases: (Me | null)[] = [
  96. null,
  97. me({}),
  98. me({ roles: ['masters'] }),
  99. me({ roles: ['C1_Admin'], companyId: 'C1' }),
  100. me({ companyId: 'C1' }),
  101. ];
  102. for (const c of cases) expect(['all', 'company', 'mine']).toContain(scopeFor(c));
  103. });
  104. });
  105. });