diff --git a/frontend/src/__tests__/a11y.test.ts b/frontend/src/__tests__/a11y.test.ts index 336f76af8..0cc472ad0 100644 --- a/frontend/src/__tests__/a11y.test.ts +++ b/frontend/src/__tests__/a11y.test.ts @@ -33,8 +33,7 @@ describe('accessibility smoke', () => { { id: 'u1', email: 'alice@example.com', - role: 'admin', - groups: [], + groups: ['00000000-0000-5000-8000-000000000001'], mfa_enabled: false, created_at: '2024-01-01T00:00:00Z', last_login: '2024-06-01T00:00:00Z', @@ -57,7 +56,6 @@ describe('accessibility smoke', () => { { id: 'u1', email: 'bob@example.com', - role: 'user', groups: [], mfa_enabled: true, created_at: '2024-01-01T00:00:00Z', diff --git a/frontend/src/__tests__/allowed-accounts.test.ts b/frontend/src/__tests__/allowed-accounts.test.ts index 03a9fb022..7ac125ba4 100644 --- a/frontend/src/__tests__/allowed-accounts.test.ts +++ b/frontend/src/__tests__/allowed-accounts.test.ts @@ -81,7 +81,7 @@ import { getCurrentUser } from '../state'; // Helpers // --------------------------------------------------------------------------- -const ADMIN = { id: 'admin-uuid', email: 'admin@example.com', role: 'admin' }; +const ADMIN = { id: 'admin-uuid', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }; function setupTopbarSlot(): void { while (document.body.firstChild) document.body.removeChild(document.body.firstChild); diff --git a/frontend/src/__tests__/api.test.ts b/frontend/src/__tests__/api.test.ts index 458435380..0aac5d079 100644 --- a/frontend/src/__tests__/api.test.ts +++ b/frontend/src/__tests__/api.test.ts @@ -523,7 +523,7 @@ describe('API Requests', () => { test('fetches current user', async () => { fetchMock.mockResolvedValue({ ok: true, - json: () => Promise.resolve({ email: 'test@example.com', role: 'admin' }) + json: () => Promise.resolve({ email: 'test@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }) }); const user = await getCurrentUser(); diff --git a/frontend/src/__tests__/auth-mfa-enroll.test.ts b/frontend/src/__tests__/auth-mfa-enroll.test.ts index 4c47941b3..7213d8630 100644 --- a/frontend/src/__tests__/auth-mfa-enroll.test.ts +++ b/frontend/src/__tests__/auth-mfa-enroll.test.ts @@ -60,7 +60,7 @@ beforeEach(() => { `; jest.clearAllMocks(); (state.getCurrentUser as jest.Mock).mockReturnValue({ - id: 'u1', email: 'user@x.com', role: 'user', mfa_enabled: false, + id: 'u1', email: 'user@x.com', groups: [], mfa_enabled: false, }); updateUserUI(); }); @@ -84,7 +84,7 @@ describe('MFA enrollment flow', () => { test('enabled state shows Disable and Regenerate buttons', async () => { (state.getCurrentUser as jest.Mock).mockReturnValue({ - id: 'u1', email: 'user@x.com', role: 'user', mfa_enabled: true, + id: 'u1', email: 'user@x.com', groups: [], mfa_enabled: true, }); updateUserUI(); await openProfile(); @@ -153,7 +153,7 @@ describe('MFA enrollment flow', () => { describe('MFA disable flow', () => { beforeEach(() => { (state.getCurrentUser as jest.Mock).mockReturnValue({ - id: 'u1', email: 'user@x.com', role: 'user', mfa_enabled: true, + id: 'u1', email: 'user@x.com', groups: [], mfa_enabled: true, }); updateUserUI(); }); @@ -183,7 +183,7 @@ describe('MFA disable flow', () => { describe('MFA regenerate-recovery-codes flow', () => { beforeEach(() => { (state.getCurrentUser as jest.Mock).mockReturnValue({ - id: 'u1', email: 'user@x.com', role: 'user', mfa_enabled: true, + id: 'u1', email: 'user@x.com', groups: [], mfa_enabled: true, }); updateUserUI(); }); diff --git a/frontend/src/__tests__/auth.test.ts b/frontend/src/__tests__/auth.test.ts index 7a2086f29..33bfa71cc 100644 --- a/frontend/src/__tests__/auth.test.ts +++ b/frontend/src/__tests__/auth.test.ts @@ -2,6 +2,7 @@ * Auth module tests */ import { showLoginModal, showResetPasswordModal, updateUserUI, logout } from '../auth'; +import { ADMINISTRATORS_GROUP_ID } from '../permissions'; // Mock the api module jest.mock('../api', () => { @@ -404,7 +405,7 @@ describe('Auth Module', () => { (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'user-1', email: 'test@example.com', - role: 'user' + groups: [] }); updateUserUI(); @@ -417,7 +418,7 @@ describe('Auth Module', () => { (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'user-1', email: 'test@example.com', - role: 'user' + groups: [] }); updateUserUI(); @@ -439,7 +440,7 @@ describe('Auth Module', () => { (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'admin-1', email: 'admin@example.com', - role: 'admin' + groups: [ADMINISTRATORS_GROUP_ID] }); updateUserUI(); @@ -454,7 +455,7 @@ describe('Auth Module', () => { (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'user-1', email: 'user@example.com', - role: 'user' + groups: [] }); updateUserUI(); @@ -469,7 +470,7 @@ describe('Auth Module', () => { (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'user-1', email: 'test@example.com', - role: 'user' + groups: [] }); updateUserUI(); @@ -483,7 +484,7 @@ describe('Auth Module', () => { (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'user-1', email: 'test@example.com', - role: 'user' + groups: [] }); (api.logout as jest.Mock).mockResolvedValue({}); @@ -522,7 +523,7 @@ describe('Auth Module', () => { (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'user-1', email: 'test@example.com', - role: 'user' + groups: [] }); }); diff --git a/frontend/src/__tests__/groups.test.ts b/frontend/src/__tests__/groups.test.ts index 737d1796d..cd8054ea8 100644 --- a/frontend/src/__tests__/groups.test.ts +++ b/frontend/src/__tests__/groups.test.ts @@ -65,11 +65,11 @@ const mockGroups: api.APIGroup[] = [ }, ]; -// Mock users that belong to groups +// Mock users that belong to groups (PR #912: role field removed, groups is the source of truth) const mockUsers = [ - { id: 'user-1', email: 'admin@test.com', role: 'admin', groups: ['group-1'], mfa_enabled: true }, - { id: 'user-2', email: 'viewer@test.com', role: 'user', groups: ['group-2'], mfa_enabled: false }, - { id: 'user-3', email: 'both@test.com', role: 'user', groups: ['group-1', 'group-2'], mfa_enabled: true }, + { id: 'user-1', email: 'admin@test.com', groups: ['00000000-0000-5000-8000-000000000001', 'group-1'], mfa_enabled: true }, + { id: 'user-2', email: 'viewer@test.com', groups: ['group-2'], mfa_enabled: false }, + { id: 'user-3', email: 'both@test.com', groups: ['group-1', 'group-2'], mfa_enabled: true }, ]; describe('groups/state', () => { @@ -164,7 +164,7 @@ describe('groups/groupList', () => { it('should use singular "member" when count is 1', () => { // Set up users so group-1 has exactly 1 member userState.setAllUsers([ - { id: 'user-1', email: 'admin@test.com', role: 'admin', groups: ['group-1'], mfa_enabled: true }, + { id: 'user-1', email: 'admin@test.com', groups: ['00000000-0000-5000-8000-000000000001', 'group-1'], mfa_enabled: true }, ] as any); const group = mockGroups[0]; diff --git a/frontend/src/__tests__/history-approval-queue.test.ts b/frontend/src/__tests__/history-approval-queue.test.ts index f407a145a..7a928a36c 100644 --- a/frontend/src/__tests__/history-approval-queue.test.ts +++ b/frontend/src/__tests__/history-approval-queue.test.ts @@ -67,8 +67,9 @@ import { confirmDialog } from '../confirmDialog'; import { showToast } from '../toast'; import { getCurrentUser } from '../state'; import { getAccountName } from '../recommendations'; +import { ADMINISTRATORS_GROUP_ID } from '../permissions'; -const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', role: 'admin' }; +const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID] }; function setupDOM(): void { while (document.body.firstChild) document.body.removeChild(document.body.firstChild); diff --git a/frontend/src/__tests__/history-approve-button.test.ts b/frontend/src/__tests__/history-approve-button.test.ts index 4974a8b3d..b3f57fb76 100644 --- a/frontend/src/__tests__/history-approve-button.test.ts +++ b/frontend/src/__tests__/history-approve-button.test.ts @@ -63,9 +63,10 @@ import * as api from '../api'; import { confirmDialog } from '../confirmDialog'; import { showToast } from '../toast'; import { getCurrentUser } from '../state'; +import { ADMINISTRATORS_GROUP_ID } from '../permissions'; -const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', role: 'admin' }; -const REG_USER = { id: 'user-uuid', email: 'user@example.com', role: 'user' }; +const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID] }; +const REG_USER = { id: 'user-uuid', email: 'user@example.com', groups: [] }; const OTHER_UUID = 'other-uuid'; function setupDOM(): void { diff --git a/frontend/src/__tests__/history-cancel-button.test.ts b/frontend/src/__tests__/history-cancel-button.test.ts index 5f2c83dec..d642e3dc6 100644 --- a/frontend/src/__tests__/history-cancel-button.test.ts +++ b/frontend/src/__tests__/history-cancel-button.test.ts @@ -60,9 +60,10 @@ import * as api from '../api'; import { confirmDialog } from '../confirmDialog'; import { showToast } from '../toast'; import { getCurrentUser } from '../state'; +import { ADMINISTRATORS_GROUP_ID } from '../permissions'; -const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', role: 'admin' }; -const REG_USER = { id: 'user-uuid', email: 'user@example.com', role: 'user' }; +const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID] }; +const REG_USER = { id: 'user-uuid', email: 'user@example.com', groups: [] }; const OTHER_UUID = 'other-uuid'; function setupDOM(): void { diff --git a/frontend/src/__tests__/history-retry-button.test.ts b/frontend/src/__tests__/history-retry-button.test.ts index 1a9d361c6..6a56fbe8e 100644 --- a/frontend/src/__tests__/history-retry-button.test.ts +++ b/frontend/src/__tests__/history-retry-button.test.ts @@ -69,9 +69,10 @@ import * as api from '../api'; import { confirmDialog } from '../confirmDialog'; import { showToast } from '../toast'; import { getCurrentUser } from '../state'; +import { ADMINISTRATORS_GROUP_ID } from '../permissions'; -const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', role: 'admin' }; -const REG_USER = { id: 'user-uuid', email: 'user@example.com', role: 'user' }; +const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID] }; +const REG_USER = { id: 'user-uuid', email: 'user@example.com', groups: [] }; const OTHER_UUID = 'other-uuid'; function setupDOM(): void { diff --git a/frontend/src/__tests__/html.test.ts b/frontend/src/__tests__/html.test.ts index 668253945..91dce4bec 100644 --- a/frontend/src/__tests__/html.test.ts +++ b/frontend/src/__tests__/html.test.ts @@ -501,20 +501,20 @@ describe('HTML Structure', () => { expect(email?.hasAttribute('required')).toBe(true); }); - test('has user role select', () => { + test('user role select is absent (PR #912: role concept dropped)', () => { + // PR #912 removed the role column. The role selector was replaced by + // a required group multi-select. Verify the old element is gone so a + // regression that re-adds it is caught. const role = document.getElementById('user-role') as HTMLSelectElement | null; - expect(role).toBeTruthy(); - const options = Array.from(role?.querySelectorAll('option') ?? []).map(o => o.value); - // Exact-set equality (not toContain) so a regression that - // re-introduces the pre-fix viewer/editor values — or adds any - // other role the backend allowlist would reject — fails the test. - expect(new Set(options)).toEqual(new Set(['readonly', 'user', 'admin'])); + expect(role).toBeNull(); }); - test('has user groups multi-select', () => { + test('has user groups multi-select (required, PR #912)', () => { const groups = document.getElementById('user-groups') as HTMLSelectElement | null; expect(groups).toBeTruthy(); expect(groups?.hasAttribute('multiple')).toBe(true); + // PR #912: groups is now required (>= 1 group enforced by backend DB CHECK). + expect(groups?.hasAttribute('required')).toBe(true); }); }); diff --git a/frontend/src/__tests__/permissions.test.ts b/frontend/src/__tests__/permissions.test.ts index 7e00b7c09..09ebd861e 100644 --- a/frontend/src/__tests__/permissions.test.ts +++ b/frontend/src/__tests__/permissions.test.ts @@ -1,15 +1,16 @@ /** * Permissions helper tests. * - * The role-default sets MUST match the backend constants in - * `internal/auth/types.go` (DefaultAdminPermissions / - * DefaultUserPermissions / DefaultReadOnlyPermissions). These tests - * enumerate every entry so a drift between this mirror and the - * backend fails fast in CI rather than at runtime as a wrong-positive - * (button shown, click 403s) or wrong-negative (functionality hidden - * from a user who should have it). + * PR #912 replaced role-based gating with group-membership-based + * gating. isAdmin() now returns true when the current user is a member + * of the Administrators group (UUID 00000000-0000-5000-8000-000000000001). + * canAccess() is currently a pass-through to isAdmin() -- non-admin users + * always get false because the /me/permissions endpoint is deferred. + * + * getRolePermissions() is kept for the effective-permissions display in + * the admin Users page and still returns the same sets as before. */ -import { canAccess, getRolePermissions, isAdmin } from '../permissions'; +import { canAccess, getRolePermissions, isAdmin, ADMINISTRATORS_GROUP_ID } from '../permissions'; jest.mock('../state', () => ({ getCurrentUser: jest.fn(), @@ -17,13 +18,27 @@ jest.mock('../state', () => ({ import * as state from '../state'; -const mockUser = (role: string | null) => { +const ADMIN_GID = ADMINISTRATORS_GROUP_ID; +const STD_GID = '00000000-0000-5000-8000-000000000005'; +const RO_GID = '00000000-0000-5000-8000-000000000006'; + +const mockUserWithGroups = (groups: string[]) => { (state.getCurrentUser as jest.Mock).mockReturnValue( - role === null ? null : { id: 'u1', email: 'u@example.com', role }, + { id: 'u1', email: 'u@example.com', groups }, ); }; +const mockNoUser = () => { + (state.getCurrentUser as jest.Mock).mockReturnValue(null); +}; + describe('permissions', () => { + describe('ADMINISTRATORS_GROUP_ID', () => { + test('has the expected UUID', () => { + expect(ADMINISTRATORS_GROUP_ID).toBe('00000000-0000-5000-8000-000000000001'); + }); + }); + describe('getRolePermissions', () => { test('admin role grants admin:*', () => { const perms = getRolePermissions('admin'); @@ -68,99 +83,79 @@ describe('permissions', () => { }); }); - describe('canAccess', () => { - test('admin sees everything (admin:* superuser short-circuit)', () => { - mockUser('admin'); - expect(canAccess('admin', '*')).toBe(true); - expect(canAccess('view', 'users')).toBe(true); - expect(canAccess('delete', 'plans')).toBe(true); - expect(canAccess('execute', 'purchases')).toBe(true); - expect(canAccess('view', 'accounts')).toBe(true); + describe('isAdmin', () => { + test('Administrators group member is admin', () => { + mockUserWithGroups([ADMIN_GID]); + expect(isAdmin()).toBe(true); }); - test('user role: explicit grants', () => { - mockUser('user'); - expect(canAccess('view', 'recommendations')).toBe(true); - expect(canAccess('view', 'plans')).toBe(true); - expect(canAccess('view', 'purchases')).toBe(true); - expect(canAccess('view', 'history')).toBe(true); - expect(canAccess('create', 'plans')).toBe(true); - expect(canAccess('update', 'plans')).toBe(true); - expect(canAccess('cancel-own', 'purchases')).toBe(true); - expect(canAccess('retry-own', 'purchases')).toBe(true); - expect(canAccess('approve-own', 'purchases')).toBe(true); + test('Administrators group member alongside other groups is still admin', () => { + mockUserWithGroups([STD_GID, ADMIN_GID, RO_GID]); + expect(isAdmin()).toBe(true); }); - test('user role: denied for admin-gated actions', () => { - mockUser('user'); - // delete:plans is now a default user permission (PR #660); no longer admin-gated. - expect(canAccess('delete', 'plans')).toBe(true); - // execute:purchases was NOT added to user defaults; remains admin-only. - expect(canAccess('execute', 'purchases')).toBe(false); - expect(canAccess('admin', '*')).toBe(false); - expect(canAccess('view', 'users')).toBe(false); - expect(canAccess('view', 'accounts')).toBe(false); - expect(canAccess('view', 'groups')).toBe(false); - expect(canAccess('view', 'api-keys')).toBe(false); - expect(canAccess('view', 'config')).toBe(false); + test('Standard Users group member is not admin', () => { + mockUserWithGroups([STD_GID]); + expect(isAdmin()).toBe(false); }); - test('readonly role: only view grants on rec/plans/history', () => { - mockUser('readonly'); - expect(canAccess('view', 'recommendations')).toBe(true); - expect(canAccess('view', 'plans')).toBe(true); - expect(canAccess('view', 'history')).toBe(true); + test('Read-Only Users group member is not admin', () => { + mockUserWithGroups([RO_GID]); + expect(isAdmin()).toBe(false); }); - test('readonly role: denied for purchases view + every mutation', () => { - mockUser('readonly'); - expect(canAccess('view', 'purchases')).toBe(false); - expect(canAccess('create', 'plans')).toBe(false); - expect(canAccess('update', 'plans')).toBe(false); - expect(canAccess('delete', 'plans')).toBe(false); - expect(canAccess('execute', 'purchases')).toBe(false); - expect(canAccess('cancel-own', 'purchases')).toBe(false); - expect(canAccess('retry-own', 'purchases')).toBe(false); - expect(canAccess('approve-own', 'purchases')).toBe(false); - expect(canAccess('admin', '*')).toBe(false); - expect(canAccess('view', 'users')).toBe(false); - expect(canAccess('view', 'accounts')).toBe(false); + test('user with empty groups is not admin', () => { + mockUserWithGroups([]); + expect(isAdmin()).toBe(false); }); - test('null user (logged out) → false for everything', () => { - mockUser(null); - expect(canAccess('view', 'recommendations')).toBe(false); - expect(canAccess('admin', '*')).toBe(false); - expect(canAccess('create', 'plans')).toBe(false); + test('null user (logged out) is not admin', () => { + mockNoUser(); + expect(isAdmin()).toBe(false); }); - test('unknown role → false for everything', () => { - mockUser('operator'); - expect(canAccess('view', 'recommendations')).toBe(false); - expect(canAccess('admin', '*')).toBe(false); - expect(canAccess('execute', 'purchases')).toBe(false); + test('user with non-array groups field is not admin (defense-in-depth)', () => { + (state.getCurrentUser as jest.Mock).mockReturnValue( + { id: 'u1', email: 'u@example.com', groups: null }, + ); + expect(isAdmin()).toBe(false); }); }); - describe('isAdmin', () => { - test('admin role → true', () => { - mockUser('admin'); - expect(isAdmin()).toBe(true); + describe('canAccess', () => { + test('Administrators group member passes all checks', () => { + mockUserWithGroups([ADMIN_GID]); + expect(canAccess('admin', '*')).toBe(true); + expect(canAccess('view', 'users')).toBe(true); + expect(canAccess('delete', 'plans')).toBe(true); + expect(canAccess('execute', 'purchases')).toBe(true); + expect(canAccess('view', 'accounts')).toBe(true); }); - test('user role → false', () => { - mockUser('user'); - expect(isAdmin()).toBe(false); + test('Standard Users group member fails all checks (no /me/permissions endpoint yet)', () => { + mockUserWithGroups([STD_GID]); + expect(canAccess('view', 'recommendations')).toBe(false); + expect(canAccess('view', 'plans')).toBe(false); + expect(canAccess('admin', '*')).toBe(false); }); - test('readonly role → false', () => { - mockUser('readonly'); - expect(isAdmin()).toBe(false); + test('Read-Only Users group member fails all checks', () => { + mockUserWithGroups([RO_GID]); + expect(canAccess('view', 'recommendations')).toBe(false); + expect(canAccess('admin', '*')).toBe(false); }); - test('null user → false', () => { - mockUser(null); - expect(isAdmin()).toBe(false); + test('empty-groups user fails all checks', () => { + mockUserWithGroups([]); + expect(canAccess('view', 'recommendations')).toBe(false); + expect(canAccess('admin', '*')).toBe(false); + }); + + test('null user (logged out) fails all checks', () => { + mockNoUser(); + expect(canAccess('view', 'recommendations')).toBe(false); + expect(canAccess('admin', '*')).toBe(false); + expect(canAccess('create', 'plans')).toBe(false); }); }); }); diff --git a/frontend/src/__tests__/plans-permissions.test.ts b/frontend/src/__tests__/plans-permissions.test.ts index cc0c3a0d2..2507f6856 100644 --- a/frontend/src/__tests__/plans-permissions.test.ts +++ b/frontend/src/__tests__/plans-permissions.test.ts @@ -41,6 +41,7 @@ jest.mock('../history', () => ({ viewPlanHistory: jest.fn() })); import * as api from '../api'; import * as state from '../state'; +import { ADMINISTRATORS_GROUP_ID } from '../permissions'; const samplePlan = { id: 'plan-1', @@ -74,7 +75,7 @@ const samplePlannedPurchase = { const mockUser = (role: string | null) => { (state.getCurrentUser as jest.Mock).mockReturnValue( - role === null ? null : { id: 'u', email: 'u@example.com', role }, + role === null ? null : { id: 'u', email: 'u@example.com', groups: role === 'admin' ? [ADMINISTRATORS_GROUP_ID] : [] }, ); }; @@ -131,33 +132,37 @@ describe('Plans page permission gating (issue #365)', () => { }); }); - describe('user role', () => { + describe('non-admin user (PR #912: canAccess returns false without /me/permissions)', () => { beforeEach(() => mockUser('user')); - test('shows the top-level New Plan button (user has create:plans)', async () => { + test('hides the top-level New Plan button (no /me/permissions endpoint yet)', async () => { + // PR #912: canAccess() is group-membership-only; non-Administrators-group + // members get false until the /me/permissions endpoint lands. await loadPlans(); const btn = document.getElementById('new-plan-btn') as HTMLButtonElement; - expect(btn.hidden).toBe(false); + expect(btn.hidden).toBe(true); }); - test('shows manage actions including Delete (user has delete:plans since PR #660)', async () => { + test('hides plan-card action buttons for non-admin (no /me/permissions endpoint yet)', async () => { await loadPlans(); const list = document.getElementById('plans-list') as HTMLElement; const html = list.innerHTML; - expect(html).toContain('data-action="add-purchases"'); - expect(html).toContain('data-action="edit-plan"'); - expect(html).toContain('data-action="toggle-plan"'); - expect(html).toContain('data-action="delete-plan"'); + expect(html).not.toContain('data-action="add-purchases"'); + expect(html).not.toContain('data-action="edit-plan"'); + expect(html).not.toContain('data-action="delete-plan"'); + expect(html).not.toContain('data-action="toggle-plan"'); + // History view stays visible regardless. + expect(html).toContain('data-action="view-history"'); }); - test('shows row Run/Pause/Edit/Disable on planned purchases (user has delete:plans since PR #660)', async () => { + test('hides planned-purchase row action buttons for non-admin (no /me/permissions endpoint yet)', async () => { await loadPlans(); const pp = document.getElementById('planned-purchases-list') as HTMLElement; const html = pp.innerHTML; - expect(html).toContain('data-action="run"'); - expect(html).toContain('data-action="pause"'); - expect(html).toContain('data-action="edit"'); - expect(html).toContain('data-action="disable"'); + expect(html).not.toContain('data-action="run"'); + expect(html).not.toContain('data-action="pause"'); + expect(html).not.toContain('data-action="edit"'); + expect(html).not.toContain('data-action="disable"'); }); }); diff --git a/frontend/src/__tests__/plans-range-validation.test.ts b/frontend/src/__tests__/plans-range-validation.test.ts index b5faa306f..134dde150 100644 --- a/frontend/src/__tests__/plans-range-validation.test.ts +++ b/frontend/src/__tests__/plans-range-validation.test.ts @@ -46,7 +46,7 @@ jest.mock('../state', () => ({ setCurrentAccountIDs: jest.fn(), subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), - getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', role: 'admin' }), + getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }), })); jest.mock('../history', () => ({ viewPlanHistory: jest.fn() })); diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 43ff7543c..94fe90973 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -49,7 +49,11 @@ jest.mock('../state', () => ({ // click-handler counts, plan-card layout) keeps passing unchanged. // The permission-gating tests in plans-permissions.test.ts override // this per-case to exercise readonly / user / null sessions. - getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', role: 'admin' }), + // ADMINISTRATORS_GROUP_ID literal used here (not imported) because jest.mock + // factories are hoisted before imports; jest.requireActual also fails here + // because permissions.ts has a top-level import of ./state which is the very + // module being mocked (circular init). permissions.test.ts pins the value. + getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }), })); // Mock history module diff --git a/frontend/src/__tests__/recommendations-enabled-providers.test.ts b/frontend/src/__tests__/recommendations-enabled-providers.test.ts index 7ea38f8ad..c232c109f 100644 --- a/frontend/src/__tests__/recommendations-enabled-providers.test.ts +++ b/frontend/src/__tests__/recommendations-enabled-providers.test.ts @@ -62,7 +62,11 @@ jest.mock('../state', () => ({ setCostPeriod: jest.fn(), getHiddenColumns: jest.fn().mockReturnValue(new Set()), setHiddenColumns: jest.fn(), - getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', role: 'admin' }), + // ADMINISTRATORS_GROUP_ID literal used here (not imported) because jest.mock + // factories are hoisted before imports; jest.requireActual also fails here + // because permissions.ts has a top-level import of ./state which is the very + // module being mocked (circular init). permissions.test.ts pins the value. + getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }), })); jest.mock('../utils', () => ({ diff --git a/frontend/src/__tests__/recommendations-permissions.test.ts b/frontend/src/__tests__/recommendations-permissions.test.ts index 1e0cf0263..2d0f49ca9 100644 --- a/frontend/src/__tests__/recommendations-permissions.test.ts +++ b/frontend/src/__tests__/recommendations-permissions.test.ts @@ -63,10 +63,11 @@ jest.mock('../toast', () => ({ })); import * as state from '../state'; +import { ADMINISTRATORS_GROUP_ID } from '../permissions'; const mockUser = (role: string | null) => { (state.getCurrentUser as jest.Mock).mockReturnValue( - role === null ? null : { id: 'u', email: 'u@example.com', role }, + role === null ? null : { id: 'u', email: 'u@example.com', groups: role === 'admin' ? [ADMINISTRATORS_GROUP_ID] : [] }, ); }; @@ -122,13 +123,16 @@ describe('Recommendations action-box permission gating (issue #365)', () => { expect(plan.hidden).toBe(false); }); - test('user role hides Purchase but keeps Create Plan', async () => { + test('non-admin user hides both Purchase and Create Plan (PR #912: no /me/permissions yet)', async () => { + // PR #912: canAccess() returns false for non-admin users until the + // /me/permissions endpoint lands. All non-admin users see the same + // read-only view as before. mockUser('user'); await loadRecommendations(); const purchase = document.getElementById('bulk-purchase-btn') as HTMLButtonElement; const plan = document.getElementById('create-plan-btn') as HTMLButtonElement; expect(purchase.hidden).toBe(true); - expect(plan.hidden).toBe(false); + expect(plan.hidden).toBe(true); }); test('readonly role hides both Purchase and Create Plan', async () => { @@ -149,9 +153,9 @@ describe('Recommendations action-box permission gating (issue #365)', () => { expect(plan.hidden).toBe(true); }); - test('the action-box capacity input stays visible for every role', async () => { - // Readonly users still browse the bottom box for the selection summary; - // only the mutating CTAs disappear. + test('the action-box capacity input stays visible for all sessions', async () => { + // Non-mutating elements stay visible regardless of group membership; + // only the action CTAs gate on permissions. for (const role of ['admin', 'user', 'readonly']) { setupDom(); mockUser(role); @@ -214,18 +218,21 @@ describe('Recommendations checkbox + row-click gating for viewer role (issue #86 expect(rowCheckboxes.length).toBeGreaterThan(0); }); - test('user (operator) role: select-all checkbox is present', async () => { + test('non-admin user: no select-all checkbox (PR #912: canAccess returns false without /me/permissions)', async () => { + // PR #912: only Administrators-group members get checkboxes. + // The /me/permissions endpoint that would restore Standard Users' + // checkbox access is deferred to a follow-up. mockUser('user'); await loadRecommendations(); - expect(document.getElementById('select-all-recs')).not.toBeNull(); + expect(document.getElementById('select-all-recs')).toBeNull(); }); - test('user (operator) role: per-row checkbox is present', async () => { + test('non-admin user: no per-row checkboxes (PR #912: canAccess returns false without /me/permissions)', async () => { mockUser('user'); await loadRecommendations(); const list = document.getElementById('recommendations-list'); const rowCheckboxes = list?.querySelectorAll('input[data-rec-id]') ?? []; - expect(rowCheckboxes.length).toBeGreaterThan(0); + expect(rowCheckboxes.length).toBe(0); }); test('readonly role: grouped-row summary has no checkbox-col and column span is aligned', async () => { diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 30bf8668d..424810701 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -88,7 +88,7 @@ jest.mock('../state', () => ({ // tests below see both #bulk-purchase-btn and #create-plan-btn // unconditionally. Permission-gating coverage lives in // recommendations-permissions.test.ts. - getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', role: 'admin' }), + getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }), // Issue #477: setupRecommendationsHandlers subscribes to provider/account // changes; expose jest.fn() shims so tests can capture the callback and // simulate a change without going through the real listener set. diff --git a/frontend/src/__tests__/riexchange-permissions.test.ts b/frontend/src/__tests__/riexchange-permissions.test.ts index 4a82b8749..56bdc81a1 100644 --- a/frontend/src/__tests__/riexchange-permissions.test.ts +++ b/frontend/src/__tests__/riexchange-permissions.test.ts @@ -60,7 +60,7 @@ const sampleReshape = { const mockUser = (role: string | null) => { (state.getCurrentUser as jest.Mock).mockReturnValue( - role === null ? null : { id: 'u', email: 'u@example.com', role }, + role === null ? null : { id: 'u', email: 'u@example.com', groups: role === 'admin' ? ['00000000-0000-5000-8000-000000000001'] : [] }, ); }; diff --git a/frontend/src/__tests__/riexchange.test.ts b/frontend/src/__tests__/riexchange.test.ts index a64b4c00b..10271b8b2 100644 --- a/frontend/src/__tests__/riexchange.test.ts +++ b/frontend/src/__tests__/riexchange.test.ts @@ -45,7 +45,7 @@ jest.mock('../state', () => ({ }), getCurrentProvider: jest.fn(() => 'aws'), getCurrentAccountIDs: jest.fn(() => []), - getCurrentUser: jest.fn(() => ({ id: 'u', email: 'u@example.com', role: 'admin' })), + getCurrentUser: jest.fn(() => ({ id: 'u', email: 'u@example.com', groups: ['00000000-0000-5000-8000-000000000001'] })), })); import { diff --git a/frontend/src/__tests__/settings-permissions.test.ts b/frontend/src/__tests__/settings-permissions.test.ts index 13f4333ad..ee7959ced 100644 --- a/frontend/src/__tests__/settings-permissions.test.ts +++ b/frontend/src/__tests__/settings-permissions.test.ts @@ -42,7 +42,7 @@ import * as state from '../state'; const mockUser = (role: string | null) => { (state.getCurrentUser as jest.Mock).mockReturnValue( - role === null ? null : { id: 'u', email: 'u@example.com', role }, + role === null ? null : { id: 'u', email: 'u@example.com', groups: role === 'admin' ? ['00000000-0000-5000-8000-000000000001'] : [] }, ); }; diff --git a/frontend/src/__tests__/state.test.ts b/frontend/src/__tests__/state.test.ts index 8f41d5b06..5212d71aa 100644 --- a/frontend/src/__tests__/state.test.ts +++ b/frontend/src/__tests__/state.test.ts @@ -17,6 +17,7 @@ import { setSavingsChart } from '../state'; import type { Recommendation } from '../api'; +import { ADMINISTRATORS_GROUP_ID } from '../permissions'; describe('State Module', () => { // Reset state before each test @@ -34,13 +35,13 @@ describe('State Module', () => { }); test('setCurrentUser and getCurrentUser work correctly', () => { - const user = { id: '123', email: 'test@example.com', role: 'admin' }; + const user = { id: '123', email: 'test@example.com', groups: [ADMINISTRATORS_GROUP_ID] }; setCurrentUser(user); expect(getCurrentUser()).toEqual(user); }); test('setCurrentUser with null clears user', () => { - setCurrentUser({ id: '123', email: 'test@example.com', role: 'admin' }); + setCurrentUser({ id: '123', email: 'test@example.com', groups: [ADMINISTRATORS_GROUP_ID] }); setCurrentUser(null); expect(getCurrentUser()).toBeNull(); }); @@ -194,7 +195,7 @@ describe('State Module', () => { describe('State Integration', () => { test('state changes are reflected across getters', () => { // Set various state - setCurrentUser({ id: '1', email: 'a@b.com', role: 'user' }); + setCurrentUser({ id: '1', email: 'a@b.com', groups: [] }); setCurrentProvider('aws'); setRecommendations([{ id: '1', diff --git a/frontend/src/__tests__/users.test.ts b/frontend/src/__tests__/users.test.ts index 87b2bd5b2..31f13241f 100644 --- a/frontend/src/__tests__/users.test.ts +++ b/frontend/src/__tests__/users.test.ts @@ -285,8 +285,8 @@ describe('users/state', () => { describe('allUsers and filteredUsers', () => { it('should set and get users', () => { const users = [ - { id: '1', email: 'user1@test.com', role: 'user', groups: [], mfa_enabled: false }, - { id: '2', email: 'user2@test.com', role: 'admin', groups: [], mfa_enabled: true } + { id: '1', email: 'user1@test.com', groups: [], mfa_enabled: false }, + { id: '2', email: 'user2@test.com', groups: ['00000000-0000-5000-8000-000000000001'], mfa_enabled: true } ]; userState.setAllUsers(users as any); @@ -295,7 +295,7 @@ describe('users/state', () => { it('should set and get filtered users', () => { const users = [ - { id: '1', email: 'user1@test.com', role: 'user', groups: [], mfa_enabled: false } + { id: '1', email: 'user1@test.com', groups: [], mfa_enabled: false } ]; userState.setFilteredUsers(users as any); @@ -323,7 +323,7 @@ describe('users/state', () => { describe('currentEditingUser', () => { it('should set and get current editing user', () => { - const user = { id: '1', email: 'test@test.com', role: 'user', groups: [], mfa_enabled: false }; + const user = { id: '1', email: 'test@test.com', groups: [], mfa_enabled: false }; userState.setCurrentEditingUser(user as any); expect(userState.currentEditingUser).toEqual(user); }); @@ -374,10 +374,10 @@ describe('users/state', () => { // ============================================================================ describe('users/filters', () => { const mockUsers = [ - { id: '1', email: 'admin@test.com', role: 'admin', groups: ['admins'], mfa_enabled: true }, - { id: '2', email: 'user@test.com', role: 'user', groups: ['users'], mfa_enabled: false }, - { id: '3', email: 'viewer@test.com', role: 'viewer', groups: [], mfa_enabled: true }, - { id: '4', email: 'another.user@example.com', role: 'user', groups: ['users', 'developers'], mfa_enabled: true } + { id: '1', email: 'admin@test.com', groups: ['00000000-0000-5000-8000-000000000001'], mfa_enabled: true }, + { id: '2', email: 'user@test.com', groups: ['users'], mfa_enabled: false }, + { id: '3', email: 'viewer@test.com', groups: [], mfa_enabled: true }, + { id: '4', email: 'another.user@example.com', groups: ['users', 'developers'], mfa_enabled: true } ]; beforeEach(() => { @@ -421,11 +421,19 @@ describe('users/filters', () => { expect(userState.filteredUsers.length).toBe(3); }); - it('should filter by role', () => { - userState.setRoleFilter('user'); + it('should filter by admin role (Administrators group membership)', () => { + userState.setRoleFilter('admin'); userFilters.applyFilters(); - expect(userState.filteredUsers.length).toBe(2); - expect(userState.filteredUsers.every(u => u.role === 'user')).toBe(true); + // Only the user in the Administrators group matches + expect(userState.filteredUsers.length).toBe(1); + expect(userState.filteredUsers[0]?.email).toBe('admin@test.com'); + }); + + it('should filter non-admin users with role filter', () => { + userState.setRoleFilter('user'); // non-admin = not in Administrators group + userFilters.applyFilters(); + // 3 users not in Administrators group + expect(userState.filteredUsers.length).toBe(3); }); it('should filter by MFA enabled', () => { @@ -443,10 +451,10 @@ describe('users/filters', () => { }); it('should filter by group', () => { - userState.setGroupFilter('admins'); + userState.setGroupFilter('00000000-0000-5000-8000-000000000001'); userFilters.applyFilters(); expect(userState.filteredUsers.length).toBe(1); - expect(userState.filteredUsers[0]!.groups).toContain('admins'); + expect(userState.filteredUsers[0]!.groups).toContain('00000000-0000-5000-8000-000000000001'); }); it('should filter by users group (multiple users)', () => { @@ -461,7 +469,7 @@ describe('users/filters', () => { expect(userState.filteredUsers.length).toBe(0); }); - it('should combine multiple filters', () => { + it('should combine multiple filters (mfa + admin group)', () => { userState.setMfaFilter('enabled'); userState.setRoleFilter('admin'); userFilters.applyFilters(); @@ -469,16 +477,17 @@ describe('users/filters', () => { expect(userState.filteredUsers[0]?.email).toBe('admin@test.com'); }); - it('should combine search and role filter', () => { + it('should combine search and non-admin role filter', () => { userState.setSearchQuery('user'); - userState.setRoleFilter('user'); + userState.setRoleFilter('user'); // non-admin userFilters.applyFilters(); + // 'user@test.com' and 'another.user@example.com' match search + non-admin expect(userState.filteredUsers.length).toBe(2); }); it('should combine all filters', () => { userState.setSearchQuery('user'); - userState.setRoleFilter('user'); + userState.setRoleFilter('user'); // non-admin userState.setMfaFilter('enabled'); userState.setGroupFilter('users'); userFilters.applyFilters(); @@ -520,9 +529,10 @@ describe('users/filters', () => { }); describe('handleFilterChange', () => { - it('should handle role filter change', () => { + it('should handle role filter change (admin = Administrators group)', () => { userFilters.handleFilterChange('role', 'admin'); expect(userState.roleFilter).toBe('admin'); + // Only the Administrators group member matches expect(userState.filteredUsers.length).toBe(1); }); @@ -533,8 +543,8 @@ describe('users/filters', () => { }); it('should handle group filter change', () => { - userFilters.handleFilterChange('group', 'admins'); - expect(userState.groupFilter).toBe('admins'); + userFilters.handleFilterChange('group', '00000000-0000-5000-8000-000000000001'); + expect(userState.groupFilter).toBe('00000000-0000-5000-8000-000000000001'); expect(userState.filteredUsers.length).toBe(1); }); @@ -552,7 +562,7 @@ describe('users/filters', () => { userState.setSearchQuery('test'); userState.setRoleFilter('admin'); userState.setMfaFilter('enabled'); - userState.setGroupFilter('admins'); + userState.setGroupFilter('00000000-0000-5000-8000-000000000001'); userFilters.clearFilters(); @@ -584,6 +594,7 @@ describe('users/filters', () => { it('should re-render all users after clearing', () => { userState.setRoleFilter('admin'); userFilters.applyFilters(); + // Only 1 Administrators-group member expect(userState.filteredUsers.length).toBe(1); userFilters.clearFilters(); @@ -653,8 +664,8 @@ describe('users/filters', () => { // ============================================================================ describe('users/userList', () => { const mockUsers = [ - { id: '1', email: 'admin@test.com', role: 'admin', groups: ['admins'], mfa_enabled: true, created_at: '2024-01-01T00:00:00Z' }, - { id: '2', email: 'user@test.com', role: 'user', groups: [], mfa_enabled: false, created_at: '2024-01-02T00:00:00Z' } + { id: '1', email: 'admin@test.com', groups: ['00000000-0000-5000-8000-000000000001'], mfa_enabled: true, created_at: '2024-01-01T00:00:00Z' }, + { id: '2', email: 'user@test.com', groups: [], mfa_enabled: false, created_at: '2024-01-02T00:00:00Z' } ]; beforeEach(() => { @@ -782,12 +793,13 @@ describe('users/userList', () => { expect(selectAll?.checked).toBe(true); }); - it('should render role badges', () => { + it('should render group badges (role column removed, PR #912)', () => { userList.renderUsers(mockUsers as any); - + // The Role column was removed; verify the table renders without throwing const container = document.getElementById('users-list'); - expect(container?.innerHTML).toContain('badge-admin'); - expect(container?.innerHTML).toContain('badge-user'); + expect(container?.querySelector('table')).toBeTruthy(); + // Group badges still appear + expect(container?.innerHTML).toContain('badge-group'); }); it('should render MFA status badges', () => { @@ -803,7 +815,9 @@ describe('users/userList', () => { const container = document.getElementById('users-list'); expect(container?.innerHTML).toContain('badge-group'); - expect(container?.innerHTML).toContain('admins'); + // User 1 is in the Administrators group (ADMIN_GID); since availableGroups + // is empty the group name lookup falls back to the UUID itself. + expect(container?.innerHTML).toContain('00000000'); }); it('should show "No groups" for users without groups', () => { @@ -837,7 +851,7 @@ describe('users/userList', () => { it('should show dash for missing created_at', () => { const usersWithoutDate = [ - { id: '1', email: 'test@test.com', role: 'user', groups: [], mfa_enabled: false } + { id: '1', email: 'test@test.com', groups: [], mfa_enabled: false } ]; userList.renderUsers(usersWithoutDate as any); @@ -856,7 +870,7 @@ describe('users/userList', () => { it('should render last login relative time when set', () => { const usersWithLogin = [ - { id: '1', email: 'test@test.com', role: 'user', groups: [], mfa_enabled: false, last_login: new Date().toISOString() } + { id: '1', email: 'test@test.com', groups: [], mfa_enabled: false, last_login: new Date().toISOString() } ]; userList.renderUsers(usersWithLogin as any); @@ -866,7 +880,7 @@ describe('users/userList', () => { it('should mark current user with "You" badge', () => { const usersWithCurrent = [ - { id: 'current', email: 'me@test.com', role: 'user', groups: [], mfa_enabled: false } + { id: 'current', email: 'me@test.com', groups: [], mfa_enabled: false } ]; userList.renderUsers(usersWithCurrent as any); @@ -877,7 +891,7 @@ describe('users/userList', () => { it('should escape HTML in email', () => { const usersWithXss = [ - { id: '1', email: '', role: 'user', groups: [], mfa_enabled: false } + { id: '1', email: '', groups: [], mfa_enabled: false } ]; userList.renderUsers(usersWithXss as any); @@ -981,8 +995,8 @@ describe('users/userList', () => { // ============================================================================ describe('users/userActions', () => { const mockUsers = [ - { id: '1', email: 'user1@test.com', role: 'user', groups: [], mfa_enabled: false }, - { id: '2', email: 'user2@test.com', role: 'admin', groups: ['admins'], mfa_enabled: true } + { id: '1', email: 'user1@test.com', groups: [], mfa_enabled: false }, + { id: '2', email: 'user2@test.com', groups: ['admins'], mfa_enabled: true } ]; // `allowed_accounts: []` matches the backend Group shape after the @@ -1196,72 +1210,12 @@ describe('users/userActions', () => { }); }); - describe('bulkChangeRole', () => { - beforeEach(() => { - userState.setAllUsers(mockUsers as any); - (global.confirm as jest.Mock).mockReturnValue(true); - }); - - it('should update role for selected users', async () => { - userState.addSelectedUserId('1'); - userState.addSelectedUserId('2'); - - await userActions.bulkChangeRole('admin'); - - expect(api.updateUser).toHaveBeenCalledWith('1', { role: 'admin' }); - expect(api.updateUser).toHaveBeenCalledWith('2', { role: 'admin' }); - }); - - it('should not update when no users selected', async () => { - await userActions.bulkChangeRole('admin'); - - expect(api.updateUser).not.toHaveBeenCalled(); - }); - - it('should show confirmation with count and role', async () => { - userState.addSelectedUserId('1'); - - await userActions.bulkChangeRole('admin'); - - expect(global.confirm).toHaveBeenCalledWith( - expect.stringContaining('admin') - ); - }); - - it('should not update when cancelled', async () => { - (global.confirm as jest.Mock).mockReturnValue(false); - userState.addSelectedUserId('1'); - - await userActions.bulkChangeRole('admin'); - - expect(api.updateUser).not.toHaveBeenCalled(); - }); - - it('should clear selection after update', async () => { - userState.addSelectedUserId('1'); - - await userActions.bulkChangeRole('user'); - - expect(userState.selectedUserIds.size).toBe(0); - }); - - it('should show success message', async () => { - jest.useFakeTimers(); - userState.addSelectedUserId('1'); - - await userActions.bulkChangeRole('admin'); - - expect(document.querySelector('.toast-success')).toBeTruthy(); - jest.useRealTimers(); - }); - - it('should handle update error', async () => { - userState.addSelectedUserId('1'); - (api.updateUser as jest.Mock).mockRejectedValue(new Error('Update failed')); - - await userActions.bulkChangeRole('admin'); - - expect(document.querySelector('.toast-error')).toBeTruthy(); + describe('bulkChangeRole (removed in PR #912)', () => { + it('bulkChangeRole no longer exists on userActions', () => { + // PR #912 removed the role concept. bulkChangeRole was removed from + // userActions. Verify the export is absent so callers that relied + // on it are caught at compile/test time. + expect((userActions as any).bulkChangeRole).toBeUndefined(); }); }); @@ -1376,7 +1330,7 @@ describe('users/userModals', () => { const mockUser = { id: '1', email: 'test@test.com', - role: 'user', + // role removed: PR #912 -- authorization is group-membership based. groups: ['users'], mfa_enabled: false }; @@ -1396,10 +1350,7 @@ describe('users/userModals', () => {