|
14 | 14 | * getRolePermissions() is kept for the effective-permissions display in |
15 | 15 | * the admin Users page and still returns the same sets as before. |
16 | 16 | */ |
17 | | -import { canAccess, getRolePermissions, isAdmin, ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions'; |
| 17 | +import { canAccess, getRolePermissions, isAdmin, isPurchaser, ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions'; |
18 | 18 | import type { PermissionEntry } from '../api/types'; |
19 | 19 |
|
20 | 20 | jest.mock('../state', () => ({ |
@@ -343,5 +343,117 @@ describe('permissions', () => { |
343 | 343 | expect(canAccess('view', 'recommendations')).toBe(false); |
344 | 344 | expect(canAccess('admin', '*')).toBe(false); |
345 | 345 | }); |
| 346 | + |
| 347 | + test('custom (non-seeded) group with carved-out grants in effectivePermissions allows spending', () => { |
| 348 | + // CR #924 F4 + F5 regression: a custom group that does not include |
| 349 | + // PURCHASER_GROUP_ID but DOES carry execute/approve-any/retry-any |
| 350 | + // on purchases must satisfy canAccess for those verbs. The seeded |
| 351 | + // Purchaser group is not the only legitimate source of the |
| 352 | + // carve-out; the backend already allows this shape via the |
| 353 | + // group's effectivePermissions, so the frontend must agree. |
| 354 | + const customGid = '00000000-0000-5000-8000-00000000abcd'; |
| 355 | + const perms: PermissionEntry[] = [ |
| 356 | + { action: 'execute', resource: 'purchases' }, |
| 357 | + { action: 'approve-any', resource: 'purchases' }, |
| 358 | + { action: 'retry-any', resource: 'purchases' }, |
| 359 | + { action: 'view', resource: 'recommendations' }, |
| 360 | + { action: 'view', resource: 'plans' }, |
| 361 | + { action: 'view', resource: 'purchases' }, |
| 362 | + { action: 'view', resource: 'history' }, |
| 363 | + ]; |
| 364 | + mockUserWithGroups([customGid], perms); |
| 365 | + // The three carved-out spending verbs are granted by the explicit |
| 366 | + // entries even without PURCHASER_GROUP_ID membership. |
| 367 | + expect(canAccess('execute', 'purchases')).toBe(true); |
| 368 | + expect(canAccess('approve-any', 'purchases')).toBe(true); |
| 369 | + expect(canAccess('retry-any', 'purchases')).toBe(true); |
| 370 | + expect(canAccess('view', 'recommendations')).toBe(true); |
| 371 | + expect(canAccess('view', 'history')).toBe(true); |
| 372 | + // Non-purchaser admin actions remain denied. |
| 373 | + expect(canAccess('admin', '*')).toBe(false); |
| 374 | + expect(canAccess('delete', 'plans')).toBe(false); |
| 375 | + expect(canAccess('view', 'users')).toBe(false); |
| 376 | + expect(canAccess('cancel-any', 'purchases')).toBe(false); |
| 377 | + }); |
| 378 | + }); |
| 379 | + |
| 380 | + describe('isPurchaser', () => { |
| 381 | + test('seeded Purchaser group member (no effectivePermissions yet) returns true via fallback', () => { |
| 382 | + // Pre-bootstrap loading window: effectivePermissions not yet |
| 383 | + // populated. The helper falls back to seeded group membership. |
| 384 | + mockUserWithGroups([PURCHASER_GROUP_ID]); |
| 385 | + expect(isPurchaser()).toBe(true); |
| 386 | + }); |
| 387 | + |
| 388 | + test('user without Purchaser group and no effectivePermissions returns false', () => { |
| 389 | + mockUserWithGroups([ADMIN_GID]); // admin only |
| 390 | + expect(isPurchaser()).toBe(false); |
| 391 | + }); |
| 392 | + |
| 393 | + test('explicit execute:purchases grant in effectivePermissions (custom group) returns true', () => { |
| 394 | + // CR #924 F4: isPurchaser() must reflect effective permissions, |
| 395 | + // not just seeded group ID. A user whose custom group grants |
| 396 | + // execute:purchases is a spender even without PURCHASER_GROUP_ID. |
| 397 | + const customGid = '00000000-0000-5000-8000-00000000beef'; |
| 398 | + mockUserWithGroups([customGid], [ |
| 399 | + { action: 'execute', resource: 'purchases' }, |
| 400 | + { action: 'view', resource: 'recommendations' }, |
| 401 | + ]); |
| 402 | + expect(isPurchaser()).toBe(true); |
| 403 | + }); |
| 404 | + |
| 405 | + test('explicit approve-any:purchases grant returns true', () => { |
| 406 | + const customGid = '00000000-0000-5000-8000-00000000cafe'; |
| 407 | + mockUserWithGroups([customGid], [ |
| 408 | + { action: 'approve-any', resource: 'purchases' }, |
| 409 | + ]); |
| 410 | + expect(isPurchaser()).toBe(true); |
| 411 | + }); |
| 412 | + |
| 413 | + test('explicit retry-any:purchases grant returns true', () => { |
| 414 | + const customGid = '00000000-0000-5000-8000-00000000face'; |
| 415 | + mockUserWithGroups([customGid], [ |
| 416 | + { action: 'retry-any', resource: 'purchases' }, |
| 417 | + ]); |
| 418 | + expect(isPurchaser()).toBe(true); |
| 419 | + }); |
| 420 | + |
| 421 | + test('wildcard resource on a carved-out action grants Purchaser (matches canAccess semantics)', () => { |
| 422 | + // canAccess('execute', 'purchases') returns true for a |
| 423 | + // {execute, *} entry. isPurchaser() must agree so the two helpers |
| 424 | + // do not diverge when an unusual-but-legal permission shape |
| 425 | + // arrives from the backend. |
| 426 | + const customGid = '00000000-0000-5000-8000-00000000d00d'; |
| 427 | + mockUserWithGroups([customGid], [ |
| 428 | + { action: 'execute', resource: '*' }, |
| 429 | + ]); |
| 430 | + expect(isPurchaser()).toBe(true); |
| 431 | + }); |
| 432 | + |
| 433 | + test('admin:* in effectivePermissions WITHOUT explicit carved-out grants returns false', () => { |
| 434 | + // The backend carves the three spending verbs OUT of admin:*. |
| 435 | + // isPurchaser() must reflect that carve-out: a user whose |
| 436 | + // effectivePermissions are admin:* only (no explicit carve-out |
| 437 | + // entries from Purchaser membership) is NOT a spender. Note that |
| 438 | + // {admin, *} does NOT match {execute, *} / {approve-any, *} / |
| 439 | + // {retry-any, *} because the actions differ. |
| 440 | + mockUserWithGroups([ADMIN_GID], [ |
| 441 | + { action: 'admin', resource: '*' }, |
| 442 | + ]); |
| 443 | + expect(isPurchaser()).toBe(false); |
| 444 | + }); |
| 445 | + |
| 446 | + test('empty effectivePermissions array returns false even with PURCHASER_GROUP_ID', () => { |
| 447 | + // Loading is complete (effectivePermissions is defined and |
| 448 | + // empty); group membership without backend confirmation is not |
| 449 | + // enough. |
| 450 | + mockUserWithGroups([PURCHASER_GROUP_ID], []); |
| 451 | + expect(isPurchaser()).toBe(false); |
| 452 | + }); |
| 453 | + |
| 454 | + test('null user (logged out) returns false', () => { |
| 455 | + mockNoUser(); |
| 456 | + expect(isPurchaser()).toBe(false); |
| 457 | + }); |
346 | 458 | }); |
347 | 459 | }); |
0 commit comments