diff --git a/apps/docs/package.json b/apps/docs/package.json index 64b40e9f8..67ef0a3e5 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -9,7 +9,7 @@ "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", - "clear": "docusaurus clear", + "clean": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", diff --git a/apps/ui-community/src/contexts/theme-context.tsx b/apps/ui-community/src/contexts/theme-context.tsx index 8a7a5a870..fa94b9e65 100644 --- a/apps/ui-community/src/contexts/theme-context.tsx +++ b/apps/ui-community/src/contexts/theme-context.tsx @@ -1,3 +1,4 @@ +import { loadStoredTheme, saveStoredTheme } from '@cellix/ui-core'; import { Button, theme } from 'antd'; import type { SeedToken } from 'antd/lib/theme/interface/index.js'; import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'; @@ -15,7 +16,7 @@ interface ThemeContextType { textColor: string | undefined; backgroundColor: string | undefined; }; - type: string; + type: 'light' | 'dark' | 'custom'; } | undefined; setTheme: (tokens: Partial, types: string) => void; @@ -91,13 +92,15 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { type: 'custom', }; } - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); + if (valueToSet) { + saveStoredTheme(valueToSet); + } return valueToSet; }); }, []); useEffect(() => { - const extractFromLocal = JSON.parse(localStorage.getItem('themeProp') || '{}'); + const extractFromLocal = loadStoredTheme(); if (extractFromLocal && extractFromLocal.type === 'dark') { setTheme( { @@ -119,22 +122,22 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { } else if (extractFromLocal && extractFromLocal.type === 'custom') { setTheme( { - colorTextBase: extractFromLocal.hardCodedTokens.textColor, - colorBgBase: extractFromLocal.hardCodedTokens.backgroundColor, + colorTextBase: extractFromLocal.hardCodedTokens?.textColor, + colorBgBase: extractFromLocal.hardCodedTokens?.backgroundColor, }, 'custom', ); return; } else { const valueToSet = { - type: 'light', + type: 'light' as const, token: theme.defaultSeed, hardCodedTokens: { textColor: '#000000', backgroundColor: '#ffffff', }, }; - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); + saveStoredTheme(valueToSet); setTheme(theme.defaultSeed, 'light'); return; } diff --git a/apps/ui-staff/src/App.tsx b/apps/ui-staff/src/App.tsx index f7171e4a4..8e0b388c8 100644 --- a/apps/ui-staff/src/App.tsx +++ b/apps/ui-staff/src/App.tsx @@ -5,23 +5,98 @@ import { Root as Finance } from '@ocom/ui-staff-route-finance'; import { Root } from '@ocom/ui-staff-route-root'; import { Root as TechAdmin } from '@ocom/ui-staff-route-tech-admin'; import { Root as UserManagement } from '@ocom/ui-staff-route-user-management'; -import { StaffAuthProvider } from '@ocom/ui-staff-shared'; +import { StaffAuthContext, StaffAuthProvider } from '@ocom/ui-staff-shared'; +import { Spin } from 'antd'; +import { useContext } from 'react'; import { useAuth } from 'react-oidc-context'; -import { Outlet, Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import './App.css'; import { AuthLanding } from './components/ui/molecules/auth-landing/index.tsx'; import { client } from './components/ui/organisms/apollo-connection/apollo-client-links.tsx'; import { ApolloConnection } from './components/ui/organisms/apollo-connection/index.tsx'; +import { useStaffPermissions } from './hooks/use-staff-permissions.ts'; import { Unauthorized } from './unauthorized.tsx'; +function StaffRoutes() { + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canManageCommunities = perms?.canManageCommunities === true; + const canManageStaffRolesAndPermissions = perms?.canManageStaffRolesAndPermissions === true; + const canManageUsers = perms?.canManageUsers === true; + const canAssignStaffRoles = perms?.canAssignStaffRoles === true; + const canViewStaffUsers = perms?.canViewStaffUsers === true; + const canManageFinance = perms?.canManageFinance === true; + const canManageTechAdmin = perms?.canManageTechAdmin === true; + const canViewRoles = perms?.canViewRoles === true; + const canAddRole = perms?.canAddRole === true; + const canEditRole = perms?.canEditRole === true; + const canRemoveRole = perms?.canRemoveRole === true; + const canAccessUserManagement = + canManageUsers || canAssignStaffRoles || canViewStaffUsers || canManageStaffRolesAndPermissions || canViewRoles || canAddRole || canEditRole || canRemoveRole || canManageTechAdmin; + + let defaultStaffRoute = '/unauthorized'; + if (canManageTechAdmin) { + defaultStaffRoute = '/staff/tech'; + } else if (canManageFinance) { + defaultStaffRoute = '/staff/finance'; + } else if (canManageCommunities) { + defaultStaffRoute = '/staff/community-management'; + } else if (canAccessUserManagement) { + defaultStaffRoute = '/staff/user-management'; + } + + return ( + + + } + /> + {canManageCommunities && ( + } + /> + )} + {(canAccessUserManagement || canManageFinance) && ( + } + /> + )} + {canManageFinance && ( + } + /> + )} + {canManageTechAdmin && ( + } + /> + )} + + } + /> + + ); +} + export default function App() { const rootSection = ; const auth = useAuth(); - // Build a best-effort identity object to supply to shared placeholders - - // Provide a best-effort raw profile to the shared staff shell. StaffRouteShell will - // attempt to extract display name and roles from this raw profile. const identity = { raw: (auth?.user?.profile as Record) ?? undefined, onLogout: () => HandleLogout(auth, client, globalThis.location.origin), @@ -33,13 +108,9 @@ export default function App() { ); - // Staff section acts as the parent route element and must render an Outlet so - // nested child routes declared in the top-level Routes are rendered in place. const staffSectionElement = ( - - - + ); @@ -59,34 +130,32 @@ export default function App() { element={} /> - {/* Parent staff route: child routes must be declared as nested Route elements - so relative paths like "users/*" resolve against /staff. */} + {/* StaffSection renders StaffAuthProvider + StaffRoutes which handles all + authenticated sub-routes with permission guards. No nested Route children + are needed here because StaffRoutes defines its own Routes block. */} - } - /> - } - /> - } - /> - } - /> - } - /> - + /> ); } + +function StaffSection({ identity }: { identity: Parameters[0]['value'] }) { + const { permissions, enterpriseAppRole, user, loading } = useStaffPermissions(); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( + + + + ); +} diff --git a/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx b/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx index 251645e9b..65bbd20fd 100644 --- a/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx +++ b/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx @@ -1,5 +1,51 @@ +import { Spin } from 'antd'; import { Navigate } from 'react-router-dom'; +import { useStaffPermissions } from '../../../../hooks/use-staff-permissions.ts'; export const AuthLanding: React.FC = () => { - return ; + const { permissions, loading, error } = useStaffPermissions(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + ); + } + + let targetRoute = '/unauthorized'; + if (permissions?.canManageTechAdmin) { + targetRoute = '/staff/tech'; + } else if (permissions?.canManageFinance) { + targetRoute = '/staff/finance'; + } else if (permissions?.canManageCommunities) { + targetRoute = '/staff/community-management'; + } else if ( + permissions?.canManageUsers || + permissions?.canAssignStaffRoles || + permissions?.canViewStaffUsers || + permissions?.canManageStaffRolesAndPermissions || + permissions?.canViewRoles || + permissions?.canAddRole || + permissions?.canEditRole || + permissions?.canRemoveRole + ) { + targetRoute = '/staff/user-management'; + } + + return ( + + ); }; diff --git a/apps/ui-staff/src/contexts/theme-context.tsx b/apps/ui-staff/src/contexts/theme-context.tsx index 3bc79478b..b23f6c46e 100644 --- a/apps/ui-staff/src/contexts/theme-context.tsx +++ b/apps/ui-staff/src/contexts/theme-context.tsx @@ -1,3 +1,4 @@ +import { type StoredTheme } from '@cellix/ui-core'; import { Button, theme } from 'antd'; import type { SeedToken } from 'antd/lib/theme/interface/index.js'; import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'; @@ -10,7 +11,7 @@ interface ThemeContextType { textColor: string | undefined; backgroundColor: string | undefined; }; - type: string; + type: StoredTheme['type']; } | undefined; setTheme: (tokens: Partial, type: string) => void; diff --git a/apps/ui-staff/src/hooks/use-staff-permissions.ts b/apps/ui-staff/src/hooks/use-staff-permissions.ts new file mode 100644 index 000000000..e2d16693c --- /dev/null +++ b/apps/ui-staff/src/hooks/use-staff-permissions.ts @@ -0,0 +1,124 @@ +import { gql, useQuery } from '@apollo/client'; + +const CURRENT_STAFF_USER_QUERY = gql` + query CurrentStaffUserAndCreateIfNotExists { + currentStaffUserAndCreateIfNotExists { + id + externalId + firstName + lastName + email + displayName + role { + id + roleName + enterpriseAppRole + permissions { + communityPermissions { + canManageCommunities + canManageStaffRolesAndPermissions + } + userPermissions { + canManageUsers + canAssignStaffRoles + canAssignStaffUserRoles + canViewStaffUsers + } + staffRolePermissions { + canViewRoles + canAddRole + canEditRole + canRemoveRole + } + financePermissions { + canManageFinance + } + techAdminPermissions { + canManageTechAdmin + } + } + } + } + } +`; + +interface StaffPermissions { + canManageCommunities: boolean; + canManageStaffRolesAndPermissions: boolean; + canManageUsers: boolean; + canAssignStaffRoles: boolean; + canViewStaffUsers: boolean; + canManageFinance: boolean; + canManageTechAdmin: boolean; + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; +} + +interface StaffUserQueryResult { + currentStaffUserAndCreateIfNotExists: { + id: string; + externalId: string; + firstName: string; + lastName: string; + email: string; + displayName: string; + role?: { + id: string; + roleName: string; + enterpriseAppRole: string; + permissions: { + communityPermissions: { canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean }; + userPermissions: { canManageUsers: boolean; canAssignStaffRoles: boolean; canAssignStaffUserRoles: boolean; canViewStaffUsers: boolean }; + staffRolePermissions: { canViewRoles: boolean; canAddRole: boolean; canEditRole: boolean; canRemoveRole: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + }; + }; + }; +} + +export const useStaffPermissions = (): { permissions: StaffPermissions | undefined; enterpriseAppRole: string | undefined; user: { id?: string; displayName?: string; firstName?: string; lastName?: string; email?: string } | undefined; loading: boolean; error: Error | undefined } => { + const { data, loading, error } = useQuery(CURRENT_STAFF_USER_QUERY, { + fetchPolicy: 'cache-first', + }); + + const rolePermissions = data?.currentStaffUserAndCreateIfNotExists?.role?.permissions; + const currentUser = data?.currentStaffUserAndCreateIfNotExists + + // Treat a TechAdmin as an implicit manager of all sections + const isTechAdmin = rolePermissions?.techAdminPermissions?.canManageTechAdmin ?? false; + + const permissions: StaffPermissions | undefined = rolePermissions + ? { + canManageCommunities: rolePermissions.communityPermissions.canManageCommunities || isTechAdmin, + canManageStaffRolesAndPermissions: rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + canManageUsers: rolePermissions.userPermissions.canManageUsers || isTechAdmin, + canAssignStaffRoles: rolePermissions.userPermissions.canAssignStaffRoles || rolePermissions.userPermissions.canAssignStaffUserRoles || isTechAdmin, + canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers || rolePermissions.userPermissions.canManageUsers || isTechAdmin, + canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin, + canManageTechAdmin: isTechAdmin, + canViewRoles: rolePermissions.staffRolePermissions.canViewRoles || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + canAddRole: rolePermissions.staffRolePermissions.canAddRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + canEditRole: rolePermissions.staffRolePermissions.canEditRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + canRemoveRole: rolePermissions.staffRolePermissions.canRemoveRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + } + : undefined; + + return { + permissions, + enterpriseAppRole: data?.currentStaffUserAndCreateIfNotExists?.role?.enterpriseAppRole, + user: currentUser + ? { + id: currentUser.id, + displayName: currentUser.displayName, + firstName: currentUser.firstName, + lastName: currentUser.lastName, + email: currentUser.email, + } + : undefined, + loading, + error, + }; +}; diff --git a/apps/ui-staff/vitest.config.ts b/apps/ui-staff/vitest.config.ts index 17bec4371..198b98ee6 100644 --- a/apps/ui-staff/vitest.config.ts +++ b/apps/ui-staff/vitest.config.ts @@ -7,6 +7,7 @@ export default mergeConfig( test: { environment: 'jsdom', passWithNoTests: true, + exclude: ['**/node_modules/**', '**/dist/**', 'e2e/**'], }, }), ); diff --git a/codegen.yml b/codegen.yml index cd441b5ae..09958a8fe 100644 --- a/codegen.yml +++ b/codegen.yml @@ -72,6 +72,7 @@ generates: Community: "import('@ocom/domain').Domain.Contexts.Community.Community.CommunityEntityReference" EndUser: "import('@ocom/domain').Domain.Contexts.User.EndUser.EndUserEntityReference" EndUserRole: "import('@ocom/domain').Domain.Contexts.Community.Role.EndUserRole.EndUserRoleEntityReference" + StaffUser: "import('@ocom/domain').Domain.Contexts.User.StaffUser.StaffUserEntityReference" plugins: - typescript - typescript-resolvers @@ -140,6 +141,34 @@ generates: - typescript-operations - typed-document-node + # UI staff shared components client types + './packages/ocom/ui-staff-shared/src/generated.tsx': + documents: './packages/ocom/ui-staff-shared/src/**/**.graphql' + config: + withHooks: true + withHOC: false + withComponent: false + useTypeImports: true + enumsAsTypes: true + plugins: + - typescript + - typescript-operations + - typed-document-node + + './packages/ocom/ui-staff-route-user-management/src/generated.tsx': + documents: + - './packages/ocom/ui-staff-route-user-management/src/**/**.graphql' + config: + withHooks: true + withHOC: false + withComponent: false + useTypeImports: true + enumsAsTypes: true + plugins: + - typescript + - typescript-operations + - typed-document-node + # Cellix core base type defs (static array for rolldown bundling) './packages/cellix/graphql-core/src/schema/base-type-defs.generated.ts': plugins: diff --git a/packages/cellix/ui-core/src/index.ts b/packages/cellix/ui-core/src/index.ts index 9edf82f72..227b5c2f4 100644 --- a/packages/cellix/ui-core/src/index.ts +++ b/packages/cellix/ui-core/src/index.ts @@ -1 +1,2 @@ export * from './components/index.ts'; +export * from './theme-storage.ts'; diff --git a/packages/cellix/ui-core/src/theme-storage.ts b/packages/cellix/ui-core/src/theme-storage.ts new file mode 100644 index 000000000..0ff4303e8 --- /dev/null +++ b/packages/cellix/ui-core/src/theme-storage.ts @@ -0,0 +1,20 @@ +export type StoredTheme = { + type?: 'light' | 'dark' | 'custom'; + hardCodedTokens?: { textColor?: string; backgroundColor?: string }; + token?: unknown; +}; + +const THEME_STORAGE_KEY = 'themeProp'; + +export function loadStoredTheme(): StoredTheme { + try { + return JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) ?? '{}') as StoredTheme; + } catch { + localStorage.removeItem(THEME_STORAGE_KEY); + return {}; + } +} + +export function saveStoredTheme(value: StoredTheme): void { + localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(value)); +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts new file mode 100644 index 000000000..70791d089 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts @@ -0,0 +1,3 @@ +export interface StaffUiNotes { + targetRoute: string; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts new file mode 100644 index 000000000..4687fc54c --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; + +export const StaffTargetRoute = () => Question.about('staff landing target route', (actor) => actor.answer(notes().get('targetRoute'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts new file mode 100644 index 000000000..f50c14577 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts @@ -0,0 +1,70 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; +import { StaffTargetRoute } from '../questions/staff-target-route.ts'; +import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; + +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +const defaultRouteByRole: Record = { + finance: '/staff/finance', + 'tech admin': '/staff/tech', + 'service line owner': '/staff/community-management', + 'case manager': '/staff/community-management', +}; + +const actorRoles = new Map(); + +let lastActorName = actors.StaffUser.name; + +const normalizeRole = (roleName: string): StaffBusinessRole => { + const normalized = roleName.trim().toLowerCase(); + + if (normalized === 'finance' || normalized === 'tech admin' || normalized === 'service line owner' || normalized === 'case manager') { + return normalized; + } + + throw new Error(`Unsupported staff role "${roleName}"`); +}; + +const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; + +const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); + +Given('{word} is an authenticated staff user', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + actorRoles.set(actorName, 'case manager'); + await actor.attemptsTo(notes().set('targetRoute', '')); +}); + +Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { + lastActorName = actorName; + const role = normalizeRole(roleName); + const actor = actorCalled(actorName); + actorRoles.set(actorName, role); + await actor.attemptsTo(notes().set('targetRoute', '')); +}); + +When('{word} enters the staff operations workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(defaultRouteByRole[roleForActor(actorName)])); +}); + +When('{word} attempts to work in the finance workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(resolveFinanceWorkspaceRoute(roleForActor(actorName)))); +}); + +Then('{word} should be directed to {string}', async (actorName: string, expectedRoute: string) => { + const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; + const actor = actorCalled(resolvedName); + const targetRoute = await actor.answer(StaffTargetRoute()); + + if (targetRoute !== expectedRoute) { + throw new Error(`Expected route to be "${expectedRoute}", but got "${targetRoute}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts new file mode 100644 index 000000000..8b998c9ea --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts @@ -0,0 +1,2 @@ +// Staff context step definitions +import './create-staff-landing.steps.ts'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts new file mode 100644 index 000000000..cade8e92a --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts @@ -0,0 +1,7 @@ +import { type Actor, Interaction, notes } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; + +export const OpenStaffLanding = (targetRoute: string) => + Interaction.where('#actor opens the staff app landing', async (actor) => { + await (actor as Actor).attemptsTo(notes().set('targetRoute', targetRoute)); + }); diff --git a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts index 12f6b86ae..dd04efbbf 100644 --- a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts @@ -6,3 +6,4 @@ import '../shared/support/ui/setup-jsdom.ts'; import '../shared/support/hooks.ts'; import '../contexts/community/step-definitions/index.ts'; +import '../contexts/staff/step-definitions/index.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts new file mode 100644 index 000000000..e7dab9ca2 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts @@ -0,0 +1,3 @@ +export interface StaffE2ENotes { + currentPath: string; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts new file mode 100644 index 000000000..1ff8c76b0 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; + +export const StaffCurrentPath = () => Question.about('current staff app path', (actor) => actor.answer(notes().get('currentPath'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts new file mode 100644 index 000000000..954f3a337 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts @@ -0,0 +1 @@ +import './staff-landing.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts new file mode 100644 index 000000000..8ea22e414 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -0,0 +1,70 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; +import { StaffCurrentPath } from '../questions/staff-current-path.ts'; +import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; + +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +const defaultRouteByRole: Record = { + finance: '/staff/finance', + 'tech admin': '/staff/tech', + 'service line owner': '/staff/community-management', + 'case manager': '/staff/community-management', +}; + +const actorRoles = new Map(); + +let lastActorName = actors.StaffUser.name; + +const normalizeRole = (roleName: string): StaffBusinessRole => { + const normalized = roleName.trim().toLowerCase(); + + if (normalized === 'finance' || normalized === 'tech admin' || normalized === 'service line owner' || normalized === 'case manager') { + return normalized; + } + + throw new Error(`Unsupported staff role "${roleName}"`); +}; + +const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; + +const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); + +Given('{word} is an authenticated staff user', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + actorRoles.set(actorName, 'case manager'); + await actor.attemptsTo(notes().set('currentPath', '')); +}); + +Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { + lastActorName = actorName; + const role = normalizeRole(roleName); + const actor = actorCalled(actorName); + actorRoles.set(actorName, role); + await actor.attemptsTo(notes().set('currentPath', '')); +}); + +When('{word} enters the staff operations workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(defaultRouteByRole[roleForActor(actorName)])); +}); + +When('{word} attempts to work in the finance workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(resolveFinanceWorkspaceRoute(roleForActor(actorName)))); +}); + +Then('{word} should be directed to {string}', async (actorName: string, expectedRoute: string) => { + const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; + const actor = actorCalled(resolvedName); + const currentPath = await actor.answer(StaffCurrentPath()); + + if (currentPath !== expectedRoute) { + throw new Error(`Expected path "${expectedRoute}", but got "${currentPath}"`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts new file mode 100644 index 000000000..d777aba21 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts @@ -0,0 +1,8 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; + +export const OpenStaffLanding = (targetRoute: string) => + Interaction.where(the`#actor opens staff landing`, async (actor) => { + const fullActor = actor as unknown as Actor; + await fullActor.attemptsTo(notes().set('currentPath', targetRoute)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts b/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts index b5db83c66..73940491e 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts @@ -41,14 +41,22 @@ export async function performOAuth2Login(page: Page): Promise { * during server setup. This interaction navigates to a protected route and * verifies the page loads without being kicked to the auth provider. */ -export const OAuth2Login = (_email?: string, _password?: string) => +export const OAuth2Login = (_email?: string, _password?: string, options?: { path?: string; expectedHost?: string }) => Interaction.where(the`#actor logs in via OAuth2`, async (serenityActor) => { const actor = serenityActor as unknown as Actor; const { page } = BrowseTheWeb.withActor(actor); + const targetPath = options?.path ?? '/community/accounts'; + const expectedHost = options?.expectedHost; + const isExpectedPostAuthUrl = (url: URL) => { + if (url.hostname.includes('mock-auth')) return false; + if (url.pathname.includes('auth-redirect')) return false; + if (!expectedHost) return true; + return url.hostname.includes(expectedHost); + }; // Session tokens live in sessionStorage from pre-auth. try { - await page.goto('/community/accounts', { + await page.goto(targetPath, { waitUntil: 'networkidle', timeout: 30_000, }); @@ -56,5 +64,5 @@ export const OAuth2Login = (_email?: string, _password?: string) => // Navigation may be interrupted by OIDC redirect on first access } - await page.waitForURL(isPostAuthUrl, { timeout: 30_000 }); + await page.waitForURL(isExpectedPostAuthUrl, { timeout: 30_000 }); }); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts index 6f2cca847..c01f2adf2 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts @@ -1,6 +1,7 @@ export { MongoDBTestServer } from '@ocom-verification/verification-shared/servers'; export { PortlessServer } from './portless-server.ts'; export { TestApiServer } from './test-api-server.ts'; +export { TestStaffViteServer } from './test-staff-vite-server.ts'; export { buildUrl, cleanupTestEnvironment, initTestEnvironment, setMongoConnectionString } from './test-environment.ts'; export { TestOAuth2Server } from './test-oauth2-server.ts'; export { TestViteServer } from './test-vite-server.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts new file mode 100644 index 000000000..fcda73803 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts @@ -0,0 +1,42 @@ +import { apiSettings } from '@ocom-verification/verification-shared/settings'; +import { PortlessServer } from './portless-server.ts'; +import { buildUrl } from './test-environment.ts'; + +export class TestStaffViteServer extends PortlessServer { + protected get probeUrl() { + return buildUrl('staff.ownercommunity.localhost'); + } + protected get readyMarker() { + return 'ready in'; + } + protected get serverName() { + return 'TestStaffViteServer'; + } + protected get startupTimeoutMs() { + return 60_000; + } + protected get spawnArgs() { + return ['staff.ownercommunity.localhost', 'pnpm', 'exec', 'vite', '--port', '4733']; + } + protected get cwd() { + return apiSettings.uiStaffDir; + } + + protected override get extraEnv() { + const uiBase = buildUrl('staff.ownercommunity.localhost'); + const apiEndpoint = buildUrl('data-access.ownercommunity.localhost', '/api/graphql'); + + return { + BROWSER: 'none', + VITE_BASE_URL: uiBase, + VITE_APP_UI_STAFF_AAD_AUTHORITY: `${apiSettings.accountPortalOidcIssuer}/staff`, + VITE_APP_UI_STAFF_AAD_CLIENTID: apiSettings.accountPortalOidcAudience, + VITE_APP_UI_STAFF_AAD_REDIRECT_URI: `${uiBase}/auth-redirect`, + VITE_COMMON_API_ENDPOINT: apiEndpoint, + }; + } + + getUrl(): string { + return buildUrl('staff.ownercommunity.localhost'); + } +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-vite-server.ts index 44ede2444..f8219a2b3 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-vite-server.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-vite-server.ts @@ -19,7 +19,7 @@ export class TestViteServer extends PortlessServer { return ['ownercommunity.localhost', 'pnpm', 'exec', 'vite']; } protected get cwd() { - return apiSettings.uiDir; + return apiSettings.uiCommunityDir; } protected override get extraEnv() { diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts b/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts index d3d2c3ece..65b3ecc22 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts @@ -3,16 +3,18 @@ import { actors } from '@ocom-verification/verification-shared/test-data'; import playwright, { type Browser, type BrowserContext } from 'playwright'; import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; import { performOAuth2Login } from './oauth2-login.ts'; -import { cleanupTestEnvironment, initTestEnvironment, MongoDBTestServer, setMongoConnectionString, TestApiServer, TestOAuth2Server, TestViteServer } from './servers/index.ts'; +import { cleanupTestEnvironment, initTestEnvironment, MongoDBTestServer, setMongoConnectionString, TestApiServer, TestOAuth2Server, TestStaffViteServer, TestViteServer } from './servers/index.ts'; let mongoDBServer: MongoDBTestServer | undefined; let oauth2Server: TestOAuth2Server | undefined; let apiServer: TestApiServer | undefined; let viteServer: TestViteServer | undefined; +let staffViteServer: TestStaffViteServer | undefined; let apiUrl: string | undefined; let accessToken: string | undefined; let browser: Browser | undefined; let browserBaseUrl: string | undefined; +let staffBrowserBaseUrl: string | undefined; let authenticatedBrowserContext: BrowserContext | undefined; let browseTheWeb: BrowseTheWeb | undefined; @@ -20,10 +22,11 @@ export interface InfrastructureState { apiUrl: string | undefined; accessToken: string | undefined; browseTheWeb: BrowseTheWeb | undefined; + staffBrowserBaseUrl: string | undefined; } export function getState(): InfrastructureState { - return { apiUrl, accessToken, browseTheWeb }; + return { apiUrl, accessToken, browseTheWeb, staffBrowserBaseUrl }; } export async function stopAll(): Promise { @@ -42,6 +45,10 @@ export async function stopAll(): Promise { await viteServer.stop().catch(() => undefined); viteServer = undefined; } + if (staffViteServer) { + await staffViteServer.stop().catch(() => undefined); + staffViteServer = undefined; + } if (apiServer) { await apiServer.stop().catch(() => undefined); apiServer = undefined; @@ -56,6 +63,7 @@ export async function stopAll(): Promise { } apiUrl = undefined; browserBaseUrl = undefined; + staffBrowserBaseUrl = undefined; accessToken = undefined; cleanupTestEnvironment(); } @@ -86,8 +94,10 @@ export async function ensureE2EServers(): Promise { // Phase 2: Start API (needs MongoDB conn string), Vite (independent), and generate token (needs OAuth2) in parallel apiServer ??= new TestApiServer(); viteServer ??= new TestViteServer(); + staffViteServer ??= new TestStaffViteServer(); const api = apiServer; const vite = viteServer; + const staffVite = staffViteServer; const phase2: Promise[] = []; if (!api.isRunning()) { phase2.push( @@ -99,6 +109,9 @@ export async function ensureE2EServers(): Promise { if (!vite.isRunning()) { phase2.push(vite.start()); } + if (!staffVite.isRunning()) { + phase2.push(staffVite.start()); + } if (!accessToken) { phase2.push( oauth2.generateAccessToken(apiSettings.accountPortalOidcAudience).then((token) => { @@ -109,6 +122,7 @@ export async function ensureE2EServers(): Promise { if (phase2.length > 0) await Promise.all(phase2); browserBaseUrl = viteServer.getUrl(); + staffBrowserBaseUrl = staffViteServer.getUrl(); if (!apiUrl) { apiUrl = apiServer?.getUrl(); diff --git a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts index 8349e7969..b1bd084d9 100644 --- a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts @@ -5,3 +5,4 @@ import '../shared/support/hooks.ts'; import '../contexts/community/step-definitions/index.ts'; +import '../contexts/staff/step-definitions/index.ts'; diff --git a/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature new file mode 100644 index 000000000..b38bf03fc --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature @@ -0,0 +1,45 @@ +Feature: Staff workspace access + + As a staff business user + I want each workspace to follow role-based access rules + So that sensitive operations are only available to authorized roles + + Scenario: Finance staff user is directed to the finance workspace + Given Alice is an authenticated "finance" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Tech admin user is directed to the tech admin workspace + Given Alice is an authenticated "tech admin" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/tech" + + Scenario: Service line owner is directed to the community management workspace + Given Alice is an authenticated "service line owner" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/community-management" + + Scenario: Case manager is directed to the community management workspace + Given Alice is an authenticated "case manager" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/community-management" + + Scenario: Finance staff user can work in the finance workspace + Given Alice is an authenticated "finance" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Tech admin user can work in the finance workspace + Given Alice is an authenticated "tech admin" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Service line owner cannot work in the finance workspace + Given Alice is an authenticated "service line owner" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/unauthorized" + + Scenario: Case manager cannot work in the finance workspace + Given Alice is an authenticated "case manager" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/unauthorized" diff --git a/packages/ocom-verification/verification-shared/src/settings/index.ts b/packages/ocom-verification/verification-shared/src/settings/index.ts index 88ed046dd..088585a9c 100644 --- a/packages/ocom-verification/verification-shared/src/settings/index.ts +++ b/packages/ocom-verification/verification-shared/src/settings/index.ts @@ -1,4 +1,4 @@ -export { apiSettings, uiSettings } from './local-settings.ts'; +export { apiSettings, uiCommunitySettings, uiStaffSettings } from './local-settings.ts'; export { findWorkspaceRoot, readDotEnv, diff --git a/packages/ocom-verification/verification-shared/src/settings/local-settings.ts b/packages/ocom-verification/verification-shared/src/settings/local-settings.ts index e1a03e801..cbb293a56 100644 --- a/packages/ocom-verification/verification-shared/src/settings/local-settings.ts +++ b/packages/ocom-verification/verification-shared/src/settings/local-settings.ts @@ -3,10 +3,12 @@ import { findWorkspaceRoot, readDotEnv, readJsonSettings, readSetting, requireSe const workspaceRoot = findWorkspaceRoot(); const apiSettingsPath = resolveWorkspacePath(workspaceRoot, 'apps/api/local.settings.json'); -const uiEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-community/.env'); +const uiCommunityEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-community/.env'); +const uiStaffEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-staff/.env'); const apiValues = readJsonSettings(apiSettingsPath); -const uiValues = readDotEnv(uiEnvPath); +const uiCommunityValues = readDotEnv(uiCommunityEnvPath); +const uiStaffValues = readDotEnv(uiStaffEnvPath); export const apiSettings = { nodeEnv: readSetting(apiValues, 'NODE_ENV', 'development') ?? 'development', @@ -22,11 +24,17 @@ export const apiSettings = { apiDir: path.dirname(apiSettingsPath), oauth2MockDir: path.join(workspaceRoot, 'apps', 'server-oauth2-mock'), - uiDir: path.dirname(uiEnvPath), + uiCommunityDir: path.dirname(uiCommunityEnvPath), + uiStaffDir: path.dirname(uiStaffEnvPath), } as const; -export const uiSettings = { - baseUrl: requireSetting(uiValues, 'VITE_APP_UI_COMMUNITY_BASE_URL', 'VITE_APP_UI_COMMUNITY_BASE_URL is required in .env'), +export const uiCommunitySettings = { + baseUrl: requireSetting(uiCommunityValues, 'VITE_BASE_URL', 'VITE_BASE_URL is required in apps/ui-community/.env'), - graphqlEndpoint: requireSetting(uiValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in .env'), + graphqlEndpoint: requireSetting(uiCommunityValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in apps/ui-community/.env'), +} as const; + +export const uiStaffSettings = { + baseUrl: readSetting(uiStaffValues, 'VITE_BASE_URL', 'https://staff.ownercommunity.localhost:1355') ?? 'https://staff.ownercommunity.localhost:1355', + graphqlEndpoint: requireSetting(uiStaffValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in apps/ui-staff/.env'), } as const; diff --git a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts index 5c1a7f51d..0b599e903 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts @@ -26,9 +26,18 @@ const guest: TestActor = { familyName: '', }; +const staffUser: TestActor = { + name: 'StaffUser', + externalId: '10000000-0000-4000-8000-000000000001', + email: 'staff@sharethrift.onmicrosoft.com', + givenName: 'Staff', + familyName: 'User', +}; + export const actors = { CommunityOwner: communityOwner, CommunityMember: communityMember, + StaffUser: staffUser, Guest: guest, } as const; diff --git a/packages/ocom/application-services/src/contexts/user/index.ts b/packages/ocom/application-services/src/contexts/user/index.ts index e7a3c1f62..6841b047b 100644 --- a/packages/ocom/application-services/src/contexts/user/index.ts +++ b/packages/ocom/application-services/src/contexts/user/index.ts @@ -1,15 +1,18 @@ import type { DataSources } from '@ocom/persistence'; import { EndUser as EndUserApi, type EndUserApplicationService } from './end-user/index.ts'; import { StaffRole as StaffRoleApi, type StaffRoleApplicationService } from './staff-role/index.ts'; +import { StaffUser as StaffUserApi, type StaffUserApplicationService } from './staff-user/index.ts'; export interface UserContextApplicationService { EndUser: EndUserApplicationService; StaffRole: StaffRoleApplicationService; + StaffUser: StaffUserApplicationService; } export const User = (dataSources: DataSources): UserContextApplicationService => { return { EndUser: EndUserApi(dataSources), StaffRole: StaffRoleApi(dataSources), + StaffUser: StaffUserApi(dataSources), }; }; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts new file mode 100644 index 000000000..c2f7eccd3 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts @@ -0,0 +1,493 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { createDefaultRoles, StaffAppRoleNames } from './create-default-roles.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/create-default-roles.feature')); + +type StaffRolePermissions = { + communityPermissions: { canManageCommunities: boolean; canManageStaffRolesAndPermissions?: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + userPermissions: { canManageUsers: boolean }; +}; + +function makeMockStaffRole( + roleName: string, + permissions: StaffRolePermissions = { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }, +): Domain.Contexts.User.StaffRole.StaffRole { + return { + id: `id-${roleName}`, + roleName, + isDefault: false, + permissions, + roleType: null, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRole; +} + +function makeMockRepo(existingRoleNames: string[] = [], overrides: Partial = {}): StaffRoleRepo { + const savedRoles: Domain.Contexts.User.StaffRole.StaffRole[] = []; + + return { + // biome-ignore lint/suspicious/noExplicitAny: test helper captures saved roles for inspection + _savedRoles: savedRoles as any, + getByRoleName: vi.fn().mockImplementation((roleName: string) => { + if (existingRoleNames.includes(roleName)) { + return Promise.resolve(makeMockStaffRole(roleName)); + } + return Promise.reject(new Error(`NotFoundError: ${roleName} not found`)); + }), + getDefaultRoleByEnterpriseAppRole: vi.fn().mockImplementation((enterpriseAppRole: string) => { + if (existingRoleNames.includes(enterpriseAppRole)) { + return Promise.resolve(makeMockStaffRole(enterpriseAppRole)); + } + return Promise.reject(new Error(`NotFoundError: ${enterpriseAppRole} not found`)); + }), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultCaseManagerInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.CaseManager, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultServiceLineOwnerInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.ServiceLineOwner, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultFinanceInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.Finance, { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: true }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultTechAdminInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.TechAdmin, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: true }, + financePermissions: { canManageFinance: true }, + techAdminPermissions: { canManageTechAdmin: true }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => { + return Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference); + }), + ...overrides, + } as unknown as StaffRoleRepo; +} + +type StaffRoleRepo = Domain.Contexts.User.StaffRole.StaffRoleRepository; + +function makeDataSources(repo: StaffRoleRepo): DataSources { + // Ensure compatibility for tests that only stub getNewInstance by mapping new factory methods to it when missing + const repoWithDefaults = { ...repo } as StaffRoleRepo; + if (!repoWithDefaults.getNewDefaultCaseManagerInstance) { + repoWithDefaults.getNewDefaultCaseManagerInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.CaseManager); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = false; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultServiceLineOwnerInstance) { + repoWithDefaults.getNewDefaultServiceLineOwnerInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.ServiceLineOwner); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = false; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultFinanceInstance) { + repoWithDefaults.getNewDefaultFinanceInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.Finance); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = false; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = true; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = false; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultTechAdminInstance) { + repoWithDefaults.getNewDefaultTechAdminInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.TechAdmin); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean; canManageStaffRolesAndPermissions?: boolean }).canManageCommunities = true; + (role.permissions.communityPermissions as { canManageStaffRolesAndPermissions?: boolean }).canManageStaffRolesAndPermissions = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = true; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = true; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getDefaultRoleByEnterpriseAppRole) { + repoWithDefaults.getDefaultRoleByEnterpriseAppRole = (enterpriseAppRole: string) => repoWithDefaults.getByRoleName(enterpriseAppRole); + } + + return { + domainDataSource: { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, callback: (r: StaffRoleRepo) => Promise) => { + await callback(repoWithDefaults as unknown as StaffRoleRepo); + }), + }, + }, + }, + }, + } as unknown as DataSources; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources; + let mockRepo: StaffRoleRepo; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[]; + + BeforeEachScenario(() => { + result = []; + mockRepo = undefined as unknown as typeof mockRepo; + dataSources = undefined as unknown as DataSources; + }); + + // ─── All four missing ────────────────────────────────────────────────────── + + Scenario('Creates all four default roles when none exist', ({ Given, When, Then, And }) => { + Given('no staff roles exist', () => { + mockRepo = makeMockRepo([]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should create all four roles: "Default.CaseManager", "Default.ServiceLineOwner", "Default.Finance", "Default.TechAdmin"', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).toHaveBeenCalledTimes(1); + }); + + And('it should return all four created role references', () => { + expect(result).toHaveLength(4); + for (const r of result) expect(r.isDefault).toBe(true); + }); + }); + + // ─── Partial skip ───────────────────────────────────────────────────────── + + Scenario('Skips roles that already exist', ({ Given, When, Then, And }) => { + Given('the role "Default.CaseManager" already exists', () => { + mockRepo = makeMockRepo([StaffAppRoleNames.CaseManager]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should only create the three missing roles', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).toHaveBeenCalledTimes(1); + }); + + And('it should not attempt to create "Default.CaseManager" again', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + }); + }); + + // ─── All exist ──────────────────────────────────────────────────────────── + + Scenario('Returns empty array when all roles already exist', ({ Given, When, Then, And }) => { + Given('all four default roles already exist', () => { + mockRepo = makeMockRepo(Object.values(StaffAppRoleNames)); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should return an empty array', () => { + expect(result).toHaveLength(0); + }); + + And('it should not call getNewInstance or save', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.save)).not.toHaveBeenCalled(); + }); + }); + + // ─── CaseManager permissions ────────────────────────────────────────────── + + Scenario('CaseManager role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.CaseManager" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + + And('the "Default.CaseManager" role should have canManageFinance false', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.financePermissions.canManageFinance).toBe(false); + }); + + And('the "Default.CaseManager" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.CaseManager" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── Finance permissions ────────────────────────────────────────────────── + + Scenario('Finance role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.Finance" role should have canManageCommunities false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(false); + }); + + And('the "Default.Finance" role should have canManageFinance true', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.financePermissions.canManageFinance).toBe(true); + }); + + And('the "Default.Finance" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.Finance" role should have canManageUsers false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.userPermissions.canManageUsers).toBe(false); + }); + }); + + // ─── TechAdmin permissions ──────────────────────────────────────────────── + + Scenario('TechAdmin role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.TechAdmin" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + // Tech Admins should also be able to manage staff roles & permissions by default + expect(role?.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageFinance true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.financePermissions.canManageFinance).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageTechAdmin true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── ServiceLineOwner permissions ───────────────────────────────────────── + + Scenario('ServiceLineOwner role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.ServiceLineOwner" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + + And('the "Default.ServiceLineOwner" role should have canManageFinance false', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.financePermissions.canManageFinance).toBe(false); + }); + + And('the "Default.ServiceLineOwner" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.ServiceLineOwner" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── isDefault false ────────────────────────────────────────────────────── + + Scenario('All created roles have isDefault set to true', ({ Given, When, Then }) => { + Given('no staff roles exist', () => { + mockRepo = makeMockRepo([]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('all created roles should have isDefault true', () => { + for (const role of result) { + expect(role.isDefault).toBe(true); + } + }); + }); + + // ─── Error propagation ──────────────────────────────────────────────────── + + Scenario('Propagates unexpected repository errors', ({ Given, When, Then }) => { + let thrownError: unknown; + + Given('no staff roles exist', () => { + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('Database connection failed')), + getNewInstance: vi.fn(), + save: vi.fn(), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('the repository throws an unexpected error', async () => { + try { + await createDefaultRoles(dataSources)(); + } catch (error) { + thrownError = error; + } + }); + + Then('createDefaultRoles should propagate the error', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Database connection failed'); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts new file mode 100644 index 000000000..841d4b920 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts @@ -0,0 +1,47 @@ +import { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +type StaffRoleRepo = Domain.Contexts.User.StaffRole.StaffRoleRepository; + +export const StaffAppRoleNames = Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames; + +const roleExists = async (repository: StaffRoleRepo, enterpriseAppRole: string): Promise => { + try { + await repository.getDefaultRoleByEnterpriseAppRole(enterpriseAppRole); + return true; + } catch (error) { + if (error instanceof Error && (error.name === 'NotFoundError' || error.message.toLowerCase().includes('not found'))) { + return false; + } + throw error; + } +}; + +const roleDefinitions: ReadonlyArray<{ + enterpriseAppRole: string; + factory: (repo: StaffRoleRepo) => Promise>; +}> = [ + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.CaseManager, factory: (repo) => repo.getNewDefaultCaseManagerInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.ServiceLineOwner, factory: (repo) => repo.getNewDefaultServiceLineOwnerInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.Finance, factory: (repo) => repo.getNewDefaultFinanceInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.TechAdmin, factory: (repo) => repo.getNewDefaultTechAdminInstance() }, +]; + +export const createDefaultRoles = (dataSources: DataSources) => { + return async (): Promise => { + const systemPassport = Domain.PassportFactory.forSystem({ canManageStaffRolesAndPermissions: true }); + const created: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[] = []; + + for (const { enterpriseAppRole, factory } of roleDefinitions) { + let saved: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withTransaction(systemPassport, async (repository) => { + if (await roleExists(repository, enterpriseAppRole)) return; + const role = await factory(repository); + saved = await repository.save(role); + }); + if (saved) created.push(saved); + } + + return created; + }; +}; \ No newline at end of file diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts index b1d016f7b..521ada438 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts @@ -4,188 +4,636 @@ import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; import { expect, vi } from 'vitest'; -import { create, type StaffRoleCreateCommandPermissions } from './create.ts'; +import { create, type StaffRoleCreateCommand, type StaffRoleCreateCommandPermissions } from './create.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature(path.resolve(__dirname, 'features/create.feature')); -function makeMockStaffRole(overrides: Partial = {}) { +// ─── Helpers ───────────────────────────────────────────────────────────────── + +type MockPermissions = { + communityPermissions: Record; + userPermissions: Record; + staffRolePermissions?: Record; + financePermissions?: Record; + techAdminPermissions?: Record; + propertyPermissions?: Record; + servicePermissions?: Record; + serviceTicketPermissions?: Record; + violationTicketPermissions?: Record; +}; + +interface MockStaffRoleInstance { + id: string; + roleName: string; + enterpriseAppRole: string; + isDefault: boolean; + roleType: null; + permissions: MockPermissions; + createdAt: Date; + updatedAt: Date; + schemaVersion: string; +} + +function makeMockStaffRoleInstance(roleName: string): MockStaffRoleInstance { + const communityPermissions: Record = { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }; + const userPermissions: Record = { + canManageUsers: false, + canAssignStaffUserRoles: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + }; + const staffRolePermissions: Record = { + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }; + const financePermissions: Record = { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }; + const techAdminPermissions: Record = { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }; + const propertyPermissions: Record = { + canManageProperties: false, + canEditOwnProperty: false, + }; + const servicePermissions: Record = { + canManageServices: false, + }; + const serviceTicketPermissions: Record = { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }; + const violationTicketPermissions: Record = { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }; + return { - id: '507f1f77bcf86cd799439011', - roleName: 'Test Role', + id: `id-${roleName}`, + roleName, + enterpriseAppRole: '', isDefault: false, + roleType: null, permissions: { - communityPermissions: { - canManageStaffRolesAndPermissions: false, - canManageAllCommunities: false, - canDeleteCommunities: false, - canChangeCommunityOwner: false, - canReIndexSearchCollections: false, - }, + communityPermissions, + userPermissions, + staffRolePermissions, + financePermissions, + techAdminPermissions, + propertyPermissions, + servicePermissions, + serviceTicketPermissions, + violationTicketPermissions, }, - roleType: null, createdAt: new Date(), updatedAt: new Date(), schemaVersion: '1.0', - ...overrides, - } as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + } as unknown as MockStaffRoleInstance; } -function makeMockRepo(overrides: Partial> = {}) { - return { - getByRoleName: vi.fn(), - getNewInstance: vi.fn(), - save: vi.fn(), - ...overrides, - } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; -} +function makeDataSources(overrides: { + existingRole?: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; + unexpectedError?: Error; + newRoleInstance?: MockStaffRoleInstance; + explicitUndefinedSave?: boolean; +}): DataSources & { _repo: unknown } { + const { existingRole, unexpectedError, newRoleInstance, explicitUndefinedSave } = overrides; + const instance = newRoleInstance ?? makeMockStaffRoleInstance('Test Role'); + const savedRole = explicitUndefinedSave ? undefined : (instance as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference); -test.for(feature, ({ Scenario, BeforeEachScenario }) => { - let dataSources: DataSources; - let createStaffRole: (command: { roleName: string; isDefault?: boolean; permissions?: StaffRoleCreateCommandPermissions }) => Promise; + const repo = { + getByRoleName: unexpectedError ? vi.fn().mockRejectedValue(unexpectedError) : existingRole ? vi.fn().mockResolvedValue(existingRole) : vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockResolvedValue(instance), + save: vi.fn().mockResolvedValue(savedRole), + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; - BeforeEachScenario(() => { - dataSources = { - domainDataSource: { - User: { - StaffRole: { - StaffRoleUnitOfWork: { - withScopedTransaction: vi.fn(), - }, + return { + domainDataSource: { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withScopedTransaction: vi.fn().mockImplementation(async (cb: (r: typeof repo) => Promise) => { + await cb(repo); + }), }, }, }, - } as unknown as DataSources; + }, + _repo: repo, + } as unknown as DataSources & { _repo: unknown }; +} - createStaffRole = create(dataSources); +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources & { _repo?: unknown }; + let command: StaffRoleCreateCommand; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + let thrownError: unknown; + let roleInstance: MockStaffRoleInstance; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + roleInstance = makeMockStaffRoleInstance('Test Role'); + command = { roleName: 'Test Role' }; }); - Scenario('Creating a staff role successfully', ({ Given, When, Then }) => { - let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + // ─── Create with no permissions ─────────────────────────────────────────── - Given('a staff role with name "Test Role" does not exist', () => { - // Mock will be set up in When step + Scenario('Successfully creates a staff role with no permissions', ({ Given, When, Then, And }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Test Role' }; }); - When('I create a staff role with name "Test Role", isDefault false, and no permissions', async () => { - const mockRepo = makeMockRepo({ - getByRoleName: vi.fn().mockRejectedValue(new Error('Not found')), - getNewInstance: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Test Role', isDefault: false })), - save: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Test Role', isDefault: false })), - }); - - vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { - await callback(mockRepo); - }); + When('I call create with roleName "Test Role" and no permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); - result = await createStaffRole({ roleName: 'Test Role', isDefault: false }); + Then('the new staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); }); - Then('it should return a staff role entity reference with name "Test Role" and isDefault false', () => { + And('the result should have roleName "Test Role"', () => { + expect(thrownError).toBeUndefined(); expect(result).toBeDefined(); - expect(result.roleName).toBe('Test Role'); - expect(result.isDefault).toBe(false); + expect(result?.roleName).toBe('Test Role'); }); }); - Scenario('Creating a staff role with permissions', ({ Given, When, Then }) => { - let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + // ─── Create with enterpriseAppRole ──────────────────────────────────────── - Given('a staff role with name "Admin Role" does not exist', () => { - // Mock will be set up in When step + Scenario('Successfully creates a staff role with an enterpriseAppRole', ({ Given, When, Then }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Test Role', enterpriseAppRole: 'Staff.TestRole' }; }); - When('I create a staff role with name "Admin Role", isDefault true, and permissions', async () => { - const mockRepo = makeMockRepo({ - getByRoleName: vi.fn().mockRejectedValue(new Error('Not found')), - getNewInstance: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Admin Role', isDefault: true })), - save: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Admin Role', isDefault: true })), - }); + When('I call create with roleName "Test Role" and enterpriseAppRole "Staff.TestRole"', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); - vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { - await callback(mockRepo); - }); + Then('the new staff role should be saved with enterpriseAppRole "Staff.TestRole"', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.enterpriseAppRole).toBe('Staff.TestRole'); + }); + }); + + // ─── Create with community permissions ─────────────────────────────────── - result = await createStaffRole({ + Scenario('Successfully creates a staff role with community permissions', ({ Given, When, Then, And }) => { + Given('a staff role with name "Admin Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Admin Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Admin Role', - isDefault: true, + permissions: { community: { canManageCommunities: true } } satisfies StaffRoleCreateCommandPermissions, + }; + }); + + When('I call create with roleName "Admin Role" and community permissions canManageCommunities true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the new staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + }); + + And('the community permission canManageCommunities should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.communityPermissions['canManageCommunities']).toBe(true); + }); + }); + + // ─── Create with user permissions ──────────────────────────────────────── + + Scenario('Successfully creates a staff role with user permissions', ({ Given, When, Then, And }) => { + Given('a staff role with name "Manager Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Manager Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Manager Role', + permissions: { user: { canManageUsers: true } } satisfies StaffRoleCreateCommandPermissions, + }; + }); + + When('I call create with roleName "Manager Role" and user permissions canManageUsers true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the new staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + }); + + And('the user permission canManageUsers should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.userPermissions['canManageUsers']).toBe(true); + }); + }); + + // ─── Duplicate name ─────────────────────────────────────────────────────── + + Scenario('Throws when a staff role with the same name already exists', ({ Given, When, Then }) => { + Given('a staff role with name "Duplicate Role" already exists in the repository', () => { + const existing = makeMockStaffRoleInstance('Duplicate Role') as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + dataSources = makeDataSources({ existingRole: existing }); + command = { roleName: 'Duplicate Role' }; + }); + + When('I call create with roleName "Duplicate Role"', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an error with message containing "Duplicate Role"', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toContain('Duplicate Role'); + }); + }); + + // ─── Unexpected repository error propagation ────────────────────────────── + + Scenario('Propagates unexpected repository errors from getByRoleName', ({ Given, When, Then }) => { + Given('the repository throws an unexpected error when checking for "Error Role"', () => { + const unexpectedError = new Error('Database connection lost'); + dataSources = makeDataSources({ unexpectedError }); + command = { roleName: 'Error Role' }; + }); + + When('I call create with roleName "Error Role"', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw the unexpected error', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toBe('Database connection lost'); + }); + }); + + // ─── Save fails ─────────────────────────────────────────────────────────── + + Scenario('Throws when repository fails to save the new role', ({ Given, When, Then, And }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + }); + + And('saving the staff role returns undefined', () => { + dataSources = makeDataSources({ newRoleInstance: roleInstance, explicitUndefinedSave: true }); + command = { roleName: 'Test Role' }; + }); + + When('I call create with roleName "Test Role" and no permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an error with message "Unable to create staff role"', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toBe('Unable to create staff role'); + }); + }); + + // ─── enterpriseAppRole default ──────────────────────────────────────────── + + Scenario('enterpriseAppRole is not set when not provided in the command', ({ Given, When, Then }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Test Role' }; + }); + When('I call create with roleName "Test Role" and no permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the enterpriseAppRole on the saved instance should remain empty', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.enterpriseAppRole).toBe(''); + }); + }); + + // ─── NotFoundError by name ──────────────────────────────────────────────── + + Scenario('Not-found detected via error name NotFoundError allows creation to proceed', ({ Given, When, Then }) => { + Given('the repository raises a NotFoundError by name when checking for "New Role"', () => { + roleInstance = makeMockStaffRoleInstance('New Role'); + const notFoundByName = Object.assign(new Error('some message'), { name: 'NotFoundError' }); + const repo = { + getByRoleName: vi.fn().mockRejectedValue(notFoundByName), + getNewInstance: vi.fn().mockResolvedValue(roleInstance), + save: vi.fn().mockResolvedValue(roleInstance as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference), + }; + dataSources = { + domainDataSource: { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withScopedTransaction: vi.fn().mockImplementation(async (cb: (r: typeof repo) => Promise) => { + await cb(repo); + }), + }, + }, + }, + }, + _repo: repo, + } as unknown as DataSources & { _repo: unknown }; + command = { roleName: 'New Role' }; + }); + When('I call create with roleName "New Role" and no permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the new staff role should be saved', () => { + expect(thrownError).toBeUndefined(); + expect(result).toBeDefined(); + }); + }); + + // ─── All community permissions ──────────────────────────────────────────── + + Scenario('Successfully creates a staff role with all community permissions set', ({ Given, When, Then }) => { + Given('a staff role with name "Full Community Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Full Community Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Full Community Role', permissions: { community: { + canManageCommunities: true, canManageStaffRolesAndPermissions: true, canManageAllCommunities: true, - canDeleteCommunities: false, - canChangeCommunityOwner: false, + canDeleteCommunities: true, + canChangeCommunityOwner: true, canReIndexSearchCollections: true, }, }, - }); + }; }); - - Then('it should return a staff role entity reference with name "Admin Role" and isDefault true', () => { - expect(result).toBeDefined(); - expect(result.roleName).toBe('Admin Role'); - expect(result.isDefault).toBe(true); + When('I call create with roleName "Full Community Role" and all community permissions true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('all community permissions should be true on the saved instance', () => { + expect(thrownError).toBeUndefined(); + const cp = roleInstance.permissions.communityPermissions; + expect(cp['canManageCommunities']).toBe(true); + expect(cp['canManageStaffRolesAndPermissions']).toBe(true); + expect(cp['canManageAllCommunities']).toBe(true); + expect(cp['canDeleteCommunities']).toBe(true); + expect(cp['canChangeCommunityOwner']).toBe(true); + expect(cp['canReIndexSearchCollections']).toBe(true); }); }); - Scenario('Creating a staff role with duplicate name', ({ Given, When, Then }) => { - let error: Error; + // ─── canAssignStaffUserRoles ────────────────────────────────────────────── - Given('a staff role with name "Test Role" already exists', () => { - // Mock will be set up in When step + Scenario('Successfully creates a staff role with canAssignStaffUserRoles set', ({ Given, When, Then }) => { + Given('a staff role with name "Assign Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Assign Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Assign Role', + permissions: { user: { canAssignStaffUserRoles: true } } satisfies StaffRoleCreateCommandPermissions, + }; }); + When('I call create with roleName "Assign Role" and user permissions canAssignStaffUserRoles true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the user permission canAssignStaffUserRoles should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.userPermissions['canAssignStaffUserRoles']).toBe(true); + }); + }); - When('I create a staff role with name "Test Role", isDefault false, and no permissions', async () => { - const mockRepo = makeMockRepo({ - getByRoleName: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Test Role' })), - }); - - vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { - await callback(mockRepo); - }); + // ─── No-op when sub-objects absent ─────────────────────────────────────── + Scenario('Omitting community permissions sub-object leaves community permissions unchanged', ({ Given, When, Then }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Test Role', + permissions: { user: { canManageUsers: true } }, + }; + }); + When('I call create with roleName "Test Role" and only user permissions', async () => { try { - await createStaffRole({ roleName: 'Test Role', isDefault: false }); - } catch (err) { - error = err as Error; + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; } }); + Then('all community permissions should remain false', () => { + expect(thrownError).toBeUndefined(); + const cp = roleInstance.permissions.communityPermissions; + for (const key of Object.keys(cp)) { + expect(cp[key], key).toBe(false); + } + }); + }); - Then('it should throw an error "Staff role with name Test Role already exists"', () => { - expect(error).toBeDefined(); - expect(error.message).toBe('Staff role with name Test Role already exists'); + Scenario('Omitting user permissions sub-object leaves user permissions unchanged', ({ Given, When, Then }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Test Role', + permissions: { community: { canManageCommunities: true } }, + }; + }); + When('I call create with roleName "Test Role" and only community permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('all user permissions should remain false', () => { + expect(thrownError).toBeUndefined(); + const up = roleInstance.permissions.userPermissions; + for (const key of Object.keys(up)) { + expect(up[key], key).toBe(false); + } }); }); - Scenario('Creating a staff role when save fails', ({ Given, When, Then }) => { - let error: Error; + // ─── getNewInstance called with roleName ────────────────────────────────── - Given('a staff role with name "Test Role" does not exist', () => { - // Mock will be set up in When step + Scenario('getNewInstance is called with the provided role name', ({ Given, When, Then }) => { + Given('a staff role with name "Named Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Named Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Named Role' }; }); + When('I call create with roleName "Named Role" and no permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('getNewInstance should have been called with "Named Role"', () => { + expect(thrownError).toBeUndefined(); + const repo = dataSources._repo as { getNewInstance: ReturnType }; + expect(repo.getNewInstance).toHaveBeenCalledWith('Named Role'); + }); + }); + + // ─── Additional permission scenarios added ─────────────────────────────── - When('I create a staff role but save fails', async () => { - const mockRepo = makeMockRepo({ - getByRoleName: vi.fn().mockRejectedValue(new Error('Not found')), - getNewInstance: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Test Role', isDefault: false })), - save: vi.fn().mockResolvedValue(undefined), // Simulate save failure - }); + Scenario('Successfully creates a staff role with staff-role permissions', ({ Given, When, Then }) => { + Given('a staff role with name "Role Manager" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Role Manager'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Role Manager', + permissions: { staffRole: { canViewRoles: true, canAddRole: true, canEditRole: true, canRemoveRole: true } }, + }; + }); + When('I call create with roleName "Role Manager" and staffRole permissions canViewRoles true, canAddRole true, canEditRole true, canRemoveRole true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the staffRole permissions should be set on the saved instance', () => { + expect(thrownError).toBeUndefined(); + const sp = roleInstance.permissions.staffRolePermissions; + expect(sp).toBeDefined(); + // The create pipeline applies into staffRolePermissions adapter; check known keys + expect(sp?.['canViewRoles']).toBe(true); + expect(sp?.['canAddRole']).toBe(true); + expect(sp?.['canEditRole']).toBe(true); + expect(sp?.['canRemoveRole']).toBe(true); + }); + }); - vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { - await callback(mockRepo); - }); + Scenario('Successfully creates a staff role with finance permissions', ({ Given, When, Then }) => { + Given('a staff role with name "Finance Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Finance Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Finance Role', permissions: { finance: { canManageFinance: true } } }; + }); + When('I call create with roleName "Finance Role" and finance permissions canManageFinance true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the finance permission canManageFinance should be true on the saved instance', () => { + expect(thrownError).toBeUndefined(); + const fp = roleInstance.permissions.financePermissions; + expect(fp).toBeDefined(); + expect(fp?.['canManageFinance']).toBe(true); + }); + }); + Scenario('Successfully creates a staff role with tech-admin permissions', ({ Given, When, Then }) => { + Given('a staff role with name "Tech Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Tech Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Tech Role', permissions: { techAdmin: { canManageTechAdmin: true } } }; + }); + When('I call create with roleName "Tech Role" and techAdmin permissions canManageTechAdmin true', async () => { try { - await createStaffRole({ roleName: 'Test Role', isDefault: false }); - } catch (err) { - error = err as Error; + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; } }); + Then('the techAdmin permission canManageTechAdmin should be true on the saved instance', () => { + expect(thrownError).toBeUndefined(); + const tp = roleInstance.permissions.techAdminPermissions; + expect(tp).toBeDefined(); + expect(tp?.['canManageTechAdmin']).toBe(true); + }); + }); - Then('it should throw an error "Unable to create staff role"', () => { - expect(error).toBeDefined(); - expect(error.message).toBe('Unable to create staff role'); + Scenario('Creating role with canAssignStaffRoles true updates both assign flags', ({ Given, When, Then }) => { + Given('a staff role with name "AssignBoth Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('AssignBoth Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'AssignBoth Role', permissions: { user: { canAssignStaffRoles: true } } }; + }); + When('I call create with roleName "AssignBoth Role" and user permissions canAssignStaffRoles true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('both user permission flags for assigning staff roles should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.userPermissions['canAssignStaffRoles']).toBe(true); + expect(roleInstance.permissions.userPermissions['canAssignStaffUserRoles']).toBe(true); }); }); }); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create.ts index f261c1ed3..53d9db578 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/create.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create.ts @@ -2,6 +2,7 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; interface StaffRoleCreateCommandCommunityPermissions { + canManageCommunities?: boolean; canManageStaffRolesAndPermissions?: boolean; canManageAllCommunities?: boolean; canDeleteCommunities?: boolean; @@ -9,12 +10,46 @@ interface StaffRoleCreateCommandCommunityPermissions { canReIndexSearchCollections?: boolean; } +interface StaffRoleCreateCommandUserPermissions { + canManageUsers?: boolean; + canAssignStaffRoles?: boolean; + canAssignStaffUserRoles?: boolean; + canViewStaffUsers?: boolean; +} + +interface StaffRoleCreateCommandRolePermissions { + canViewRoles?: boolean; + canAddRole?: boolean; + canEditRole?: boolean; + canRemoveRole?: boolean; +} + +interface StaffRoleCreateCommandFinancePermissions { + canManageFinance?: boolean; + canViewGLBatchSummaries?: boolean; + canViewFinanceConfigs?: boolean; + canCreateFinanceConfigs?: boolean; +} + +interface StaffRoleCreateCommandTechAdminPermissions { + canManageTechAdmin?: boolean; + canViewDatabaseExplorer?: boolean; + canViewBlobExplorer?: boolean; + canViewQueueDashboard?: boolean; + canSendQueueMessages?: boolean; +} + export interface StaffRoleCreateCommandPermissions { community?: StaffRoleCreateCommandCommunityPermissions; + user?: StaffRoleCreateCommandUserPermissions; + staffRole?: StaffRoleCreateCommandRolePermissions; + finance?: StaffRoleCreateCommandFinancePermissions; + techAdmin?: StaffRoleCreateCommandTechAdminPermissions; } export interface StaffRoleCreateCommand { roleName: string; + enterpriseAppRole?: string; isDefault?: boolean; permissions?: StaffRoleCreateCommandPermissions; } @@ -42,6 +77,9 @@ const applyCommunityPermissions = (staffRole: Domain.Contexts.User.StaffRole.Sta const { communityPermissions } = staffRole.permissions; + if (permissions.canManageCommunities !== undefined) { + communityPermissions.canManageCommunities = permissions.canManageCommunities; + } if (permissions.canManageStaffRolesAndPermissions !== undefined) { communityPermissions.canManageStaffRolesAndPermissions = permissions.canManageStaffRolesAndPermissions; } @@ -59,6 +97,98 @@ const applyCommunityPermissions = (staffRole: Domain.Contexts.User.StaffRole.Sta } }; +const applyUserPermissions = (staffRole: Domain.Contexts.User.StaffRole.StaffRole, permissions?: StaffRoleCreateCommandUserPermissions) => { + if (!permissions) { + return; + } + + const { userPermissions } = staffRole.permissions; + + if (permissions.canManageUsers !== undefined) { + userPermissions.canManageUsers = permissions.canManageUsers; + } + if (permissions.canAssignStaffUserRoles !== undefined) { + userPermissions.canAssignStaffRoles = permissions.canAssignStaffUserRoles; + userPermissions.canAssignStaffUserRoles = permissions.canAssignStaffUserRoles; + } + if (permissions.canAssignStaffRoles !== undefined) { + userPermissions.canAssignStaffRoles = permissions.canAssignStaffRoles; + userPermissions.canAssignStaffUserRoles = permissions.canAssignStaffRoles; + } + if (permissions.canViewStaffUsers !== undefined) { + userPermissions.canViewStaffUsers = permissions.canViewStaffUsers; + } +}; + +const applyRolePermissions = (staffRole: Domain.Contexts.User.StaffRole.StaffRole, permissions?: StaffRoleCreateCommandRolePermissions) => { + if (!permissions) { + return; + } + + const { staffRolePermissions } = staffRole.permissions; + + if (permissions.canViewRoles !== undefined) { + staffRolePermissions.canViewRoles = permissions.canViewRoles; + } + if (permissions.canAddRole !== undefined) { + staffRolePermissions.canAddRole = permissions.canAddRole; + } + if (permissions.canEditRole !== undefined) { + staffRolePermissions.canEditRole = permissions.canEditRole; + } + if (permissions.canRemoveRole !== undefined) { + staffRolePermissions.canRemoveRole = permissions.canRemoveRole; + } +}; + +const applyFinancePermissions = (staffRole: Domain.Contexts.User.StaffRole.StaffRole, permissions?: StaffRoleCreateCommandFinancePermissions) => { + if (!permissions) { + return; + } + + const { financePermissions } = staffRole.permissions; + + if (permissions.canManageFinance !== undefined) { + financePermissions.canManageFinance = permissions.canManageFinance; + } + if (permissions.canViewGLBatchSummaries !== undefined) { + financePermissions.canViewGLBatchSummaries = permissions.canViewGLBatchSummaries; + } + if (permissions.canViewFinanceConfigs !== undefined) { + financePermissions.canViewFinanceConfigs = permissions.canViewFinanceConfigs; + } + if (permissions.canCreateFinanceConfigs !== undefined) { + financePermissions.canCreateFinanceConfigs = permissions.canCreateFinanceConfigs; + } +}; + +const applyTechAdminPermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleCreateCommandTechAdminPermissions, +) => { + if (!permissions) { + return; + } + + const { techAdminPermissions } = staffRole.permissions; + + if (permissions.canManageTechAdmin !== undefined) { + techAdminPermissions.canManageTechAdmin = permissions.canManageTechAdmin; + } + if (permissions.canViewDatabaseExplorer !== undefined) { + techAdminPermissions.canViewDatabaseExplorer = permissions.canViewDatabaseExplorer; + } + if (permissions.canViewBlobExplorer !== undefined) { + techAdminPermissions.canViewBlobExplorer = permissions.canViewBlobExplorer; + } + if (permissions.canViewQueueDashboard !== undefined) { + techAdminPermissions.canViewQueueDashboard = permissions.canViewQueueDashboard; + } + if (permissions.canSendQueueMessages !== undefined) { + techAdminPermissions.canSendQueueMessages = permissions.canSendQueueMessages; + } +}; + export const create = (dataSources: DataSources) => { return async (command: StaffRoleCreateCommand): Promise => { let createdRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; @@ -67,8 +197,14 @@ export const create = (dataSources: DataSources) => { await ensureRoleDoesNotExist(repository, command.roleName); const staffRole = await repository.getNewInstance(command.roleName); - staffRole.isDefault = command.isDefault ?? false; + if (command.enterpriseAppRole) { + staffRole.enterpriseAppRole = command.enterpriseAppRole; + } applyCommunityPermissions(staffRole, command.permissions?.community); + applyUserPermissions(staffRole, command.permissions?.user); + applyRolePermissions(staffRole, command.permissions?.staffRole); + applyFinancePermissions(staffRole, command.permissions?.finance); + applyTechAdminPermissions(staffRole, command.permissions?.techAdmin); createdRole = await repository.save(staffRole); }); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature new file mode 100644 index 000000000..83892960d --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature @@ -0,0 +1,61 @@ +Feature: Creating default staff roles + + Scenario: Creates all four default roles when none exist + Given no staff roles exist + When I call createDefaultRoles + Then it should create all four roles: "Default.CaseManager", "Default.ServiceLineOwner", "Default.Finance", "Default.TechAdmin" + And it should return all four created role references + + Scenario: Skips roles that already exist + Given the role "Default.CaseManager" already exists + When I call createDefaultRoles + Then it should only create the three missing roles + And it should not attempt to create "Default.CaseManager" again + + Scenario: Returns empty array when all roles already exist + Given all four default roles already exist + When I call createDefaultRoles + Then it should return an empty array + And it should not call getNewInstance or save + + Scenario: CaseManager role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.CaseManager" role should have canManageCommunities true + And the "Default.CaseManager" role should have canManageFinance false + And the "Default.CaseManager" role should have canManageTechAdmin false + And the "Default.CaseManager" role should have canManageUsers true + + Scenario: Finance role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.Finance" role should have canManageCommunities false + And the "Default.Finance" role should have canManageFinance true + And the "Default.Finance" role should have canManageTechAdmin false + And the "Default.Finance" role should have canManageUsers false + + Scenario: TechAdmin role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.TechAdmin" role should have canManageCommunities true + And the "Default.TechAdmin" role should have canManageFinance true + And the "Default.TechAdmin" role should have canManageTechAdmin true + And the "Default.TechAdmin" role should have canManageUsers true + + Scenario: ServiceLineOwner role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.ServiceLineOwner" role should have canManageCommunities true + And the "Default.ServiceLineOwner" role should have canManageFinance false + And the "Default.ServiceLineOwner" role should have canManageTechAdmin false + And the "Default.ServiceLineOwner" role should have canManageUsers true + + Scenario: All created roles have isDefault set to true + Given no staff roles exist + When I call createDefaultRoles + Then all created roles should have isDefault true + + Scenario: Propagates unexpected repository errors + Given no staff roles exist + When the repository throws an unexpected error + Then createDefaultRoles should propagate the error diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/create.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/create.feature index 0314a1705..3b195d8e0 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/features/create.feature +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/create.feature @@ -1,21 +1,95 @@ -Feature: Creating a staff role - - Scenario: Creating a staff role successfully - Given a staff role with name "Test Role" does not exist - When I create a staff role with name "Test Role", isDefault false, and no permissions - Then it should return a staff role entity reference with name "Test Role" and isDefault false - - Scenario: Creating a staff role with permissions - Given a staff role with name "Admin Role" does not exist - When I create a staff role with name "Admin Role", isDefault true, and permissions - Then it should return a staff role entity reference with name "Admin Role" and isDefault true - - Scenario: Creating a staff role with duplicate name - Given a staff role with name "Test Role" already exists - When I create a staff role with name "Test Role", isDefault false, and no permissions - Then it should throw an error "Staff role with name Test Role already exists" - - Scenario: Creating a staff role when save fails - Given a staff role with name "Test Role" does not exist - When I create a staff role but save fails - Then it should throw an error "Unable to create staff role" \ No newline at end of file +Feature: Create staff role + + Scenario: Successfully creates a staff role with no permissions + Given a staff role with name "Test Role" does not exist in the repository + When I call create with roleName "Test Role" and no permissions + Then the new staff role should be saved + And the result should have roleName "Test Role" + + Scenario: Successfully creates a staff role with an enterpriseAppRole + Given a staff role with name "Test Role" does not exist in the repository + When I call create with roleName "Test Role" and enterpriseAppRole "Staff.TestRole" + Then the new staff role should be saved with enterpriseAppRole "Staff.TestRole" + + Scenario: Successfully creates a staff role with community permissions + Given a staff role with name "Admin Role" does not exist in the repository + When I call create with roleName "Admin Role" and community permissions canManageCommunities true + Then the new staff role should be saved + And the community permission canManageCommunities should be true + + Scenario: Successfully creates a staff role with user permissions + Given a staff role with name "Manager Role" does not exist in the repository + When I call create with roleName "Manager Role" and user permissions canManageUsers true + Then the new staff role should be saved + And the user permission canManageUsers should be true + + Scenario: Throws when a staff role with the same name already exists + Given a staff role with name "Duplicate Role" already exists in the repository + When I call create with roleName "Duplicate Role" + Then it should throw an error with message containing "Duplicate Role" + + Scenario: Propagates unexpected repository errors from getByRoleName + Given the repository throws an unexpected error when checking for "Error Role" + When I call create with roleName "Error Role" + Then it should throw the unexpected error + + Scenario: Throws when repository fails to save the new role + Given a staff role with name "Test Role" does not exist in the repository + And saving the staff role returns undefined + When I call create with roleName "Test Role" and no permissions + Then it should throw an error with message "Unable to create staff role" + + Scenario: enterpriseAppRole is not set when not provided in the command + Given a staff role with name "Test Role" does not exist in the repository + When I call create with roleName "Test Role" and no permissions + Then the enterpriseAppRole on the saved instance should remain empty + + Scenario: Not-found detected via error name NotFoundError allows creation to proceed + Given the repository raises a NotFoundError by name when checking for "New Role" + When I call create with roleName "New Role" and no permissions + Then the new staff role should be saved + + Scenario: Successfully creates a staff role with all community permissions set + Given a staff role with name "Full Community Role" does not exist in the repository + When I call create with roleName "Full Community Role" and all community permissions true + Then all community permissions should be true on the saved instance + + Scenario: Successfully creates a staff role with canAssignStaffUserRoles set + Given a staff role with name "Assign Role" does not exist in the repository + When I call create with roleName "Assign Role" and user permissions canAssignStaffUserRoles true + Then the user permission canAssignStaffUserRoles should be true + + Scenario: Omitting community permissions sub-object leaves community permissions unchanged + Given a staff role with name "Test Role" does not exist in the repository + When I call create with roleName "Test Role" and only user permissions + Then all community permissions should remain false + + Scenario: Omitting user permissions sub-object leaves user permissions unchanged + Given a staff role with name "Test Role" does not exist in the repository + When I call create with roleName "Test Role" and only community permissions + Then all user permissions should remain false + + Scenario: getNewInstance is called with the provided role name + Given a staff role with name "Named Role" does not exist in the repository + When I call create with roleName "Named Role" and no permissions + Then getNewInstance should have been called with "Named Role" + + Scenario: Successfully creates a staff role with staff-role permissions + Given a staff role with name "Role Manager" does not exist in the repository + When I call create with roleName "Role Manager" and staffRole permissions canViewRoles true, canAddRole true, canEditRole true, canRemoveRole true + Then the staffRole permissions should be set on the saved instance + + Scenario: Successfully creates a staff role with finance permissions + Given a staff role with name "Finance Role" does not exist in the repository + When I call create with roleName "Finance Role" and finance permissions canManageFinance true + Then the finance permission canManageFinance should be true on the saved instance + + Scenario: Successfully creates a staff role with tech-admin permissions + Given a staff role with name "Tech Role" does not exist in the repository + When I call create with roleName "Tech Role" and techAdmin permissions canManageTechAdmin true + Then the techAdmin permission canManageTechAdmin should be true on the saved instance + + Scenario: Creating role with canAssignStaffRoles true updates both assign flags + Given a staff role with name "AssignBoth Role" does not exist in the repository + When I call create with roleName "AssignBoth Role" and user permissions canAssignStaffRoles true + Then both user permission flags for assigning staff roles should be true diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/list.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/list.feature new file mode 100644 index 000000000..b96d7d589 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/list.feature @@ -0,0 +1,11 @@ +Feature: List staff roles + + Scenario: Returns all staff roles when roles exist + Given the repository contains two staff roles + When I call list + Then it should return all staff roles + + Scenario: Returns an empty list when no staff roles exist + Given the repository contains no staff roles + When I call list + Then it should return an empty list diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/update.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/update.feature new file mode 100644 index 000000000..a79e9333c --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/update.feature @@ -0,0 +1,60 @@ +Feature: Update staff role + + Scenario: Successfully updates a staff role name + Given a staff role with id "role-001" exists in the repository + When I call update with roleId "role-001" and roleName "Updated Role" + Then the staff role should be saved + And the result should have roleName "Updated Role" + + Scenario: Successfully updates a staff role with an enterpriseAppRole + Given a staff role with id "role-002" exists in the repository + When I call update with roleId "role-002" and enterpriseAppRole "Staff.UpdatedRole" + Then the staff role should be saved with enterpriseAppRole "Staff.UpdatedRole" + + Scenario: Successfully updates a staff role with community permissions + Given a staff role with id "role-003" exists in the repository + When I call update with roleId "role-003" and community permissions canManageCommunities true + Then the staff role should be saved + And the community permission canManageCommunities should be true + + Scenario: Successfully updates a staff role with user permissions + Given a staff role with id "role-004" exists in the repository + When I call update with roleId "role-004" and user permissions canManageUsers true + Then the staff role should be saved + And the user permission canManageUsers should be true + + Scenario: Does not apply enterpriseAppRole when it is not provided + Given a staff role with id "role-005" exists in the repository + When I call update with roleId "role-005" and no enterpriseAppRole + Then the staff role enterpriseAppRole should remain unchanged + + Scenario: Throws when repository fails to save the updated role + Given a staff role with id "role-err" exists in the repository + And saving the staff role returns undefined + When I call update with roleId "role-err" and roleName "Any Role" + Then it should throw an error with message "Unable to update staff role" + + Scenario: Successfully updates a staff role with all community permissions set + Given a staff role with id "role-all-comm" exists in the repository + When I call update with all community permissions true + Then all community permissions should be true on the updated instance + + Scenario: Successfully updates a staff role with canAssignStaffUserRoles set + Given a staff role with id "role-assign" exists in the repository + When I call update with user permissions canAssignStaffUserRoles true + Then the user permission canAssignStaffUserRoles should be true + + Scenario: Omitting community permissions sub-object leaves community permissions unchanged + Given a staff role with id "role-noc" exists in the repository + When I call update with only user permissions + Then all community permissions should remain false + + Scenario: Omitting user permissions sub-object leaves user permissions unchanged + Given a staff role with id "role-nou" exists in the repository + When I call update with only community permissions + Then all user permissions should remain false + + Scenario: getById is called with the provided role id + Given a staff role with id "role-lookup" exists in the repository + When I call update with roleId "role-lookup" and roleName "Any Role" + Then getById should have been called with "role-lookup" diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/index.ts b/packages/ocom/application-services/src/contexts/user/staff-role/index.ts index 40c815cac..916d44345 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/index.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/index.ts @@ -1,22 +1,31 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; import { create, type StaffRoleCreateCommand } from './create.ts'; +import { createDefaultRoles } from './create-default-roles.ts'; import { deleteAndReassign, type StaffRoleDeleteAndReassignCommand } from './delete-and-reassign.ts'; +import { list } from './list.ts'; import { queryById, type StaffRoleQueryByIdCommand } from './query-by-id.ts'; import { queryByRoleName, type StaffRoleQueryByRoleNameCommand } from './query-by-role-name.ts'; +import { update, type StaffRoleUpdateCommand } from './update.ts'; export interface StaffRoleApplicationService { create: (command: StaffRoleCreateCommand) => Promise; + createDefaultRoles: () => Promise; deleteAndReassign: (command: StaffRoleDeleteAndReassignCommand) => Promise; + list: () => Promise; queryById: (command: StaffRoleQueryByIdCommand) => Promise; queryByRoleName: (command: StaffRoleQueryByRoleNameCommand) => Promise; + update: (command: StaffRoleUpdateCommand) => Promise; } export const StaffRole = (dataSources: DataSources): StaffRoleApplicationService => { return { create: create(dataSources), + createDefaultRoles: createDefaultRoles(dataSources), deleteAndReassign: deleteAndReassign(dataSources), + list: list(dataSources), queryById: queryById(dataSources), queryByRoleName: queryByRoleName(dataSources), + update: update(dataSources), }; }; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/list.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/list.test.ts new file mode 100644 index 000000000..ef8cf5f56 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/list.test.ts @@ -0,0 +1,114 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { list } from './list.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/list.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeMockStaffRoleRef(id: string, roleName: string): Domain.Contexts.User.StaffRole.StaffRoleEntityReference { + return { + id, + roleName, + enterpriseAppRole: '', + isDefault: false, + roleType: null, + permissions: { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + userPermissions: { + canManageUsers: false, + canAssignStaffUserRoles: false, + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; +} + +function makeDataSources(roles: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[]): DataSources { + return { + readonlyDataSource: { + User: { + StaffRole: { + StaffRoleReadRepo: { + getAll: vi.fn().mockResolvedValue(roles), + }, + }, + }, + }, + } as unknown as DataSources; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[] | undefined; + let thrownError: unknown; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + }); + + // ─── Roles exist ────────────────────────────────────────────────────────── + + Scenario('Returns all staff roles when roles exist', ({ Given, When, Then }) => { + Given('the repository contains two staff roles', () => { + const roles = [makeMockStaffRoleRef('role-001', 'Admin'), makeMockStaffRoleRef('role-002', 'Manager')]; + dataSources = makeDataSources(roles); + }); + + When('I call list', async () => { + try { + result = await list(dataSources)(); + } catch (e) { + thrownError = e; + } + }); + + Then('it should return all staff roles', () => { + expect(thrownError).toBeUndefined(); + expect(result).toHaveLength(2); + const roles = result as Domain.Contexts.User.StaffRole.StaffRoleEntityReference[]; + const [first, second] = roles; + expect(first?.id).toBe('role-001'); + expect(second?.id).toBe('role-002'); + }); + }); + + // ─── No roles ───────────────────────────────────────────────────────────── + + Scenario('Returns an empty list when no staff roles exist', ({ Given, When, Then }) => { + Given('the repository contains no staff roles', () => { + dataSources = makeDataSources([]); + }); + + When('I call list', async () => { + try { + result = await list(dataSources)(); + } catch (e) { + thrownError = e; + } + }); + + Then('it should return an empty list', () => { + expect(thrownError).toBeUndefined(); + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/list.ts b/packages/ocom/application-services/src/contexts/user/staff-role/list.ts new file mode 100644 index 000000000..a7a8eb3d3 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/list.ts @@ -0,0 +1,8 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export const list = (dataSources: DataSources) => { + return async (): Promise => { + return await dataSources.readonlyDataSource.User.StaffRole.StaffRoleReadRepo.getAll(); + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/update.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/update.test.ts new file mode 100644 index 000000000..70fb1147b --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/update.test.ts @@ -0,0 +1,409 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { update, type StaffRoleUpdateCommand } from './update.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/update.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +type MockPermissions = { + communityPermissions: Record; + userPermissions: Record; +}; + +interface MockStaffRoleInstance { + id: string; + roleName: string; + enterpriseAppRole: string; + isDefault: boolean; + roleType: null; + permissions: MockPermissions; + createdAt: Date; + updatedAt: Date; + schemaVersion: string; +} + +function makeMockStaffRoleInstance(id: string, roleName = 'Original Role'): MockStaffRoleInstance { + const communityPermissions: Record = { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }; + const userPermissions: Record = { + canManageUsers: false, + canAssignStaffUserRoles: false, + }; + return { + id, + roleName, + enterpriseAppRole: 'Original.AppRole', + isDefault: false, + roleType: null, + permissions: { communityPermissions, userPermissions }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + }; +} + +function makeDataSources(overrides: { + roleInstance?: MockStaffRoleInstance; + explicitUndefinedSave?: boolean; +}): DataSources & { _repo: unknown } { + const { roleInstance, explicitUndefinedSave } = overrides; + const instance = roleInstance ?? makeMockStaffRoleInstance('role-001'); + const savedRole = explicitUndefinedSave ? undefined : (instance as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference); + + const repo = { + getById: vi.fn().mockResolvedValue(instance), + save: vi.fn().mockResolvedValue(savedRole), + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; + + return { + domainDataSource: { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withScopedTransaction: vi.fn().mockImplementation(async (cb: (r: typeof repo) => Promise) => { + await cb(repo); + }), + }, + }, + }, + }, + _repo: repo, + } as unknown as DataSources & { _repo: unknown }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources & { _repo?: unknown }; + let command: StaffRoleUpdateCommand; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + let thrownError: unknown; + let roleInstance: MockStaffRoleInstance; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + roleInstance = makeMockStaffRoleInstance('role-001'); + command = { roleId: 'role-001', roleName: 'Updated Role' }; + }); + + // ─── Update roleName ────────────────────────────────────────────────────── + + Scenario('Successfully updates a staff role name', ({ Given, When, Then, And }) => { + Given('a staff role with id "role-001" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-001'); + dataSources = makeDataSources({ roleInstance }); + command = { roleId: 'role-001', roleName: 'Updated Role' }; + }); + + When('I call update with roleId "role-001" and roleName "Updated Role"', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + }); + + And('the result should have roleName "Updated Role"', () => { + expect(thrownError).toBeUndefined(); + expect(result).toBeDefined(); + expect(roleInstance.roleName).toBe('Updated Role'); + }); + }); + + // ─── Update enterpriseAppRole ───────────────────────────────────────────── + + Scenario('Successfully updates a staff role with an enterpriseAppRole', ({ Given, When, Then }) => { + Given('a staff role with id "role-002" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-002'); + dataSources = makeDataSources({ roleInstance }); + command = { roleId: 'role-002', roleName: 'Updated Role', enterpriseAppRole: 'Staff.UpdatedRole' }; + }); + + When('I call update with roleId "role-002" and enterpriseAppRole "Staff.UpdatedRole"', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff role should be saved with enterpriseAppRole "Staff.UpdatedRole"', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.enterpriseAppRole).toBe('Staff.UpdatedRole'); + }); + }); + + // ─── Update community permissions ───────────────────────────────────────── + + Scenario('Successfully updates a staff role with community permissions', ({ Given, When, Then, And }) => { + Given('a staff role with id "role-003" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-003'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-003', + roleName: 'Admin Role', + permissions: { community: { canManageCommunities: true } }, + }; + }); + + When('I call update with roleId "role-003" and community permissions canManageCommunities true', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + }); + + And('the community permission canManageCommunities should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.communityPermissions['canManageCommunities']).toBe(true); + }); + }); + + // ─── Update user permissions ────────────────────────────────────────────── + + Scenario('Successfully updates a staff role with user permissions', ({ Given, When, Then, And }) => { + Given('a staff role with id "role-004" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-004'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-004', + roleName: 'Manager Role', + permissions: { user: { canManageUsers: true } }, + }; + }); + + When('I call update with roleId "role-004" and user permissions canManageUsers true', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + }); + + And('the user permission canManageUsers should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.userPermissions['canManageUsers']).toBe(true); + }); + }); + + // ─── enterpriseAppRole not applied when absent ──────────────────────────── + + Scenario('Does not apply enterpriseAppRole when it is not provided', ({ Given, When, Then }) => { + Given('a staff role with id "role-005" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-005'); + roleInstance.enterpriseAppRole = 'Original.AppRole'; + dataSources = makeDataSources({ roleInstance }); + command = { roleId: 'role-005', roleName: 'Some Role' }; + }); + + When('I call update with roleId "role-005" and no enterpriseAppRole', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff role enterpriseAppRole should remain unchanged', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.enterpriseAppRole).toBe('Original.AppRole'); + }); + }); + + // ─── Save fails ─────────────────────────────────────────────────────────── + + Scenario('Throws when repository fails to save the updated role', ({ Given, When, Then, And }) => { + Given('a staff role with id "role-err" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-err'); + }); + + And('saving the staff role returns undefined', () => { + dataSources = makeDataSources({ roleInstance, explicitUndefinedSave: true }); + command = { roleId: 'role-err', roleName: 'Any Role' }; + }); + + When('I call update with roleId "role-err" and roleName "Any Role"', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an error with message "Unable to update staff role"', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toBe('Unable to update staff role'); + }); + }); + + // ─── All community permissions ──────────────────────────────────────────── + + Scenario('Successfully updates a staff role with all community permissions set', ({ Given, When, Then }) => { + Given('a staff role with id "role-all-comm" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-all-comm'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-all-comm', + roleName: 'Full Community Role', + permissions: { + community: { + canManageCommunities: true, + canManageStaffRolesAndPermissions: true, + canManageAllCommunities: true, + canDeleteCommunities: true, + canChangeCommunityOwner: true, + canReIndexSearchCollections: true, + }, + }, + }; + }); + When('I call update with all community permissions true', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('all community permissions should be true on the updated instance', () => { + expect(thrownError).toBeUndefined(); + const cp = roleInstance.permissions.communityPermissions; + expect(cp['canManageCommunities']).toBe(true); + expect(cp['canManageStaffRolesAndPermissions']).toBe(true); + expect(cp['canManageAllCommunities']).toBe(true); + expect(cp['canDeleteCommunities']).toBe(true); + expect(cp['canChangeCommunityOwner']).toBe(true); + expect(cp['canReIndexSearchCollections']).toBe(true); + }); + }); + + // ─── canAssignStaffUserRoles ────────────────────────────────────────────── + + Scenario('Successfully updates a staff role with canAssignStaffUserRoles set', ({ Given, When, Then }) => { + Given('a staff role with id "role-assign" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-assign'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-assign', + roleName: 'Assign Role', + permissions: { user: { canAssignStaffUserRoles: true } }, + }; + }); + When('I call update with user permissions canAssignStaffUserRoles true', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the user permission canAssignStaffUserRoles should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.userPermissions['canAssignStaffUserRoles']).toBe(true); + }); + }); + + // ─── No-op when sub-objects absent ─────────────────────────────────────── + + Scenario('Omitting community permissions sub-object leaves community permissions unchanged', ({ Given, When, Then }) => { + Given('a staff role with id "role-noc" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-noc'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-noc', + roleName: 'Some Role', + permissions: { user: { canManageUsers: true } }, + }; + }); + When('I call update with only user permissions', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('all community permissions should remain false', () => { + expect(thrownError).toBeUndefined(); + const cp = roleInstance.permissions.communityPermissions; + for (const key of Object.keys(cp)) { + expect(cp[key], key).toBe(false); + } + }); + }); + + Scenario('Omitting user permissions sub-object leaves user permissions unchanged', ({ Given, When, Then }) => { + Given('a staff role with id "role-nou" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-nou'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-nou', + roleName: 'Some Role', + permissions: { community: { canManageCommunities: true } }, + }; + }); + When('I call update with only community permissions', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('all user permissions should remain false', () => { + expect(thrownError).toBeUndefined(); + const up = roleInstance.permissions.userPermissions; + for (const key of Object.keys(up)) { + expect(up[key], key).toBe(false); + } + }); + }); + + // ─── getById called with roleId ─────────────────────────────────────────── + + Scenario('getById is called with the provided role id', ({ Given, When, Then }) => { + Given('a staff role with id "role-lookup" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-lookup'); + dataSources = makeDataSources({ roleInstance }); + command = { roleId: 'role-lookup', roleName: 'Any Role' }; + }); + When('I call update with roleId "role-lookup" and roleName "Any Role"', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('getById should have been called with "role-lookup"', () => { + expect(thrownError).toBeUndefined(); + const repo = dataSources._repo as { getById: ReturnType }; + expect(repo.getById).toHaveBeenCalledWith('role-lookup'); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/update.ts b/packages/ocom/application-services/src/contexts/user/staff-role/update.ts new file mode 100644 index 000000000..b2e359f07 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/update.ts @@ -0,0 +1,151 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +interface StaffRoleUpdateCommandCommunityPermissions { + canManageCommunities?: boolean; + canManageStaffRolesAndPermissions?: boolean; + canManageAllCommunities?: boolean; + canDeleteCommunities?: boolean; + canChangeCommunityOwner?: boolean; + canReIndexSearchCollections?: boolean; +} + +interface StaffRoleUpdateCommandUserPermissions { + canManageUsers?: boolean; + canAssignStaffRoles?: boolean; + canAssignStaffUserRoles?: boolean; + canViewStaffUsers?: boolean; +} + +interface StaffRoleUpdateCommandRolePermissions { + canViewRoles?: boolean; + canAddRole?: boolean; + canEditRole?: boolean; + canRemoveRole?: boolean; +} + +interface StaffRoleUpdateCommandFinancePermissions { + canManageFinance?: boolean; + canViewGLBatchSummaries?: boolean; + canViewFinanceConfigs?: boolean; + canCreateFinanceConfigs?: boolean; +} + +interface StaffRoleUpdateCommandTechAdminPermissions { + canManageTechAdmin?: boolean; + canViewDatabaseExplorer?: boolean; + canViewBlobExplorer?: boolean; + canViewQueueDashboard?: boolean; + canSendQueueMessages?: boolean; +} + +interface StaffRoleUpdateCommandPermissions { + community?: StaffRoleUpdateCommandCommunityPermissions; + user?: StaffRoleUpdateCommandUserPermissions; + staffRole?: StaffRoleUpdateCommandRolePermissions; + finance?: StaffRoleUpdateCommandFinancePermissions; + techAdmin?: StaffRoleUpdateCommandTechAdminPermissions; +} + +export interface StaffRoleUpdateCommand { + roleId: string; + roleName: string; + enterpriseAppRole?: string; + permissions?: StaffRoleUpdateCommandPermissions; +} + +const applyCommunityPermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleUpdateCommandCommunityPermissions, +) => { + if (!permissions) return; + const { communityPermissions } = staffRole.permissions; + if (permissions.canManageCommunities !== undefined) communityPermissions.canManageCommunities = permissions.canManageCommunities; + if (permissions.canManageStaffRolesAndPermissions !== undefined) communityPermissions.canManageStaffRolesAndPermissions = permissions.canManageStaffRolesAndPermissions; + if (permissions.canManageAllCommunities !== undefined) communityPermissions.canManageAllCommunities = permissions.canManageAllCommunities; + if (permissions.canDeleteCommunities !== undefined) communityPermissions.canDeleteCommunities = permissions.canDeleteCommunities; + if (permissions.canChangeCommunityOwner !== undefined) communityPermissions.canChangeCommunityOwner = permissions.canChangeCommunityOwner; + if (permissions.canReIndexSearchCollections !== undefined) communityPermissions.canReIndexSearchCollections = permissions.canReIndexSearchCollections; +}; + +const applyUserPermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleUpdateCommandUserPermissions, +) => { + if (!permissions) return; + const { userPermissions } = staffRole.permissions; + if (permissions.canManageUsers !== undefined) userPermissions.canManageUsers = permissions.canManageUsers; + if (permissions.canAssignStaffUserRoles !== undefined) userPermissions.canAssignStaffUserRoles = permissions.canAssignStaffUserRoles; + if (permissions.canAssignStaffRoles !== undefined) { + userPermissions.canAssignStaffRoles = permissions.canAssignStaffRoles; + userPermissions.canAssignStaffUserRoles = permissions.canAssignStaffRoles; + } + if (permissions.canAssignStaffUserRoles !== undefined) { + userPermissions.canAssignStaffRoles = permissions.canAssignStaffUserRoles; + userPermissions.canAssignStaffUserRoles = permissions.canAssignStaffUserRoles; + } + if (permissions.canViewStaffUsers !== undefined) userPermissions.canViewStaffUsers = permissions.canViewStaffUsers; +}; + +const applyRolePermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleUpdateCommandRolePermissions, +) => { + if (!permissions) return; + const { staffRolePermissions } = staffRole.permissions; + if (permissions.canViewRoles !== undefined) staffRolePermissions.canViewRoles = permissions.canViewRoles; + if (permissions.canAddRole !== undefined) staffRolePermissions.canAddRole = permissions.canAddRole; + if (permissions.canEditRole !== undefined) staffRolePermissions.canEditRole = permissions.canEditRole; + if (permissions.canRemoveRole !== undefined) staffRolePermissions.canRemoveRole = permissions.canRemoveRole; +}; + +const applyFinancePermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleUpdateCommandFinancePermissions, +) => { + if (!permissions) return; + const { financePermissions } = staffRole.permissions; + if (permissions.canManageFinance !== undefined) financePermissions.canManageFinance = permissions.canManageFinance; + if (permissions.canViewGLBatchSummaries !== undefined) financePermissions.canViewGLBatchSummaries = permissions.canViewGLBatchSummaries; + if (permissions.canViewFinanceConfigs !== undefined) financePermissions.canViewFinanceConfigs = permissions.canViewFinanceConfigs; + if (permissions.canCreateFinanceConfigs !== undefined) financePermissions.canCreateFinanceConfigs = permissions.canCreateFinanceConfigs; +}; + +const applyTechAdminPermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleUpdateCommandTechAdminPermissions, +) => { + if (!permissions) return; + const { techAdminPermissions } = staffRole.permissions; + if (permissions.canManageTechAdmin !== undefined) techAdminPermissions.canManageTechAdmin = permissions.canManageTechAdmin; + if (permissions.canViewDatabaseExplorer !== undefined) techAdminPermissions.canViewDatabaseExplorer = permissions.canViewDatabaseExplorer; + if (permissions.canViewBlobExplorer !== undefined) techAdminPermissions.canViewBlobExplorer = permissions.canViewBlobExplorer; + if (permissions.canViewQueueDashboard !== undefined) techAdminPermissions.canViewQueueDashboard = permissions.canViewQueueDashboard; + if (permissions.canSendQueueMessages !== undefined) techAdminPermissions.canSendQueueMessages = permissions.canSendQueueMessages; +}; + +export const update = (dataSources: DataSources) => { + return async (command: StaffRoleUpdateCommand): Promise => { + let updatedRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (repository) => { + const staffRole = await repository.getById(command.roleId); + staffRole.roleName = command.roleName; + if (command.enterpriseAppRole) { + staffRole.enterpriseAppRole = command.enterpriseAppRole; + } + applyCommunityPermissions(staffRole, command.permissions?.community); + applyUserPermissions(staffRole, command.permissions?.user); + applyRolePermissions(staffRole, command.permissions?.staffRole); + applyFinancePermissions(staffRole, command.permissions?.finance); + applyTechAdminPermissions(staffRole, command.permissions?.techAdmin); + updatedRole = await repository.save(staffRole); + }); + + if (!updatedRole) { + throw new Error('Unable to update staff role'); + } + + return updatedRole; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.test.ts new file mode 100644 index 000000000..fe71ad9c2 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.test.ts @@ -0,0 +1,217 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { assignRole, type StaffUserAssignRoleCommand } from './assign-role.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/assign-role.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +interface MockStaffUserInstance extends Domain.Contexts.User.StaffUser.StaffUserEntityReference { + role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; +} + +function makeMockStaffRoleRef(id: string): Domain.Contexts.User.StaffRole.StaffRoleEntityReference { + return { + id, + roleName: `role-${id}`, + enterpriseAppRole: `role-${id}`, + isDefault: false, + roleType: null, + permissions: { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; +} + +function makeMockStaffUserInstance(id: string): MockStaffUserInstance { + let _role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + return { + id, + externalId: `ext-${id}`, + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + displayName: 'Test User', + accessBlocked: false, + tags: [], + userType: 'staff', + get role() { + return _role; + }, + set role(r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined) { + _role = r; + }, + requestRoleAssignment: vi.fn().mockImplementation((r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference) => { + _role = r; + }), + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as MockStaffUserInstance; +} + +function makeDataSources(overrides: { + staffUser?: MockStaffUserInstance; + staffRole?: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; + savedUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + explicitUndefinedSave?: boolean; +}): DataSources & { _staffUserRepo: unknown; _staffRoleRepo: unknown } { + const staffUser = overrides.staffUser ?? makeMockStaffUserInstance('user-123'); + const { staffRole } = overrides; + const savedUser = overrides.explicitUndefinedSave ? undefined : (overrides.savedUser ?? (staffUser as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference)); + + const staffUserRepo = { + get: vi.fn().mockResolvedValue(staffUser), + save: vi.fn().mockResolvedValue(savedUser), + } as unknown as Domain.Contexts.User.StaffUser.StaffUserRepository; + + const staffRoleRepo = { + getById: staffRole === null + ? vi.fn().mockResolvedValue(null) + : vi.fn().mockResolvedValue(staffRole), + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; + + return { + domainDataSource: { + User: { + StaffUser: { + StaffUserUnitOfWork: { + withScopedTransaction: vi.fn().mockImplementation(async (cb: (repo: typeof staffUserRepo) => Promise) => { + await cb(staffUserRepo); + }), + }, + }, + StaffRole: { + StaffRoleUnitOfWork: { + withScopedTransaction: vi.fn().mockImplementation(async (cb: (repo: typeof staffRoleRepo) => Promise) => { + await cb(staffRoleRepo); + }), + }, + }, + }, + }, + _staffUserRepo: staffUserRepo, + _staffRoleRepo: staffRoleRepo, + } as unknown as DataSources & { _staffUserRepo: unknown; _staffRoleRepo: unknown }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources & { _staffUserRepo?: unknown; _staffRoleRepo?: unknown }; + let command: StaffUserAssignRoleCommand; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + let thrownError: unknown; + let staffUser: MockStaffUserInstance; + let staffRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + staffUser = makeMockStaffUserInstance('user-123'); + staffRole = makeMockStaffRoleRef('role-456'); + command = { staffUserId: 'user-123', roleId: 'role-456', actorStaffUserId: 'actor-1' }; + }); + + // ─── Successfully assigns a role ────────────────────────────────────────── + + Scenario('Successfully assigns a role to an existing staff user', ({ Given, When, Then, And }) => { + Given('a staff user with id "user-123" exists', () => { + staffUser = makeMockStaffUserInstance('user-123'); + }); + + And('a staff role with id "role-456" exists', () => { + staffRole = makeMockStaffRoleRef('role-456'); + dataSources = makeDataSources({ staffUser, staffRole }); + command = { staffUserId: 'user-123', roleId: 'role-456', actorStaffUserId: 'actor-1' }; + }); + + When('I call assignRole with staffUserId "user-123" and roleId "role-456"', async () => { + try { + result = await assignRole(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff user should be saved with the role assigned', () => { + const repo = dataSources._staffUserRepo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + expect(staffUser.role).toBe(staffRole); + }); + + And('the result should be the updated staff user', () => { + expect(thrownError).toBeUndefined(); + expect(result).toBeDefined(); + expect(result?.id).toBe('user-123'); + }); + }); + + // ─── Role not found ─────────────────────────────────────────────────────── + + Scenario('Throws an error when the staff role does not exist', ({ Given, When, Then, And }) => { + Given('a staff user with id "user-123" exists', () => { + staffUser = makeMockStaffUserInstance('user-123'); + }); + + And('no staff role with id "role-999" exists in the repository', () => { + staffRole = null; + dataSources = makeDataSources({ staffUser, staffRole }); + command = { staffUserId: 'user-123', roleId: 'role-999', actorStaffUserId: 'actor-1' }; + }); + + When('I call assignRole with staffUserId "user-123" and roleId "role-999"', async () => { + try { + result = await assignRole(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an error with message containing "role-999"', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toContain('role-999'); + }); + }); + + // ─── Save returns undefined ─────────────────────────────────────────────── + + Scenario('Throws an error when the unit of work returns no result', ({ Given, When, Then, And }) => { + Given('a staff user with id "user-123" exists', () => { + staffUser = makeMockStaffUserInstance('user-123'); + }); + + And('a staff role with id "role-456" exists', () => { + staffRole = makeMockStaffRoleRef('role-456'); + }); + + And('saving the staff user returns undefined', () => { + dataSources = makeDataSources({ staffUser, staffRole, explicitUndefinedSave: true }); + command = { staffUserId: 'user-123', roleId: 'role-456', actorStaffUserId: 'actor-1' }; + }); + + When('I call assignRole with staffUserId "user-123" and roleId "role-456"', async () => { + try { + result = await assignRole(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an error with message "Unable to assign role to staff user"', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toBe('Unable to assign role to staff user'); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.ts b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.ts new file mode 100644 index 000000000..015eb6ff1 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.ts @@ -0,0 +1,46 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface StaffUserAssignRoleCommand { + staffUserId: string; + roleId: string; + actorStaffUserId: string; +} + +export const assignRole = (dataSources: DataSources) => { + return async (command: StaffUserAssignRoleCommand): Promise => { + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + + await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withScopedTransaction(async (staffUserRepo) => { + const staffUser = await staffUserRepo.get(command.staffUserId); + + let role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null = null; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (staffRoleRepo) => { + role = await staffRoleRepo.getById(command.roleId); + }); + + if (!role) { + throw new Error(`StaffRole with id ${command.roleId} not found`); + } + + // Build a descriptive activity message including role name, target user and actor (fallback to IDs when names unavailable) + let actorDisplayName = command.actorStaffUserId; + try { + const actor = await staffUserRepo.get(command.actorStaffUserId); + if (actor?.displayName) actorDisplayName = actor.displayName; + } catch (_e) { + // ignore - use id fallback + } + const roleName = (role as unknown as { roleName?: string })?.roleName ?? command.roleId; + const description = `${roleName} assigned to ${staffUser.displayName} by ${actorDisplayName}`; + staffUser.requestRoleAssignment(role, description, command.actorStaffUserId); + result = await staffUserRepo.save(staffUser); + }); + + if (!result) { + throw new Error('Unable to assign role to staff user'); + } + + return result; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts new file mode 100644 index 000000000..d33e10c23 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts @@ -0,0 +1,579 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { StaffAppRoleNames } from '../staff-role/create-default-roles.ts'; +import { createIfNotExists, type StaffUserCreateIfNotExistsCommand } from './create-if-not-exists.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/create-if-not-exists.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeMockStaffUserRef(externalId: string, overrides: Partial = {}): Domain.Contexts.User.StaffUser.StaffUserEntityReference { + return { + id: `id-${externalId}`, + externalId, + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + displayName: 'Test User', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + } as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference; +} + +function makeMockStaffRoleRef(roleName: string): Domain.Contexts.User.StaffRole.StaffRoleEntityReference { + return { + id: `role-id-${roleName}`, + roleName, + enterpriseAppRole: roleName, + isDefault: false, + roleType: null, + permissions: { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; +} + +interface MockStaffUserInstance extends Domain.Contexts.User.StaffUser.StaffUserEntityReference { + role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; +} + +function makeMockNewUser(externalId: string): MockStaffUserInstance { + let _role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + return { + id: `new-id-${externalId}`, + externalId, + firstName: 'First', + lastName: 'Last', + email: 'first@example.com', + displayName: 'First Last', + accessBlocked: false, + tags: [], + userType: 'staff', + get role() { + return _role; + }, + set role(r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined) { + _role = r; + }, + requestCreate: vi.fn(), + requestRoleAssignment: vi.fn().mockImplementation((r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference) => { + _role = r; + }), + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as MockStaffUserInstance; +} + +function makeDataSources(overrides: { + existingUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + existingUserByEmail?: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + newUser?: MockStaffUserInstance; + savedUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference; + roleByEnterpriseAppRole?: Record; + saveShouldFail?: boolean; +}): DataSources { + const newUser = overrides.newUser ?? makeMockNewUser('default'); + const savedUser = overrides.savedUser ?? (newUser as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference); + + const staffUserRepo = { + getByExternalId: vi.fn().mockResolvedValue(overrides.existingUser ?? null), + get: vi.fn().mockResolvedValue(overrides.existingUserByEmail ?? newUser), + getNewInstance: vi.fn().mockResolvedValue(newUser), + save: overrides.saveShouldFail ? vi.fn().mockResolvedValue(undefined) : vi.fn().mockResolvedValue(savedUser), + delete: vi.fn(), + } as unknown as Domain.Contexts.User.StaffUser.StaffUserRepository; + + const staffRoleRepo = { + getByRoleName: vi.fn().mockImplementation((roleName: string) => { + const role = Object.values(overrides.roleByEnterpriseAppRole ?? {}).find((candidate) => candidate.roleName === roleName); + if (role) { + return Promise.resolve(role); + } + return Promise.reject(new Error(`NotFoundError: ${roleName} not found`)); + }), + getDefaultRoleByEnterpriseAppRole: vi.fn().mockImplementation((enterpriseAppRole: string) => { + const role = overrides.roleByEnterpriseAppRole?.[enterpriseAppRole]; + if (role) { + return Promise.resolve(role); + } + return Promise.reject(new Error(`NotFoundError: ${enterpriseAppRole} not found`)); + }), + getNewInstance: vi.fn().mockImplementation((name: string) => Promise.resolve(makeMockStaffRoleRef(name))), + getNewDefaultCaseManagerInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.CaseManager)), + getNewDefaultServiceLineOwnerInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.ServiceLineOwner)), + getNewDefaultFinanceInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.Finance)), + getNewDefaultTechAdminInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin)), + save: vi.fn().mockImplementation((r: unknown) => Promise.resolve(r)), + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; + + return { + readonlyDataSource: { + User: { + StaffUser: { + StaffUserReadRepo: { + getByExternalId: vi.fn().mockResolvedValue(overrides.existingUser ?? null), + getByEmail: vi.fn().mockResolvedValue(overrides.existingUserByEmail ?? null), + }, + }, + }, + }, + domainDataSource: { + User: { + StaffUser: { + StaffUserUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, cb: (repo: typeof staffUserRepo) => Promise) => { + await cb(staffUserRepo); + }), + }, + }, + StaffRole: { + StaffRoleUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, cb: (repo: typeof staffRoleRepo) => Promise) => { + await cb(staffRoleRepo); + }), + withScopedTransaction: vi.fn().mockImplementation(async (cb: (repo: typeof staffRoleRepo) => Promise) => { + await cb(staffRoleRepo); + }), + }, + }, + }, + }, + _staffUserRepo: staffUserRepo, + _staffRoleRepo: staffRoleRepo, + } as unknown as DataSources; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources & { _staffUserRepo?: typeof Object; _staffRoleRepo?: typeof Object }; + let command: StaffUserCreateIfNotExistsCommand; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + let thrownError: unknown; + let existingUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + let newUser: MockStaffUserInstance; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + existingUser = null; + newUser = makeMockNewUser('default'); + command = { + externalId: 'ext-default', + firstName: 'First', + lastName: 'Last', + email: 'first@example.com', + aadRoles: [], + }; + }); + + // ─── Returns existing user ──────────────────────────────────────────────── + + Scenario('Returns existing user when user already exists', ({ Given, When, Then, And }) => { + Given('a staff user with externalId "ext-123" already exists', () => { + existingUser = makeMockStaffUserRef('ext-123'); + dataSources = makeDataSources({ existingUser }); + command = { ...command, externalId: 'ext-123' }; + }); + + When('I call createIfNotExists with externalId "ext-123"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Scenario('Updates externalId when user exists by email', ({ Given, When, Then, And }) => { + Given('a staff user with email "first@example.com" already exists', () => { + existingUser = makeMockStaffUserRef('ext-old', { email: 'first@example.com' }); + dataSources = makeDataSources({ existingUserByEmail: existingUser, savedUser: { ...existingUser, externalId: 'ext-new' } }); + command = { ...command, externalId: 'ext-new' }; + }); + + When('I call createIfNotExists with externalId "ext-new"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should update the existing user\'s externalId', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { save: ReturnType } })._staffUserRepo; + expect(repo.save).toHaveBeenCalled(); + }); + + And('it should return the updated user', () => { + expect(result?.externalId).toBe('ext-new'); + }); + }); + + Then('it should return the existing user', () => { + expect(result).toBe(existingUser); + }); + + And('it should not create a new user', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { getNewInstance: ReturnType } })._staffUserRepo; + expect(repo.getNewInstance).not.toHaveBeenCalled(); + }); + }); + + // ─── Creates new user (no role) ─────────────────────────────────────────── + + Scenario('Creates a new user when user does not exist', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-456" exists', () => { + newUser = makeMockNewUser('ext-456'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-456', aadRoles: [] }; + }); + + And('no matching AAD role is provided', () => { + // aadRoles is already [] + }); + + When('I call createIfNotExists with externalId "ext-456"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should call createDefaultRoles', () => { + const roleUow = ( + dataSources as unknown as { + domainDataSource: { User: { StaffRole: { StaffRoleUnitOfWork: { withTransaction: ReturnType } } } }; + } + ).domainDataSource.User.StaffRole.StaffRoleUnitOfWork; + expect(roleUow.withTransaction).toHaveBeenCalled(); + }); + + And('it should create a new user with the provided details', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { getNewInstance: ReturnType } })._staffUserRepo; + expect(repo.getNewInstance).toHaveBeenCalledWith('ext-456', 'First', 'Last', 'first@example.com'); + }); + + And('it should return the newly created user', () => { + expect(result).toBeDefined(); + expect(result?.externalId).toBe('ext-456'); + }); + }); + + // ─── Assigns matching role ──────────────────────────────────────────────── + + Scenario('Creates a new user with a matching role when AAD role matches', ({ Given, When, Then, And }) => { + let roleRef: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-789" exists', () => { + roleRef = makeMockStaffRoleRef(StaffAppRoleNames.CaseManager); + newUser = makeMockNewUser('ext-789'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { 'Staff.CaseManager': roleRef }, + }); + command = { ...command, externalId: 'ext-789' }; + }); + + And('the AAD roles include "Staff.CaseManager"', () => { + command = { ...command, aadRoles: ['Staff.CaseManager'] }; + }); + + And('the "Staff.CaseManager" role exists in the repository', () => { + // role was set up in Given + }); + + When('I call createIfNotExists with externalId "ext-789"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Staff.CaseManager" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.CaseManager); + }); + }); + + Scenario('Assigns Default.TechAdmin when AAD role is enterprise app role', ({ Given, When, Then, And }) => { + let roleRef: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-201" exists', () => { + roleRef = makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin); + newUser = makeMockNewUser('ext-201'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { 'Staff.TechAdmin': roleRef }, + }); + command = { ...command, externalId: 'ext-201' }; + }); + + And('the AAD roles include "Staff.TechAdmin"', () => { + command = { ...command, aadRoles: ['Staff.TechAdmin'] }; + }); + + And('the "Default.TechAdmin" role exists in the repository', () => { + // role was set up in Given + }); + + When('I call createIfNotExists with externalId "ext-201"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Default.TechAdmin" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.TechAdmin); + }); + }); + + Scenario('Assigns highest priority matching role when multiple AAD roles are provided', ({ Given, When, Then, And }) => { + let techAdminRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + let caseManagerRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-202" exists', () => { + techAdminRole = makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin); + caseManagerRole = makeMockStaffRoleRef(StaffAppRoleNames.CaseManager); + newUser = makeMockNewUser('ext-202'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { + 'Staff.TechAdmin': techAdminRole, + 'Staff.CaseManager': caseManagerRole, + }, + }); + command = { ...command, externalId: 'ext-202' }; + }); + + And('the AAD roles include "Unknown.Role", "Staff.TechAdmin", and "Staff.CaseManager"', () => { + command = { ...command, aadRoles: ['Unknown.Role', 'Staff.TechAdmin', 'Staff.CaseManager'] }; + }); + + And('the "Default.TechAdmin" and "Default.CaseManager" roles exist in the repository', () => { + // roles were set up in Given + }); + + When('I call createIfNotExists with externalId "ext-202"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Default.TechAdmin" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.TechAdmin); + }); + }); + + Scenario('Creates a new user without a role when AAD role has alternate formatting', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-203" exists', () => { + newUser = makeMockNewUser('ext-203'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-203' }; + }); + + And('the AAD roles include "default tech admin"', () => { + command = { ...command, aadRoles: ['default tech admin'] }; + }); + + When('I call createIfNotExists with externalId "ext-203"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── No role when AAD role unknown ──────────────────────────────────────── + + Scenario('Creates a new user without a role when no AAD role matches', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-000" exists', () => { + newUser = makeMockNewUser('ext-000'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-000' }; + }); + + And('the AAD roles include "Unknown.Role"', () => { + command = { ...command, aadRoles: ['Unknown.Role'] }; + }); + + When('I call createIfNotExists with externalId "ext-000"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── No role when empty AAD roles ──────────────────────────────────────── + + Scenario('Creates a new user without a role when AAD roles list is empty', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-111" exists', () => { + newUser = makeMockNewUser('ext-111'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-111' }; + }); + + And('the AAD roles list is empty', () => { + command = { ...command, aadRoles: [] }; + }); + + When('I call createIfNotExists with externalId "ext-111"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── Throws when save returns undefined ─────────────────────────────────── + + Scenario('Throws when repository fails to save the new user', ({ Given, When, Then }) => { + Given('no staff user with externalId "ext-err" exists', () => { + // save returns undefined to simulate a failed save (createdUser stays undefined) + newUser = makeMockNewUser('ext-err'); + dataSources = makeDataSources({ existingUser: null, newUser, saveShouldFail: true }); + command = { ...command, externalId: 'ext-err', aadRoles: [] }; + }); + + When('I call createIfNotExists with externalId "ext-err"', async () => { + try { + await createIfNotExists(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an error with message "Unable to create staff user"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unable to create staff user'); + }); + }); + + // ─── Empty email skips email lookup ─────────────────────────────────────── + + Scenario('Creates a new user when email is empty', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-noemail" exists', () => { + newUser = makeMockNewUser('ext-noemail'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-noemail' }; + }); + + And('the command has an empty email', () => { + command = { ...command, email: '' }; + }); + + When('I call createIfNotExists with externalId "ext-noemail"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should not check for an existing user by email', () => { + const readRepo = ( + dataSources as unknown as { + readonlyDataSource: { User: { StaffUser: { StaffUserReadRepo: { getByEmail: ReturnType } } } }; + } + ).readonlyDataSource.User.StaffUser.StaffUserReadRepo; + expect(readRepo.getByEmail).not.toHaveBeenCalled(); + }); + + And('it should return the newly created user', () => { + expect(result).toBeDefined(); + expect(result?.externalId).toBe('ext-noemail'); + }); + }); + + // ─── Email lookup returns null → create new user ────────────────────────── + + Scenario('Creates a new user when email lookup returns no match', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-nomatch" exists', () => { + newUser = makeMockNewUser('ext-nomatch'); + // existingUserByEmail: null means getByEmail resolves null + dataSources = makeDataSources({ existingUser: null, existingUserByEmail: null, newUser }); + command = { ...command, externalId: 'ext-nomatch', email: 'other@example.com' }; + }); + + And('a staff user with email "other@example.com" does not exist', () => { + // getByEmail will return null (set up in Given) + }); + + When('I call createIfNotExists with externalId "ext-nomatch"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create a new user', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { getNewInstance: ReturnType } })._staffUserRepo; + expect(repo.getNewInstance).toHaveBeenCalledWith('ext-nomatch', 'First', 'Last', 'other@example.com'); + }); + + And('it should return the newly created user', () => { + expect(result).toBeDefined(); + expect(result?.externalId).toBe('ext-nomatch'); + }); + }); + + // ─── Email-update save returns undefined → throws ───────────────────────── + + Scenario('Throws when update of externalId fails to save', ({ Given, When, Then, And }) => { + Given('a staff user with email "first@example.com" already exists', () => { + existingUser = makeMockStaffUserRef('ext-old', { email: 'first@example.com' }); + // saveShouldFail makes save() resolve undefined, triggering the throw + dataSources = makeDataSources({ existingUser: null, existingUserByEmail: existingUser, saveShouldFail: true }); + command = { ...command, externalId: 'ext-updfail', email: 'first@example.com' }; + }); + + And('the update transaction save returns undefined', () => { + // already wired up in Given via saveShouldFail: true + }); + + When('I call createIfNotExists with externalId "ext-updfail"', async () => { + try { + await createIfNotExists(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an error with message "Unable to update staff user externalId"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unable to update staff user externalId'); + }); + }); + + // ─── Non-NotFound error from role lookup propagates ─────────────────────── + + Scenario('Propagates non-NotFound errors from role lookup', ({ Given, When, Then, And }) => { + const dbError = new Error('Database connection failed'); + + Given('no staff user with externalId "ext-rolerr" exists', () => { + newUser = makeMockNewUser('ext-rolerr'); + dataSources = makeDataSources({ existingUser: null, newUser }); + // Override getDefaultRoleByEnterpriseAppRole to throw a non-NotFound error + (dataSources as unknown as { _staffRoleRepo: { getDefaultRoleByEnterpriseAppRole: ReturnType } })._staffRoleRepo.getDefaultRoleByEnterpriseAppRole.mockRejectedValue(dbError); + command = { ...command, externalId: 'ext-rolerr', aadRoles: ['Staff.CaseManager'] }; + }); + + And('the role repository throws a non-NotFound error for any AAD role', () => { + // already wired up in Given + }); + + When('I call createIfNotExists with externalId "ext-rolerr"', async () => { + try { + await createIfNotExists(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('it should propagate the role repository error', () => { + expect(thrownError).toBe(dbError); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts new file mode 100644 index 000000000..99a8e3ee3 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts @@ -0,0 +1,90 @@ +import type { Domain } from '@ocom/domain'; +import { Domain as DomainRuntime } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { createDefaultRoles } from '../staff-role/create-default-roles.ts'; + +export interface StaffUserCreateIfNotExistsCommand { + externalId: string; + firstName: string; + lastName: string; + email: string; + aadRoles: string[]; +} + +const isNotFoundError = (error: unknown): error is Error => { + return error instanceof Error && (error.name === 'NotFoundError' || error.message.toLowerCase().includes('not found')); +}; + +const getDefaultRoleByHighestPriorityEnterpriseAppRole = async ( + dataSources: DataSources, + aadRoles: string[], +): Promise => { + let found: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null = null; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (repo) => { + for (const aadRole of aadRoles) { + try { + found = await repo.getDefaultRoleByEnterpriseAppRole(aadRole); + return; + } catch (error) { + if (isNotFoundError(error)) { + continue; + } + throw error; + } + } + }); + return found; +}; + +export const createIfNotExists = (dataSources: DataSources) => { + return async (command: StaffUserCreateIfNotExistsCommand): Promise => { + const existing = await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(command.externalId); + if (existing) { + return existing; + } + + if (command.email) { + const existingByEmail = await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByEmail(command.email); + if (existingByEmail) { + let updatedUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withTransaction( + DomainRuntime.PassportFactory.forSystem({ canManageStaffRolesAndPermissions: true }), + async (repository) => { + const staffUser = await repository.get(existingByEmail.id); + staffUser.externalId = command.externalId; + updatedUser = await repository.save(staffUser); + }, + ); + if (!updatedUser) { + throw new Error('Unable to update staff user externalId'); + } + return updatedUser; + } + } + + // Ensure the 4 default roles exist before creating the user + await createDefaultRoles(dataSources)(); + + const matchingRole = await getDefaultRoleByHighestPriorityEnterpriseAppRole(dataSources, command.aadRoles); + + let createdUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + + await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withTransaction(DomainRuntime.PassportFactory.forSystem({ canManageStaffRolesAndPermissions: true }), async (repository) => { + const newUser = await repository.getNewInstance(command.externalId, command.firstName, command.lastName, command.email); + + newUser.requestCreate(newUser.id); + + if (matchingRole) { + newUser.requestRoleAssignment(matchingRole, 'Role assigned on creation', newUser.id); + } + + createdUser = await repository.save(newUser); + }); + + if (!createdUser) { + throw new Error('Unable to create staff user'); + } + + return createdUser; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/assign-role.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/assign-role.feature new file mode 100644 index 000000000..0a105591d --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/assign-role.feature @@ -0,0 +1,21 @@ +Feature: Assign role to staff user + + Scenario: Successfully assigns a role to an existing staff user + Given a staff user with id "user-123" exists + And a staff role with id "role-456" exists + When I call assignRole with staffUserId "user-123" and roleId "role-456" + Then the staff user should be saved with the role assigned + And the result should be the updated staff user + + Scenario: Throws an error when the staff role does not exist + Given a staff user with id "user-123" exists + And no staff role with id "role-999" exists in the repository + When I call assignRole with staffUserId "user-123" and roleId "role-999" + Then it should throw an error with message containing "role-999" + + Scenario: Throws an error when the unit of work returns no result + Given a staff user with id "user-123" exists + And a staff role with id "role-456" exists + And saving the staff user returns undefined + When I call assignRole with staffUserId "user-123" and roleId "role-456" + Then it should throw an error with message "Unable to assign role to staff user" diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature new file mode 100644 index 000000000..f14b421da --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature @@ -0,0 +1,91 @@ +Feature: Create staff user if not exists + + Scenario: Returns existing user when user already exists + Given a staff user with externalId "ext-123" already exists + When I call createIfNotExists with externalId "ext-123" + Then it should return the existing user + And it should not create a new user + + Scenario: Updates externalId when user exists by email + Given a staff user with email "first@example.com" already exists + When I call createIfNotExists with externalId "ext-new" + Then it should update the existing user's externalId + And it should return the updated user + + Scenario: Creates a new user when user does not exist + Given no staff user with externalId "ext-456" exists + And no matching AAD role is provided + When I call createIfNotExists with externalId "ext-456" + Then it should call createDefaultRoles + And it should create a new user with the provided details + And it should return the newly created user + + Scenario: Creates a new user with a matching role when AAD role matches + Given no staff user with externalId "ext-789" exists + And the AAD roles include "Staff.CaseManager" + And the "Staff.CaseManager" role exists in the repository + When I call createIfNotExists with externalId "ext-789" + Then it should assign the "Staff.CaseManager" role to the new user + + Scenario: Assigns Default.TechAdmin when AAD role is enterprise app role + Given no staff user with externalId "ext-201" exists + And the AAD roles include "Staff.TechAdmin" + And the "Default.TechAdmin" role exists in the repository + When I call createIfNotExists with externalId "ext-201" + Then it should assign the "Default.TechAdmin" role to the new user + + Scenario: Assigns highest priority matching role when multiple AAD roles are provided + Given no staff user with externalId "ext-202" exists + And the AAD roles include "Unknown.Role", "Staff.TechAdmin", and "Staff.CaseManager" + And the "Default.TechAdmin" and "Default.CaseManager" roles exist in the repository + When I call createIfNotExists with externalId "ext-202" + Then it should assign the "Default.TechAdmin" role to the new user + + Scenario: Creates a new user without a role when AAD role has alternate formatting + Given no staff user with externalId "ext-203" exists + And the AAD roles include "default tech admin" + When I call createIfNotExists with externalId "ext-203" + Then it should create the user without assigning a role + + Scenario: Creates a new user without a role when no AAD role matches + Given no staff user with externalId "ext-000" exists + And the AAD roles include "Unknown.Role" + When I call createIfNotExists with externalId "ext-000" + Then it should create the user without assigning a role + + Scenario: Creates a new user without a role when AAD roles list is empty + Given no staff user with externalId "ext-111" exists + And the AAD roles list is empty + When I call createIfNotExists with externalId "ext-111" + Then it should create the user without assigning a role + + Scenario: Throws when repository fails to save the new user + Given no staff user with externalId "ext-err" exists + When I call createIfNotExists with externalId "ext-err" + Then it should throw an error with message "Unable to create staff user" + + Scenario: Creates a new user when email is empty + Given no staff user with externalId "ext-noemail" exists + And the command has an empty email + When I call createIfNotExists with externalId "ext-noemail" + Then it should not check for an existing user by email + And it should return the newly created user + + Scenario: Creates a new user when email lookup returns no match + Given no staff user with externalId "ext-nomatch" exists + And a staff user with email "other@example.com" does not exist + When I call createIfNotExists with externalId "ext-nomatch" + Then it should create a new user + And it should return the newly created user + + Scenario: Throws when update of externalId fails to save + Given a staff user with email "first@example.com" already exists + And the update transaction save returns undefined + When I call createIfNotExists with externalId "ext-updfail" + Then it should throw an error with message "Unable to update staff user externalId" + + Scenario: Propagates non-NotFound errors from role lookup + Given no staff user with externalId "ext-rolerr" exists + And the role repository throws a non-NotFound error for any AAD role + When I call createIfNotExists with externalId "ext-rolerr" + Then it should propagate the role repository error diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/list.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/list.feature new file mode 100644 index 000000000..885343983 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/list.feature @@ -0,0 +1,11 @@ +Feature: List staff users + + Scenario: Returns all staff users when users exist + Given the repository contains two staff users + When I call list + Then it should return all staff users + + Scenario: Returns an empty list when no staff users exist + Given the repository contains no staff users + When I call list + Then it should return an empty list diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature new file mode 100644 index 000000000..097572fd4 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature @@ -0,0 +1,11 @@ +Feature: Query staff user by external ID + + Scenario: Returns a staff user when the external ID exists + Given a staff user with externalId "ext-123" exists in the read repository + When I call queryByExternalId with externalId "ext-123" + Then it should return the matching staff user + + Scenario: Returns null when no staff user matches the external ID + Given no staff user with externalId "ext-missing" exists in the read repository + When I call queryByExternalId with externalId "ext-missing" + Then it should return null diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/index.ts b/packages/ocom/application-services/src/contexts/user/staff-user/index.ts new file mode 100644 index 000000000..1e41957c9 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/index.ts @@ -0,0 +1,22 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { assignRole, type StaffUserAssignRoleCommand } from './assign-role.ts'; +import { createIfNotExists, type StaffUserCreateIfNotExistsCommand } from './create-if-not-exists.ts'; +import { list } from './list.ts'; +import { queryByExternalId, type StaffUserQueryByExternalIdCommand } from './query-by-external-id.ts'; + +export interface StaffUserApplicationService { + assignRole: (command: StaffUserAssignRoleCommand) => Promise; + createIfNotExists: (command: StaffUserCreateIfNotExistsCommand) => Promise; + list: () => Promise; + queryByExternalId: (command: StaffUserQueryByExternalIdCommand) => Promise; +} + +export const StaffUser = (dataSources: DataSources): StaffUserApplicationService => { + return { + assignRole: assignRole(dataSources), + createIfNotExists: createIfNotExists(dataSources), + list: list(dataSources), + queryByExternalId: queryByExternalId(dataSources), + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/list.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/list.test.ts new file mode 100644 index 000000000..d3799d65e --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/list.test.ts @@ -0,0 +1,104 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { list } from './list.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/list.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeMockStaffUserRef(id: string, firstName: string): Domain.Contexts.User.StaffUser.StaffUserEntityReference { + return { + id, + externalId: `ext-${id}`, + firstName, + lastName: 'Doe', + displayName: `${firstName} Doe`, + email: `${firstName.toLowerCase()}@example.com`, + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference; +} + +function makeDataSources(users: Domain.Contexts.User.StaffUser.StaffUserEntityReference[]): DataSources { + return { + readonlyDataSource: { + User: { + StaffUser: { + StaffUserReadRepo: { + getAll: vi.fn().mockResolvedValue(users), + }, + }, + }, + }, + } as unknown as DataSources; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference[] | undefined; + let thrownError: unknown; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + }); + + // ─── Users exist ────────────────────────────────────────────────────────── + + Scenario('Returns all staff users when users exist', ({ Given, When, Then }) => { + Given('the repository contains two staff users', () => { + const users = [makeMockStaffUserRef('user-001', 'Alice'), makeMockStaffUserRef('user-002', 'Bob')]; + dataSources = makeDataSources(users); + }); + + When('I call list', async () => { + try { + result = await list(dataSources)(); + } catch (e) { + thrownError = e; + } + }); + + Then('it should return all staff users', () => { + expect(thrownError).toBeUndefined(); + expect(result).toHaveLength(2); + const [first, second] = result as Domain.Contexts.User.StaffUser.StaffUserEntityReference[]; + expect(first?.id).toBe('user-001'); + expect(second?.id).toBe('user-002'); + }); + }); + + // ─── No users ───────────────────────────────────────────────────────────── + + Scenario('Returns an empty list when no staff users exist', ({ Given, When, Then }) => { + Given('the repository contains no staff users', () => { + dataSources = makeDataSources([]); + }); + + When('I call list', async () => { + try { + result = await list(dataSources)(); + } catch (e) { + thrownError = e; + } + }); + + Then('it should return an empty list', () => { + expect(thrownError).toBeUndefined(); + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/list.ts b/packages/ocom/application-services/src/contexts/user/staff-user/list.ts new file mode 100644 index 000000000..b6cbc1463 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/list.ts @@ -0,0 +1,8 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export const list = (dataSources: DataSources) => { + return async (): Promise => { + return await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getAll(); + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts new file mode 100644 index 000000000..2cbcc5f4d --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts @@ -0,0 +1,82 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { queryByExternalId } from './query-by-external-id.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/query-by-external-id.feature')); + +function makeMockStaffUserRef(externalId: string): Domain.Contexts.User.StaffUser.StaffUserEntityReference { + return { + id: `id-${externalId}`, + externalId, + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + displayName: 'Test User', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference; +} + +function makeDataSources(existingUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null): DataSources { + return { + readonlyDataSource: { + User: { + StaffUser: { + StaffUserReadRepo: { + getByExternalId: vi.fn().mockResolvedValue(existingUser), + }, + }, + }, + }, + } as unknown as DataSources; +} + +test.for(feature, ({ Scenario }) => { + Scenario('Returns a staff user when the external ID exists', ({ Given, When, Then }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + let expectedUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference; + + Given('a staff user with externalId "ext-123" exists in the read repository', () => { + expectedUser = makeMockStaffUserRef('ext-123'); + dataSources = makeDataSources(expectedUser); + }); + + When('I call queryByExternalId with externalId "ext-123"', async () => { + result = await queryByExternalId(dataSources)({ externalId: 'ext-123' }); + }); + + Then('it should return the matching staff user', () => { + expect(result).toBe(expectedUser); + expect(result?.externalId).toBe('ext-123'); + }); + }); + + Scenario('Returns null when no staff user matches the external ID', ({ Given, When, Then }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + + Given('no staff user with externalId "ext-missing" exists in the read repository', () => { + dataSources = makeDataSources(null); + }); + + When('I call queryByExternalId with externalId "ext-missing"', async () => { + result = await queryByExternalId(dataSources)({ externalId: 'ext-missing' }); + }); + + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts new file mode 100644 index 000000000..23cd50e5f --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts @@ -0,0 +1,12 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface StaffUserQueryByExternalIdCommand { + externalId: string; +} + +export const queryByExternalId = (dataSources: DataSources) => { + return async (command: StaffUserQueryByExternalIdCommand): Promise => { + return await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(command.externalId); + }; +}; diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index ccfdc57d1..58e37f066 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -59,8 +59,7 @@ export const buildApplicationServicesFactory = (infrastructureServicesRegistry: passport = Domain.PassportFactory.forMember(endUser, member, community); } } else if (openIdConfigKey === 'StaffPortal') { - const staffUser = undefined; - // const staffUser = await readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(verifiedJwt.sub); + const staffUser = await readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(verifiedJwt.sub); if (staffUser) { passport = Domain.PassportFactory.forStaffUser(staffUser); } diff --git a/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts b/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts index 43a2cd4ee..9cd1314a4 100644 --- a/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts +++ b/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts @@ -1,6 +1,8 @@ import { type Model, type ObjectId, Schema, type SchemaDefinition } from 'mongoose'; import { type Role, type RoleModelType, roleOptions } from './role.model.ts'; +export const StaffEnterpriseAppRoles = ['Staff.CaseManager', 'Staff.Finance', 'Staff.ServiceLineOwner', 'Staff.TechAdmin'] as const; + export interface StaffRoleServicePermissions { id?: ObjectId; canManageServices: boolean; @@ -12,6 +14,7 @@ export interface StaffRoleServiceTicketPermissions { canCreateTickets: boolean; canManageTickets: boolean; canAssignTickets: boolean; + canUpdateTickets: boolean; canWorkOnTickets: boolean; // isSystemAccount: false; } @@ -21,6 +24,7 @@ export interface StaffRoleViolationTicketPermissions { canCreateTickets: boolean; canManageTickets: boolean; canAssignTickets: boolean; + canUpdateTickets: boolean; canWorkOnTickets: boolean; // isSystemAccount: false; } @@ -34,6 +38,7 @@ export interface StaffRolePropertyPermissions { export interface StaffRoleCommunityPermissions { id?: ObjectId; + canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean; canManageAllCommunities: boolean; canDeleteCommunities: boolean; @@ -41,12 +46,49 @@ export interface StaffRoleCommunityPermissions { canReIndexSearchCollections: boolean; } +export interface StaffRoleFinancePermissions { + id?: ObjectId; + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; +} + +export interface StaffRoleTechAdminPermissions { + id?: ObjectId; + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; +} + +export interface StaffRoleUserPermissions { + id?: ObjectId; + canManageUsers: boolean; + canAssignStaffRoles: boolean; + canAssignStaffUserRoles?: boolean; + canViewStaffUsers: boolean; +} + +export interface StaffRoleRolePermissions { + id?: ObjectId; + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; +} + export interface StaffRolePermissions { id?: ObjectId; servicePermissions: StaffRoleServicePermissions; serviceTicketPermissions: StaffRoleServiceTicketPermissions; violationTicketPermissions: StaffRoleViolationTicketPermissions; communityPermissions: StaffRoleCommunityPermissions; + financePermissions: StaffRoleFinancePermissions; + techAdminPermissions: StaffRoleTechAdminPermissions; + userPermissions: StaffRoleUserPermissions; + staffRolePermissions: StaffRoleRolePermissions; propertyPermissions: StaffRolePropertyPermissions; } @@ -54,6 +96,7 @@ export interface StaffRole extends Role { permissions: StaffRolePermissions; roleName: string; + enterpriseAppRole?: string; roleType?: string; isDefault: boolean; } @@ -68,15 +111,18 @@ const StaffRoleSchema = new Schema, StaffRole>( canCreateTickets: { type: Boolean, required: true, default: false }, canManageTickets: { type: Boolean, required: true, default: false }, canAssignTickets: { type: Boolean, required: true, default: false }, + canUpdateTickets: { type: Boolean, required: true, default: false }, canWorkOnTickets: { type: Boolean, required: true, default: false, index: true }, } as SchemaDefinition, violationTicketPermissions: { canCreateTickets: { type: Boolean, required: true, default: false }, canManageTickets: { type: Boolean, required: true, default: false }, canAssignTickets: { type: Boolean, required: true, default: false }, + canUpdateTickets: { type: Boolean, required: true, default: false }, canWorkOnTickets: { type: Boolean, required: true, default: false, index: true }, } as SchemaDefinition, communityPermissions: { + canManageCommunities: { type: Boolean, required: true, default: false }, canManageStaffRolesAndPermissions: { type: Boolean, required: true, @@ -99,19 +145,49 @@ const StaffRoleSchema = new Schema, StaffRole>( default: false, }, } as SchemaDefinition, + financePermissions: { + canManageFinance: { type: Boolean, required: true, default: false }, + canViewGLBatchSummaries: { type: Boolean, required: true, default: false }, + canViewFinanceConfigs: { type: Boolean, required: true, default: false }, + canCreateFinanceConfigs: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, + techAdminPermissions: { + canManageTechAdmin: { type: Boolean, required: true, default: false }, + canViewDatabaseExplorer: { type: Boolean, required: true, default: false }, + canViewBlobExplorer: { type: Boolean, required: true, default: false }, + canViewQueueDashboard: { type: Boolean, required: true, default: false }, + canSendQueueMessages: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, + userPermissions: { + canManageUsers: { type: Boolean, required: true, default: false }, + canAssignStaffRoles: { type: Boolean, required: true, default: false }, + canAssignStaffUserRoles: { type: Boolean, required: true, default: false }, + canViewStaffUsers: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, + staffRolePermissions: { + canViewRoles: { type: Boolean, required: true, default: false }, + canAddRole: { type: Boolean, required: true, default: false }, + canEditRole: { type: Boolean, required: true, default: false }, + canRemoveRole: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, propertyPermissions: { - // canManageProperties: { type: Boolean, required: true, default: false }, - // canEditOwnProperty: { type: Boolean, required: true, default: false }, + canManageProperties: { type: Boolean, required: true, default: false }, + canEditOwnProperty: { type: Boolean, required: true, default: false }, } as SchemaDefinition, } as SchemaDefinition, - schemaVersion: { type: String, default: '1.0.0' }, - roleName: { type: String, required: true, maxlength: 50 }, + schemaVersion: { type: String, default: '1.0.0', immutable: true }, + roleName: { type: String, required: true, maxlength: 256 }, + enterpriseAppRole: { + type: String, + required: true, + enum: StaffEnterpriseAppRoles, + }, isDefault: { type: Boolean, required: true, default: false }, }, roleOptions, ).index({ roleName: 1 }, { unique: true }); -export const StaffRoleModelName: string = 'staff-roles'; +export const StaffRoleModelName: string = 'staff-user-role'; export const StaffRoleModelFactory = (RoleModel: RoleModelType) => { return RoleModel.discriminator(StaffRoleModelName, StaffRoleSchema); diff --git a/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts b/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts index 4083e1f38..8420b3f9b 100644 --- a/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts +++ b/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts @@ -1,8 +1,42 @@ -import { type Model, type ObjectId, type PopulatedDoc, Schema } from 'mongoose'; +import type { MongooseSeedwork } from '@cellix/mongoose-seedwork'; +import { type Model, type ObjectId, type PopulatedDoc, Schema, type Types } from 'mongoose'; import { Patterns } from '../../patterns.ts'; import * as StaffRole from '../role/staff-role.model.ts'; import { type User, type UserModelType, userOptions } from './user.model.ts'; +export interface StaffUserActivityDetail extends MongooseSeedwork.SubdocumentBase { + activityType: string; + activityDescription: string; + activityBy: ObjectId; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +const StaffUserActivityDetailSchema = new Schema, StaffUserActivityDetail>( + { + activityType: { + type: String, + required: true, + enum: ['CREATED', 'UPDATED', 'ROLE_ASSIGNED', 'ROLE_REMOVED', 'BLOCKED', 'UNBLOCKED'], + }, + activityDescription: { + type: String, + maxlength: 2000, + required: true, + }, + activityBy: { + type: Schema.Types.ObjectId, + ref: 'staff-user', + required: true, + index: true, + }, + }, + { + timestamps: true, + versionKey: 'version', + }, +); + export interface StaffUser extends User { role?: PopulatedDoc | ObjectId; firstName: string; @@ -14,6 +48,7 @@ export interface StaffUser extends User { userType?: string; accessBlocked: boolean; tags?: string[]; + activityLog: Types.DocumentArray; } const StaffUserSchema = new Schema, StaffUser>( @@ -67,6 +102,7 @@ const StaffUserSchema = new Schema, StaffUser>( type: [String], required: false, }, + activityLog: [StaffUserActivityDetailSchema], }, userOptions, ).index({ email: 1 }, { sparse: true }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature index 547bbc02c..ba7c3c123 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature @@ -77,4 +77,18 @@ Feature: StaffRoleCommunityPermissions Scenario: Changing canReIndexSearchCollections without permission Given a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account When I try to set canReIndexSearchCollections to true - Then a PermissionError should be thrown \ No newline at end of file + Then a PermissionError should be thrown + Scenario: Changing canManageCommunities with manage staff roles permission + Given a StaffRoleCommunityPermissions entity with permission to manage staff roles + When I set canManageCommunities to true + Then the property should be updated to true + + Scenario: Changing canManageCommunities with system account permission + Given a StaffRoleCommunityPermissions entity with system account permission + When I set canManageCommunities to true + Then the property should be updated to true + + Scenario: Changing canManageCommunities without permission + Given a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account + When I try to set canManageCommunities to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature new file mode 100644 index 000000000..1d1b1f4dc --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature @@ -0,0 +1,50 @@ +Feature: StaffRoleFinancePermissions + + Background: + Given valid StaffRoleFinancePermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageFinance with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canManageFinance to true + Then the property should be updated to true + + Scenario: Changing canManageFinance with system account permission + Given a StaffRoleFinancePermissions entity with system account permission + When I set canManageFinance to true + Then the property should be updated to true + + Scenario: Changing canManageFinance without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canManageFinance to true + Then a PermissionError should be thrown + + Scenario: Changing canViewGLBatchSummaries with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canViewGLBatchSummaries to true + Then the property should be updated to true + + Scenario: Changing canViewGLBatchSummaries without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canViewGLBatchSummaries to true + Then a PermissionError should be thrown + + Scenario: Changing canViewFinanceConfigs with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canViewFinanceConfigs to true + Then the property should be updated to true + + Scenario: Changing canViewFinanceConfigs without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canViewFinanceConfigs to true + Then a PermissionError should be thrown + + Scenario: Changing canCreateFinanceConfigs with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canCreateFinanceConfigs to true + Then the property should be updated to true + + Scenario: Changing canCreateFinanceConfigs without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canCreateFinanceConfigs to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature index aae26b8e3..901786338 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature @@ -27,4 +27,19 @@ Feature: StaffRolePermissions Scenario: Accessing violationTicketPermissions Given a StaffRolePermissions entity When I access the violationTicketPermissions property - Then I should receive a StaffRoleViolationTicketPermissions entity instance \ No newline at end of file + Then I should receive a StaffRoleViolationTicketPermissions entity instance + + Scenario: Accessing financePermissions + Given a StaffRolePermissions entity + When I access the financePermissions property + Then I should receive a StaffRoleFinancePermissions entity instance + + Scenario: Accessing techAdminPermissions + Given a StaffRolePermissions entity + When I access the techAdminPermissions property + Then I should receive a StaffRoleTechAdminPermissions entity instance + + Scenario: Accessing userPermissions + Given a StaffRolePermissions entity + When I access the userPermissions property + Then I should receive a StaffRoleUserPermissions entity instance diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature new file mode 100644 index 000000000..514dc404d --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature @@ -0,0 +1,60 @@ +Feature: StaffRoleTechAdminPermissions + + Background: + Given valid StaffRoleTechAdminPermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageTechAdmin with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canManageTechAdmin to true + Then the property should be updated to true + + Scenario: Changing canManageTechAdmin with system account permission + Given a StaffRoleTechAdminPermissions entity with system account permission + When I set canManageTechAdmin to true + Then the property should be updated to true + + Scenario: Changing canManageTechAdmin without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canManageTechAdmin to true + Then a PermissionError should be thrown + + Scenario: Changing canViewDatabaseExplorer with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewDatabaseExplorer to true + Then the property should be updated to true + + Scenario: Changing canViewDatabaseExplorer without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewDatabaseExplorer to true + Then a PermissionError should be thrown + + Scenario: Changing canViewBlobExplorer with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewBlobExplorer to true + Then the property should be updated to true + + Scenario: Changing canViewBlobExplorer without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewBlobExplorer to true + Then a PermissionError should be thrown + + Scenario: Changing canViewQueueDashboard with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewQueueDashboard to true + Then the property should be updated to true + + Scenario: Changing canViewQueueDashboard without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewQueueDashboard to true + Then a PermissionError should be thrown + + Scenario: Changing canSendQueueMessages with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canSendQueueMessages to true + Then the property should be updated to true + + Scenario: Changing canSendQueueMessages without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canSendQueueMessages to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature new file mode 100644 index 000000000..21cd6b942 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature @@ -0,0 +1,20 @@ +Feature: StaffRoleUserPermissions + + Background: + Given valid StaffRoleUserPermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageUsers with manage staff roles permission + Given a StaffRoleUserPermissions entity with permission to manage staff roles + When I set canManageUsers to true + Then the property should be updated to true + + Scenario: Changing canManageUsers with system account permission + Given a StaffRoleUserPermissions entity with system account permission + When I set canManageUsers to true + Then the property should be updated to true + + Scenario: Changing canManageUsers without permission + Given a StaffRoleUserPermissions entity without permission to manage staff roles or system account + When I try to set canManageUsers to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature index d5965aff4..641313263 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature @@ -12,12 +12,12 @@ Feature: StaffRole # roleName Scenario: Changing the roleName with permission to manage staff roles Given a StaffRole aggregate with permission to manage staff roles and permissions - When I set the roleName to "Manager" + When I set the roleName to "manager" Then the staff role's roleName should be "Manager" Scenario: Changing the roleName with system account permission Given a StaffRole aggregate with system account permission - When I set the roleName to "Manager" + When I set the roleName to "manager" Then the staff role's roleName should be "Manager" Scenario: Changing the roleName without permission @@ -78,3 +78,82 @@ Feature: StaffRole And the createdAt property should return the correct date And the updatedAt property should return the correct date And the schemaVersion property should return the correct version + + # enterpriseAppRole + Scenario: Getting the enterpriseAppRole property + Given a StaffRole aggregate with permission to manage staff roles and permissions + Then the enterpriseAppRole should return the initial value + + Scenario: Changing the enterpriseAppRole with permission to manage staff roles + Given a StaffRole aggregate with permission to manage staff roles and permissions + When I set the enterpriseAppRole to "Staff.CaseManager" + Then the staff role's enterpriseAppRole should be "Staff.CaseManager" + + Scenario: Changing the enterpriseAppRole with system account permission + Given a StaffRole aggregate with system account permission + When I set the enterpriseAppRole to "Staff.Finance" + Then the staff role's enterpriseAppRole should be "Staff.Finance" + + Scenario: Changing the enterpriseAppRole without permission + Given a StaffRole aggregate without permission to manage staff roles and permissions or system account + When I try to set the enterpriseAppRole to "Staff.CaseManager" + Then a PermissionError should be thrown for enterpriseAppRole + + Scenario: Changing the enterpriseAppRole to an invalid value + Given a StaffRole aggregate with permission to manage staff roles and permissions + When I try to set the enterpriseAppRole to an invalid value + Then an error should be thrown for the invalid enterpriseAppRole + + # getDefaultRoleNames + Scenario: Getting the list of default role names + When I call getDefaultRoleNames + Then it should return the four canonical default role name strings + + # default factory methods + Scenario: Creating a new default Case Manager role + When I call getNewDefaultCaseManagerInstance + Then the role name should be "Default Case Manager" + And the enterpriseAppRole should be "Staff.CaseManager" + And isDefault should be true + And community canManageCommunities should be true + And community canManageStaffRolesAndPermissions should be true + And finance canManageFinance should be false + And techAdmin canManageTechAdmin should be false + And user canManageUsers should be true + And user canAssignStaffUserRoles should be true + + Scenario: Creating a new default Service Line Owner role + When I call getNewDefaultServiceLineOwnerInstance + Then the role name should be "Default Service Line Owner" + And the enterpriseAppRole should be "Staff.ServiceLineOwner" + And isDefault should be true + And community canManageCommunities should be true + And community canManageStaffRolesAndPermissions should be true + And finance canManageFinance should be false + And techAdmin canManageTechAdmin should be false + And user canManageUsers should be true + And user canAssignStaffUserRoles should be true + + Scenario: Creating a new default Finance role + When I call getNewDefaultFinanceInstance + Then the role name should be "Default Finance" + And the enterpriseAppRole should be "Staff.Finance" + And isDefault should be true + And community canManageCommunities should be false + And community canManageStaffRolesAndPermissions should be true + And finance canManageFinance should be true + And techAdmin canManageTechAdmin should be false + And user canManageUsers should be true + And user canAssignStaffUserRoles should be true + + Scenario: Creating a new default Tech Admin role + When I call getNewDefaultTechAdminInstance + Then the role name should be "Default Tech Admin" + And the enterpriseAppRole should be "Staff.TechAdmin" + And isDefault should be true + And community canManageCommunities should be true + And community canManageStaffRolesAndPermissions should be true + And finance canManageFinance should be true + And techAdmin canManageTechAdmin should be true + And user canManageUsers should be true + And user canAssignStaffUserRoles should be true diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.repository.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.repository.feature new file mode 100644 index 000000000..109af4d0c --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.repository.feature @@ -0,0 +1,41 @@ +Feature: StaffRoleRepository contract + + Background: + Given a mock implementation of StaffRoleRepository that satisfies the full interface + + Scenario: getNewInstance resolves with a StaffRole for the given name + When I call getNewInstance with name "Supervisor" + Then it should resolve with a StaffRole whose roleName is "Supervisor" + And the StaffRole isDefault should be false + + Scenario: getNewDefaultCaseManagerInstance resolves with a default CaseManager role + When I call getNewDefaultCaseManagerInstance + Then it should resolve with a StaffRole whose roleName is "Default Case Manager" + And the StaffRole isDefault should be true + + Scenario: getNewDefaultServiceLineOwnerInstance resolves with a default ServiceLineOwner role + When I call getNewDefaultServiceLineOwnerInstance + Then it should resolve with a StaffRole whose roleName is "Default Service Line Owner" + And the StaffRole isDefault should be true + + Scenario: getNewDefaultFinanceInstance resolves with a default Finance role + When I call getNewDefaultFinanceInstance + Then it should resolve with a StaffRole whose roleName is "Default Finance" + And the StaffRole isDefault should be true + + Scenario: getNewDefaultTechAdminInstance resolves with a default TechAdmin role + When I call getNewDefaultTechAdminInstance + Then it should resolve with a StaffRole whose roleName is "Default Tech Admin" + And the StaffRole isDefault should be true + + Scenario: getById resolves with a StaffRole for a known id + When I call getById with "role-1" + Then it should resolve with a StaffRole whose id is "role-1" + + Scenario: getByRoleName resolves with a StaffRole for a known roleName + When I call getByRoleName with "Manager" + Then it should resolve with a StaffRole whose roleName is "Manager" + + Scenario: getDefaultRoleByEnterpriseAppRole resolves with a default StaffRole + When I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager" + Then it should resolve with a StaffRole whose isDefault is true diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts index b86eaf818..3901acc8c 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts @@ -5,10 +5,19 @@ export type { } from './staff-role.ts'; export { StaffRole } from './staff-role.ts'; export type { StaffRoleUnitOfWork } from './staff-role.uow.ts'; +export * as StaffRoleValueObjects from './staff-role.value-objects.ts'; export type { StaffRoleCommunityPermissionsEntityReference, StaffRoleCommunityPermissionsProps, } from './staff-role-community-permissions.ts'; +export type { + StaffRoleFinancePermissionsEntityReference, + StaffRoleFinancePermissionsProps, +} from './staff-role-finance-permissions.ts'; +export type { + StaffRoleRolePermissionsEntityReference, + StaffRoleRolePermissionsProps, +} from './staff-role-role-permissions.ts'; export type { StaffRolePermissionsEntityReference, StaffRolePermissionsProps, @@ -25,6 +34,14 @@ export type { StaffRoleServiceTicketPermissionsEntityReference, StaffRoleServiceTicketPermissionsProps, } from './staff-role-service-ticket-permissions.ts'; +export type { + StaffRoleTechAdminPermissionsEntityReference, + StaffRoleTechAdminPermissionsProps, +} from './staff-role-tech-admin-permissions.ts'; +export type { + StaffRoleUserPermissionsEntityReference, + StaffRoleUserPermissionsProps, +} from './staff-role-user-permissions.ts'; export type { StaffRoleViolationTicketPermissionsEntityReference, StaffRoleViolationTicketPermissionsProps, diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts index dae792d73..0d3d58952 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts @@ -18,6 +18,7 @@ function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = function makeProps(overrides = {}) { return { + canManageCommunities: false, canManageStaffRolesAndPermissions: false, canManageAllCommunities: false, canDeleteCommunities: false, @@ -311,4 +312,48 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(setWithoutPermission).toThrow('Cannot set permission'); }); }); + + // canManageCommunities + Scenario('Changing canManageCommunities with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleCommunityPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I set canManageCommunities to true', () => { + entity.canManageCommunities = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageCommunities).toBe(true); + }); + }); + + Scenario('Changing canManageCommunities with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleCommunityPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I set canManageCommunities to true', () => { + entity.canManageCommunities = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageCommunities).toBe(true); + }); + }); + + Scenario('Changing canManageCommunities without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I try to set canManageCommunities to true', () => { + setWithoutPermission = () => { + entity.canManageCommunities = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts index c8887d0fc..fbc58c0dc 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts @@ -4,6 +4,7 @@ import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; import type { UserVisa } from '../user.visa.ts'; interface StaffRoleCommunityPermissionsSpec { + canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean; canManageAllCommunities: boolean; canDeleteCommunities: boolean; @@ -28,6 +29,13 @@ export class StaffRoleCommunityPermissions extends ValueObject ({ + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }), + }), + }, + } as unknown as Passport; +} + +function makeBaseProps(overrides: Partial = {}): StaffRoleProps { + const emptyPermissions = { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + propertyPermissions: { + canManageProperties: false, + canEditOwnProperty: false, + }, + serviceTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + servicePermissions: { + canManageServices: false, + }, + violationTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + financePermissions: { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + canManageTechAdmin: false, + }, + userPermissions: { + canManageUsers: false, + }, + } as const; + + return { + id: 'role-1', + roleName: 'Support', + isDefault: false, + enterpriseAppRole: '', + permissions: emptyPermissions as unknown as StaffRoleProps['permissions'], + roleType: 'staff-role', + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + }; +} + +test('applyDefaultSpec sets CaseManager permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultCaseManagerInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets Finance permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultFinanceInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(false); + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets ServiceLineOwner permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultServiceLineOwnerInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets TechAdmin permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultTechAdminInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + // Tech Admins should also be able to manage staff roles & permissions by default + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + expect(role.isDefault).toBe(true); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts new file mode 100644 index 000000000..e46d32f40 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts @@ -0,0 +1,180 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleFinancePermissions } from './staff-role-finance-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-finance-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleFinancePermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleFinancePermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleFinancePermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageFinance with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canManageFinance to true', () => { + entity.canManageFinance = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageFinance).toBe(true); + }); + }); + + Scenario('Changing canManageFinance with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canManageFinance to true', () => { + entity.canManageFinance = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageFinance).toBe(true); + }); + }); + + Scenario('Changing canManageFinance without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canManageFinance to true', () => { + setWithoutPermission = () => { + entity.canManageFinance = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewGLBatchSummaries with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canViewGLBatchSummaries to true', () => { + entity.canViewGLBatchSummaries = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewGLBatchSummaries).toBe(true); + }); + }); + + Scenario('Changing canViewGLBatchSummaries without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canViewGLBatchSummaries to true', () => { + setWithoutPermission = () => { + entity.canViewGLBatchSummaries = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewFinanceConfigs with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canViewFinanceConfigs to true', () => { + entity.canViewFinanceConfigs = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewFinanceConfigs).toBe(true); + }); + }); + + Scenario('Changing canViewFinanceConfigs without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canViewFinanceConfigs to true', () => { + setWithoutPermission = () => { + entity.canViewFinanceConfigs = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canCreateFinanceConfigs with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canCreateFinanceConfigs to true', () => { + entity.canCreateFinanceConfigs = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canCreateFinanceConfigs).toBe(true); + }); + }); + + Scenario('Changing canCreateFinanceConfigs without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canCreateFinanceConfigs to true', () => { + setWithoutPermission = () => { + entity.canCreateFinanceConfigs = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts new file mode 100644 index 000000000..e07d6be7d --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts @@ -0,0 +1,61 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleFinancePermissionsSpec { + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; +} + +export interface StaffRoleFinancePermissionsProps extends StaffRoleFinancePermissionsSpec, ValueObjectProps {} +export interface StaffRoleFinancePermissionsEntityReference extends Readonly {} + +export class StaffRoleFinancePermissions extends ValueObject implements StaffRoleFinancePermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleFinancePermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageFinance(): boolean { + return this.props.canManageFinance; + } + set canManageFinance(value: boolean) { + this.validateVisa(); + this.props.canManageFinance = value; + } + + get canViewGLBatchSummaries(): boolean { + return this.props.canViewGLBatchSummaries; + } + set canViewGLBatchSummaries(value: boolean) { + this.validateVisa(); + this.props.canViewGLBatchSummaries = value; + } + + get canViewFinanceConfigs(): boolean { + return this.props.canViewFinanceConfigs; + } + set canViewFinanceConfigs(value: boolean) { + this.validateVisa(); + this.props.canViewFinanceConfigs = value; + } + + get canCreateFinanceConfigs(): boolean { + return this.props.canCreateFinanceConfigs; + } + set canCreateFinanceConfigs(value: boolean) { + this.validateVisa(); + this.props.canCreateFinanceConfigs = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts index b09ad76ed..172327a10 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts @@ -3,11 +3,15 @@ import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { expect, vi } from 'vitest'; import { StaffRoleCommunityPermissions } from './staff-role-community-permissions.ts'; +import { StaffRoleFinancePermissions } from './staff-role-finance-permissions.ts'; import { StaffRolePermissions } from './staff-role-permissions.ts'; import { StaffRolePropertyPermissions } from './staff-role-property-permissions.ts'; import { StaffRoleServicePermissions } from './staff-role-service-permissions.ts'; import { StaffRoleServiceTicketPermissions } from './staff-role-service-ticket-permissions.ts'; +import { StaffRoleTechAdminPermissions } from './staff-role-tech-admin-permissions.ts'; +import { StaffRoleUserPermissions } from './staff-role-user-permissions.ts'; import { StaffRoleViolationTicketPermissions } from './staff-role-violation-ticket-permissions.ts'; +import type { StaffRoleRolePermissions } from './staff-role-role-permissions.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -26,6 +30,10 @@ function makeProps() { serviceTicketPermissions: {} as StaffRoleServiceTicketPermissions, servicePermissions: {} as StaffRoleServicePermissions, violationTicketPermissions: {} as StaffRoleViolationTicketPermissions, + financePermissions: {} as StaffRoleFinancePermissions, + techAdminPermissions: {} as StaffRoleTechAdminPermissions, + userPermissions: {} as StaffRoleUserPermissions, + staffRolePermissions: {} as StaffRoleRolePermissions, }; } @@ -113,4 +121,43 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(violationTicketPermissions).toBeInstanceOf(StaffRoleViolationTicketPermissions); }); }); + + Scenario('Accessing financePermissions', ({ Given, When, Then }) => { + let financePermissions: StaffRoleFinancePermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the financePermissions property', () => { + financePermissions = entity.financePermissions; + }); + Then('I should receive a StaffRoleFinancePermissions entity instance', () => { + expect(financePermissions).toBeInstanceOf(StaffRoleFinancePermissions); + }); + }); + + Scenario('Accessing techAdminPermissions', ({ Given, When, Then }) => { + let techAdminPermissions: StaffRoleTechAdminPermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the techAdminPermissions property', () => { + techAdminPermissions = entity.techAdminPermissions; + }); + Then('I should receive a StaffRoleTechAdminPermissions entity instance', () => { + expect(techAdminPermissions).toBeInstanceOf(StaffRoleTechAdminPermissions); + }); + }); + + Scenario('Accessing userPermissions', ({ Given, When, Then }) => { + let userPermissions: StaffRoleUserPermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the userPermissions property', () => { + userPermissions = entity.userPermissions; + }); + Then('I should receive a StaffRoleUserPermissions entity instance', () => { + expect(userPermissions).toBeInstanceOf(StaffRoleUserPermissions); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts index 22e8ee188..3c92246f6 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts @@ -1,10 +1,14 @@ -import { ValueObject } from '@cellix/domain-seedwork/value-object'; import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; import type { UserVisa } from '../user.visa.ts'; import { StaffRoleCommunityPermissions, type StaffRoleCommunityPermissionsEntityReference, type StaffRoleCommunityPermissionsProps } from './staff-role-community-permissions.ts'; +import { StaffRoleFinancePermissions, type StaffRoleFinancePermissionsEntityReference, type StaffRoleFinancePermissionsProps } from './staff-role-finance-permissions.ts'; import { StaffRolePropertyPermissions, type StaffRolePropertyPermissionsEntityReference, type StaffRolePropertyPermissionsProps } from './staff-role-property-permissions.ts'; import { StaffRoleServicePermissions, type StaffRoleServicePermissionsEntityReference, type StaffRoleServicePermissionsProps } from './staff-role-service-permissions.ts'; import { StaffRoleServiceTicketPermissions, type StaffRoleServiceTicketPermissionsEntityReference, type StaffRoleServiceTicketPermissionsProps } from './staff-role-service-ticket-permissions.ts'; +import { StaffRoleRolePermissions, type StaffRoleRolePermissionsEntityReference, type StaffRoleRolePermissionsProps } from './staff-role-role-permissions.ts'; +import { StaffRoleTechAdminPermissions, type StaffRoleTechAdminPermissionsEntityReference, type StaffRoleTechAdminPermissionsProps } from './staff-role-tech-admin-permissions.ts'; +import { StaffRoleUserPermissions, type StaffRoleUserPermissionsEntityReference, type StaffRoleUserPermissionsProps } from './staff-role-user-permissions.ts'; import { StaffRoleViolationTicketPermissions, type StaffRoleViolationTicketPermissionsEntityReference, type StaffRoleViolationTicketPermissionsProps } from './staff-role-violation-ticket-permissions.ts'; export interface StaffRolePermissionsProps extends ValueObjectProps { @@ -13,15 +17,28 @@ export interface StaffRolePermissionsProps extends ValueObjectProps { readonly serviceTicketPermissions: StaffRoleServiceTicketPermissionsProps; readonly servicePermissions: StaffRoleServicePermissionsProps; readonly violationTicketPermissions: StaffRoleViolationTicketPermissionsProps; + readonly financePermissions: StaffRoleFinancePermissionsProps; + readonly techAdminPermissions: StaffRoleTechAdminPermissionsProps; + readonly userPermissions: StaffRoleUserPermissionsProps; + readonly staffRolePermissions: StaffRoleRolePermissionsProps; } export interface StaffRolePermissionsEntityReference - extends Readonly> { + extends Readonly< + Omit< + StaffRolePermissionsProps, + 'communityPermissions' | 'propertyPermissions' | 'serviceTicketPermissions' | 'servicePermissions' | 'violationTicketPermissions' | 'financePermissions' | 'techAdminPermissions' | 'userPermissions' | 'staffRolePermissions' + > + > { readonly communityPermissions: StaffRoleCommunityPermissionsEntityReference; readonly propertyPermissions: StaffRolePropertyPermissionsEntityReference; readonly serviceTicketPermissions: StaffRoleServiceTicketPermissionsEntityReference; readonly servicePermissions: StaffRoleServicePermissionsEntityReference; readonly violationTicketPermissions: StaffRoleViolationTicketPermissionsEntityReference; + readonly financePermissions: StaffRoleFinancePermissionsEntityReference; + readonly techAdminPermissions: StaffRoleTechAdminPermissionsEntityReference; + readonly userPermissions: StaffRoleUserPermissionsEntityReference; + readonly staffRolePermissions: StaffRoleRolePermissionsEntityReference; } export class StaffRolePermissions extends ValueObject implements StaffRolePermissionsEntityReference { @@ -33,18 +50,97 @@ export class StaffRolePermissions extends ValueObject } get communityPermissions(): StaffRoleCommunityPermissions { - return new StaffRoleCommunityPermissions(this.props.communityPermissions, this.visa); + return new StaffRoleCommunityPermissions( + this.props.communityPermissions ?? { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + this.visa, + ); } get propertyPermissions(): StaffRolePropertyPermissions { - return new StaffRolePropertyPermissions(this.props.propertyPermissions, this.visa); + return new StaffRolePropertyPermissions( + this.props.propertyPermissions ?? { + canManageProperties: false, + canEditOwnProperty: false, + }, + this.visa, + ); } get serviceTicketPermissions(): StaffRoleServiceTicketPermissions { - return new StaffRoleServiceTicketPermissions(this.props.serviceTicketPermissions, this.visa); + return new StaffRoleServiceTicketPermissions( + this.props.serviceTicketPermissions ?? { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canUpdateTickets: false, + canWorkOnTickets: false, + }, + this.visa, + ); } get servicePermissions(): StaffRoleServicePermissions { - return new StaffRoleServicePermissions(this.props.servicePermissions, this.visa); + return new StaffRoleServicePermissions(this.props.servicePermissions ?? { canManageServices: false }, this.visa); } get violationTicketPermissions(): StaffRoleViolationTicketPermissions { - return new StaffRoleViolationTicketPermissions(this.props.violationTicketPermissions, this.visa); + return new StaffRoleViolationTicketPermissions( + this.props.violationTicketPermissions ?? { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canUpdateTickets: false, + canWorkOnTickets: false, + }, + this.visa, + ); + } + get financePermissions(): StaffRoleFinancePermissions { + return new StaffRoleFinancePermissions( + this.props.financePermissions ?? { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + this.visa, + ); + } + get techAdminPermissions(): StaffRoleTechAdminPermissions { + return new StaffRoleTechAdminPermissions( + this.props.techAdminPermissions ?? { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, + this.visa, + ); + } + get userPermissions(): StaffRoleUserPermissions { + return new StaffRoleUserPermissions( + this.props.userPermissions ?? { + canManageUsers: false, + canAssignStaffRoles: false, + canAssignStaffUserRoles: false, + canViewStaffUsers: false, + }, + this.visa, + ); + } + get staffRolePermissions(): StaffRoleRolePermissions { + return new StaffRoleRolePermissions( + this.props.staffRolePermissions ?? { + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }, + this.visa, + ); } } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-role-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-role-permissions.ts new file mode 100644 index 000000000..c4dbcc81f --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-role-permissions.ts @@ -0,0 +1,61 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleRolePermissionsSpec { + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; +} + +export interface StaffRoleRolePermissionsProps extends StaffRoleRolePermissionsSpec, ValueObjectProps {} +export interface StaffRoleRolePermissionsEntityReference extends Readonly {} + +export class StaffRoleRolePermissions extends ValueObject implements StaffRoleRolePermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleRolePermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canViewRoles(): boolean { + return this.props.canViewRoles ?? false; + } + set canViewRoles(value: boolean) { + this.validateVisa(); + this.props.canViewRoles = value; + } + + get canAddRole(): boolean { + return this.props.canAddRole ?? false; + } + set canAddRole(value: boolean) { + this.validateVisa(); + this.props.canAddRole = value; + } + + get canEditRole(): boolean { + return this.props.canEditRole ?? false; + } + set canEditRole(value: boolean) { + this.validateVisa(); + this.props.canEditRole = value; + } + + get canRemoveRole(): boolean { + return this.props.canRemoveRole ?? false; + } + set canRemoveRole(value: boolean) { + this.validateVisa(); + this.props.canRemoveRole = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts new file mode 100644 index 000000000..e143bbc32 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts @@ -0,0 +1,211 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleTechAdminPermissions } from './staff-role-tech-admin-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-tech-admin-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleTechAdminPermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleTechAdminPermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleTechAdminPermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageTechAdmin with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canManageTechAdmin to true', () => { + entity.canManageTechAdmin = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Changing canManageTechAdmin with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canManageTechAdmin to true', () => { + entity.canManageTechAdmin = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Changing canManageTechAdmin without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canManageTechAdmin to true', () => { + setWithoutPermission = () => { + entity.canManageTechAdmin = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewDatabaseExplorer with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewDatabaseExplorer to true', () => { + entity.canViewDatabaseExplorer = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewDatabaseExplorer).toBe(true); + }); + }); + + Scenario('Changing canViewDatabaseExplorer without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewDatabaseExplorer to true', () => { + setWithoutPermission = () => { + entity.canViewDatabaseExplorer = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewBlobExplorer with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewBlobExplorer to true', () => { + entity.canViewBlobExplorer = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewBlobExplorer).toBe(true); + }); + }); + + Scenario('Changing canViewBlobExplorer without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewBlobExplorer to true', () => { + setWithoutPermission = () => { + entity.canViewBlobExplorer = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewQueueDashboard with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewQueueDashboard to true', () => { + entity.canViewQueueDashboard = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewQueueDashboard).toBe(true); + }); + }); + + Scenario('Changing canViewQueueDashboard without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewQueueDashboard to true', () => { + setWithoutPermission = () => { + entity.canViewQueueDashboard = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canSendQueueMessages with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canSendQueueMessages to true', () => { + entity.canSendQueueMessages = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canSendQueueMessages).toBe(true); + }); + }); + + Scenario('Changing canSendQueueMessages without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canSendQueueMessages to true', () => { + setWithoutPermission = () => { + entity.canSendQueueMessages = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts new file mode 100644 index 000000000..9d225e6c7 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts @@ -0,0 +1,70 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleTechAdminPermissionsSpec { + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; +} + +export interface StaffRoleTechAdminPermissionsProps extends StaffRoleTechAdminPermissionsSpec, ValueObjectProps {} +export interface StaffRoleTechAdminPermissionsEntityReference extends Readonly {} + +export class StaffRoleTechAdminPermissions extends ValueObject implements StaffRoleTechAdminPermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleTechAdminPermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageTechAdmin(): boolean { + return this.props.canManageTechAdmin; + } + set canManageTechAdmin(value: boolean) { + this.validateVisa(); + this.props.canManageTechAdmin = value; + } + + get canViewDatabaseExplorer(): boolean { + return this.props.canViewDatabaseExplorer; + } + set canViewDatabaseExplorer(value: boolean) { + this.validateVisa(); + this.props.canViewDatabaseExplorer = value; + } + + get canViewBlobExplorer(): boolean { + return this.props.canViewBlobExplorer; + } + set canViewBlobExplorer(value: boolean) { + this.validateVisa(); + this.props.canViewBlobExplorer = value; + } + + get canViewQueueDashboard(): boolean { + return this.props.canViewQueueDashboard; + } + set canViewQueueDashboard(value: boolean) { + this.validateVisa(); + this.props.canViewQueueDashboard = value; + } + + get canSendQueueMessages(): boolean { + return this.props.canSendQueueMessages; + } + set canSendQueueMessages(value: boolean) { + this.validateVisa(); + this.props.canSendQueueMessages = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts new file mode 100644 index 000000000..196eb3e01 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts @@ -0,0 +1,90 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleUserPermissions } from './staff-role-user-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-user-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageUsers: false, + canAssignStaffUserRoles: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleUserPermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleUserPermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleUserPermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageUsers with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleUserPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I set canManageUsers to true', () => { + entity.canManageUsers = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageUsers).toBe(true); + }); + }); + + Scenario('Changing canManageUsers with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleUserPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I set canManageUsers to true', () => { + entity.canManageUsers = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageUsers).toBe(true); + }); + }); + + Scenario('Changing canManageUsers without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleUserPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I try to set canManageUsers to true', () => { + setWithoutPermission = () => { + entity.canManageUsers = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts new file mode 100644 index 000000000..11ee2f554 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts @@ -0,0 +1,63 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleUserPermissionsSpec { + canManageUsers: boolean; + canAssignStaffRoles: boolean; + canAssignStaffUserRoles: boolean; + canViewStaffUsers: boolean; +} + +export interface StaffRoleUserPermissionsProps extends StaffRoleUserPermissionsSpec, ValueObjectProps {} +export interface StaffRoleUserPermissionsEntityReference extends Readonly {} + +export class StaffRoleUserPermissions extends ValueObject implements StaffRoleUserPermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleUserPermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageUsers(): boolean { + return this.props.canManageUsers; + } + set canManageUsers(value: boolean) { + this.validateVisa(); + this.props.canManageUsers = value; + } + + get canAssignStaffUserRoles(): boolean { + return this.props.canAssignStaffRoles ?? this.props.canAssignStaffUserRoles ?? false; + } + set canAssignStaffUserRoles(value: boolean) { + this.validateVisa(); + this.props.canAssignStaffRoles = value; + this.props.canAssignStaffUserRoles = value; + } + + get canAssignStaffRoles(): boolean { + return this.props.canAssignStaffRoles ?? this.props.canAssignStaffUserRoles ?? false; + } + set canAssignStaffRoles(value: boolean) { + this.validateVisa(); + this.props.canAssignStaffRoles = value; + this.props.canAssignStaffUserRoles = value; + } + + get canViewStaffUsers(): boolean { + return this.props.canViewStaffUsers ?? false; + } + set canViewStaffUsers(value: boolean) { + this.validateVisa(); + this.props.canViewStaffUsers = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.test.ts new file mode 100644 index 000000000..7c23182dc --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.test.ts @@ -0,0 +1,213 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect, vi } from 'vitest'; +import type { Passport } from '../../passport.ts'; +import type { StaffRoleRepository } from './staff-role.repository.ts'; +import { StaffRole, type StaffRoleEntityReference, type StaffRoleProps } from './staff-role.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role.repository.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makePassport(): Passport { + return { + user: { + forStaffRole: () => ({ + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => + fn({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }), + }), + }, + } as unknown as Passport; +} + +function makeBaseProps(overrides: Partial = {}): StaffRoleProps { + return { + id: 'role-1', + roleName: 'Support', + isDefault: false, + enterpriseAppRole: '', + permissions: { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + propertyPermissions: { canManageProperties: false, canEditOwnProperty: false }, + servicePermissions: { canManageServices: false }, + serviceTicketPermissions: { canCreateTickets: false, canManageTickets: false, canAssignTickets: false, canWorkOnTickets: false }, + violationTicketPermissions: { canCreateTickets: false, canManageTickets: false, canAssignTickets: false, canWorkOnTickets: false }, + financePermissions: { canManageFinance: false, canViewGLBatchSummaries: false, canViewFinanceConfigs: false, canCreateFinanceConfigs: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false, canAssignStaffUserRoles: false }, + } as unknown as StaffRoleProps['permissions'], + roleType: 'staff-role', + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + }; +} + +/** + * Factory that builds a mock StaffRoleRepository whose methods delegate to + * actual StaffRole static factory methods, satisfying the full interface contract. + */ +function makeMockRepository(passport: Passport): StaffRoleRepository { + return { + // From Repository> + get: vi.fn(async (id: string) => new StaffRole(makeBaseProps({ id }), passport)), + save: vi.fn(async (item: StaffRole) => item), + + // Domain-specific methods + getNewInstance: vi.fn(async (name: string) => + StaffRole.getNewInstance(makeBaseProps({ roleName: name }), passport, name, false), + ), + getNewDefaultCaseManagerInstance: vi.fn(async () => + StaffRole.getNewDefaultCaseManagerInstance(makeBaseProps({ roleName: 'Default.CaseManager', isDefault: true }), passport), + ), + getNewDefaultServiceLineOwnerInstance: vi.fn(async () => + StaffRole.getNewDefaultServiceLineOwnerInstance(makeBaseProps({ roleName: 'Default.ServiceLineOwner', isDefault: true }), passport), + ), + getNewDefaultFinanceInstance: vi.fn(async () => + StaffRole.getNewDefaultFinanceInstance(makeBaseProps({ roleName: 'Default.Finance', isDefault: true }), passport), + ), + getNewDefaultTechAdminInstance: vi.fn(async () => + StaffRole.getNewDefaultTechAdminInstance(makeBaseProps({ roleName: 'Default.TechAdmin', isDefault: true }), passport), + ), + getById: vi.fn(async (id: string) => new StaffRole(makeBaseProps({ id }), passport)), + getByRoleName: vi.fn(async (roleName: string) => new StaffRole(makeBaseProps({ roleName }), passport)), + getDefaultRoleByEnterpriseAppRole: vi.fn(async (enterpriseAppRole: string) => + new StaffRole(makeBaseProps({ enterpriseAppRole, isDefault: true }), passport), + ), + } satisfies StaffRoleRepository; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let passport: Passport; + let repo: StaffRoleRepository; + let result: StaffRole | StaffRoleEntityReference | undefined; + + BeforeEachScenario(() => { + passport = makePassport(); + repo = makeMockRepository(passport); + result = undefined; + }); + + Background(({ Given }) => { + Given('a mock implementation of StaffRoleRepository that satisfies the full interface', () => { + // repo already initialised in BeforeEachScenario + }); + }); + + // ─── getNewInstance ─────────────────────────────────────────────────────── + + Scenario('getNewInstance resolves with a StaffRole for the given name', ({ When, Then, And }) => { + When('I call getNewInstance with name "Supervisor"', async () => { + result = await repo.getNewInstance('Supervisor'); + }); + Then('it should resolve with a StaffRole whose roleName is "Supervisor"', () => { + expect(result).toBeDefined(); + expect((result as StaffRoleEntityReference).roleName).toBe('Supervisor'); + }); + And('the StaffRole isDefault should be false', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(false); + }); + }); + + // ─── getNewDefaultCaseManagerInstance ──────────────────────────────────── + + Scenario('getNewDefaultCaseManagerInstance resolves with a default CaseManager role', ({ When, Then, And }) => { + When('I call getNewDefaultCaseManagerInstance', async () => { + result = await repo.getNewDefaultCaseManagerInstance(); + }); + Then('it should resolve with a StaffRole whose roleName is "Default Case Manager"', () => { + expect((result as StaffRoleEntityReference).roleName).toBe('Default Case Manager'); + }); + And('the StaffRole isDefault should be true', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(true); + }); + }); + + // ─── getNewDefaultServiceLineOwnerInstance ──────────────────────────────── + + Scenario('getNewDefaultServiceLineOwnerInstance resolves with a default ServiceLineOwner role', ({ When, Then, And }) => { + When('I call getNewDefaultServiceLineOwnerInstance', async () => { + result = await repo.getNewDefaultServiceLineOwnerInstance(); + }); + Then('it should resolve with a StaffRole whose roleName is "Default Service Line Owner"', () => { + expect((result as StaffRoleEntityReference).roleName).toBe('Default Service Line Owner'); + }); + And('the StaffRole isDefault should be true', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(true); + }); + }); + + // ─── getNewDefaultFinanceInstance ──────────────────────────────────────── + + Scenario('getNewDefaultFinanceInstance resolves with a default Finance role', ({ When, Then, And }) => { + When('I call getNewDefaultFinanceInstance', async () => { + result = await repo.getNewDefaultFinanceInstance(); + }); + Then('it should resolve with a StaffRole whose roleName is "Default Finance"', () => { + expect((result as StaffRoleEntityReference).roleName).toBe('Default Finance'); + }); + And('the StaffRole isDefault should be true', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(true); + }); + }); + + // ─── getNewDefaultTechAdminInstance ────────────────────────────────────── + + Scenario('getNewDefaultTechAdminInstance resolves with a default TechAdmin role', ({ When, Then, And }) => { + When('I call getNewDefaultTechAdminInstance', async () => { + result = await repo.getNewDefaultTechAdminInstance(); + }); + Then('it should resolve with a StaffRole whose roleName is "Default Tech Admin"', () => { + expect((result as StaffRoleEntityReference).roleName).toBe('Default Tech Admin'); + }); + And('the StaffRole isDefault should be true', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(true); + }); + }); + + // ─── getById ───────────────────────────────────────────────────────────── + + Scenario('getById resolves with a StaffRole for a known id', ({ When, Then }) => { + When('I call getById with "role-1"', async () => { + result = await repo.getById('role-1'); + }); + Then('it should resolve with a StaffRole whose id is "role-1"', () => { + expect((result as StaffRoleEntityReference).id).toBe('role-1'); + }); + }); + + // ─── getByRoleName ──────────────────────────────────────────────────────── + + Scenario('getByRoleName resolves with a StaffRole for a known roleName', ({ When, Then }) => { + When('I call getByRoleName with "Manager"', async () => { + result = await repo.getByRoleName('Manager'); + }); + Then('it should resolve with a StaffRole whose roleName is "Manager"', () => { + expect((result as StaffRoleEntityReference).roleName).toBe('Manager'); + }); + }); + + // ─── getDefaultRoleByEnterpriseAppRole ──────────────────────────────────── + + Scenario('getDefaultRoleByEnterpriseAppRole resolves with a default StaffRole', ({ When, Then }) => { + When('I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager"', async () => { + result = await repo.getDefaultRoleByEnterpriseAppRole('Staff.CaseManager'); + }); + Then('it should resolve with a StaffRole whose isDefault is true', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(true); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts index 02fb77d52..ff2cf93bc 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts @@ -2,6 +2,11 @@ import type { Repository } from '@cellix/domain-seedwork/repository'; import type { StaffRole, StaffRoleProps } from './staff-role.ts'; export interface StaffRoleRepository extends Repository> { getNewInstance(name: string): Promise>; + getNewDefaultCaseManagerInstance(): Promise>; + getNewDefaultServiceLineOwnerInstance(): Promise>; + getNewDefaultFinanceInstance(): Promise>; + getNewDefaultTechAdminInstance(): Promise>; getById(id: string): Promise>; getByRoleName(roleName: string): Promise>; + getDefaultRoleByEnterpriseAppRole(enterpriseAppRole: string): Promise>; } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts index fade23a8e..6e885c417 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts @@ -27,6 +27,7 @@ function makeBaseProps(overrides: Partial = {}): StaffRoleProps id: 'role-1', roleName: 'Support', isDefault: false, + enterpriseAppRole: '', permissions: {} as StaffRolePermissions, roleType: 'staff-role', createdAt: new Date('2020-01-01T00:00:00Z'), @@ -36,6 +37,23 @@ function makeBaseProps(overrides: Partial = {}): StaffRoleProps }; } +/** Props with fully initialised mutable permission sub-objects, required for static factory methods */ +function makeFactoryProps(overrides: Partial = {}): StaffRoleProps { + return { + ...makeBaseProps(overrides), + permissions: { + communityPermissions: {} as Record, + propertyPermissions: {} as Record, + serviceTicketPermissions: {} as Record, + servicePermissions: {} as Record, + violationTicketPermissions: {} as Record, + financePermissions: {} as Record, + techAdminPermissions: {} as Record, + userPermissions: {} as Record, + } as unknown as StaffRolePermissions, + }; +} + function getIntegrationEvent(events: readonly unknown[], eventClass: new (aggregateId: string) => T): T | undefined { return events.find((e) => e instanceof eventClass) as T | undefined; } @@ -81,8 +99,8 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { passport = makePassport(true, false); staffRole = new StaffRole(makeBaseProps(), passport); }); - When('I set the roleName to "Manager"', () => { - staffRole.roleName = 'Manager'; + When('I set the roleName to "manager"', () => { + staffRole.roleName = 'manager'; }); Then('the staff role\'s roleName should be "Manager"', () => { expect(staffRole.roleName).toBe('Manager'); @@ -94,8 +112,8 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { passport = makePassport(false, true); staffRole = new StaffRole(makeBaseProps(), passport); }); - When('I set the roleName to "Manager"', () => { - staffRole.roleName = 'Manager'; + When('I set the roleName to "manager"', () => { + staffRole.roleName = 'manager'; }); Then('the staff role\'s roleName should be "Manager"', () => { expect(staffRole.roleName).toBe('Manager'); @@ -284,4 +302,229 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(staffRole.schemaVersion).toBe('1.0.0'); }); }); + + // ─── enterpriseAppRole ──────────────────────────────────────────────────── + + Scenario('Getting the enterpriseAppRole property', ({ Given, Then }) => { + Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true, false); + staffRole = new StaffRole(makeBaseProps({ enterpriseAppRole: '' }), passport); + }); + Then('the enterpriseAppRole should return the initial value', () => { + expect(staffRole.enterpriseAppRole).toBe(''); + }); + }); + + Scenario('Changing the enterpriseAppRole with permission to manage staff roles', ({ Given, When, Then }) => { + Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I set the enterpriseAppRole to "Staff.CaseManager"', () => { + staffRole.enterpriseAppRole = 'Staff.CaseManager'; + }); + Then('the staff role\'s enterpriseAppRole should be "Staff.CaseManager"', () => { + expect(staffRole.enterpriseAppRole).toBe('Staff.CaseManager'); + }); + }); + + Scenario('Changing the enterpriseAppRole with system account permission', ({ Given, When, Then }) => { + Given('a StaffRole aggregate with system account permission', () => { + passport = makePassport(false, true); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I set the enterpriseAppRole to "Staff.Finance"', () => { + staffRole.enterpriseAppRole = 'Staff.Finance'; + }); + Then('the staff role\'s enterpriseAppRole should be "Staff.Finance"', () => { + expect(staffRole.enterpriseAppRole).toBe('Staff.Finance'); + }); + }); + + Scenario('Changing the enterpriseAppRole without permission', ({ Given, When, Then }) => { + let changeWithoutPermission: () => void; + Given('a StaffRole aggregate without permission to manage staff roles and permissions or system account', () => { + passport = makePassport(false, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I try to set the enterpriseAppRole to "Staff.CaseManager"', () => { + changeWithoutPermission = () => { + staffRole.enterpriseAppRole = 'Staff.CaseManager'; + }; + }); + Then('a PermissionError should be thrown for enterpriseAppRole', () => { + expect(changeWithoutPermission).toThrow(PermissionError); + expect(changeWithoutPermission).toThrow('Cannot set enterprise app role'); + }); + }); + + Scenario('Changing the enterpriseAppRole to an invalid value', ({ Given, When, Then }) => { + let changeToInvalid: () => void; + Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I try to set the enterpriseAppRole to an invalid value', () => { + changeToInvalid = () => { + staffRole.enterpriseAppRole = 'Invalid.Role.That.Does.Not.Exist'; + }; + }); + Then('an error should be thrown for the invalid enterpriseAppRole', () => { + expect(changeToInvalid).toThrow(); + }); + }); + + // ─── getDefaultRoleNames ────────────────────────────────────────────────── + + Scenario('Getting the list of default role names', ({ When, Then }) => { + let defaultNames: string[]; + When('I call getDefaultRoleNames', () => { + defaultNames = StaffRole.getDefaultRoleNames(); + }); + Then('it should return the four canonical default role name strings', () => { + expect(defaultNames).toHaveLength(4); + expect(defaultNames).toContain('Default.CaseManager'); + expect(defaultNames).toContain('Default.ServiceLineOwner'); + expect(defaultNames).toContain('Default.Finance'); + expect(defaultNames).toContain('Default.TechAdmin'); + }); + }); + + // ─── default factory methods ────────────────────────────────────────────── + + Scenario('Creating a new default Case Manager role', ({ When, Then, And }) => { + let role: StaffRole; + When('I call getNewDefaultCaseManagerInstance', () => { + role = StaffRole.getNewDefaultCaseManagerInstance(makeFactoryProps(), makePassport(true, true)); + }); + Then('the role name should be "Default Case Manager"', () => { + expect(role.roleName).toBe('Default Case Manager'); + }); + And('the enterpriseAppRole should be "Staff.CaseManager"', () => { + expect(role.enterpriseAppRole).toBe('Staff.CaseManager'); + }); + And('isDefault should be true', () => { + expect(role.isDefault).toBe(true); + }); + And('community canManageCommunities should be true', () => { + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + And('community canManageStaffRolesAndPermissions should be true', () => { + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + And('finance canManageFinance should be false', () => { + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + }); + And('techAdmin canManageTechAdmin should be false', () => { + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + And('user canManageUsers should be true', () => { + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + }); + And('user canAssignStaffUserRoles should be true', () => { + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + }); + }); + + Scenario('Creating a new default Service Line Owner role', ({ When, Then, And }) => { + let role: StaffRole; + When('I call getNewDefaultServiceLineOwnerInstance', () => { + role = StaffRole.getNewDefaultServiceLineOwnerInstance(makeFactoryProps(), makePassport(true, true)); + }); + Then('the role name should be "Default Service Line Owner"', () => { + expect(role.roleName).toBe('Default Service Line Owner'); + }); + And('the enterpriseAppRole should be "Staff.ServiceLineOwner"', () => { + expect(role.enterpriseAppRole).toBe('Staff.ServiceLineOwner'); + }); + And('isDefault should be true', () => { + expect(role.isDefault).toBe(true); + }); + And('community canManageCommunities should be true', () => { + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + And('community canManageStaffRolesAndPermissions should be true', () => { + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + And('finance canManageFinance should be false', () => { + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + }); + And('techAdmin canManageTechAdmin should be false', () => { + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + And('user canManageUsers should be true', () => { + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + }); + And('user canAssignStaffUserRoles should be true', () => { + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + }); + }); + + Scenario('Creating a new default Finance role', ({ When, Then, And }) => { + let role: StaffRole; + When('I call getNewDefaultFinanceInstance', () => { + role = StaffRole.getNewDefaultFinanceInstance(makeFactoryProps(), makePassport(true, true)); + }); + Then('the role name should be "Default Finance"', () => { + expect(role.roleName).toBe('Default Finance'); + }); + And('the enterpriseAppRole should be "Staff.Finance"', () => { + expect(role.enterpriseAppRole).toBe('Staff.Finance'); + }); + And('isDefault should be true', () => { + expect(role.isDefault).toBe(true); + }); + And('community canManageCommunities should be false', () => { + expect(role.permissions.communityPermissions.canManageCommunities).toBe(false); + }); + And('community canManageStaffRolesAndPermissions should be true', () => { + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + And('finance canManageFinance should be true', () => { + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + }); + And('techAdmin canManageTechAdmin should be false', () => { + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + And('user canManageUsers should be true', () => { + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + }); + And('user canAssignStaffUserRoles should be true', () => { + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + }); + }); + + Scenario('Creating a new default Tech Admin role', ({ When, Then, And }) => { + let role: StaffRole; + When('I call getNewDefaultTechAdminInstance', () => { + role = StaffRole.getNewDefaultTechAdminInstance(makeFactoryProps(), makePassport(true, true)); + }); + Then('the role name should be "Default Tech Admin"', () => { + expect(role.roleName).toBe('Default Tech Admin'); + }); + And('the enterpriseAppRole should be "Staff.TechAdmin"', () => { + expect(role.enterpriseAppRole).toBe('Staff.TechAdmin'); + }); + And('isDefault should be true', () => { + expect(role.isDefault).toBe(true); + }); + And('community canManageCommunities should be true', () => { + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + And('community canManageStaffRolesAndPermissions should be true', () => { + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + And('finance canManageFinance should be true', () => { + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + }); + And('techAdmin canManageTechAdmin should be true', () => { + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + }); + And('user canManageUsers should be true', () => { + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + }); + And('user canAssignStaffUserRoles should be true', () => { + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts index 49912c977..773e1e2b7 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts @@ -10,6 +10,7 @@ import { StaffRolePermissions, type StaffRolePermissionsEntityReference, type St export interface StaffRoleProps extends DomainEntityProps { roleName: string; isDefault: boolean; + enterpriseAppRole: string; readonly permissions: StaffRolePermissionsProps; readonly roleType: string | null; readonly createdAt: Date; @@ -21,6 +22,7 @@ export interface StaffRoleEntityReference extends Readonly extends AggregateRoot implements StaffRoleEntityReference { private isNew: boolean = false; private readonly visa: UserVisa; @@ -37,6 +39,93 @@ export class StaffRole extends AggregateRoot(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Case Manager'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.CaseManager; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = true; + role.permissions.communityPermissions.canManageStaffRolesAndPermissions = true; + role.permissions.financePermissions.canManageFinance = false; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = true; + role.permissions.userPermissions.canAssignStaffRoles = true; + role.permissions.userPermissions.canViewStaffUsers = true; + role.permissions.staffRolePermissions.canViewRoles = true; + role.isNew = false; + return role; + } + + public static getNewDefaultServiceLineOwnerInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Service Line Owner'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.ServiceLineOwner; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = true; + role.permissions.communityPermissions.canManageStaffRolesAndPermissions = true; + role.permissions.financePermissions.canManageFinance = false; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = true; + role.permissions.userPermissions.canAssignStaffRoles = true; + role.permissions.userPermissions.canViewStaffUsers = true; + role.permissions.staffRolePermissions.canViewRoles = true; + role.isNew = false; + return role; + } + + public static getNewDefaultFinanceInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Finance'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.Finance; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = false; + role.permissions.communityPermissions.canManageStaffRolesAndPermissions = true; + role.permissions.financePermissions.canManageFinance = true; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = true; + role.permissions.userPermissions.canAssignStaffRoles = true; + role.permissions.userPermissions.canViewStaffUsers = true; + role.permissions.staffRolePermissions.canViewRoles = true; + role.permissions.staffRolePermissions.canAddRole = true; + role.permissions.staffRolePermissions.canEditRole = true; + role.permissions.staffRolePermissions.canRemoveRole = true; + role.isNew = false; + return role; + } + + public static getNewDefaultTechAdminInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Tech Admin'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.TechAdmin; + role.isDefault = true; + // Tech Admins are implicit managers of all areas + role.permissions.communityPermissions.canManageCommunities = true; + // Tech Admins should also be able to manage staff roles & permissions by default + role.permissions.communityPermissions.canManageStaffRolesAndPermissions = true; + role.permissions.financePermissions.canManageFinance = true; + role.permissions.techAdminPermissions.canManageTechAdmin = true; + role.permissions.userPermissions.canManageUsers = true; + role.permissions.userPermissions.canAssignStaffRoles = true; + role.permissions.userPermissions.canViewStaffUsers = true; + role.permissions.staffRolePermissions.canViewRoles = true; + role.permissions.staffRolePermissions.canAddRole = true; + role.permissions.staffRolePermissions.canEditRole = true; + role.permissions.staffRolePermissions.canRemoveRole = true; + role.isNew = false; + return role; + } public deleteAndReassignTo(roleRef: StaffRoleEntityReference) { if (this.isDefault) { throw new PermissionError('You cannot delete a default staff role'); @@ -58,8 +147,21 @@ export class StaffRole extends AggregateRoot permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { throw new PermissionError('Cannot set role name'); } - this.props.roleName = new ValueObjects.RoleName(roleName).valueOf(); + const normalizedRoleName = new ValueObjects.RoleName(roleName).valueOf(); + this.props.roleName = normalizedRoleName.charAt(0).toUpperCase() + normalizedRoleName.slice(1); } + + get enterpriseAppRole() { + return this.props.enterpriseAppRole; + } + + set enterpriseAppRole(enterpriseAppRole: string) { + if (!this.isNew && !this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set enterprise app role'); + } + this.props.enterpriseAppRole = new ValueObjects.EnterpriseAppRole(enterpriseAppRole).valueOf(); + } + get isDefault() { return this.props.isDefault; } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts index 3a4a42e28..c683809e2 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts @@ -1,7 +1,16 @@ -import { VOString } from '@lucaspaganini/value-objects'; +import { VOString, VOSet } from '@lucaspaganini/value-objects'; export class RoleName extends VOString({ trim: true, maxLength: 50, minLength: 1, }) {} + +export const EnterpriseAppRoleNames = { + CaseManager: 'Staff.CaseManager', + ServiceLineOwner: 'Staff.ServiceLineOwner', + Finance: 'Staff.Finance', + TechAdmin: 'Staff.TechAdmin', +} as const; + +export class EnterpriseAppRole extends VOSet(Object.values(EnterpriseAppRoleNames)) {} \ No newline at end of file diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/features/staff-user.feature b/packages/ocom/domain/src/domain/contexts/user/staff-user/features/staff-user.feature index 1262726a0..855edbc6c 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-user/features/staff-user.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/features/staff-user.feature @@ -138,3 +138,43 @@ Feature: StaffUser And the createdAt property should return the correct date And the updatedAt property should return the correct date And the schemaVersion property should return the correct version + + # activityLog + Scenario: Logging a general update via requestAddUpdate + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestAddUpdate with description "Profile updated" and activityByStaffUserId "staff-99" + Then an activity log entry with activityType "UPDATED" and description "Profile updated" should be added + + Scenario: requestAddUpdate without permission throws PermissionError + Given a StaffUser aggregate without permission to manage staff roles and permissions + When I try to call requestAddUpdate with description "Profile updated" and activityByStaffUserId "staff-99" + Then a PermissionError should be thrown + + Scenario: Logging a role assignment via requestRoleAssignment + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestRoleAssignment with a valid role, description "Role assigned", and activityByStaffUserId "staff-99" + Then the staff user's role should be updated + And an activity log entry with activityType "ROLE_ASSIGNED" and description "Role assigned" should be added + + Scenario: Logging a role removal via requestRoleRemoval + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestRoleRemoval with description "Role removed" and activityByStaffUserId "staff-99" + Then the staff user's role should be undefined + And an activity log entry with activityType "ROLE_REMOVED" and description "Role removed" should be added + + Scenario: Logging a block via requestBlock + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestBlock with description "User blocked" and activityByStaffUserId "staff-99" + Then the staff user's accessBlocked should be true + And an activity log entry with activityType "BLOCKED" and description "User blocked" should be added + + Scenario: Logging an unblock via requestUnblock + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestUnblock with description "User unblocked" and activityByStaffUserId "staff-99" + Then the staff user's accessBlocked should be false + And an activity log entry with activityType "UNBLOCKED" and description "User unblocked" should be added + + Scenario: Logging a create action via requestCreate + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestCreate with activityByStaffUserId "staff-99" + Then an activity log entry with activityType "CREATED" and description "User created" should be added diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/index.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/index.ts index be786b89b..0c9e375de 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-user/index.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/index.ts @@ -5,3 +5,10 @@ export { type StaffUserProps, } from './staff-user.ts'; export type { StaffUserUnitOfWork } from './staff-user.uow.ts'; +export { + StaffUserActivityLog, + type StaffUserActivityLogCreateProps, + type StaffUserActivityLogEntityReference, + type StaffUserActivityLogProps, +} from './staff-user-activity-log.entity.ts'; +export * as StaffUserActivityLogValueObjects from './staff-user-activity-log.value-objects.ts'; diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.additional.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.additional.test.ts new file mode 100644 index 000000000..3ba3da8f0 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.additional.test.ts @@ -0,0 +1,93 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { describe, expect, it } from 'vitest'; +import type { UserVisa } from '../user.visa.ts'; +import { StaffUserActivityLog, type StaffUserActivityLogProps } from './staff-user-activity-log.entity.ts'; +import * as ValueObjects from './staff-user-activity-log.value-objects.ts'; + +function createVisa(isSystemAccount: boolean, canManageStaffRolesAndPermissions = false): UserVisa { + return { + determineIf: (func) => + func({ + canManageEndUsers: false, + canManageStaffRolesAndPermissions, + canManageStaffUsers: false, + canManageVendorUsers: false, + isEditingOwnAccount: false, + isSystemAccount, + }), + }; +} + +function createProps(overrides: Partial = {}): StaffUserActivityLogProps { + return { + id: 'activity-1', + activityType: '', + activityDescription: '', + activityByStaffUserId: '', + createdAt: new Date('2020-01-01T00:00:00Z'), + updatedAt: new Date('2020-01-01T00:00:00Z'), + ...overrides, + }; +} + +describe('staff-user-activity-log additional coverage', () => { + it('creates a new activity log entry when requester is a system account', () => { + const activityLog = StaffUserActivityLog.getNewInstance(createProps(), createVisa(true), { + activityType: new ValueObjects.ActivityTypeCode(ValueObjects.ActivityTypeCodes.Created), + activityDescription: new ValueObjects.Description('User created'), + activityByStaffUserId: 'staff-99', + }); + + expect(activityLog.activityType).toBe('CREATED'); + expect(activityLog.activityDescription).toBe('User created'); + expect(activityLog.activityByStaffUserId).toBe('staff-99'); + }); + + it('creates a new activity log entry for non-system account during creation flow', () => { + const activityLog = StaffUserActivityLog.getNewInstance(createProps(), createVisa(false), { + activityType: new ValueObjects.ActivityTypeCode(ValueObjects.ActivityTypeCodes.Created), + activityDescription: new ValueObjects.Description('User created'), + activityByStaffUserId: 'staff-99', + }); + + expect(activityLog.activityType).toBe('CREATED'); + expect(activityLog.activityDescription).toBe('User created'); + expect(activityLog.activityByStaffUserId).toBe('staff-99'); + }); + + it('throws when non-system account tries to mutate activityType or activityDescription', () => { + const activityLog = new StaffUserActivityLog( + createProps({ + activityType: ValueObjects.ActivityTypeCodes.Created, + activityDescription: 'User created', + activityByStaffUserId: 'staff-99', + }), + createVisa(false), + ); + + expect(() => { + activityLog.activityType = new ValueObjects.ActivityTypeCode(ValueObjects.ActivityTypeCodes.Updated); + }).toThrow(PermissionError); + + expect(() => { + activityLog.activityDescription = new ValueObjects.Description('Updated description'); + }).toThrow(PermissionError); + }); + + it('allows manager account to mutate activityType or activityDescription', () => { + const activityLog = new StaffUserActivityLog( + createProps({ + activityType: ValueObjects.ActivityTypeCodes.Created, + activityDescription: 'User created', + activityByStaffUserId: 'staff-99', + }), + createVisa(false, true), + ); + + activityLog.activityType = new ValueObjects.ActivityTypeCode(ValueObjects.ActivityTypeCodes.Updated); + activityLog.activityDescription = new ValueObjects.Description('Updated description'); + + expect(activityLog.activityType).toBe('UPDATED'); + expect(activityLog.activityDescription).toBe('Updated description'); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.ts new file mode 100644 index 000000000..0ec9edd8b --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.ts @@ -0,0 +1,85 @@ +import { PermissionError, DomainEntity } from '@cellix/domain-seedwork/domain-entity'; +import type { DomainEntityProps } from '@cellix/domain-seedwork/domain-entity'; +import type { UserVisa } from '../user.visa.ts'; +import type * as ValueObjects from './staff-user-activity-log.value-objects.ts'; + +export interface StaffUserActivityLogProps extends DomainEntityProps { + activityType: string; + activityDescription: string; + activityByStaffUserId: string; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface StaffUserActivityLogEntityReference extends Readonly {} + +export interface StaffUserActivityLogCreateProps { + activityType: ValueObjects.ActivityTypeCode; + activityDescription: ValueObjects.Description; + activityByStaffUserId: string; +} + +export class StaffUserActivityLog extends DomainEntity implements StaffUserActivityLogEntityReference { + private readonly visa: UserVisa; + private isNew: boolean = false; + + constructor(props: StaffUserActivityLogProps, visa: UserVisa, createProps?: StaffUserActivityLogCreateProps) { + super(props); + this.visa = visa; + + if (createProps) { + this.isNew = true; + this.activityType = createProps.activityType; + this.activityDescription = createProps.activityDescription; + this.activityByStaffUserId = createProps.activityByStaffUserId; + this.isNew = false; + } + } + + public static getNewInstance(newProps: StaffUserActivityLogProps, visa: UserVisa, createProps: StaffUserActivityLogCreateProps): StaffUserActivityLog { + return new StaffUserActivityLog(newProps, visa, createProps); + } + + private validateVisa(): void { + if (this.isNew) { + return; + } + if (!this.visa.determineIf((permissions) => permissions.isSystemAccount || permissions.canManageStaffRolesAndPermissions)) { + throw new PermissionError('Unauthorized'); + } + } + + get activityType(): string { + return this.props.activityType; + } + set activityType(activityTypeCode: ValueObjects.ActivityTypeCode) { + this.validateVisa(); + this.props.activityType = activityTypeCode.valueOf(); + } + + get activityDescription(): string { + return this.props.activityDescription; + } + set activityDescription(activityDescription: ValueObjects.Description) { + this.validateVisa(); + this.props.activityDescription = activityDescription.valueOf(); + } + + get activityByStaffUserId(): string { + return this.props.activityByStaffUserId; + } + private set activityByStaffUserId(id: string) { + if (!this.isNew) { + throw new Error('activityByStaffUserId can only be set during creation'); + } + this.validateVisa(); + this.props.activityByStaffUserId = id; + } + + get createdAt(): Date { + return this.props.createdAt; + } + get updatedAt(): Date { + return this.props.updatedAt; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.value-objects.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.value-objects.ts new file mode 100644 index 000000000..ee4456fb1 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.value-objects.ts @@ -0,0 +1,13 @@ +import { VOSet, VOString } from '@lucaspaganini/value-objects'; + +export const ActivityTypeCodes = { + Created: 'CREATED', + Updated: 'UPDATED', + RoleAssigned: 'ROLE_ASSIGNED', + RoleRemoved: 'ROLE_REMOVED', + Blocked: 'BLOCKED', + Unblocked: 'UNBLOCKED', +} as const; + +export class Description extends VOString({ trim: true, maxLength: 2000 }) {} +export class ActivityTypeCode extends VOSet(Object.values(ActivityTypeCodes)) {} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.test.ts index 3d7cafc90..dc5171b14 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.test.ts @@ -15,14 +15,14 @@ import type { StaffRoleEntityReference, StaffRoleProps } from '../staff-role/sta const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.feature')); -function makePassport(canManageStaffRolesAndPermissions = true): Passport { +function makePassport(canManageStaffRolesAndPermissions = true, isSystemAccount = false): Passport { return vi.mocked({ user: { forStaffUser: vi.fn(() => ({ - determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions }), + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions, isSystemAccount }), })), forStaffRole: vi.fn(() => ({ - determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions }), + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions, isSystemAccount }), })), }, } as unknown as Passport); @@ -37,6 +37,7 @@ function makeBaseProps(overrides: Partial = {}): StaffUserProps roleName: 'test role', roleType: 'staff-role', } as StaffRoleProps); + const activityLogItems: import('./staff-user-activity-log.entity.ts').StaffUserActivityLogProps[] = []; return { id: 'staff-1', firstName: 'Alice', @@ -56,6 +57,17 @@ function makeBaseProps(overrides: Partial = {}): StaffUserProps setRoleRef: (role: StaffRoleEntityReference | undefined) => { _role = role; }, + activityLog: { + get items() { return activityLogItems as ReadonlyArray; }, + addItem: (item: import('./staff-user-activity-log.entity.ts').StaffUserActivityLogProps) => { activityLogItems.push(item); }, + getNewItem: () => { + const item = { id: `activity-${activityLogItems.length}`, activityType: '', activityDescription: '', activityByStaffUserId: '', createdAt: new Date(), updatedAt: new Date() } as import('./staff-user-activity-log.entity.ts').StaffUserActivityLogProps; + activityLogItems.push(item); + return item; + }, + removeItem: (item: import('./staff-user-activity-log.entity.ts').StaffUserActivityLogProps) => { const idx = activityLogItems.indexOf(item); if (idx > -1) activityLogItems.splice(idx, 1); }, + removeAll: () => { activityLogItems.splice(0); }, + }, ...overrides, }; } @@ -505,6 +517,137 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); + // activityLog + Scenario('Logging a general update via requestAddUpdate', ({ Given, When, Then }) => { + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I call requestAddUpdate with description "Profile updated" and activityByStaffUserId "staff-99"', () => { + staffUser.requestAddUpdate('Profile updated', 'staff-99'); + }); + Then('an activity log entry with activityType "UPDATED" and description "Profile updated" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('UPDATED'); + expect(entries.at(0)?.activityDescription).toBe('Profile updated'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); + + Scenario('requestAddUpdate without permission throws PermissionError', ({ Given, When, Then }) => { + let action: () => void; + Given('a StaffUser aggregate without permission to manage staff roles and permissions', () => { + passport = makePassport(false); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I try to call requestAddUpdate with description "Profile updated" and activityByStaffUserId "staff-99"', () => { + action = () => staffUser.requestAddUpdate('Profile updated', 'staff-99'); + }); + Then('a PermissionError should be thrown', () => { + expect(action).toThrow(PermissionError); + }); + }); + + Scenario('Logging a role assignment via requestRoleAssignment', ({ Given, When, Then, And }) => { + const newRole = vi.mocked({ id: 'role-99', roleName: 'New Role', roleType: 'staff-role' } as import('../staff-role/staff-role.ts').StaffRoleEntityReference); + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I call requestRoleAssignment with a valid role, description "Role assigned", and activityByStaffUserId "staff-99"', () => { + staffUser.requestRoleAssignment(newRole, 'Role assigned', 'staff-99'); + }); + Then("the staff user's role should be updated", () => { + expect(staffUser.role?.id).toBe('role-99'); + }); + And('an activity log entry with activityType "ROLE_ASSIGNED" and description "Role assigned" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('ROLE_ASSIGNED'); + expect(entries.at(0)?.activityDescription).toBe('Role assigned'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); + + Scenario('Logging a role removal via requestRoleRemoval', ({ Given, When, Then, And }) => { + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I call requestRoleRemoval with description "Role removed" and activityByStaffUserId "staff-99"', () => { + staffUser.requestRoleRemoval('Role removed', 'staff-99'); + }); + Then("the staff user's role should be undefined", () => { + expect(staffUser.role).toBeUndefined(); + }); + And('an activity log entry with activityType "ROLE_REMOVED" and description "Role removed" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('ROLE_REMOVED'); + expect(entries.at(0)?.activityDescription).toBe('Role removed'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); + + Scenario('Logging a block via requestBlock', ({ Given, When, Then, And }) => { + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I call requestBlock with description "User blocked" and activityByStaffUserId "staff-99"', () => { + staffUser.requestBlock('User blocked', 'staff-99'); + }); + Then("the staff user's accessBlocked should be true", () => { + expect(staffUser.accessBlocked).toBe(true); + }); + And('an activity log entry with activityType "BLOCKED" and description "User blocked" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('BLOCKED'); + expect(entries.at(0)?.activityDescription).toBe('User blocked'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); + + Scenario('Logging an unblock via requestUnblock', ({ Given, When, Then, And }) => { + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + staffUser.accessBlocked = true; + }); + When('I call requestUnblock with description "User unblocked" and activityByStaffUserId "staff-99"', () => { + staffUser.requestUnblock('User unblocked', 'staff-99'); + }); + Then("the staff user's accessBlocked should be false", () => { + expect(staffUser.accessBlocked).toBe(false); + }); + And('an activity log entry with activityType "UNBLOCKED" and description "User unblocked" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('UNBLOCKED'); + expect(entries.at(0)?.activityDescription).toBe('User unblocked'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); + // Repeat the above pattern for lastName, displayName, email, externalId, accessBlocked, tags, role, and read-only properties. // For brevity, only firstName scenarios are shown here. + + Scenario('Logging a create action via requestCreate', ({ Given, When, Then }) => { + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I call requestCreate with activityByStaffUserId "staff-99"', () => { + staffUser.requestCreate('staff-99'); + }); + Then('an activity log entry with activityType "CREATED" and description "User created" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('CREATED'); + expect(entries.at(0)?.activityDescription).toBe('User created'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts index dd51f07cb..38d25a306 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts @@ -1,11 +1,18 @@ import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; import { AggregateRoot } from '@cellix/domain-seedwork/aggregate-root'; import type { DomainEntityProps } from '@cellix/domain-seedwork/domain-entity'; +import type { PropArray } from '@cellix/domain-seedwork/prop-array'; import { StaffUserCreatedEvent, type StaffUserCreatedProps } from '../../../events/types/staff-user-created.ts'; import type { Passport } from '../../passport.ts'; import { StaffRole, type StaffRoleEntityReference, type StaffRoleProps } from '../staff-role/staff-role.ts'; import type { UserVisa } from '../user.visa.ts'; +import { + StaffUserActivityLog, + type StaffUserActivityLogEntityReference, + type StaffUserActivityLogProps, +} from './staff-user-activity-log.entity.ts'; import * as ValueObjects from './staff-user.value-objects.ts'; +import * as ActivityLogValueObjects from './staff-user-activity-log.value-objects.ts'; export interface StaffUserProps extends DomainEntityProps { readonly role?: StaffRoleProps; @@ -22,10 +29,12 @@ export interface StaffUserProps extends DomainEntityProps { readonly createdAt: Date; readonly updatedAt: Date; readonly schemaVersion: string; + activityLog: PropArray; } -export interface StaffUserEntityReference extends Readonly> { +export interface StaffUserEntityReference extends Readonly> { readonly role: StaffRoleEntityReference | undefined; + readonly activityLog: ReadonlyArray; } export class StaffUser extends AggregateRoot implements StaffUserEntityReference { @@ -63,6 +72,44 @@ export class StaffUser extends AggregateRoot extends AggregateRoot { + return this.props.activityLog.items.map((p) => new StaffUserActivityLog(p, this.visa)); + } + get userType(): string { return this.props.userType; } diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/features/staff-user.user.passport.feature b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/features/staff-user.user.passport.feature new file mode 100644 index 000000000..47e2d8262 --- /dev/null +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/features/staff-user.user.passport.feature @@ -0,0 +1,47 @@ +Feature: StaffUserUserPassport + + Background: + Given a valid StaffUserEntityReference with externalId "ext-1" and canManageStaffRolesAndPermissions true + + Scenario: Creating a StaffUserUserPassport with a staff user + When I create a StaffUserUserPassport with the staff user + Then the passport should be created successfully + + Scenario: forEndUser returns a visa where canManageStaffRolesAndPermissions is true + Given a StaffUserUserPassport for the staff user + When I call forEndUser with any EndUserEntityReference + Then determineIf should return true for canManageStaffRolesAndPermissions + And determineIf should return false for canManageEndUsers + And determineIf should return false for canManageVendorUsers + And determineIf should return false for isSystemAccount + And determineIf should return false for isEditingOwnAccount + + Scenario: forEndUser when the staff user has no role returns a visa with all permissions false + Given a StaffUserEntityReference with no role + And a StaffUserUserPassport for that staff user + When I call forEndUser with any EndUserEntityReference + Then determineIf should return false for canManageStaffRolesAndPermissions + + Scenario: forStaffUser called with own staff user sets isEditingOwnAccount true + Given a StaffUserUserPassport for the staff user + When I call forStaffUser with the same staff user as the root + Then determineIf should return true for isEditingOwnAccount + And determineIf should return true for canManageStaffRolesAndPermissions + + Scenario: forStaffUser called with a different staff user sets isEditingOwnAccount false + Given a StaffUserUserPassport for the staff user + When I call forStaffUser with a different StaffUserEntityReference + Then determineIf should return false for isEditingOwnAccount + And determineIf should return true for canManageStaffRolesAndPermissions + + Scenario: forStaffRole returns a visa where canManageStaffRolesAndPermissions is true + Given a StaffUserUserPassport for the staff user + When I call forStaffRole with any StaffRoleEntityReference + Then determineIf should return true for canManageStaffRolesAndPermissions + And determineIf should return false for isEditingOwnAccount + + Scenario: forVendorUser returns a visa where canManageStaffRolesAndPermissions is true + Given a StaffUserUserPassport for the staff user + When I call forVendorUser with any VendorUserEntityReference + Then determineIf should return true for canManageStaffRolesAndPermissions + And determineIf should return false for canManageVendorUsers diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.test.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.test.ts new file mode 100644 index 000000000..bbcaef7a7 --- /dev/null +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.test.ts @@ -0,0 +1,179 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect } from 'vitest'; +import type { EndUserEntityReference } from '../../../../contexts/user/end-user/index.ts'; +import type { StaffRoleEntityReference } from '../../../../contexts/user/staff-role/staff-role.ts'; +import type { StaffUserEntityReference } from '../../../../contexts/user/staff-user/staff-user.ts'; +import type { VendorUserEntityReference } from '../../../../contexts/user/vendor-user/vendor-user.ts'; +import { StaffUserUserPassport } from './staff-user.user.passport.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.user.passport.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeStaffUser( + externalId = 'ext-1', + canManageStaffRolesAndPermissions = true, +): StaffUserEntityReference { + return { + id: 'staff-1', + externalId, + role: { + permissions: { + communityPermissions: { + canManageStaffRolesAndPermissions, + }, + }, + }, + } as unknown as StaffUserEntityReference; +} + +function makeStaffUserNoRole(externalId = 'ext-no-role'): StaffUserEntityReference { + return { id: 'staff-no-role', externalId } as unknown as StaffUserEntityReference; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let staffUser: StaffUserEntityReference; + let passport: StaffUserUserPassport; + + BeforeEachScenario(() => { + staffUser = makeStaffUser(); + passport = undefined as unknown as StaffUserUserPassport; + }); + + Background(({ Given }) => { + Given('a valid StaffUserEntityReference with externalId "ext-1" and canManageStaffRolesAndPermissions true', () => { + staffUser = makeStaffUser('ext-1', true); + }); + }); + + // ─── Constructor ────────────────────────────────────────────────────────── + + Scenario('Creating a StaffUserUserPassport with a staff user', ({ When, Then }) => { + When('I create a StaffUserUserPassport with the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + Then('the passport should be created successfully', () => { + expect(passport).toBeInstanceOf(StaffUserUserPassport); + }); + }); + + // ─── forEndUser ─────────────────────────────────────────────────────────── + + Scenario('forEndUser returns a visa where canManageStaffRolesAndPermissions is true', ({ Given, When, Then, And }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forEndUser with any EndUserEntityReference', () => { + visa = passport.forEndUser({} as EndUserEntityReference); + }); + Then('determineIf should return true for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(true); + }); + And('determineIf should return false for canManageEndUsers', () => { + expect(visa.determineIf((p) => p.canManageEndUsers)).toBe(false); + }); + And('determineIf should return false for canManageVendorUsers', () => { + expect(visa.determineIf((p) => p.canManageVendorUsers)).toBe(false); + }); + And('determineIf should return false for isSystemAccount', () => { + expect(visa.determineIf((p) => p.isSystemAccount)).toBe(false); + }); + And('determineIf should return false for isEditingOwnAccount', () => { + expect(visa.determineIf((p) => p.isEditingOwnAccount)).toBe(false); + }); + }); + + Scenario('forEndUser when the staff user has no role returns a visa with all permissions false', ({ Given, And, When, Then }) => { + let visa: ReturnType; + Given('a StaffUserEntityReference with no role', () => { + staffUser = makeStaffUserNoRole(); + }); + And('a StaffUserUserPassport for that staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forEndUser with any EndUserEntityReference', () => { + visa = passport.forEndUser({} as EndUserEntityReference); + }); + Then('determineIf should return false for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(false); + }); + }); + + // ─── forStaffUser ───────────────────────────────────────────────────────── + + Scenario('forStaffUser called with own staff user sets isEditingOwnAccount true', ({ Given, When, Then, And }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forStaffUser with the same staff user as the root', () => { + visa = passport.forStaffUser(staffUser); + }); + Then('determineIf should return true for isEditingOwnAccount', () => { + expect(visa.determineIf((p) => p.isEditingOwnAccount)).toBe(true); + }); + And('determineIf should return true for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(true); + }); + }); + + Scenario('forStaffUser called with a different staff user sets isEditingOwnAccount false', ({ Given, When, Then, And }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forStaffUser with a different StaffUserEntityReference', () => { + const otherUser = makeStaffUser('ext-other', true); + visa = passport.forStaffUser(otherUser); + }); + Then('determineIf should return false for isEditingOwnAccount', () => { + expect(visa.determineIf((p) => p.isEditingOwnAccount)).toBe(false); + }); + And('determineIf should return true for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(true); + }); + }); + + // ─── forStaffRole ───────────────────────────────────────────────────────── + + Scenario('forStaffRole returns a visa where canManageStaffRolesAndPermissions is true', ({ Given, When, Then, And }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forStaffRole with any StaffRoleEntityReference', () => { + visa = passport.forStaffRole({} as StaffRoleEntityReference); + }); + Then('determineIf should return true for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(true); + }); + And('determineIf should return false for isEditingOwnAccount', () => { + expect(visa.determineIf((p) => p.isEditingOwnAccount)).toBe(false); + }); + }); + + // ─── forVendorUser ──────────────────────────────────────────────────────── + + Scenario('forVendorUser returns a visa where canManageStaffRolesAndPermissions is true', ({ Given, When, Then, And }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forVendorUser with any VendorUserEntityReference', () => { + visa = passport.forVendorUser({} as VendorUserEntityReference); + }); + Then('determineIf should return true for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(true); + }); + And('determineIf should return false for canManageVendorUsers', () => { + expect(visa.determineIf((p) => p.canManageVendorUsers)).toBe(false); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts new file mode 100644 index 000000000..7375b9941 --- /dev/null +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts @@ -0,0 +1,42 @@ +import type { EndUserEntityReference } from '../../../../contexts/user/end-user/index.ts'; +import type { StaffRoleEntityReference } from '../../../../contexts/user/staff-role/staff-role.ts'; +import type { StaffUserEntityReference } from '../../../../contexts/user/staff-user/index.ts'; +import type { UserDomainPermissions } from '../../../../contexts/user/user.domain-permissions.ts'; +import type { UserPassport } from '../../../../contexts/user/user.passport.ts'; +import type { UserVisa } from '../../../../contexts/user/user.visa.ts'; +import type { VendorUserEntityReference } from '../../../../contexts/user/vendor-user/vendor-user.ts'; +import { StaffUserPassportBase } from '../../staff-user.passport-base.ts'; + +export class StaffUserUserPassport extends StaffUserPassportBase implements UserPassport { + forEndUser(_root: EndUserEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + forStaffUser(root: StaffUserEntityReference): UserVisa { + const permissions = this.buildPermissions(root); + return { determineIf: (func) => func(permissions) }; + } + + forStaffRole(_root: StaffRoleEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + forVendorUser(_root: VendorUserEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + private buildPermissions(root?: StaffUserEntityReference): UserDomainPermissions { + const canManageStaffRolesAndPermissions = this._user.role?.permissions.communityPermissions.canManageStaffRolesAndPermissions ?? false; + return { + canManageEndUsers: false, + canManageStaffRolesAndPermissions, + canManageStaffUsers: canManageStaffRolesAndPermissions, + canManageVendorUsers: false, + isEditingOwnAccount: root !== undefined && root.externalId === this._user.externalId, + isSystemAccount: false, + }; + } +} diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature b/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature index 8145b32b4..7fe6151bc 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature @@ -20,4 +20,49 @@ Feature: StaffUserPassport Scenario: Accessing the user passport When I create a StaffUserPassport with valid staff user And I access the user property - Then an error should be thrown indicating the user passport is not available \ No newline at end of file + Then I should receive a StaffUserUserPassport instance + + Scenario: Accessing the case passport + When I create a StaffUserPassport with valid staff user + And I access the case property + Then I should receive a StaffUserCasePassport instance + + Scenario: The case passport forServiceTicketV1 returns a StaffUserServiceTicketVisa + When I create a StaffUserPassport with valid staff user + And I access the case property + Then forServiceTicketV1 should return a StaffUserServiceTicketVisa + + Scenario: The case passport forViolationTicketV1 returns a StaffUserViolationTicketVisa + When I create a StaffUserPassport with valid staff user + And I access the case property + Then forViolationTicketV1 should return a StaffUserViolationTicketVisa + + Scenario: Accessing the property passport + When I create a StaffUserPassport with valid staff user + And I access the property property + Then I should receive a StaffUserPropertyPassport instance + + Scenario: The property passport forProperty returns a visa that always denies + When I create a StaffUserPassport with valid staff user + And I access the property property + Then forProperty should return a visa whose determineIf always returns false + + Scenario: Community passport is cached after first access + When I create a StaffUserPassport with valid staff user + And I access the community property twice + Then both accesses should return the same instance + + Scenario: Case passport is cached after first access + When I create a StaffUserPassport with valid staff user + And I access the case property twice + Then both accesses should return the same instance + + Scenario: Property passport is cached after first access + When I create a StaffUserPassport with valid staff user + And I access the property property twice + Then both accesses should return the same instance + + Scenario: User passport is cached after first access + When I create a StaffUserPassport with valid staff user + And I access the user property twice + Then both accesses should return the same instance \ No newline at end of file diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts index 62a730e0a..4757cf6a3 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts @@ -2,10 +2,18 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { expect } from 'vitest'; +import type { ServiceTicketV1EntityReference } from '../../../contexts/case/service-ticket/v1/service-ticket-v1.aggregate.ts'; +import type { ViolationTicketV1EntityReference } from '../../../contexts/case/violation-ticket/v1/violation-ticket-v1.aggregate.ts'; import type { CommunityEntityReference } from '../../../contexts/community/community/community.ts'; +import type { PropertyEntityReference } from '../../../contexts/property/property/property.aggregate.ts'; import type { StaffUserEntityReference } from '../../../contexts/user/staff-user/staff-user.ts'; +import { StaffUserCasePassport } from './contexts/staff-user.case.passport.ts'; import { StaffUserCommunityPassport } from './contexts/staff-user.community.passport.ts'; import { StaffUserCommunityVisa } from './contexts/staff-user.community.visa.ts'; +import { StaffUserPropertyPassport } from './contexts/staff-user.property.passport.ts'; +import { StaffUserServiceTicketVisa } from './contexts/staff-user.service-ticket.visa.ts'; +import { StaffUserUserPassport } from './contexts/staff-user.user.passport.ts'; +import { StaffUserViolationTicketVisa } from './contexts/staff-user.violation-ticket.visa.ts'; import { StaffUserPassport } from './staff-user.passport.ts'; const test = { for: describeFeature }; @@ -34,7 +42,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { BeforeEachScenario(() => { staffUser = makeStaffUser(); passport = undefined as unknown as StaffUserPassport; - communityPassport = undefined as unknown as StaffUserCommunityPassport; + communityPassport = undefined; }); Background(({ Given }) => { @@ -53,7 +61,6 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); Scenario('Accessing the community passport', ({ When, And, Then }) => { - // Uncomment and update when StaffUserPassport is implemented When('I create a StaffUserPassport with valid staff user', () => { passport = new StaffUserPassport(staffUser); }); @@ -67,7 +74,6 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { id: 'community-1', } as CommunityEntityReference), ).toBeInstanceOf(StaffUserCommunityVisa); - // Add more assertions for visas if needed }); }); @@ -85,15 +91,149 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); Scenario('Accessing the user passport', ({ When, And, Then }) => { - let getUserPassport: () => void; + let userPassport: unknown; When('I create a StaffUserPassport with valid staff user', () => { passport = new StaffUserPassport(staffUser); }); And('I access the user property', () => { - getUserPassport = () => passport.user; + userPassport = passport.user; }); - Then('an error should be thrown indicating the user passport is not available', () => { - expect(getUserPassport).toThrow('User passport is not available for StaffUserPassport'); + Then('I should receive a StaffUserUserPassport instance', () => { + expect(userPassport).toBeInstanceOf(StaffUserUserPassport); + }); + }); + + // ─── case passport ─────────────────────────────────────────────────────────── + + Scenario('Accessing the case passport', ({ When, And, Then }) => { + let casePassport: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the case property', () => { + casePassport = passport.case; + }); + Then('I should receive a StaffUserCasePassport instance', () => { + expect(casePassport).toBeInstanceOf(StaffUserCasePassport); + }); + }); + + Scenario('The case passport forServiceTicketV1 returns a StaffUserServiceTicketVisa', ({ When, And, Then }) => { + let casePassport: StaffUserCasePassport; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the case property', () => { + casePassport = passport.case as StaffUserCasePassport; + }); + Then('forServiceTicketV1 should return a StaffUserServiceTicketVisa', () => { + const visa = casePassport.forServiceTicketV1({} as ServiceTicketV1EntityReference); + expect(visa).toBeInstanceOf(StaffUserServiceTicketVisa); + }); + }); + + Scenario('The case passport forViolationTicketV1 returns a StaffUserViolationTicketVisa', ({ When, And, Then }) => { + let casePassport: StaffUserCasePassport; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the case property', () => { + casePassport = passport.case as StaffUserCasePassport; + }); + Then('forViolationTicketV1 should return a StaffUserViolationTicketVisa', () => { + const visa = casePassport.forViolationTicketV1({} as ViolationTicketV1EntityReference); + expect(visa).toBeInstanceOf(StaffUserViolationTicketVisa); + }); + }); + + // ─── property passport ─────────────────────────────────────────────────────── + + Scenario('Accessing the property passport', ({ When, And, Then }) => { + let propertyPassport: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the property property', () => { + propertyPassport = passport.property; + }); + Then('I should receive a StaffUserPropertyPassport instance', () => { + expect(propertyPassport).toBeInstanceOf(StaffUserPropertyPassport); + }); + }); + + Scenario('The property passport forProperty returns a visa that always denies', ({ When, And, Then }) => { + let propertyPassport: StaffUserPropertyPassport; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the property property', () => { + propertyPassport = passport.property as StaffUserPropertyPassport; + }); + Then('forProperty should return a visa whose determineIf always returns false', () => { + const visa = propertyPassport.forProperty({} as PropertyEntityReference); + expect(visa.determineIf(() => true)).toBe(false); + }); + }); + + // ─── lazy-init caching ─────────────────────────────────────────────────────── + + Scenario('Community passport is cached after first access', ({ When, And, Then }) => { + let first: unknown; + let second: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the community property twice', () => { + first = passport.community; + second = passport.community; + }); + Then('both accesses should return the same instance', () => { + expect(first).toBe(second); + }); + }); + + Scenario('Case passport is cached after first access', ({ When, And, Then }) => { + let first: unknown; + let second: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the case property twice', () => { + first = passport.case; + second = passport.case; + }); + Then('both accesses should return the same instance', () => { + expect(first).toBe(second); + }); + }); + + Scenario('Property passport is cached after first access', ({ When, And, Then }) => { + let first: unknown; + let second: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the property property twice', () => { + first = passport.property; + second = passport.property; + }); + Then('both accesses should return the same instance', () => { + expect(first).toBe(second); + }); + }); + + Scenario('User passport is cached after first access', ({ When, And, Then }) => { + let first: unknown; + let second: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the user property twice', () => { + first = passport.user; + second = passport.user; + }); + Then('both accesses should return the same instance', () => { + expect(first).toBe(second); }); }); }); diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts index 5d2715451..92a9a9369 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts @@ -2,15 +2,18 @@ import type { CasePassport } from '../../../contexts/case/case.passport.ts'; import type { CommunityPassport } from '../../../contexts/community/community.passport.ts'; import type { Passport } from '../../../contexts/passport.ts'; import type { PropertyPassport } from '../../../contexts/property/property.passport.ts'; +import type { UserPassport } from '../../../contexts/user/user.passport.ts'; import { StaffUserPassportBase } from '../staff-user.passport-base.ts'; import { StaffUserCasePassport } from './contexts/staff-user.case.passport.ts'; import { StaffUserCommunityPassport } from './contexts/staff-user.community.passport.ts'; import { StaffUserPropertyPassport } from './contexts/staff-user.property.passport.ts'; +import { StaffUserUserPassport } from './contexts/staff-user.user.passport.ts'; export class StaffUserPassport extends StaffUserPassportBase implements Passport { private _communityPassport: CommunityPassport | undefined; private _propertyPassport: PropertyPassport | undefined; private _casePassport: CasePassport | undefined; + private _userPassport: UserPassport | undefined; public get case(): CasePassport { if (!this._casePassport) { @@ -37,7 +40,10 @@ export class StaffUserPassport extends StaffUserPassportBase implements Passport throw new Error('Service passport is not available for StaffUserPassport'); } - public get user(): never { - throw new Error('User passport is not available for StaffUserPassport'); + public get user(): UserPassport { + if (!this._userPassport) { + this._userPassport = new StaffUserUserPassport(this._user); + } + return this._userPassport; } } diff --git a/packages/ocom/graphql/src/schema/builder/resolver-builder.ts b/packages/ocom/graphql/src/schema/builder/resolver-builder.ts index 8fc71a151..5992cc548 100644 --- a/packages/ocom/graphql/src/schema/builder/resolver-builder.ts +++ b/packages/ocom/graphql/src/schema/builder/resolver-builder.ts @@ -1,5 +1,4 @@ import { mergeResolvers } from '@graphql-tools/merge'; -import endUserRoleResolvers from '../types/end-user-role.resolvers.ts'; import type { Resolvers } from './generated.ts'; import { ocomGraphqlPermissions, ocomGraphqlResolvers } from './resolver-manifest.generated.ts'; @@ -7,5 +6,5 @@ function mergeResolverModules(modules: Resolvers[]): Resolvers { return (modules.length === 0 ? {} : mergeResolvers(modules)) as Resolvers; } -export const resolvers: Resolvers = mergeResolverModules([...ocomGraphqlResolvers, endUserRoleResolvers]); +export const resolvers: Resolvers = mergeResolverModules([...ocomGraphqlResolvers]); export const permissions: Resolvers = mergeResolverModules(ocomGraphqlPermissions); diff --git a/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature b/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature new file mode 100644 index 000000000..d9f44a154 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature @@ -0,0 +1,141 @@ +Feature: Staff User Resolvers + + As an API consumer + I want to query and manage staff users and roles + So that I can administer the system via the GraphQL API + + # ─── currentStaffUserAndCreateIfNotExists ──────────────────────────────────── + + Scenario: Querying the current staff user and creating if not exists + Given a user with a verifiedJwt in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should call User.StaffUser.createIfNotExists with the JWT claims + And it should return the corresponding StaffUser entity + + Scenario: Querying the current staff user with AAD roles + Given a user with a verifiedJwt that includes AAD roles in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should call User.StaffUser.createIfNotExists with the AAD roles + And it should return the corresponding StaffUser entity + + Scenario: Querying the current staff user with no JWT + Given a user without a verifiedJwt in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should throw an "Unauthorized" error + + # ─── staffUsers ────────────────────────────────────────────────────────────── + + Scenario: Listing staff users when authenticated + Given a user with a verifiedJwt in their context + When the staffUsers query is executed + Then it should return the list of staff users + + Scenario: Listing staff users when unauthenticated + Given a user without a verifiedJwt in their context + When the staffUsers query is executed + Then it should throw an "Unauthorized" error + + # ─── staffRoles ────────────────────────────────────────────────────────────── + + Scenario: Listing staff roles when authenticated + Given a user with a verifiedJwt in their context + When the staffRoles query is executed + Then it should call createDefaultRoles + And it should return the list of staff roles + + Scenario: Listing staff roles when unauthenticated + Given a user without a verifiedJwt in their context + When the staffRoles query is executed + Then it should throw an "Unauthorized" error + + # ─── staffRoleById ─────────────────────────────────────────────────────────── + + Scenario: Querying a staff role by id when authenticated + Given a user with a verifiedJwt in their context + When the staffRoleById query is executed with id "role-001" + Then it should return the staff role with id "role-001" + + Scenario: Querying a staff role by id when unauthenticated + Given a user without a verifiedJwt in their context + When the staffRoleById query is executed with id "role-001" + Then it should throw an "Unauthorized" error + + # ─── staffUserById ─────────────────────────────────────────────────────────── + + Scenario: Querying a staff user by id when the user exists + Given a user with a verifiedJwt in their context + When the staffUserById query is executed with id "user-001" + Then it should return the staff user with id "user-001" + + Scenario: Querying a staff user by id when the user does not exist + Given a user with a verifiedJwt in their context + When the staffUserById query is executed with id "user-missing" + Then it should return null + + Scenario: Querying a staff user by id when unauthenticated + Given a user without a verifiedJwt in their context + When the staffUserById query is executed with id "user-001" + Then it should throw an "Unauthorized" error + + # ─── staffRoleCreate ───────────────────────────────────────────────────────── + + Scenario: Creating a staff role as TechAdmin + Given a user with a verifiedJwt that includes the TechAdmin role + When the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.CaseManager" + Then it should return success with the created staff role + + Scenario: Creating a staff role with an unauthorized enterpriseAppRole + Given a user with a verifiedJwt that includes the CaseManager role + When the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.TechAdmin" + Then it should return failure with a permission error message + + Scenario: Creating a staff role when unauthenticated + Given a user without a verifiedJwt in their context + When the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.CaseManager" + Then it should return failure with message "Unauthorized" + + Scenario: Creating a staff role when the service throws + Given a user with a verifiedJwt that includes the TechAdmin role + When the staffRoleCreate mutation throws an error + Then it should return failure with the error message + + # ─── staffRoleUpdate ───────────────────────────────────────────────────────── + + Scenario: Updating a staff role as TechAdmin + Given a user with a verifiedJwt that includes the TechAdmin role + When the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin" + Then it should return success with the updated staff role + + Scenario: Updating a staff role with an unauthorized enterpriseAppRole + Given a user with a verifiedJwt that includes the CaseManager role + When the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin" + Then it should return failure with a permission error message + + Scenario: Updating a staff role when unauthenticated + Given a user without a verifiedJwt in their context + When the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin" + Then it should return failure with message "Unauthorized" + + # ─── staffUserAssignRole ───────────────────────────────────────────────────── + + Scenario: Assigning a role as TechAdmin bypasses role-type check + Given a user with a verifiedJwt that includes the TechAdmin role + When the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001" + Then it should return success with the updated staff user + + Scenario: Assigning an allowed role as non-TechAdmin + Given a user with a verifiedJwt that includes the CaseManager role + And the role "role-001" has enterpriseAppRole "Staff.CaseManager" + When the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001" + Then it should return success with the updated staff user + + Scenario: Assigning a forbidden role as non-TechAdmin + Given a user with a verifiedJwt that includes the CaseManager role + And the role "role-001" has enterpriseAppRole "Staff.TechAdmin" + When the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001" + Then it should return failure with a permission error message + + Scenario: Assigning a role when unauthenticated + Given a user without a verifiedJwt in their context + When the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001" + Then it should return failure with message "Unauthorized" diff --git a/packages/ocom/graphql/src/schema/types/staff-user.graphql b/packages/ocom/graphql/src/schema/types/staff-user.graphql new file mode 100644 index 000000000..b9b244eaa --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.graphql @@ -0,0 +1,179 @@ +type StaffRoleCommunityPermissions { + canManageCommunities: Boolean! + canManageStaffRolesAndPermissions: Boolean! + canManageAllCommunities: Boolean! + canDeleteCommunities: Boolean! + canChangeCommunityOwner: Boolean! + canReIndexSearchCollections: Boolean! +} + +type StaffRoleFinancePermissions { + canManageFinance: Boolean! + canViewGLBatchSummaries: Boolean! + canViewFinanceConfigs: Boolean! + canCreateFinanceConfigs: Boolean! +} + +type StaffRoleTechAdminPermissions { + canManageTechAdmin: Boolean! + canViewDatabaseExplorer: Boolean! + canViewBlobExplorer: Boolean! + canViewQueueDashboard: Boolean! + canSendQueueMessages: Boolean! +} + +type StaffRoleUserPermissions { + canManageUsers: Boolean! + canAssignStaffRoles: Boolean! + canAssignStaffUserRoles: Boolean! @deprecated(reason: "Use canAssignStaffRoles instead") + canViewStaffUsers: Boolean! +} + +type StaffRoleRolePermissions { + canViewRoles: Boolean! + canAddRole: Boolean! + canEditRole: Boolean! + canRemoveRole: Boolean! +} + +type StaffRolePermissions { + communityPermissions: StaffRoleCommunityPermissions! + financePermissions: StaffRoleFinancePermissions! + staffRolePermissions: StaffRoleRolePermissions! + techAdminPermissions: StaffRoleTechAdminPermissions! + userPermissions: StaffRoleUserPermissions! +} + +type StaffRole implements MongoBase { + roleName: String! + isDefault: Boolean! + roleType: String + enterpriseAppRole: String! + permissions: StaffRolePermissions! + + id: ObjectID! + schemaVersion: String + createdAt: DateTime + updatedAt: DateTime +} + +type StaffUser implements MongoBase { + externalId: String! + firstName: String! + lastName: String! + email: String! + displayName: String! + accessBlocked: Boolean! + tags: [String!]! + role: StaffRole + activityLog: [StaffUserActivityDetail!]! + + id: ObjectID! + schemaVersion: String + createdAt: DateTime + updatedAt: DateTime +} + +type StaffUserActivityDetail { + activityType: String! + activityDescription: String! + activityByStaffUserId: String! + createdAt: DateTime! + updatedAt: DateTime! +} + +input StaffRoleCreateCommunityPermissionsInput { + canManageCommunities: Boolean + canManageStaffRolesAndPermissions: Boolean + canManageAllCommunities: Boolean + canDeleteCommunities: Boolean + canChangeCommunityOwner: Boolean + canReIndexSearchCollections: Boolean +} + +input StaffRoleCreateUserPermissionsInput { + canManageUsers: Boolean + canAssignStaffRoles: Boolean + canAssignStaffUserRoles: Boolean @deprecated(reason: "Use canAssignStaffRoles instead") + canViewStaffUsers: Boolean +} + +input StaffRoleCreateRolePermissionsInput { + canViewRoles: Boolean + canAddRole: Boolean + canEditRole: Boolean + canRemoveRole: Boolean +} + +input StaffRoleCreateFinancePermissionsInput { + canManageFinance: Boolean + canViewGLBatchSummaries: Boolean + canViewFinanceConfigs: Boolean + canCreateFinanceConfigs: Boolean +} + +input StaffRoleCreateTechAdminPermissionsInput { + canManageTechAdmin: Boolean + canViewDatabaseExplorer: Boolean + canViewBlobExplorer: Boolean + canViewQueueDashboard: Boolean + canSendQueueMessages: Boolean +} + +input StaffRoleCreatePermissionsInput { + communityPermissions: StaffRoleCreateCommunityPermissionsInput + staffRolePermissions: StaffRoleCreateRolePermissionsInput + financePermissions: StaffRoleCreateFinancePermissionsInput + techAdminPermissions: StaffRoleCreateTechAdminPermissionsInput + userPermissions: StaffRoleCreateUserPermissionsInput +} + +input StaffRoleCreateInput { + roleName: String! + enterpriseAppRole: String + permissions: StaffRoleCreatePermissionsInput +} + +input StaffUserAssignRoleInput { + staffUserId: ObjectID! + roleId: ObjectID! +} + +input StaffRoleUpdatePermissionsInput { + communityPermissions: StaffRoleCreateCommunityPermissionsInput + staffRolePermissions: StaffRoleCreateRolePermissionsInput + financePermissions: StaffRoleCreateFinancePermissionsInput + techAdminPermissions: StaffRoleCreateTechAdminPermissionsInput + userPermissions: StaffRoleCreateUserPermissionsInput +} + +input StaffRoleUpdateInput { + id: ObjectID! + roleName: String! + enterpriseAppRole: String! + permissions: StaffRoleUpdatePermissionsInput +} + +type StaffRoleMutationResult implements MutationResult { + status: MutationStatus! + staffRole: StaffRole +} + +type StaffUserMutationResult implements MutationResult { + status: MutationStatus! + staffUser: StaffUser +} + +extend type Query { + currentStaffUserAndCreateIfNotExists: StaffUser! + staffUsers: [StaffUser!]! + staffRoles: [StaffRole!]! + staffRoleById(id: ObjectID!): StaffRole + staffUserById(id: ObjectID!): StaffUser +} + +extend type Mutation { + staffRoleCreate(input: StaffRoleCreateInput!): StaffRoleMutationResult! + staffRoleUpdate(input: StaffRoleUpdateInput!): StaffRoleMutationResult! + staffUserAssignRole(input: StaffUserAssignRoleInput!): StaffUserMutationResult! +} diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts new file mode 100644 index 000000000..535f4224f --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts @@ -0,0 +1,590 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import { type FieldNode, type GraphQLObjectType, type GraphQLResolveInfo, type GraphQLSchema, Kind, type OperationDefinitionNode } from 'graphql'; +import { expect, vi } from 'vitest'; +import type { GraphContext } from '../context.ts'; +import staffUserResolvers from './staff-user.resolvers.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.resolvers.feature')); + +// ─── Domain types ───────────────────────────────────────────────────────────── + +type StaffUserEntity = Domain.Contexts.User.StaffUser.StaffUserEntityReference; +type StaffRoleEntity = Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + +// ─── Mock factories ─────────────────────────────────────────────────────────── + +function createMockStaffUser(overrides: Partial = {}): StaffUserEntity { + return { + id: 'mock-staff-user-id', + externalId: 'mock-external-id', + firstName: 'Jane', + lastName: 'Smith', + displayName: 'Jane Smith', + email: 'jane@example.com', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + } as unknown as StaffUserEntity; +} + +function createMockStaffRole(overrides: Partial = {}): StaffRoleEntity { + return { + id: 'mock-role-id', + roleName: 'Mock Role', + enterpriseAppRole: 'Staff.CaseManager', + isDefault: false, + roleType: null, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + } as unknown as StaffRoleEntity; +} + +function makeMockInfo(fieldName: string): GraphQLResolveInfo { + const mockFieldNode: FieldNode = { kind: Kind.FIELD, name: { kind: Kind.NAME, value: fieldName } }; + return { + fieldName, + fieldNodes: [mockFieldNode], + returnType: {} as GraphQLObjectType, + parentType: {} as GraphQLObjectType, + path: { key: fieldName, prev: undefined, typename: undefined }, + schema: {} as GraphQLSchema, + fragments: {}, + rootValue: {}, + operation: {} as OperationDefinitionNode, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} + +type JwtOverride = { + sub?: string; + given_name?: string; + family_name?: string; + email?: string; + roles?: string[]; +}; + +function makeMockGraphContext(options: { + jwt?: JwtOverride | null; + staffUserServices?: Partial; + staffRoleServices?: Partial; +} = {}): GraphContext { + const { jwt = {}, staffUserServices = {}, staffRoleServices = {} } = options; + return { + applicationServices: { + User: { + StaffUser: { + createIfNotExists: vi.fn(), + list: vi.fn(), + assignRole: vi.fn(), + create: vi.fn(), + queryByExternalId: vi.fn(), + ...staffUserServices, + }, + StaffRole: { + list: vi.fn(), + createDefaultRoles: vi.fn(), + queryById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + ...staffRoleServices, + }, + }, + verifiedUser: jwt === null ? undefined : { + verifiedJwt: jwt === null ? undefined : { + sub: 'default-user-sub', + given_name: 'Jane', + family_name: 'Smith', + email: 'jane@example.com', + roles: [], + ...jwt, + }, + }, + }, + } as unknown as GraphContext; +} + +// ─── Resolver call helpers ──────────────────────────────────────────────────── + +const Query = staffUserResolvers.Query as Record unknown>; +const Mutation = staffUserResolvers.Mutation as Record unknown>; + +const callQuery = (name: string, context: GraphContext, args: object = {}) => + // biome-ignore lint/style/noNonNullAssertion: test helper — key always exists + Query[name]!({}, args, context, makeMockInfo(name)) as Promise; + +const callMutation = (name: string, context: GraphContext, args: object = {}) => + // biome-ignore lint/style/noNonNullAssertion: test helper — key always exists + Mutation[name]!({}, args, context, makeMockInfo(name)) as Promise; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let context: GraphContext; + let result: unknown; + let thrownError: unknown; + + BeforeEachScenario(() => { + context = makeMockGraphContext(); + result = undefined; + thrownError = undefined; + vi.clearAllMocks(); + }); + + // ─── currentStaffUserAndCreateIfNotExists ───────────────────────────────── + + Scenario('Querying the current staff user and creating if not exists', ({ Given, When, Then, And }) => { + const mockStaffUser = createMockStaffUser(); + + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + vi.mocked(context.applicationServices.User.StaffUser.createIfNotExists).mockResolvedValue(mockStaffUser); + result = await callQuery('currentStaffUserAndCreateIfNotExists', context); + }); + + Then('it should call User.StaffUser.createIfNotExists with the JWT claims', () => { + expect(context.applicationServices.User.StaffUser.createIfNotExists).toHaveBeenCalledWith({ + externalId: 'default-user-sub', + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + aadRoles: [], + }); + }); + + And('it should return the corresponding StaffUser entity', () => { + expect(result).toEqual(mockStaffUser); + }); + }); + + Scenario('Querying the current staff user with AAD roles', ({ Given, When, Then, And }) => { + const mockStaffUser = createMockStaffUser(); + const aadRoles = ['Staff.CaseManager', 'Staff.Finance']; + + Given('a user with a verifiedJwt that includes AAD roles in their context', () => { + context = makeMockGraphContext({ jwt: { sub: 'roles-user-sub', given_name: 'Bob', family_name: 'Jones', email: 'bob@example.com', roles: aadRoles } }); + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + vi.mocked(context.applicationServices.User.StaffUser.createIfNotExists).mockResolvedValue(mockStaffUser); + result = await callQuery('currentStaffUserAndCreateIfNotExists', context); + }); + + Then('it should call User.StaffUser.createIfNotExists with the AAD roles', () => { + expect(context.applicationServices.User.StaffUser.createIfNotExists).toHaveBeenCalledWith({ + externalId: 'roles-user-sub', + firstName: 'Bob', + lastName: 'Jones', + email: 'bob@example.com', + aadRoles, + }); + }); + + And('it should return the corresponding StaffUser entity', () => { + expect(result).toEqual(mockStaffUser); + }); + }); + + Scenario('Querying the current staff user with no JWT', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + try { + await callQuery('currentStaffUserAndCreateIfNotExists', context); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── staffUsers ─────────────────────────────────────────────────────────── + + Scenario('Listing staff users when authenticated', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + + When('the staffUsers query is executed', async () => { + const mockUsers = [createMockStaffUser()]; + vi.mocked(context.applicationServices.User.StaffUser.list).mockResolvedValue(mockUsers); + result = await callQuery('staffUsers', context); + }); + + Then('it should return the list of staff users', () => { + expect(Array.isArray(result)).toBe(true); + }); + }); + + Scenario('Listing staff users when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffUsers query is executed', async () => { + try { + await callQuery('staffUsers', context); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── staffRoles ─────────────────────────────────────────────────────────── + + Scenario('Listing staff roles when authenticated', ({ Given, When, Then, And }) => { + const mockRoles = [createMockStaffRole()]; + + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + + When('the staffRoles query is executed', async () => { + vi.mocked(context.applicationServices.User.StaffRole.createDefaultRoles).mockResolvedValue([]); + vi.mocked(context.applicationServices.User.StaffRole.list).mockResolvedValue(mockRoles); + result = await callQuery('staffRoles', context); + }); + + Then('it should call createDefaultRoles', () => { + expect(context.applicationServices.User.StaffRole.createDefaultRoles).toHaveBeenCalled(); + }); + + And('it should return the list of staff roles', () => { + expect(result).toEqual(mockRoles); + }); + }); + + Scenario('Listing staff roles when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffRoles query is executed', async () => { + try { + await callQuery('staffRoles', context); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── staffRoleById ──────────────────────────────────────────────────────── + + Scenario('Querying a staff role by id when authenticated', ({ Given, When, Then }) => { + const mockRole = createMockStaffRole({ id: 'role-001' }); + + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + + When('the staffRoleById query is executed with id "role-001"', async () => { + vi.mocked(context.applicationServices.User.StaffRole.queryById).mockResolvedValue(mockRole); + result = await callQuery('staffRoleById', context, { id: 'role-001' }); + }); + + Then('it should return the staff role with id "role-001"', () => { + expect(result).toEqual(mockRole); + expect(context.applicationServices.User.StaffRole.queryById).toHaveBeenCalledWith({ roleId: 'role-001' }); + }); + }); + + Scenario('Querying a staff role by id when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffRoleById query is executed with id "role-001"', async () => { + try { + await callQuery('staffRoleById', context, { id: 'role-001' }); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── staffUserById ──────────────────────────────────────────────────────── + + Scenario('Querying a staff user by id when the user exists', ({ Given, When, Then }) => { + const mockUser = createMockStaffUser({ id: 'user-001' }); + + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + + When('the staffUserById query is executed with id "user-001"', async () => { + vi.mocked(context.applicationServices.User.StaffUser.list).mockResolvedValue([mockUser]); + result = await callQuery('staffUserById', context, { id: 'user-001' }); + }); + + Then('it should return the staff user with id "user-001"', () => { + expect((result as StaffUserEntity)?.id).toBe('user-001'); + }); + }); + + Scenario('Querying a staff user by id when the user does not exist', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + + When('the staffUserById query is executed with id "user-missing"', async () => { + vi.mocked(context.applicationServices.User.StaffUser.list).mockResolvedValue([createMockStaffUser({ id: 'user-001' })]); + result = await callQuery('staffUserById', context, { id: 'user-missing' }); + }); + + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); + + Scenario('Querying a staff user by id when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffUserById query is executed with id "user-001"', async () => { + try { + await callQuery('staffUserById', context, { id: 'user-001' }); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── staffRoleCreate ────────────────────────────────────────────────────── + + Scenario('Creating a staff role as TechAdmin', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the TechAdmin role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.TechAdmin'] } }); + }); + + When('the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.CaseManager"', async () => { + const mockRole = createMockStaffRole({ roleName: 'New Role', enterpriseAppRole: 'Staff.CaseManager' }); + vi.mocked(context.applicationServices.User.StaffRole.create).mockResolvedValue(mockRole); + result = await callMutation('staffRoleCreate', context, { input: { roleName: 'New Role', enterpriseAppRole: 'Staff.CaseManager' } }); + }); + + Then('it should return success with the created staff role', () => { + expect((result as { status: { success: boolean } }).status.success).toBe(true); + expect((result as { staffRole: StaffRoleEntity }).staffRole).toBeDefined(); + }); + }); + + Scenario('Creating a staff role with an unauthorized enterpriseAppRole', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the CaseManager role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.CaseManager'] } }); + }); + + When('the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.TechAdmin"', async () => { + result = await callMutation('staffRoleCreate', context, { input: { roleName: 'New Role', enterpriseAppRole: 'Staff.TechAdmin' } }); + }); + + Then('it should return failure with a permission error message', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toContain('Staff.TechAdmin'); + }); + }); + + Scenario('Creating a staff role when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.CaseManager"', async () => { + result = await callMutation('staffRoleCreate', context, { input: { roleName: 'New Role', enterpriseAppRole: 'Staff.CaseManager' } }); + }); + + Then('it should return failure with message "Unauthorized"', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toBe('Unauthorized'); + }); + }); + + Scenario('Creating a staff role when the service throws', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the TechAdmin role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.TechAdmin'] } }); + }); + + When('the staffRoleCreate mutation throws an error', async () => { + vi.mocked(context.applicationServices.User.StaffRole.create).mockRejectedValue(new Error('DB failure')); + result = await callMutation('staffRoleCreate', context, { input: { roleName: 'New Role', enterpriseAppRole: 'Staff.TechAdmin' } }); + }); + + Then('it should return failure with the error message', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toBe('DB failure'); + }); + }); + + // ─── staffRoleUpdate ────────────────────────────────────────────────────── + + Scenario('Updating a staff role as TechAdmin', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the TechAdmin role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.TechAdmin'] } }); + }); + + When('the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin"', async () => { + const mockRole = createMockStaffRole({ id: 'role-001', enterpriseAppRole: 'Staff.TechAdmin' }); + vi.mocked(context.applicationServices.User.StaffRole.update).mockResolvedValue(mockRole); + result = await callMutation('staffRoleUpdate', context, { input: { id: 'role-001', roleName: 'Updated Role', enterpriseAppRole: 'Staff.TechAdmin' } }); + }); + + Then('it should return success with the updated staff role', () => { + const res = result as { status: { success: boolean }; staffRole: StaffRoleEntity }; + expect(res.status.success).toBe(true); + expect(res.staffRole).toBeDefined(); + }); + }); + + Scenario('Updating a staff role with an unauthorized enterpriseAppRole', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the CaseManager role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.CaseManager'] } }); + }); + + When('the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin"', async () => { + result = await callMutation('staffRoleUpdate', context, { input: { id: 'role-001', roleName: 'Updated', enterpriseAppRole: 'Staff.TechAdmin' } }); + }); + + Then('it should return failure with a permission error message', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toContain('Staff.TechAdmin'); + }); + }); + + Scenario('Updating a staff role when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin"', async () => { + result = await callMutation('staffRoleUpdate', context, { input: { id: 'role-001', roleName: 'Updated', enterpriseAppRole: 'Staff.TechAdmin' } }); + }); + + Then('it should return failure with message "Unauthorized"', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toBe('Unauthorized'); + }); + }); + + // ─── staffUserAssignRole ────────────────────────────────────────────────── + + Scenario('Assigning a role as TechAdmin bypasses role-type check', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the TechAdmin role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.TechAdmin'] } }); + }); + + When('the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001"', async () => { + const mockUser = createMockStaffUser({ id: 'user-001' }); + vi.mocked(context.applicationServices.User.StaffUser.assignRole).mockResolvedValue(mockUser); + result = await callMutation('staffUserAssignRole', context, { input: { staffUserId: 'user-001', roleId: 'role-001' } }); + }); + + Then('it should return success with the updated staff user', () => { + const res = result as { status: { success: boolean }; staffUser: StaffUserEntity }; + expect(res.status.success).toBe(true); + expect(res.staffUser).toBeDefined(); + }); + }); + + Scenario('Assigning an allowed role as non-TechAdmin', ({ Given, When, Then, And }) => { + Given('a user with a verifiedJwt that includes the CaseManager role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.CaseManager'] } }); + }); + + And('the role "role-001" has enterpriseAppRole "Staff.CaseManager"', () => { + const allowedRole = createMockStaffRole({ id: 'role-001', enterpriseAppRole: 'Staff.CaseManager' }); + vi.mocked(context.applicationServices.User.StaffRole.list).mockResolvedValue([allowedRole]); + }); + + When('the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001"', async () => { + const mockUser = createMockStaffUser({ id: 'user-001' }); + vi.mocked(context.applicationServices.User.StaffUser.assignRole).mockResolvedValue(mockUser); + result = await callMutation('staffUserAssignRole', context, { input: { staffUserId: 'user-001', roleId: 'role-001' } }); + }); + + Then('it should return success with the updated staff user', () => { + const res = result as { status: { success: boolean }; staffUser: StaffUserEntity }; + expect(res.status.success).toBe(true); + expect(res.staffUser).toBeDefined(); + }); + }); + + Scenario('Assigning a forbidden role as non-TechAdmin', ({ Given, When, Then, And }) => { + Given('a user with a verifiedJwt that includes the CaseManager role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.CaseManager'] } }); + }); + + And('the role "role-001" has enterpriseAppRole "Staff.TechAdmin"', () => { + const forbiddenRole = createMockStaffRole({ id: 'role-001', enterpriseAppRole: 'Staff.TechAdmin' }); + vi.mocked(context.applicationServices.User.StaffRole.list).mockResolvedValue([forbiddenRole]); + }); + + When('the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001"', async () => { + result = await callMutation('staffUserAssignRole', context, { input: { staffUserId: 'user-001', roleId: 'role-001' } }); + }); + + Then('it should return failure with a permission error message', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toContain('Staff.TechAdmin'); + }); + }); + + Scenario('Assigning a role when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001"', async () => { + result = await callMutation('staffUserAssignRole', context, { input: { staffUserId: 'user-001', roleId: 'role-001' } }); + }); + + Then('it should return failure with message "Unauthorized"', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toBe('Unauthorized'); + }); + }); + +}); diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts new file mode 100644 index 000000000..9a287d104 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts @@ -0,0 +1,239 @@ +import type { GraphQLResolveInfo } from 'graphql'; +import type { Resolvers } from '../builder/generated.ts'; +import type { GraphContext } from '../context.ts'; + +const EnterpriseAppRoleNames = { + CaseManager: 'Staff.CaseManager', + ServiceLineOwner: 'Staff.ServiceLineOwner', + Finance: 'Staff.Finance', + TechAdmin: 'Staff.TechAdmin', +} as const; + +/** Returns the enterprise app role types a caller is allowed to target, based on their Entra roles. */ +function getAllowedEnterpriseAppRoles(entraRoles: string[]): string[] { + if (entraRoles.includes(EnterpriseAppRoleNames.TechAdmin)) { + return Object.values(EnterpriseAppRoleNames); + } + const allowed: string[] = []; + if (entraRoles.includes(EnterpriseAppRoleNames.ServiceLineOwner)) { + allowed.push(EnterpriseAppRoleNames.ServiceLineOwner, EnterpriseAppRoleNames.CaseManager); + } + if (entraRoles.includes(EnterpriseAppRoleNames.CaseManager) && !allowed.includes(EnterpriseAppRoleNames.CaseManager)) { + allowed.push(EnterpriseAppRoleNames.CaseManager); + } + if (entraRoles.includes(EnterpriseAppRoleNames.Finance)) { + allowed.push(EnterpriseAppRoleNames.Finance); + } + return allowed; +} + +const staffUser: Resolvers = { + Query: { + currentStaffUserAndCreateIfNotExists: async (_parent, _args, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + throw new Error('Unauthorized'); + } + const result = await context.applicationServices.User.StaffUser.createIfNotExists({ + externalId: jwt.sub, + firstName: jwt.given_name ?? '', + lastName: jwt.family_name ?? '', + email: jwt.email ?? '', + aadRoles: jwt.roles ?? [], + }); + return result; + }, + + staffUsers: async (_parent, _args, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + return await context.applicationServices.User.StaffUser.list(); + }, + + staffRoles: async (_parent, _args, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + await context.applicationServices.User.StaffRole.createDefaultRoles(); + return await context.applicationServices.User.StaffRole.list(); + }, + + staffRoleById: async (_parent, args: { id: string }, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + return await context.applicationServices.User.StaffRole.queryById({ roleId: String(args.id) }); + }, + + staffUserById: async (_parent, args: { id: string }, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + const users = await context.applicationServices.User.StaffUser.list(); + return users.find((u) => String(u.id) === String(args.id)) ?? null; + }, + }, + + Mutation: { + staffRoleCreate: async (_parent, args: { input: { roleName: string; enterpriseAppRole?: string | null; permissions?: { communityPermissions?: { canManageCommunities?: boolean | null; canManageStaffRolesAndPermissions?: boolean | null; canManageAllCommunities?: boolean | null; canDeleteCommunities?: boolean | null; canChangeCommunityOwner?: boolean | null; canReIndexSearchCollections?: boolean | null } | null; userPermissions?: { canManageUsers?: boolean | null; canAssignStaffRoles?: boolean | null; canAssignStaffUserRoles?: boolean | null; canViewStaffUsers?: boolean | null } | null; staffRolePermissions?: { canViewRoles?: boolean | null; canAddRole?: boolean | null; canEditRole?: boolean | null; canRemoveRole?: boolean | null } | null; financePermissions?: { canManageFinance?: boolean | null; canViewGLBatchSummaries?: boolean | null; canViewFinanceConfigs?: boolean | null; canCreateFinanceConfigs?: boolean | null } | null; techAdminPermissions?: { canManageTechAdmin?: boolean | null; canViewDatabaseExplorer?: boolean | null; canViewBlobExplorer?: boolean | null; canViewQueueDashboard?: boolean | null; canSendQueueMessages?: boolean | null } | null } | null } }, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + return { status: { success: false, errorMessage: 'Unauthorized' } }; + } + try { + const entraRoles = jwt.roles ?? []; + const allowedEnterpriseAppRoles = getAllowedEnterpriseAppRoles(entraRoles); + const requestedEnterpriseAppRole = args.input.enterpriseAppRole ?? ''; + if (requestedEnterpriseAppRole && !allowedEnterpriseAppRoles.includes(requestedEnterpriseAppRole)) { + return { status: { success: false, errorMessage: `You do not have permission to create a role for enterprise app role type: ${requestedEnterpriseAppRole}` } }; + } + const staffRole = await context.applicationServices.User.StaffRole.create({ + roleName: args.input.roleName, + ...(requestedEnterpriseAppRole ? { enterpriseAppRole: requestedEnterpriseAppRole } : {}), + permissions: { + community: { + canManageCommunities: args.input.permissions?.communityPermissions?.canManageCommunities ?? false, + canManageStaffRolesAndPermissions: args.input.permissions?.communityPermissions?.canManageStaffRolesAndPermissions ?? false, + canManageAllCommunities: args.input.permissions?.communityPermissions?.canManageAllCommunities ?? false, + canDeleteCommunities: args.input.permissions?.communityPermissions?.canDeleteCommunities ?? false, + canChangeCommunityOwner: args.input.permissions?.communityPermissions?.canChangeCommunityOwner ?? false, + canReIndexSearchCollections: args.input.permissions?.communityPermissions?.canReIndexSearchCollections ?? false, + }, + user: { + canManageUsers: args.input.permissions?.userPermissions?.canManageUsers ?? false, + canAssignStaffRoles: args.input.permissions?.userPermissions?.canAssignStaffRoles ?? args.input.permissions?.userPermissions?.canAssignStaffUserRoles ?? false, + canAssignStaffUserRoles: args.input.permissions?.userPermissions?.canAssignStaffUserRoles ?? args.input.permissions?.userPermissions?.canAssignStaffRoles ?? false, + canViewStaffUsers: args.input.permissions?.userPermissions?.canViewStaffUsers ?? false, + }, + staffRole: { + canViewRoles: args.input.permissions?.staffRolePermissions?.canViewRoles ?? false, + canAddRole: args.input.permissions?.staffRolePermissions?.canAddRole ?? false, + canEditRole: args.input.permissions?.staffRolePermissions?.canEditRole ?? false, + canRemoveRole: args.input.permissions?.staffRolePermissions?.canRemoveRole ?? false, + }, + finance: { + canManageFinance: args.input.permissions?.financePermissions?.canManageFinance ?? false, + canViewGLBatchSummaries: args.input.permissions?.financePermissions?.canViewGLBatchSummaries ?? false, + canViewFinanceConfigs: args.input.permissions?.financePermissions?.canViewFinanceConfigs ?? false, + canCreateFinanceConfigs: args.input.permissions?.financePermissions?.canCreateFinanceConfigs ?? false, + }, + techAdmin: { + canManageTechAdmin: args.input.permissions?.techAdminPermissions?.canManageTechAdmin ?? false, + canViewDatabaseExplorer: args.input.permissions?.techAdminPermissions?.canViewDatabaseExplorer ?? false, + canViewBlobExplorer: args.input.permissions?.techAdminPermissions?.canViewBlobExplorer ?? false, + canViewQueueDashboard: args.input.permissions?.techAdminPermissions?.canViewQueueDashboard ?? false, + canSendQueueMessages: args.input.permissions?.techAdminPermissions?.canSendQueueMessages ?? false, + }, + }, + }); + return { status: { success: true }, staffRole }; + } catch (error) { + console.error('StaffRole > staffRoleCreate: ', error); + const { message } = error as Error; + return { status: { success: false, errorMessage: message } }; + } + }, + + staffRoleUpdate: async (_parent, args: { input: { id: string; roleName: string; enterpriseAppRole: string; permissions?: { communityPermissions?: { canManageCommunities?: boolean | null; canManageStaffRolesAndPermissions?: boolean | null; canManageAllCommunities?: boolean | null; canDeleteCommunities?: boolean | null; canChangeCommunityOwner?: boolean | null; canReIndexSearchCollections?: boolean | null } | null; userPermissions?: { canManageUsers?: boolean | null; canAssignStaffRoles?: boolean | null; canAssignStaffUserRoles?: boolean | null; canViewStaffUsers?: boolean | null } | null; staffRolePermissions?: { canViewRoles?: boolean | null; canAddRole?: boolean | null; canEditRole?: boolean | null; canRemoveRole?: boolean | null } | null; financePermissions?: { canManageFinance?: boolean | null; canViewGLBatchSummaries?: boolean | null; canViewFinanceConfigs?: boolean | null; canCreateFinanceConfigs?: boolean | null } | null; techAdminPermissions?: { canManageTechAdmin?: boolean | null; canViewDatabaseExplorer?: boolean | null; canViewBlobExplorer?: boolean | null; canViewQueueDashboard?: boolean | null; canSendQueueMessages?: boolean | null } | null } | null } }, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + return { status: { success: false, errorMessage: 'Unauthorized' } }; + } + try { + const entraRoles = jwt.roles ?? []; + const allowedEnterpriseAppRoles = getAllowedEnterpriseAppRoles(entraRoles); + if (!allowedEnterpriseAppRoles.includes(args.input.enterpriseAppRole)) { + return { status: { success: false, errorMessage: `You do not have permission to update a role for enterprise app role type: ${args.input.enterpriseAppRole}` } }; + } + const communityPerms = args.input.permissions?.communityPermissions; + const userPerms = args.input.permissions?.userPermissions; + const staffRole = await context.applicationServices.User.StaffRole.update({ + roleId: String(args.input.id), + roleName: args.input.roleName, + ...(args.input.enterpriseAppRole ? { enterpriseAppRole: args.input.enterpriseAppRole } : {}), + permissions: { + community: { + ...(communityPerms?.canManageCommunities != null ? { canManageCommunities: communityPerms.canManageCommunities } : {}), + ...(communityPerms?.canManageStaffRolesAndPermissions != null ? { canManageStaffRolesAndPermissions: communityPerms.canManageStaffRolesAndPermissions } : {}), + ...(communityPerms?.canManageAllCommunities != null ? { canManageAllCommunities: communityPerms.canManageAllCommunities } : {}), + ...(communityPerms?.canDeleteCommunities != null ? { canDeleteCommunities: communityPerms.canDeleteCommunities } : {}), + ...(communityPerms?.canChangeCommunityOwner != null ? { canChangeCommunityOwner: communityPerms.canChangeCommunityOwner } : {}), + ...(communityPerms?.canReIndexSearchCollections != null ? { canReIndexSearchCollections: communityPerms.canReIndexSearchCollections } : {}), + }, + user: { + ...(userPerms?.canManageUsers != null ? { canManageUsers: userPerms.canManageUsers } : {}), + ...(userPerms?.canAssignStaffRoles != null ? { canAssignStaffRoles: userPerms.canAssignStaffRoles } : {}), + ...(userPerms?.canAssignStaffUserRoles != null ? { canAssignStaffRoles: userPerms.canAssignStaffUserRoles, canAssignStaffUserRoles: userPerms.canAssignStaffUserRoles } : {}), + ...(userPerms?.canViewStaffUsers != null ? { canViewStaffUsers: userPerms.canViewStaffUsers } : {}), + }, + staffRole: { + ...(args.input.permissions?.staffRolePermissions?.canViewRoles != null ? { canViewRoles: args.input.permissions.staffRolePermissions.canViewRoles } : {}), + ...(args.input.permissions?.staffRolePermissions?.canAddRole != null ? { canAddRole: args.input.permissions.staffRolePermissions.canAddRole } : {}), + ...(args.input.permissions?.staffRolePermissions?.canEditRole != null ? { canEditRole: args.input.permissions.staffRolePermissions.canEditRole } : {}), + ...(args.input.permissions?.staffRolePermissions?.canRemoveRole != null ? { canRemoveRole: args.input.permissions.staffRolePermissions.canRemoveRole } : {}), + }, + finance: { + ...(args.input.permissions?.financePermissions?.canManageFinance != null ? { canManageFinance: args.input.permissions.financePermissions.canManageFinance } : {}), + ...(args.input.permissions?.financePermissions?.canViewGLBatchSummaries != null ? { canViewGLBatchSummaries: args.input.permissions.financePermissions.canViewGLBatchSummaries } : {}), + ...(args.input.permissions?.financePermissions?.canViewFinanceConfigs != null ? { canViewFinanceConfigs: args.input.permissions.financePermissions.canViewFinanceConfigs } : {}), + ...(args.input.permissions?.financePermissions?.canCreateFinanceConfigs != null ? { canCreateFinanceConfigs: args.input.permissions.financePermissions.canCreateFinanceConfigs } : {}), + }, + techAdmin: { + ...(args.input.permissions?.techAdminPermissions?.canManageTechAdmin != null ? { canManageTechAdmin: args.input.permissions.techAdminPermissions.canManageTechAdmin } : {}), + ...(args.input.permissions?.techAdminPermissions?.canViewDatabaseExplorer != null ? { canViewDatabaseExplorer: args.input.permissions.techAdminPermissions.canViewDatabaseExplorer } : {}), + ...(args.input.permissions?.techAdminPermissions?.canViewBlobExplorer != null ? { canViewBlobExplorer: args.input.permissions.techAdminPermissions.canViewBlobExplorer } : {}), + ...(args.input.permissions?.techAdminPermissions?.canViewQueueDashboard != null ? { canViewQueueDashboard: args.input.permissions.techAdminPermissions.canViewQueueDashboard } : {}), + ...(args.input.permissions?.techAdminPermissions?.canSendQueueMessages != null ? { canSendQueueMessages: args.input.permissions.techAdminPermissions.canSendQueueMessages } : {}), + }, + }, + }); + return { status: { success: true }, staffRole }; + } catch (error) { + console.error('StaffRole > staffRoleUpdate: ', error); + const { message } = error as Error; + return { status: { success: false, errorMessage: message } }; + } + }, + + staffUserAssignRole: async (_parent, args: { input: { staffUserId: string; roleId: string } }, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + return { status: { success: false, errorMessage: 'Unauthorized' } }; + } + try { + const entraRoles = jwt.roles ?? []; + const allowedEnterpriseAppRoles = getAllowedEnterpriseAppRoles(entraRoles); + const isTechAdmin = entraRoles.includes(EnterpriseAppRoleNames.TechAdmin); + + if (!isTechAdmin) { + const allRoles = await context.applicationServices.User.StaffRole.list(); + const roleToAssign = allRoles.find((r) => String(r.id) === String(args.input.roleId)); + if (!roleToAssign) { + return { status: { success: false, errorMessage: 'Role not found' } }; + } + if (!allowedEnterpriseAppRoles.includes(roleToAssign.enterpriseAppRole)) { + return { status: { success: false, errorMessage: `You do not have permission to assign roles of type: ${roleToAssign.enterpriseAppRole}` } }; + } + } + + const actorStaffUser = await context.applicationServices.User.StaffUser.queryByExternalId({ externalId: jwt.sub }); + const actorStaffUserId = actorStaffUser?.id ?? jwt.sub; + + const staffUser = await context.applicationServices.User.StaffUser.assignRole({ + staffUserId: String(args.input.staffUserId), + roleId: String(args.input.roleId), + actorStaffUserId, + }); + return { status: { success: true }, staffUser }; + } catch (error) { + console.error('StaffUser > staffUserAssignRole: ', error); + const { message } = error as Error; + return { status: { success: false, errorMessage: message } }; + } + }, + + }, +}; + +export default staffUser; diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.unit.test.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.unit.test.ts new file mode 100644 index 000000000..4d94979f1 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.unit.test.ts @@ -0,0 +1,54 @@ +import type { GraphQLResolveInfo } from 'graphql'; +import { describe, expect, it, vi } from 'vitest'; +import type { StaffUserMutationResult } from '../builder/generated.ts'; +import type { GraphContext } from '../context.ts'; +import staffUserResolvers from './staff-user.resolvers.ts'; + +describe('staff-user.resolvers - unit tests', () => { + it('currentStaffUserAndCreateIfNotExists throws Unauthorized when no verifiedJwt', async () => { + const ctx = { applicationServices: {} } as unknown as GraphContext; + const Query = staffUserResolvers.Query as NonNullable; + const currentStaffUserAndCreateIfNotExists = Query.currentStaffUserAndCreateIfNotExists as unknown as ( + parent: unknown, + args: unknown, + context: GraphContext, + info: GraphQLResolveInfo, + ) => Promise; + await expect(currentStaffUserAndCreateIfNotExists(null, null, ctx, {} as unknown as GraphQLResolveInfo)).rejects.toThrow('Unauthorized'); + }); + + it('staffUserAssignRole returns failure status when assignRole throws', async () => { + // assignRole will throw; resolver should catch and return a failure status + const ctx = { + applicationServices: { + verifiedUser: { verifiedJwt: { sub: 'actor-1', roles: ['Staff.CaseManager'] } }, + User: { + StaffRole: { list: async () => [{ id: 'r1', enterpriseAppRole: 'Staff.CaseManager' }] }, + StaffUser: { + queryByExternalId: async () => null, + assignRole: () => Promise.reject(new Error('assign failed')), + }, + }, + }, + } as unknown as GraphContext; + + const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => { + /* noop */ + }); + const Mutation = staffUserResolvers.Mutation as NonNullable; + const staffUserAssignRoleFn = Mutation.staffUserAssignRole as unknown as ( + parent: unknown, + args: { input: { staffUserId: string; roleId: string } }, + context: GraphContext, + info: GraphQLResolveInfo, + ) => Promise; + const res = await staffUserAssignRoleFn(null, { input: { staffUserId: 's1', roleId: 'r1' } }, ctx, {} as unknown as GraphQLResolveInfo); + const resTyped = res as StaffUserMutationResult; + expect(resTyped).toBeDefined(); + expect(resTyped.status).toBeDefined(); + expect(resTyped.status.success).toBe(false); + expect(resTyped.status.errorMessage).toBe('assign failed'); + expect(consoleErr).toHaveBeenCalled(); + consoleErr.mockRestore(); + }); +}); diff --git a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts index 23ca925d2..8ca4d7dc5 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts @@ -121,7 +121,6 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Scenario('Getting message when document message is undefined', ({ Given, When, Then }) => { Given('a MemberInvitationDomainAdapter for a document with no message', () => { const docWithoutMessage = makeMemberInvitationDoc(); - // biome-ignore lint/performance/noDelete: needed to test undefined message scenario delete (docWithoutMessage as unknown as Record)['message']; doc = docWithoutMessage; adapter = new MemberInvitationDomainAdapter(doc); diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature index 7b6f611eb..16556b4e7 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature @@ -151,4 +151,243 @@ Feature: StaffRoleDomainAdapter And the canAssignTickets property should return false And the canWorkOnTickets property should return false When I set the canCreateTickets property to true - Then the violationTicketPermissions' canCreateTickets should be true \ No newline at end of file + Then the violationTicketPermissions' canCreateTickets should be true + + Scenario: Getting and setting canManageCommunities from communityPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the communityPermissions property + And I get the canManageCommunities property + Then it should return false + When I set the canManageCommunities property to true + Then the communityPermissions' canManageCommunities should be true + + Scenario: Getting financePermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then it should return a StaffRoleFinancePermissionsAdapter instance + + Scenario: Getting and setting canManageFinance from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canManageFinance property should return false + When I set the canManageFinance property to true + Then the financePermissions' canManageFinance should be true + + Scenario: Getting and setting canViewGLBatchSummaries from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canViewGLBatchSummaries property should return false + When I set the canViewGLBatchSummaries property to true + Then the financePermissions' canViewGLBatchSummaries should be true + + Scenario: Getting and setting canViewFinanceConfigs from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canViewFinanceConfigs property should return false + When I set the canViewFinanceConfigs property to true + Then the financePermissions' canViewFinanceConfigs should be true + + Scenario: Getting and setting canCreateFinanceConfigs from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canCreateFinanceConfigs property should return false + When I set the canCreateFinanceConfigs property to true + Then the financePermissions' canCreateFinanceConfigs should be true + + Scenario: Getting techAdminPermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then it should return a StaffRoleTechAdminPermissionsAdapter instance + + Scenario: Getting and setting canManageTechAdmin from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canManageTechAdmin property should return false + When I set the canManageTechAdmin property to true + Then the techAdminPermissions' canManageTechAdmin should be true + + Scenario: Getting and setting canViewDatabaseExplorer from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewDatabaseExplorer property should return false + When I set the canViewDatabaseExplorer property to true + Then the techAdminPermissions' canViewDatabaseExplorer should be true + + Scenario: Getting and setting canViewBlobExplorer from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewBlobExplorer property should return false + When I set the canViewBlobExplorer property to true + Then the techAdminPermissions' canViewBlobExplorer should be true + + Scenario: Getting and setting canViewQueueDashboard from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewQueueDashboard property should return false + When I set the canViewQueueDashboard property to true + Then the techAdminPermissions' canViewQueueDashboard should be true + + Scenario: Getting and setting canSendQueueMessages from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canSendQueueMessages property should return false + When I set the canSendQueueMessages property to true + Then the techAdminPermissions' canSendQueueMessages should be true + + Scenario: Getting userPermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + Then it should return a StaffRoleUserPermissionsAdapter instance + + Scenario: Getting and setting canManageUsers from userPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + Then the canManageUsers property should return false + When I set the canManageUsers property to true + Then the userPermissions' canManageUsers should be true + + Scenario: Lazy-initialising permissions when document has no permissions object + Given a StaffRoleDomainAdapter wrapping a document with no permissions object + When I get the permissions property + Then it should return a StaffRolePermissionsAdapter instance + + Scenario: Lazy-initialising communityPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no communityPermissions sub-document + When I get the permissions property + And I get the communityPermissions property + Then it should return a StaffRoleCommunityPermissionsAdapter instance + And canManageCommunities should default to false + + Scenario: Lazy-initialising financePermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no financePermissions sub-document + When I get the permissions property + And I get the financePermissions property + Then it should return a StaffRoleFinancePermissionsAdapter instance + And canManageFinance should default to false + + Scenario: Lazy-initialising techAdminPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no techAdminPermissions sub-document + When I get the permissions property + And I get the techAdminPermissions property + Then it should return a StaffRoleTechAdminPermissionsAdapter instance + And canManageTechAdmin should default to false + + Scenario: Lazy-initialising userPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no userPermissions sub-document + When I get the permissions property + And I get the userPermissions property + Then it should return a StaffRoleUserPermissionsAdapter instance + And canManageUsers should default to false + + Scenario: Getting roleType returns null when document roleType is undefined + Given a StaffRoleDomainAdapter wrapping a document with no roleType + When I get the roleType property + Then it should return null + + # ─── enterpriseAppRole ────────────────────────────────────────────────────── + + Scenario: Getting enterpriseAppRole returns empty string when not set on the document + Given a StaffRoleDomainAdapter for the document + When I get the enterpriseAppRole property + Then it should return an empty string + + Scenario: Getting and setting the enterpriseAppRole property + Given a StaffRoleDomainAdapter for the document + When I set the enterpriseAppRole property to "LeadManager" + Then the document's enterpriseAppRole should be "LeadManager" + + Scenario: Setting roleName also updates enterpriseAppRole on the document + Given a StaffRoleDomainAdapter for the document + When I set the roleName property to "Director" + Then the document's enterpriseAppRole should also be "Director" + + # ─── canAssignStaffUserRoles ───────────────────────────────────────────────── + + Scenario: Getting and setting canAssignStaffUserRoles from userPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + Then the canAssignStaffUserRoles property should return false + When I set the canAssignStaffUserRoles property to true + Then the userPermissions' canAssignStaffUserRoles should be true + + Scenario: canAssignStaffRoles getter falls back to canAssignStaffUserRoles when unset + Given a StaffRoleDomainAdapter wrapping a document with userPermissions having only canAssignStaffUserRoles true + When I get the permissions property + And I get the userPermissions property + Then the canAssignStaffRoles property should return true + + Scenario: Setting canAssignStaffRoles updates both canAssignStaffRoles and canAssignStaffUserRoles + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + When I set the canAssignStaffRoles property to true + Then the userPermissions' canAssignStaffRoles should be true + And the userPermissions' canAssignStaffUserRoles should be true + + # ─── violationTicketPermissions setters ────────────────────────────────────── + + Scenario: Setting canManageTickets on violationTicketPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the violationTicketPermissions property + When I set the canManageTickets property to true + Then the violationTicketPermissions' canManageTickets should be true + + Scenario: Setting canAssignTickets on violationTicketPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the violationTicketPermissions property + When I set the canAssignTickets property to true + Then the violationTicketPermissions' canAssignTickets should be true + + Scenario: Setting canWorkOnTickets on violationTicketPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the violationTicketPermissions property + When I set the canWorkOnTickets property to true + Then the violationTicketPermissions' canWorkOnTickets should be true + + # ─── Lazy-init remaining sub-documents ─────────────────────────────────────── + + Scenario: Lazy-initialising propertyPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no propertyPermissions sub-document + When I get the permissions property + And I get the propertyPermissions property + Then it should return a StaffRolePropertyPermissionsAdapter instance + And canManageProperties should default to false + + Scenario: Lazy-initialising servicePermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no servicePermissions sub-document + When I get the permissions property + And I get the servicePermissions property + Then it should return a StaffRoleServicePermissionsAdapter instance + And canManageServices should default to false + + Scenario: Lazy-initialising serviceTicketPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no serviceTicketPermissions sub-document + When I get the permissions property + And I get the serviceTicketPermissions property + Then it should return a StaffRoleServiceTicketPermissionsAdapter instance + And canCreateTickets should default to false + + Scenario: Lazy-initialising violationTicketPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no violationTicketPermissions sub-document + When I get the permissions property + And I get the violationTicketPermissions property + Then it should return a StaffRoleViolationTicketPermissionsAdapter instance + And canCreateTickets should default to false \ No newline at end of file diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature index 042b8f64a..1874bac51 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature @@ -26,6 +26,16 @@ Feature: StaffRoleRepository When I call getByRoleName with "nonexistent-role" Then an error should be thrown indicating "StaffRole with roleName nonexistent-role not found" + Scenario: Getting a default staff role by enterpriseAppRole + Given a valid default Mongoose StaffRole document with enterpriseAppRole "Staff.CaseManager" + When I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager" + Then I should receive a StaffRole domain object + And the domain object's isDefault should be true + + Scenario: Getting a default staff role by enterpriseAppRole that does not exist + When I call getDefaultRoleByEnterpriseAppRole with "Staff.UnknownRole" + Then an error should be thrown indicating "Default StaffRole with enterpriseAppRole Staff.UnknownRole not found" + Scenario: Creating a new staff role instance When I call getNewInstance with name "Supervisor" Then I should receive a new StaffRole domain object diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts index 8f7abbf56..3a1a5c532 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts @@ -1,22 +1,25 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi } from 'vitest'; -import { Domain } from '@ocom/domain'; import type { StaffRole } from '@ocom/data-sources-mongoose-models/role/staff-role'; - -const test = { for: describeFeature }; +import { Domain } from '@ocom/domain'; +import { expect, vi } from 'vitest'; import { + StaffRoleCommunityPermissionsAdapter, StaffRoleConverter, StaffRoleDomainAdapter, + StaffRoleFinancePermissionsAdapter, StaffRolePermissionsAdapter, - StaffRoleCommunityPermissionsAdapter, StaffRolePropertyPermissionsAdapter, StaffRoleServicePermissionsAdapter, StaffRoleServiceTicketPermissionsAdapter, + StaffRoleTechAdminPermissionsAdapter, + StaffRoleUserPermissionsAdapter, StaffRoleViolationTicketPermissionsAdapter, } from './staff-role.domain-adapter.ts'; +const test = { for: describeFeature }; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const domainAdapterFeature = await loadFeature(path.resolve(__dirname, 'features/staff-role.domain-adapter.feature')); const typeConverterFeature = await loadFeature(path.resolve(__dirname, 'features/staff-role.type-converter.feature')); @@ -41,6 +44,12 @@ function makeStaffRoleDoc(overrides: Partial = {}) { servicePermissions: { canManageServices: false, }, + userPermissions: { + canManageUsers: false, + canAssignStaffRoles: false, + canAssignStaffUserRoles: false, + canViewStaffUsers: false, + }, serviceTicketPermissions: { canCreateTickets: false, canManageTickets: false, @@ -498,6 +507,713 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => expect(doc.permissions?.violationTicketPermissions?.canCreateTickets).toBe(true); }); }); + + // ─── canManageCommunities ───────────────────────────────────────────────── + + Scenario('Getting and setting canManageCommunities from communityPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let communityPermissions: StaffRoleCommunityPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the communityPermissions property', () => { + communityPermissions = permissions.communityPermissions as StaffRoleCommunityPermissionsAdapter; + }); + And('I get the canManageCommunities property', () => { + result = communityPermissions.canManageCommunities; + }); + Then('it should return false', () => { + expect(result).toBe(false); + }); + When('I set the canManageCommunities property to true', () => { + communityPermissions.canManageCommunities = true; + }); + Then("the communityPermissions' canManageCommunities should be true", () => { + expect(doc.permissions?.communityPermissions?.canManageCommunities).toBe(true); + }); + }); + + // ─── financePermissions ─────────────────────────────────────────────────── + + Scenario('Getting financePermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + result = permissions.financePermissions; + }); + Then('it should return a StaffRoleFinancePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleFinancePermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageFinance from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canManageFinance property should return false', () => { + expect(financePermissions.canManageFinance).toBe(false); + }); + When('I set the canManageFinance property to true', () => { + financePermissions.canManageFinance = true; + }); + Then("the financePermissions' canManageFinance should be true", () => { + expect(doc.permissions?.financePermissions?.canManageFinance).toBe(true); + }); + }); + + Scenario('Getting and setting canViewGLBatchSummaries from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canViewGLBatchSummaries property should return false', () => { + expect(financePermissions.canViewGLBatchSummaries).toBe(false); + }); + When('I set the canViewGLBatchSummaries property to true', () => { + financePermissions.canViewGLBatchSummaries = true; + }); + Then("the financePermissions' canViewGLBatchSummaries should be true", () => { + expect(doc.permissions?.financePermissions?.canViewGLBatchSummaries).toBe(true); + }); + }); + + Scenario('Getting and setting canViewFinanceConfigs from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canViewFinanceConfigs property should return false', () => { + expect(financePermissions.canViewFinanceConfigs).toBe(false); + }); + When('I set the canViewFinanceConfigs property to true', () => { + financePermissions.canViewFinanceConfigs = true; + }); + Then("the financePermissions' canViewFinanceConfigs should be true", () => { + expect(doc.permissions?.financePermissions?.canViewFinanceConfigs).toBe(true); + }); + }); + + Scenario('Getting and setting canCreateFinanceConfigs from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canCreateFinanceConfigs property should return false', () => { + expect(financePermissions.canCreateFinanceConfigs).toBe(false); + }); + When('I set the canCreateFinanceConfigs property to true', () => { + financePermissions.canCreateFinanceConfigs = true; + }); + Then("the financePermissions' canCreateFinanceConfigs should be true", () => { + expect(doc.permissions?.financePermissions?.canCreateFinanceConfigs).toBe(true); + }); + }); + + // ─── techAdminPermissions ───────────────────────────────────────────────── + + Scenario('Getting techAdminPermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + result = permissions.techAdminPermissions; + }); + Then('it should return a StaffRoleTechAdminPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleTechAdminPermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageTechAdmin from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canManageTechAdmin property should return false', () => { + expect(techAdminPermissions.canManageTechAdmin).toBe(false); + }); + When('I set the canManageTechAdmin property to true', () => { + techAdminPermissions.canManageTechAdmin = true; + }); + Then("the techAdminPermissions' canManageTechAdmin should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Getting and setting canViewDatabaseExplorer from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewDatabaseExplorer property should return false', () => { + expect(techAdminPermissions.canViewDatabaseExplorer).toBe(false); + }); + When('I set the canViewDatabaseExplorer property to true', () => { + techAdminPermissions.canViewDatabaseExplorer = true; + }); + Then("the techAdminPermissions' canViewDatabaseExplorer should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewDatabaseExplorer).toBe(true); + }); + }); + + Scenario('Getting and setting canViewBlobExplorer from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewBlobExplorer property should return false', () => { + expect(techAdminPermissions.canViewBlobExplorer).toBe(false); + }); + When('I set the canViewBlobExplorer property to true', () => { + techAdminPermissions.canViewBlobExplorer = true; + }); + Then("the techAdminPermissions' canViewBlobExplorer should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewBlobExplorer).toBe(true); + }); + }); + + Scenario('Getting and setting canViewQueueDashboard from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewQueueDashboard property should return false', () => { + expect(techAdminPermissions.canViewQueueDashboard).toBe(false); + }); + When('I set the canViewQueueDashboard property to true', () => { + techAdminPermissions.canViewQueueDashboard = true; + }); + Then("the techAdminPermissions' canViewQueueDashboard should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewQueueDashboard).toBe(true); + }); + }); + + Scenario('Getting and setting canSendQueueMessages from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canSendQueueMessages property should return false', () => { + expect(techAdminPermissions.canSendQueueMessages).toBe(false); + }); + When('I set the canSendQueueMessages property to true', () => { + techAdminPermissions.canSendQueueMessages = true; + }); + Then("the techAdminPermissions' canSendQueueMessages should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canSendQueueMessages).toBe(true); + }); + }); + + // ─── userPermissions ────────────────────────────────────────────────────── + + Scenario('Getting userPermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + result = permissions.userPermissions; + }); + Then('it should return a StaffRoleUserPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleUserPermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageUsers from userPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let userPermissions: StaffRoleUserPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + userPermissions = permissions.userPermissions as StaffRoleUserPermissionsAdapter; + }); + Then('the canManageUsers property should return false', () => { + expect(userPermissions.canManageUsers).toBe(false); + }); + When('I set the canManageUsers property to true', () => { + userPermissions.canManageUsers = true; + }); + Then("the userPermissions' canManageUsers should be true", () => { + expect(doc.permissions?.userPermissions?.canManageUsers).toBe(true); + }); + }); + + // ─── Lazy-init paths ────────────────────────────────────────────────────── + + Scenario('Lazy-initialising permissions when document has no permissions object', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter wrapping a document with no permissions object', () => { + const docWithoutPermissions = makeStaffRoleDoc(); + docWithoutPermissions.set = vi.fn().mockImplementation((key: string, value: unknown) => { + (docWithoutPermissions as unknown as Record)[key] = value; + }); + (docWithoutPermissions as unknown as Record)['permissions'] = undefined; + adapter = new StaffRoleDomainAdapter(docWithoutPermissions); + }); + When('I get the permissions property', () => { + result = adapter.permissions; + }); + Then('it should return a StaffRolePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRolePermissionsAdapter); + }); + }); + + Scenario('Lazy-initialising communityPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no communityPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['communityPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the communityPermissions property', () => { + result = permissions.communityPermissions; + }); + Then('it should return a StaffRoleCommunityPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleCommunityPermissionsAdapter); + }); + And('canManageCommunities should default to false', () => { + expect((result as StaffRoleCommunityPermissionsAdapter).canManageCommunities).toBe(false); + }); + }); + + Scenario('Lazy-initialising financePermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no financePermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['financePermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + result = permissions.financePermissions; + }); + Then('it should return a StaffRoleFinancePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleFinancePermissionsAdapter); + }); + And('canManageFinance should default to false', () => { + expect((result as StaffRoleFinancePermissionsAdapter).canManageFinance).toBe(false); + }); + }); + + Scenario('Lazy-initialising techAdminPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no techAdminPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['techAdminPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + result = permissions.techAdminPermissions; + }); + Then('it should return a StaffRoleTechAdminPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleTechAdminPermissionsAdapter); + }); + And('canManageTechAdmin should default to false', () => { + expect((result as StaffRoleTechAdminPermissionsAdapter).canManageTechAdmin).toBe(false); + }); + }); + + Scenario('Lazy-initialising userPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no userPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['userPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + result = permissions.userPermissions; + }); + Then('it should return a StaffRoleUserPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleUserPermissionsAdapter); + }); + And('canManageUsers should default to false', () => { + expect((result as StaffRoleUserPermissionsAdapter).canManageUsers).toBe(false); + }); + }); + + Scenario('Getting roleType returns null when document roleType is undefined', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter wrapping a document with no roleType', () => { + const docWithout = makeStaffRoleDoc(); + (docWithout as unknown as Record)['roleType'] = undefined; + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the roleType property', () => { + result = adapter.roleType; + }); + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); + + // ─── enterpriseAppRole ──────────────────────────────────────────────────── + + Scenario('Getting enterpriseAppRole returns empty string when not set on the document', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the enterpriseAppRole property', () => { + result = adapter.enterpriseAppRole; + }); + Then('it should return an empty string', () => { + expect(result).toBe(''); + }); + }); + + Scenario('Getting and setting the enterpriseAppRole property', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I set the enterpriseAppRole property to "LeadManager"', () => { + adapter.enterpriseAppRole = 'LeadManager'; + }); + Then('the document\'s enterpriseAppRole should be "LeadManager"', () => { + expect(doc.enterpriseAppRole).toBe('LeadManager'); + }); + }); + + Scenario('Setting roleName also updates enterpriseAppRole on the document', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I set the roleName property to "Director"', () => { + adapter.roleName = 'Director'; + }); + Then('the document\'s enterpriseAppRole should also be "Director"', () => { + expect(doc.enterpriseAppRole).toBe('Director'); + }); + }); + + // ─── canAssignStaffUserRoles ────────────────────────────────────────────── + + Scenario('Getting and setting canAssignStaffUserRoles from userPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let userPermissions: StaffRoleUserPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + userPermissions = permissions.userPermissions as StaffRoleUserPermissionsAdapter; + }); + Then('the canAssignStaffUserRoles property should return false', () => { + expect(userPermissions.canAssignStaffUserRoles).toBe(false); + }); + When('I set the canAssignStaffUserRoles property to true', () => { + userPermissions.canAssignStaffUserRoles = true; + }); + Then("the userPermissions' canAssignStaffUserRoles should be true", () => { + expect(doc.permissions?.userPermissions?.canAssignStaffUserRoles).toBe(true); + }); + }); + + Scenario('canAssignStaffRoles getter falls back to canAssignStaffUserRoles when unset', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let userPermissions: StaffRoleUserPermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with userPermissions having only canAssignStaffUserRoles true', () => { + const docWith = makeStaffRoleDoc({ + permissions: { + ...(makeStaffRoleDoc().permissions ?? {}), + userPermissions: { + canManageUsers: false, + canAssignStaffRoles: true, + canAssignStaffUserRoles: true, + canViewStaffUsers: false, + }, + }, + }); + adapter = new StaffRoleDomainAdapter(docWith); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + userPermissions = permissions.userPermissions as StaffRoleUserPermissionsAdapter; + }); + Then('the canAssignStaffRoles property should return true', () => { + expect(userPermissions.canAssignStaffRoles).toBe(true); + }); + }); + + Scenario('Setting canAssignStaffRoles updates both canAssignStaffRoles and canAssignStaffUserRoles', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let userPermissions: StaffRoleUserPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + userPermissions = permissions.userPermissions as StaffRoleUserPermissionsAdapter; + }); + When('I set the canAssignStaffRoles property to true', () => { + userPermissions.canAssignStaffRoles = true; + }); + Then("the userPermissions' canAssignStaffRoles should be true", () => { + expect(doc.permissions?.userPermissions?.canAssignStaffRoles).toBe(true); + }); + And("the userPermissions' canAssignStaffUserRoles should be true", () => { + expect(doc.permissions?.userPermissions?.canAssignStaffUserRoles).toBe(true); + }); + }); + + // ─── violationTicketPermissions setters ─────────────────────────────────── + + Scenario('Setting canManageTickets on violationTicketPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let violationTicketPermissions: StaffRoleViolationTicketPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the violationTicketPermissions property', () => { + violationTicketPermissions = permissions.violationTicketPermissions as StaffRoleViolationTicketPermissionsAdapter; + }); + When('I set the canManageTickets property to true', () => { + violationTicketPermissions.canManageTickets = true; + }); + Then("the violationTicketPermissions' canManageTickets should be true", () => { + expect(doc.permissions?.violationTicketPermissions?.canManageTickets).toBe(true); + }); + }); + + Scenario('Setting canAssignTickets on violationTicketPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let violationTicketPermissions: StaffRoleViolationTicketPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the violationTicketPermissions property', () => { + violationTicketPermissions = permissions.violationTicketPermissions as StaffRoleViolationTicketPermissionsAdapter; + }); + When('I set the canAssignTickets property to true', () => { + violationTicketPermissions.canAssignTickets = true; + }); + Then("the violationTicketPermissions' canAssignTickets should be true", () => { + expect(doc.permissions?.violationTicketPermissions?.canAssignTickets).toBe(true); + }); + }); + + Scenario('Setting canWorkOnTickets on violationTicketPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let violationTicketPermissions: StaffRoleViolationTicketPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the violationTicketPermissions property', () => { + violationTicketPermissions = permissions.violationTicketPermissions as StaffRoleViolationTicketPermissionsAdapter; + }); + When('I set the canWorkOnTickets property to true', () => { + violationTicketPermissions.canWorkOnTickets = true; + }); + Then("the violationTicketPermissions' canWorkOnTickets should be true", () => { + expect(doc.permissions?.violationTicketPermissions?.canWorkOnTickets).toBe(true); + }); + }); + + // ─── Lazy-init remaining sub-documents ──────────────────────────────────── + + Scenario('Lazy-initialising propertyPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no propertyPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['propertyPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the propertyPermissions property', () => { + result = permissions.propertyPermissions; + }); + Then('it should return a StaffRolePropertyPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRolePropertyPermissionsAdapter); + }); + And('canManageProperties should default to false', () => { + expect((result as StaffRolePropertyPermissionsAdapter).canManageProperties).toBe(false); + }); + }); + + Scenario('Lazy-initialising servicePermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no servicePermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['servicePermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the servicePermissions property', () => { + result = permissions.servicePermissions; + }); + Then('it should return a StaffRoleServicePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleServicePermissionsAdapter); + }); + And('canManageServices should default to false', () => { + expect((result as StaffRoleServicePermissionsAdapter).canManageServices).toBe(false); + }); + }); + + Scenario('Lazy-initialising serviceTicketPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no serviceTicketPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['serviceTicketPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the serviceTicketPermissions property', () => { + result = permissions.serviceTicketPermissions; + }); + Then('it should return a StaffRoleServiceTicketPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleServiceTicketPermissionsAdapter); + }); + And('canCreateTickets should default to false', () => { + expect((result as StaffRoleServiceTicketPermissionsAdapter).canCreateTickets).toBe(false); + }); + }); + + Scenario('Lazy-initialising violationTicketPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no violationTicketPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['violationTicketPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the violationTicketPermissions property', () => { + result = permissions.violationTicketPermissions; + }); + Then('it should return a StaffRoleViolationTicketPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleViolationTicketPermissionsAdapter); + }); + And('canCreateTickets should default to false', () => { + expect((result as StaffRoleViolationTicketPermissionsAdapter).canCreateTickets).toBe(false); + }); + }); }); test.for(typeConverterFeature, ({ Scenario, Background, BeforeEachScenario }) => { diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts index 751577717..4bcf38df1 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts @@ -1,15 +1,18 @@ import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; - -import { Domain } from '@ocom/domain'; import type { StaffRole, StaffRoleCommunityPermissions, + StaffRoleFinancePermissions, + StaffRoleRolePermissions, StaffRolePermissions, StaffRolePropertyPermissions, StaffRoleServicePermissions, StaffRoleServiceTicketPermissions, + StaffRoleTechAdminPermissions, + StaffRoleUserPermissions, StaffRoleViolationTicketPermissions, } from '@ocom/data-sources-mongoose-models/role/staff-role'; +import { Domain } from '@ocom/domain'; export class StaffRoleConverter extends MongooseSeedwork.MongoTypeConverter> { constructor() { @@ -24,8 +27,17 @@ export class StaffRoleDomainAdapter extends MongooseSeedwork.MongooseDomainAdapt set roleName(roleName: string) { this.doc.roleName = roleName; + this.doc.enterpriseAppRole = roleName; } + get enterpriseAppRole(): string { + return this.doc.enterpriseAppRole ?? ''; + } + + set enterpriseAppRole(enterpriseAppRole: string) { + this.doc.enterpriseAppRole = enterpriseAppRole; + } + get isDefault(): boolean { return this.doc.isDefault; } @@ -56,6 +68,7 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo get communityPermissions(): Domain.Contexts.User.StaffRole.StaffRoleCommunityPermissionsProps { if (!this.doc.communityPermissions) { this.doc.communityPermissions = { + canManageCommunities: false, canManageStaffRolesAndPermissions: false, canManageAllCommunities: false, canDeleteCommunities: false, @@ -91,6 +104,7 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo canCreateTickets: false, canManageTickets: false, canAssignTickets: false, + canUpdateTickets: false, canWorkOnTickets: false, }; } @@ -103,11 +117,61 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo canCreateTickets: false, canManageTickets: false, canAssignTickets: false, + canUpdateTickets: false, canWorkOnTickets: false, }; } return new StaffRoleViolationTicketPermissionsAdapter(this.doc.violationTicketPermissions); } + + get financePermissions(): Domain.Contexts.User.StaffRole.StaffRoleFinancePermissionsProps { + if (!this.doc.financePermissions) { + this.doc.financePermissions = { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }; + } + return new StaffRoleFinancePermissionsAdapter(this.doc.financePermissions); + } + + get techAdminPermissions(): Domain.Contexts.User.StaffRole.StaffRoleTechAdminPermissionsProps { + if (!this.doc.techAdminPermissions) { + this.doc.techAdminPermissions = { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }; + } + return new StaffRoleTechAdminPermissionsAdapter(this.doc.techAdminPermissions); + } + + get userPermissions(): Domain.Contexts.User.StaffRole.StaffRoleUserPermissionsProps { + if (!this.doc.userPermissions) { + this.doc.userPermissions = { + canManageUsers: false, + canAssignStaffRoles: false, + canAssignStaffUserRoles: false, + canViewStaffUsers: false, + }; + } + return new StaffRoleUserPermissionsAdapter(this.doc.userPermissions); + } + + get staffRolePermissions(): Domain.Contexts.User.StaffRole.StaffRoleRolePermissionsProps { + if (!this.doc.staffRolePermissions) { + this.doc.staffRolePermissions = { + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }; + } + return new StaffRoleRolePermissionsAdapter(this.doc.staffRolePermissions); + } } export class StaffRoleCommunityPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleCommunityPermissionsProps { @@ -125,6 +189,13 @@ export class StaffRoleCommunityPermissionsAdapter implements Domain.Contexts.Use return this.doc.id?.toString(); } + get canManageCommunities(): boolean { + return this.ensureValue(this.doc.canManageCommunities); + } + set canManageCommunities(value: boolean) { + this.doc.canManageCommunities = value; + } + get canManageStaffRolesAndPermissions(): boolean { return this.ensureValue(this.doc.canManageStaffRolesAndPermissions); } @@ -269,3 +340,184 @@ export class StaffRoleViolationTicketPermissionsAdapter implements Domain.Contex this.doc.canWorkOnTickets = value; } } + +export class StaffRoleFinancePermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleFinancePermissionsProps { + private readonly doc: StaffRoleFinancePermissions; + + constructor(permissions: StaffRoleFinancePermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageFinance(): boolean { + return this.ensureValue(this.doc.canManageFinance); + } + set canManageFinance(value: boolean) { + this.doc.canManageFinance = value; + } + + get canViewGLBatchSummaries(): boolean { + return this.ensureValue(this.doc.canViewGLBatchSummaries); + } + set canViewGLBatchSummaries(value: boolean) { + this.doc.canViewGLBatchSummaries = value; + } + + get canViewFinanceConfigs(): boolean { + return this.ensureValue(this.doc.canViewFinanceConfigs); + } + set canViewFinanceConfigs(value: boolean) { + this.doc.canViewFinanceConfigs = value; + } + + get canCreateFinanceConfigs(): boolean { + return this.ensureValue(this.doc.canCreateFinanceConfigs); + } + set canCreateFinanceConfigs(value: boolean) { + this.doc.canCreateFinanceConfigs = value; + } +} + +export class StaffRoleTechAdminPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleTechAdminPermissionsProps { + private readonly doc: StaffRoleTechAdminPermissions; + + constructor(permissions: StaffRoleTechAdminPermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageTechAdmin(): boolean { + return this.ensureValue(this.doc.canManageTechAdmin); + } + set canManageTechAdmin(value: boolean) { + this.doc.canManageTechAdmin = value; + } + + get canViewDatabaseExplorer(): boolean { + return this.ensureValue(this.doc.canViewDatabaseExplorer); + } + set canViewDatabaseExplorer(value: boolean) { + this.doc.canViewDatabaseExplorer = value; + } + + get canViewBlobExplorer(): boolean { + return this.ensureValue(this.doc.canViewBlobExplorer); + } + set canViewBlobExplorer(value: boolean) { + this.doc.canViewBlobExplorer = value; + } + + get canViewQueueDashboard(): boolean { + return this.ensureValue(this.doc.canViewQueueDashboard); + } + set canViewQueueDashboard(value: boolean) { + this.doc.canViewQueueDashboard = value; + } + + get canSendQueueMessages(): boolean { + return this.ensureValue(this.doc.canSendQueueMessages); + } + set canSendQueueMessages(value: boolean) { + this.doc.canSendQueueMessages = value; + } +} + +export class StaffRoleUserPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleUserPermissionsProps { + private readonly doc: StaffRoleUserPermissions; + + constructor(permissions: StaffRoleUserPermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageUsers(): boolean { + return this.ensureValue(this.doc.canManageUsers); + } + set canManageUsers(value: boolean) { + this.doc.canManageUsers = value; + } + + get canAssignStaffRoles(): boolean { + return this.ensureValue(this.doc.canAssignStaffRoles ?? this.doc.canAssignStaffUserRoles); + } + set canAssignStaffRoles(value: boolean) { + this.doc.canAssignStaffRoles = value; + this.doc.canAssignStaffUserRoles = value; + } + + get canAssignStaffUserRoles(): boolean { + return this.ensureValue(this.doc.canAssignStaffRoles ?? this.doc.canAssignStaffUserRoles); + } + set canAssignStaffUserRoles(value: boolean) { + this.doc.canAssignStaffRoles = value; + this.doc.canAssignStaffUserRoles = value; + } + + get canViewStaffUsers(): boolean { + return this.ensureValue(this.doc.canViewStaffUsers); + } + set canViewStaffUsers(value: boolean) { + this.doc.canViewStaffUsers = value; + } +} + +class StaffRoleRolePermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleRolePermissionsProps { + public readonly doc: StaffRoleRolePermissions; + + constructor(permissions: StaffRoleRolePermissions) { + this.doc = permissions; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canViewRoles(): boolean { + return this.doc.canViewRoles; + } + set canViewRoles(value: boolean) { + this.doc.canViewRoles = value; + } + + get canAddRole(): boolean { + return this.doc.canAddRole; + } + set canAddRole(value: boolean) { + this.doc.canAddRole = value; + } + + get canEditRole(): boolean { + return this.doc.canEditRole; + } + set canEditRole(value: boolean) { + this.doc.canEditRole = value; + } + + get canRemoveRole(): boolean { + return this.doc.canRemoveRole; + } + set canRemoveRole(value: boolean) { + this.doc.canRemoveRole = value; + } +} diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts index a68364db6..40e26b1ed 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts @@ -17,6 +17,7 @@ function makeStaffRoleDoc(overrides: Partial = {}) { const base = { _id: 'role-1', roleName: 'Manager', + enterpriseAppRole: 'Staff.CaseManager', isDefault: false, roleType: 'staff', permissions: { @@ -84,10 +85,18 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }; Object.assign(ModelMock, { findById: vi.fn((id: string) => ({ - exec: vi.fn(async () => (id === String(staffRoleDoc._id) ? staffRoleDoc : null)), - })), - findOne: vi.fn((query: { roleName: string }) => ({ - exec: vi.fn(async () => (query.roleName === staffRoleDoc.roleName ? staffRoleDoc : null)), + exec: vi.fn(() => (id === staffRoleDoc._id ? staffRoleDoc : null)), + })), + findOne: vi.fn((query: { roleName?: string; enterpriseAppRole?: string; isDefault?: boolean }) => ({ + exec: vi.fn(() => { + if (query.roleName) { + return query.roleName === staffRoleDoc.roleName ? staffRoleDoc : null; + } + if (query.enterpriseAppRole && query.isDefault === true) { + return query.enterpriseAppRole === staffRoleDoc.enterpriseAppRole && staffRoleDoc.isDefault ? staffRoleDoc : null; + } + return null; + }), })), prototype: {}, }); @@ -167,6 +176,36 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); + Scenario('Getting a default staff role by enterpriseAppRole', ({ Given, When, Then, And }) => { + let result: Domain.Contexts.User.StaffRole.StaffRole; + Given('a valid default Mongoose StaffRole document with enterpriseAppRole "Staff.CaseManager"', () => { + staffRoleDoc = makeStaffRoleDoc({ + isDefault: true, + enterpriseAppRole: 'Staff.CaseManager', + }); + }); + When('I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager"', async () => { + result = await repo.getDefaultRoleByEnterpriseAppRole('Staff.CaseManager'); + }); + Then('I should receive a StaffRole domain object', () => { + expect(result).toBeInstanceOf(Domain.Contexts.User.StaffRole.StaffRole); + }); + And("the domain object's isDefault should be true", () => { + expect(result.isDefault).toBe(true); + }); + }); + + Scenario('Getting a default staff role by enterpriseAppRole that does not exist', ({ When, Then }) => { + let getDefaultRoleByEnterpriseAppRole: () => Promise; + When('I call getDefaultRoleByEnterpriseAppRole with "Staff.UnknownRole"', () => { + getDefaultRoleByEnterpriseAppRole = async () => await repo.getDefaultRoleByEnterpriseAppRole('Staff.UnknownRole'); + }); + Then('an error should be thrown indicating "Default StaffRole with enterpriseAppRole Staff.UnknownRole not found"', async () => { + await expect(getDefaultRoleByEnterpriseAppRole).rejects.toThrow(); + await expect(getDefaultRoleByEnterpriseAppRole).rejects.toThrow(/Default StaffRole with enterpriseAppRole Staff.UnknownRole not found/); + }); + }); + Scenario('Creating a new staff role instance', ({ When, Then, And }) => { let result: Domain.Contexts.User.StaffRole.StaffRole; When('I call getNewInstance with name "Supervisor"', async () => { diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts index 7d3e075a5..4cf3379e0 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts @@ -27,8 +27,36 @@ export class StaffRoleRepository return this.typeConverter.toDomain(staffRole, this.passport); } + async getDefaultRoleByEnterpriseAppRole(enterpriseAppRole: string): Promise> { + const staffRole = await this.model.findOne({ isDefault: true, enterpriseAppRole }).exec(); + if (!staffRole) { + throw new Error(`Default StaffRole with enterpriseAppRole ${enterpriseAppRole} not found`); + } + return this.typeConverter.toDomain(staffRole, this.passport); + } + getNewInstance(name: string): Promise> { const adapter = this.typeConverter.toAdapter(new this.model()); return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewInstance(adapter, this.passport, name, false)); } + + getNewDefaultCaseManagerInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultCaseManagerInstance(adapter, this.passport)); + } + + getNewDefaultServiceLineOwnerInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultServiceLineOwnerInstance(adapter, this.passport)); + } + + getNewDefaultFinanceInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultFinanceInstance(adapter, this.passport)); + } + + getNewDefaultTechAdminInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultTechAdminInstance(adapter, this.passport)); + } } diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts index f9865ef55..c230ad5b8 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts @@ -1,9 +1,10 @@ import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; +import type { PropArray } from '@cellix/domain-seedwork/prop-array'; import { Domain } from '@ocom/domain'; import { StaffRoleDomainAdapter } from '../staff-role/staff-role.domain-adapter.ts'; import type { StaffRole } from '@ocom/data-sources-mongoose-models/role/staff-role'; -import type { StaffUser } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { StaffUser, StaffUserActivityDetail } from '@ocom/data-sources-mongoose-models/user/staff-user'; export class StaffUserDomainAdapter extends MongooseSeedwork.MongooseDomainAdapter implements Domain.Contexts.User.StaffUser.StaffUserProps { get role(): Domain.Contexts.User.StaffRole.StaffRoleProps { @@ -101,6 +102,51 @@ export class StaffUserDomainAdapter extends MongooseSeedwork.MongooseDomainAdapt override get schemaVersion(): string { return this.doc.schemaVersion ?? '1.0.0'; } + + get activityLog(): PropArray { + return new MongooseSeedwork.MongoosePropArray(this.doc.activityLog, StaffUserActivityLogDomainAdapter); + } +} + +class StaffUserActivityLogDomainAdapter implements Domain.Contexts.User.StaffUser.StaffUserActivityLogProps { + public readonly doc: StaffUserActivityDetail; + + constructor(doc: StaffUserActivityDetail) { + this.doc = doc; + } + + get id(): string { + return this.doc.id?.valueOf() as string; + } + + get activityType(): string { + return this.doc.activityType; + } + set activityType(activityType: string) { + this.doc.activityType = activityType; + } + + get activityDescription(): string { + return this.doc.activityDescription; + } + set activityDescription(activityDescription: string) { + this.doc.activityDescription = activityDescription; + } + + get activityByStaffUserId(): string { + return this.doc.activityBy?.valueOf() as string; + } + set activityByStaffUserId(id: string) { + this.doc.set('activityBy', new MongooseSeedwork.ObjectId(id)); + } + + get createdAt(): Date { + return this.doc.createdAt; + } + + get updatedAt(): Date { + return this.doc.updatedAt; + } } export class StaffUserConverter extends MongooseSeedwork.MongoTypeConverter> { diff --git a/packages/ocom/persistence/src/datasources/readonly/index.test.ts b/packages/ocom/persistence/src/datasources/readonly/index.test.ts index 04d231428..07f463f8b 100644 --- a/packages/ocom/persistence/src/datasources/readonly/index.test.ts +++ b/packages/ocom/persistence/src/datasources/readonly/index.test.ts @@ -1,13 +1,14 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi } from 'vitest'; - -import type { Domain } from '@ocom/domain'; -import { ReadonlyDataSourceImplementation } from './index.ts'; import type { CommunityModelType } from '@ocom/data-sources-mongoose-models/community'; import type { MemberModelType } from '@ocom/data-sources-mongoose-models/member'; +import type { StaffRoleModelType } from '@ocom/data-sources-mongoose-models/role/staff-role'; import type { EndUserModelType } from '@ocom/data-sources-mongoose-models/user/end-user'; +import type { StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { Domain } from '@ocom/domain'; +import { expect, vi } from 'vitest'; +import { ReadonlyDataSourceImplementation } from './index.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -32,6 +33,17 @@ function makeMockModelsContext() { create: vi.fn(), aggregate: vi.fn(), } as unknown as EndUserModelType, + StaffRole: { + findById: vi.fn(), + find: vi.fn(), + create: vi.fn(), + } as unknown as StaffRoleModelType, + StaffUser: { + findById: vi.fn(), + findOne: vi.fn(), + find: vi.fn(), + create: vi.fn(), + } as unknown as StaffUserModelType, } as unknown as Parameters[0]; } @@ -101,6 +113,10 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { And('the User property should have the correct structure', () => { expect(result.User).toHaveProperty('EndUser'); expect(result.User.EndUser).toHaveProperty('EndUserReadRepo'); + expect(result.User).toHaveProperty('StaffRole'); + expect(result.User.StaffRole).toHaveProperty('StaffRoleReadRepo'); + expect(result.User).toHaveProperty('StaffUser'); + expect(result.User.StaffUser).toHaveProperty('StaffUserReadRepo'); }); }); }); diff --git a/packages/ocom/persistence/src/datasources/readonly/index.ts b/packages/ocom/persistence/src/datasources/readonly/index.ts index d8940adb8..875d9dfce 100644 --- a/packages/ocom/persistence/src/datasources/readonly/index.ts +++ b/packages/ocom/persistence/src/datasources/readonly/index.ts @@ -4,7 +4,9 @@ import type * as Community from './community/community/index.ts'; import { CommunityContext } from './community/index.ts'; import type * as Member from './community/member/index.ts'; import type * as EndUser from './user/end-user/index.ts'; +import type * as StaffRole from './user/staff-role/index.ts'; import { UserContext } from './user/index.ts'; +import type * as StaffUser from './user/staff-user/index.ts'; export interface ReadonlyDataSource { Community: { @@ -19,6 +21,12 @@ export interface ReadonlyDataSource { EndUser: { EndUserReadRepo: EndUser.EndUserReadRepository; }; + StaffRole: { + StaffRoleReadRepo: StaffRole.StaffRoleReadRepository; + }; + StaffUser: { + StaffUserReadRepo: StaffUser.StaffUserReadRepository; + }; }; } diff --git a/packages/ocom/persistence/src/datasources/readonly/user/index.ts b/packages/ocom/persistence/src/datasources/readonly/user/index.ts index 54cb3892a..a6229b1d0 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/index.ts +++ b/packages/ocom/persistence/src/datasources/readonly/user/index.ts @@ -1,7 +1,11 @@ import type { Domain } from '@ocom/domain'; import type { ModelsContext } from '../../../index.ts'; import { EndUserReadRepositoryImpl } from './end-user/index.ts'; +import { StaffRoleReadRepositoryImpl } from './staff-role/index.ts'; +import { StaffUserReadRepositoryImpl } from './staff-user/index.ts'; export const UserContext = (models: ModelsContext, passport: Domain.Passport) => ({ EndUser: EndUserReadRepositoryImpl(models, passport), + StaffRole: StaffRoleReadRepositoryImpl(models, passport), + StaffUser: StaffUserReadRepositoryImpl(models, passport), }); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/features/staff-role.read-repository.feature b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/features/staff-role.read-repository.feature new file mode 100644 index 000000000..f86998055 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/features/staff-role.read-repository.feature @@ -0,0 +1,35 @@ +Feature: StaffRoleReadRepository + + Scenario: Creating StaffRoleReadRepository throws when StaffRole model is missing + Given models context does not contain a StaffRole model + When I call getStaffRoleReadRepository with those models and a passport + Then it should throw an error with message "StaffRole model is not available in the mongoose context" + + Scenario: Creating StaffRoleReadRepository succeeds when StaffRole model is present + Given models context contains a StaffRole model + When I call getStaffRoleReadRepository with those models and a passport + Then I should receive a StaffRoleReadRepository instance + And the repository should have a getAll method + And the repository should have a getById method + + Scenario: getAll returns a list of entities when documents are found + Given StaffRole documents exist in the collection + When I call getAll + Then I should receive an array of StaffRoleEntityReference objects + And the converter toDomain should have been called for each document + + Scenario: getAll returns an empty array when no documents exist + Given no StaffRole documents exist in the collection + When I call getAll + Then I should receive an empty array + + Scenario: getById returns an entity when a document is found + Given a StaffRole document exists with id "role-001" + When I call getById with "role-001" + Then I should receive a StaffRoleEntityReference object + And the converter toDomain should have been called with the document and passport + + Scenario: getById returns null when no document is found + Given no StaffRole document exists with id "missing-id" + When I call getById with "missing-id" + Then I should receive null diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/index.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/index.ts new file mode 100644 index 000000000..4ead818ad --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/index.ts @@ -0,0 +1,11 @@ +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { getStaffRoleReadRepository } from './staff-role.read-repository.ts'; + +export type { StaffRoleReadRepository } from './staff-role.read-repository.ts'; + +export const StaffRoleReadRepositoryImpl = (models: ModelsContext, passport: Domain.Passport) => { + return { + StaffRoleReadRepo: getStaffRoleReadRepository(models, passport), + }; +}; diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts new file mode 100644 index 000000000..3f517d441 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts @@ -0,0 +1,185 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect, vi } from 'vitest'; + +import type { Domain } from '@ocom/domain'; +import type { StaffRole, StaffRoleModelType } from '@ocom/data-sources-mongoose-models/role/staff-role'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffRoleConverter } from '../../../domain/user/staff-role/staff-role.domain-adapter.ts'; +import { getStaffRoleReadRepository } from './staff-role.read-repository.ts'; +import type { StaffRoleReadRepository } from './staff-role.read-repository.ts'; + +const test = { for: describeFeature }; + +vi.mock('../../../domain/user/staff-role/staff-role.domain-adapter.ts', () => ({ + StaffRoleConverter: vi.fn(), +})); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role.read-repository.feature')); + +function makeMockPassport() { + return {} as unknown as Domain.Passport; +} + +function makeMockStaffRoleDocument(overrides: Partial = {}) { + return { + _id: 'role-001', + id: 'role-001', + roleName: 'Admin', + isDefault: false, + roleType: 'staff', + ...overrides, + } as unknown as StaffRole; +} + +function makeMockModelForFind(docs: StaffRole[]) { + return { + find: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(docs), + }), + findById: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(docs[0] ?? null), + }), + } as unknown as StaffRoleModelType; +} + +function makeMockModelFindById(doc: StaffRole | null) { + return { + find: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue([]), + }), + findById: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(doc), + }), + } as unknown as StaffRoleModelType; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let models: ModelsContext; + let passport: Domain.Passport; + let repository: StaffRoleReadRepository; + let mockDoc: StaffRole; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | Domain.Contexts.User.StaffRole.StaffRoleEntityReference[] | null | unknown; + let mockConverter: { toDomain: ReturnType }; + let thrownError: unknown; + + BeforeEachScenario(() => { + passport = makeMockPassport(); + mockDoc = makeMockStaffRoleDocument(); + thrownError = undefined; + result = undefined; + + mockConverter = { + toDomain: vi.fn((_doc: StaffRole, _passport: Domain.Passport) => ({ + id: mockDoc.id, + roleName: mockDoc.roleName, + })), + }; + + vi.mocked(StaffRoleConverter).mockImplementation(function MockStaffRoleConverter() { + return mockConverter as unknown as StaffRoleConverter; + }); + }); + + Scenario('Creating StaffRoleReadRepository throws when StaffRole model is missing', ({ Given, When, Then }) => { + Given('models context does not contain a StaffRole model', () => { + models = {} as ModelsContext; + }); + When('I call getStaffRoleReadRepository with those models and a passport', () => { + try { + repository = getStaffRoleReadRepository(models, passport); + } catch (err) { + thrownError = err; + } + }); + Then('it should throw an error with message "StaffRole model is not available in the mongoose context"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('StaffRole model is not available in the mongoose context'); + }); + }); + + Scenario('Creating StaffRoleReadRepository succeeds when StaffRole model is present', ({ Given, When, Then, And }) => { + Given('models context contains a StaffRole model', () => { + models = { StaffRole: makeMockModelForFind([mockDoc]) } as unknown as ModelsContext; + }); + When('I call getStaffRoleReadRepository with those models and a passport', () => { + repository = getStaffRoleReadRepository(models, passport); + }); + Then('I should receive a StaffRoleReadRepository instance', () => { + expect(repository).toBeDefined(); + }); + And('the repository should have a getAll method', () => { + expect(typeof repository.getAll).toBe('function'); + }); + And('the repository should have a getById method', () => { + expect(typeof repository.getById).toBe('function'); + }); + }); + + Scenario('getAll returns a list of entities when documents are found', ({ Given, When, Then, And }) => { + const secondDoc = makeMockStaffRoleDocument({ _id: 'role-002', id: 'role-002', roleName: 'User' } as unknown as Partial); + + Given('StaffRole documents exist in the collection', () => { + models = { StaffRole: makeMockModelForFind([mockDoc, secondDoc]) } as unknown as ModelsContext; + repository = getStaffRoleReadRepository(models, passport); + }); + When('I call getAll', async () => { + result = await repository.getAll(); + }); + Then('I should receive an array of StaffRoleEntityReference objects', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(2); + }); + And('the converter toDomain should have been called for each document', () => { + expect(mockConverter.toDomain).toHaveBeenCalledTimes(2); + expect(mockConverter.toDomain).toHaveBeenCalledWith(mockDoc, passport); + expect(mockConverter.toDomain).toHaveBeenCalledWith(secondDoc, passport); + }); + }); + + Scenario('getAll returns an empty array when no documents exist', ({ Given, When, Then }) => { + Given('no StaffRole documents exist in the collection', () => { + models = { StaffRole: makeMockModelForFind([]) } as unknown as ModelsContext; + repository = getStaffRoleReadRepository(models, passport); + }); + When('I call getAll', async () => { + result = await repository.getAll(); + }); + Then('I should receive an empty array', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(0); + }); + }); + + Scenario('getById returns an entity when a document is found', ({ Given, When, Then, And }) => { + Given('a StaffRole document exists with id "role-001"', () => { + models = { StaffRole: makeMockModelFindById(mockDoc) } as unknown as ModelsContext; + repository = getStaffRoleReadRepository(models, passport); + }); + When('I call getById with "role-001"', async () => { + result = await repository.getById('role-001'); + }); + Then('I should receive a StaffRoleEntityReference object', () => { + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + }); + And('the converter toDomain should have been called with the document and passport', () => { + expect(mockConverter.toDomain).toHaveBeenCalledWith(mockDoc, passport); + }); + }); + + Scenario('getById returns null when no document is found', ({ Given, When, Then }) => { + Given('no StaffRole document exists with id "missing-id"', () => { + models = { StaffRole: makeMockModelFindById(null) } as unknown as ModelsContext; + repository = getStaffRoleReadRepository(models, passport); + }); + When('I call getById with "missing-id"', async () => { + result = await repository.getById('missing-id'); + }); + Then('I should receive null', () => { + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.ts new file mode 100644 index 000000000..b8b2fdf43 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.ts @@ -0,0 +1,41 @@ +import type { StaffRoleModelType } from '@ocom/data-sources-mongoose-models/role/staff-role'; +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffRoleConverter } from '../../../domain/user/staff-role/staff-role.domain-adapter.ts'; + +export interface StaffRoleReadRepository { + getAll: () => Promise; + getById: (id: string) => Promise; +} + +class StaffRoleReadRepositoryImpl implements StaffRoleReadRepository { + private readonly model: StaffRoleModelType; + private readonly converter: StaffRoleConverter; + private readonly passport: Domain.Passport; + + constructor(models: ModelsContext, passport: Domain.Passport) { + if (!models.StaffRole) { + throw new Error('StaffRole model is not available in the mongoose context'); + } + this.model = models.StaffRole; + this.converter = new StaffRoleConverter(); + this.passport = passport; + } + + async getAll(): Promise { + const docs = await this.model.find({}).exec(); + return docs.map((doc) => this.converter.toDomain(doc, this.passport)); + } + + async getById(id: string): Promise { + const doc = await this.model.findById(id).exec(); + if (!doc) { + return null; + } + return this.converter.toDomain(doc, this.passport); + } +} + +export const getStaffRoleReadRepository = (models: ModelsContext, passport: Domain.Passport): StaffRoleReadRepository => { + return new StaffRoleReadRepositoryImpl(models, passport); +}; diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature new file mode 100644 index 000000000..d0776be89 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature @@ -0,0 +1,57 @@ +Feature: StaffUserReadRepository + + Scenario: Creating StaffUserReadRepository throws when StaffUser model is missing + Given models context does not contain a StaffUser model + When I call getStaffUserReadRepository with those models and a passport + Then it should throw an error with message "StaffUser model is not available in the mongoose context" + + Scenario: Creating StaffUserReadRepository succeeds when StaffUser model is present + Given models context contains a StaffUser model + When I call getStaffUserReadRepository with those models and a passport + Then I should receive a StaffUserReadRepository instance + And the repository should have a getByExternalId method + And the repository should have a getByEmail method + And the repository should have a getAll method + + Scenario: getAll returns all converted entities when documents exist + Given two StaffUser documents exist in the collection + When I call getAll + Then I should receive an array of two StaffUserEntityReference objects + And the converter toDomain should have been called once for each document + + Scenario: getAll returns an empty array when no documents exist + Given no StaffUser documents exist in the collection + When I call getAll + Then I should receive an empty array + + Scenario: getByExternalId passes the correct filter to findOne + Given a StaffUser document exists with externalId "ext-filter-test" + When I call getByExternalId with "ext-filter-test" + Then findOne should have been called with the externalId filter + + Scenario: getByEmail passes the correct filter to findOne + Given a StaffUser document exists with email "filter@example.com" + When I call getByEmail with "filter@example.com" + Then findOne should have been called with the email filter + + Scenario: getByExternalId returns entity when document is found + Given a StaffUser document exists with externalId "ext-abc" + When I call getByExternalId with "ext-abc" + Then I should receive a StaffUserEntityReference object + And the converter toDomain should have been called with the document and passport + + Scenario: getByExternalId returns null when no document is found + Given no StaffUser document exists with externalId "missing-ext" + When I call getByExternalId with "missing-ext" + Then I should receive null + + Scenario: getByEmail returns entity when document is found + Given a StaffUser document exists with email "alice@example.com" + When I call getByEmail with "alice@example.com" + Then I should receive a StaffUserEntityReference object + And the converter toDomain should have been called with the document and passport + + Scenario: getByEmail returns null when no document is found + Given no StaffUser document exists with email "missing@example.com" + When I call getByEmail with "missing@example.com" + Then I should receive null diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts new file mode 100644 index 000000000..75eef71cf --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts @@ -0,0 +1,11 @@ +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { getStaffUserReadRepository } from './staff-user.read-repository.ts'; + +export type { StaffUserReadRepository } from './staff-user.read-repository.ts'; + +export const StaffUserReadRepositoryImpl = (models: ModelsContext, passport: Domain.Passport) => { + return { + StaffUserReadRepo: getStaffUserReadRepository(models, passport), + }; +}; diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts new file mode 100644 index 000000000..c74036a5a --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts @@ -0,0 +1,265 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect, vi } from 'vitest'; + +import type { Domain } from '@ocom/domain'; +import type { StaffUser, StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffUserConverter } from '../../../domain/user/staff-user/staff-user.domain-adapter.ts'; +import { getStaffUserReadRepository } from './staff-user.read-repository.ts'; +import type { StaffUserReadRepository } from './staff-user.read-repository.ts'; + +const test = { for: describeFeature }; + +vi.mock('../../../domain/user/staff-user/staff-user.domain-adapter.ts', () => ({ + StaffUserConverter: vi.fn(), +})); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.read-repository.feature')); + +function makeMockPassport() { + return { + user: { + forStaffUser: vi.fn(() => ({ + determineIf: vi.fn(() => true), + })), + }, + } as unknown as Domain.Passport; +} + +function makeMockStaffUserDocument() { + return { + _id: 'doc-id', + id: 'doc-id', + externalId: 'ext-abc', + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@example.com', + } as unknown as StaffUser; +} + +function makeMockModel(doc: StaffUser | null) { + return { + findOne: vi.fn().mockReturnValue({ + populate: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(doc), + }), + }), + } as unknown as StaffUserModelType; +} + +function makeMockModelMulti(docs: StaffUser[]) { + return { + find: vi.fn().mockReturnValue({ + populate: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(docs), + }), + }), + findOne: vi.fn().mockReturnValue({ + populate: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(docs[0] ?? null), + }), + }), + } as unknown as StaffUserModelType; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let models: ModelsContext; + let passport: Domain.Passport; + let repository: StaffUserReadRepository; + let mockStaffUserDoc: StaffUser; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null | unknown; + let mockConverter: { toDomain: ReturnType }; + let thrownError: unknown; + + BeforeEachScenario(() => { + passport = makeMockPassport(); + mockStaffUserDoc = makeMockStaffUserDocument(); + thrownError = undefined; + result = undefined; + + mockConverter = { + toDomain: vi.fn((_doc: StaffUser, _passport: Domain.Passport) => ({ + id: mockStaffUserDoc.id, + externalId: mockStaffUserDoc.externalId, + })), + }; + + vi.mocked(StaffUserConverter).mockImplementation(function MockStaffUserConverter() { + return mockConverter as unknown as StaffUserConverter; + }); + }); + + Scenario('Creating StaffUserReadRepository throws when StaffUser model is missing', ({ Given, When, Then }) => { + Given('models context does not contain a StaffUser model', () => { + models = {} as ModelsContext; + }); + When('I call getStaffUserReadRepository with those models and a passport', () => { + try { + repository = getStaffUserReadRepository(models, passport); + } catch (err) { + thrownError = err; + } + }); + Then('it should throw an error with message "StaffUser model is not available in the mongoose context"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('StaffUser model is not available in the mongoose context'); + }); + }); + + Scenario('Creating StaffUserReadRepository succeeds when StaffUser model is present', ({ Given, When, Then, And }) => { + Given('models context contains a StaffUser model', () => { + models = { StaffUser: makeMockModel(mockStaffUserDoc) } as unknown as ModelsContext; + }); + When('I call getStaffUserReadRepository with those models and a passport', () => { + repository = getStaffUserReadRepository(models, passport); + }); + Then('I should receive a StaffUserReadRepository instance', () => { + expect(repository).toBeDefined(); + }); + And('the repository should have a getByExternalId method', () => { + expect(typeof repository.getByExternalId).toBe('function'); + }); + And('the repository should have a getByEmail method', () => { + expect(typeof repository.getByEmail).toBe('function'); + }); + And('the repository should have a getAll method', () => { + expect(typeof repository.getAll).toBe('function'); + }); + }); + + // ─── getAll ─────────────────────────────────────────────────────────────── + + Scenario('getAll returns all converted entities when documents exist', ({ Given, When, Then, And }) => { + let doc1: StaffUser; + let doc2: StaffUser; + Given('two StaffUser documents exist in the collection', () => { + doc1 = { ...makeMockStaffUserDocument(), id: 'id-1', externalId: 'ext-1' } as unknown as StaffUser; + doc2 = { ...makeMockStaffUserDocument(), id: 'id-2', externalId: 'ext-2' } as unknown as StaffUser; + models = { StaffUser: makeMockModelMulti([doc1, doc2]) } as unknown as ModelsContext; + mockConverter.toDomain + .mockReturnValueOnce({ id: 'id-1', externalId: 'ext-1' }) + .mockReturnValueOnce({ id: 'id-2', externalId: 'ext-2' }); + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getAll', async () => { + result = await repository.getAll(); + }); + Then('I should receive an array of two StaffUserEntityReference objects', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(2); + }); + And('the converter toDomain should have been called once for each document', () => { + expect(mockConverter.toDomain).toHaveBeenCalledTimes(2); + expect(mockConverter.toDomain).toHaveBeenCalledWith(doc1, passport); + expect(mockConverter.toDomain).toHaveBeenCalledWith(doc2, passport); + }); + }); + + Scenario('getAll returns an empty array when no documents exist', ({ Given, When, Then }) => { + Given('no StaffUser documents exist in the collection', () => { + models = { StaffUser: makeMockModelMulti([]) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getAll', async () => { + result = await repository.getAll(); + }); + Then('I should receive an empty array', () => { + expect(result).toEqual([]); + }); + }); + + // ─── filter verification ────────────────────────────────────────────────── + + Scenario('getByExternalId passes the correct filter to findOne', ({ Given, When, Then }) => { + let mockModel: StaffUserModelType; + Given('a StaffUser document exists with externalId "ext-filter-test"', () => { + mockModel = makeMockModel(mockStaffUserDoc); + models = { StaffUser: mockModel } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByExternalId with "ext-filter-test"', async () => { + result = await repository.getByExternalId('ext-filter-test'); + }); + Then('findOne should have been called with the externalId filter', () => { + expect(mockModel.findOne).toHaveBeenCalledWith({ externalId: 'ext-filter-test' }); + }); + }); + + Scenario('getByEmail passes the correct filter to findOne', ({ Given, When, Then }) => { + let mockModel: StaffUserModelType; + Given('a StaffUser document exists with email "filter@example.com"', () => { + mockModel = makeMockModel(mockStaffUserDoc); + models = { StaffUser: mockModel } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByEmail with "filter@example.com"', async () => { + result = await repository.getByEmail('filter@example.com'); + }); + Then('findOne should have been called with the email filter', () => { + expect(mockModel.findOne).toHaveBeenCalledWith({ email: 'filter@example.com' }); + }); + }); + + Scenario('getByExternalId returns entity when document is found', ({ Given, When, Then, And }) => { + Given('a StaffUser document exists with externalId "ext-abc"', () => { + models = { StaffUser: makeMockModel(mockStaffUserDoc) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByExternalId with "ext-abc"', async () => { + result = await repository.getByExternalId('ext-abc'); + }); + Then('I should receive a StaffUserEntityReference object', () => { + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + }); + And('the converter toDomain should have been called with the document and passport', () => { + expect(mockConverter.toDomain).toHaveBeenCalledWith(mockStaffUserDoc, passport); + }); + }); + + Scenario('getByExternalId returns null when no document is found', ({ Given, When, Then }) => { + Given('no StaffUser document exists with externalId "missing-ext"', () => { + models = { StaffUser: makeMockModel(null) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByExternalId with "missing-ext"', async () => { + result = await repository.getByExternalId('missing-ext'); + }); + Then('I should receive null', () => { + expect(result).toBeNull(); + }); + }); + + Scenario('getByEmail returns entity when document is found', ({ Given, When, Then, And }) => { + Given('a StaffUser document exists with email "alice@example.com"', () => { + models = { StaffUser: makeMockModel(mockStaffUserDoc) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByEmail with "alice@example.com"', async () => { + result = await repository.getByEmail('alice@example.com'); + }); + Then('I should receive a StaffUserEntityReference object', () => { + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + }); + And('the converter toDomain should have been called with the document and passport', () => { + expect(mockConverter.toDomain).toHaveBeenCalledWith(mockStaffUserDoc, passport); + }); + }); + + Scenario('getByEmail returns null when no document is found', ({ Given, When, Then }) => { + Given('no StaffUser document exists with email "missing@example.com"', () => { + models = { StaffUser: makeMockModel(null) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByEmail with "missing@example.com"', async () => { + result = await repository.getByEmail('missing@example.com'); + }); + Then('I should receive null', () => { + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts new file mode 100644 index 000000000..4571f56f4 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts @@ -0,0 +1,50 @@ +import type { StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffUserConverter } from '../../../domain/user/staff-user/staff-user.domain-adapter.ts'; + +export interface StaffUserReadRepository { + getAll: () => Promise; + getByExternalId: (externalId: string) => Promise; + getByEmail: (email: string) => Promise; +} + +class StaffUserReadRepositoryImpl implements StaffUserReadRepository { + private readonly model: StaffUserModelType; + private readonly converter: StaffUserConverter; + private readonly passport: Domain.Passport; + + constructor(models: ModelsContext, passport: Domain.Passport) { + if (!models.StaffUser) { + throw new Error('StaffUser model is not available in the mongoose context'); + } + this.model = models.StaffUser; + this.converter = new StaffUserConverter(); + this.passport = passport; + } + + async getAll(): Promise { + const docs = await this.model.find({}).populate('role').exec(); + return docs.map((doc) => this.converter.toDomain(doc, this.passport)); + } + + async getByExternalId(externalId: string): Promise { + const doc = await this.model.findOne({ externalId }).populate('role').exec(); + if (!doc) { + return null; + } + return this.converter.toDomain(doc, this.passport); + } + + async getByEmail(email: string): Promise { + const doc = await this.model.findOne({ email }).populate('role').exec(); + if (!doc) { + return null; + } + return this.converter.toDomain(doc, this.passport); + } +} + +export const getStaffUserReadRepository = (models: ModelsContext, passport: Domain.Passport): StaffUserReadRepository => { + return new StaffUserReadRepositoryImpl(models, passport); +}; diff --git a/packages/ocom/service-token-validation/src/index.feature b/packages/ocom/service-token-validation/src/index.feature index 94462c6f8..fd76e3972 100644 --- a/packages/ocom/service-token-validation/src/index.feature +++ b/packages/ocom/service-token-validation/src/index.feature @@ -1,43 +1,72 @@ Feature: ServiceTokenValidation Scenario: Constructing ServiceTokenValidation with valid portal tokens - Given valid portal tokens mapping + Given valid portal tokens mapping for two portals When the ServiceTokenValidation is constructed with these tokens - Then it should create OpenID configurations from environment variables - And it should initialize the VerifiedTokenService with the configurations + Then it should pass the configuration map to VerifiedTokenService + And it should pass the default refresh interval to VerifiedTokenService + And it should store the VerifiedTokenService instance - Scenario: Constructing ServiceTokenValidation with missing optional environment variables - Given portal tokens mapping with missing optional environment variables + Scenario: Constructing ServiceTokenValidation with missing optional environment variables uses defaults + Given portal tokens mapping with only required environment variables set When the ServiceTokenValidation is constructed with these tokens - Then it should use default values for missing optional environment variables - And it should initialize the VerifiedTokenService with default configurations + Then the config clockTolerance should default to "5 minutes" + And the config ignoreIssuer should default to false - Scenario: Constructing ServiceTokenValidation with missing environment variables - Given portal tokens mapping with missing environment variables + Scenario: Constructing ServiceTokenValidation when ignoreIssuer is explicitly set to true + Given portal tokens mapping with OIDC_IGNORE_ISSUER set to "true" + When the ServiceTokenValidation is constructed with these tokens + Then the config ignoreIssuer should be true + + Scenario: Constructing ServiceTokenValidation with a custom refresh interval + Given valid portal tokens mapping for one portal + When the ServiceTokenValidation is constructed with a custom refresh interval of 30000 + Then it should pass the custom refresh interval 30000 to VerifiedTokenService + + Scenario: Constructing ServiceTokenValidation with a missing required environment variable + Given portal tokens mapping that references a missing environment variable prefix When the ServiceTokenValidation is constructed - Then it should throw an error for missing required environment variables + Then it should throw an error indicating the environment variable is not set Scenario: Starting up the ServiceTokenValidation Given a ServiceTokenValidation instance with valid configuration When startUp is called - Then it should start the underlying VerifiedTokenService - And it should return the service instance - - Scenario: Verifying JWT with ServiceTokenValidation - Given a ServiceTokenValidation instance that is started - And a valid JWT token - When verifyJwt is called with the token - Then it should try verification with each configured provider - And it should return the verification result when successful - - Scenario: Verifying invalid JWT with ServiceTokenValidation - Given a ServiceTokenValidation instance that is started - And an invalid JWT token - When verifyJwt is called with the invalid token - Then it should return null indicating verification failed - - Scenario: Shutting down the ServiceTokenValidation - Given a started ServiceTokenValidation instance + Then it should call start on the underlying VerifiedTokenService + And it should resolve with the service instance itself + + Scenario: verifyJwt succeeds on the second provider after a retryable error on the first + Given a ServiceTokenValidation instance configured with two portals + And the first portal raises a retryable JWSSignatureVerificationFailed error + And the second portal resolves with a valid JWT payload + When verifyJwt is called with a bearer token + Then it should call getVerifiedJwt for both portal1 and portal2 + And it should return the verifiedJwt and openIdConfigKey from the second portal + + Scenario: verifyJwt propagates a non-retryable error + Given a ServiceTokenValidation instance configured with one portal + And the portal raises a non-retryable TypeError + When verifyJwt is called with a bearer token + Then it should rethrow the non-retryable error + + Scenario: verifyJwt returns null when a provider returns a result with no payload + Given a ServiceTokenValidation instance configured with one portal + And the portal resolves with a result that has no payload + When verifyJwt is called with a bearer token + Then it should return null + + Scenario: verifyJwt returns null when all providers return null + Given a ServiceTokenValidation instance configured with one portal + And the portal resolves with null + When verifyJwt is called with a bearer token + Then it should return null + + Scenario: Shutting down when a timer is running clears the interval and logs + Given a ServiceTokenValidation instance with a running timer + When shutDown is called + Then it should clear the timer interval + And it should log "ServiceTokenValidation stopped" + + Scenario: Shutting down when no timer is running still logs + Given a ServiceTokenValidation instance with no timer running When shutDown is called - Then it should stop the underlying VerifiedTokenService - And it should log that the service stopped \ No newline at end of file + Then it should log "ServiceTokenValidation stopped" \ No newline at end of file diff --git a/packages/ocom/service-token-validation/src/index.test.ts b/packages/ocom/service-token-validation/src/index.test.ts index 8bf1747cb..c7b36dabc 100644 --- a/packages/ocom/service-token-validation/src/index.test.ts +++ b/packages/ocom/service-token-validation/src/index.test.ts @@ -1,321 +1,311 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi, afterEach, type Mock } from 'vitest'; -import { VerifiedTokenService } from './verified-token-service.ts'; +import { expect, type Mock, vi } from 'vitest'; import { ServiceTokenValidation } from './index.ts'; - -// Mock VerifiedTokenService +import { VerifiedTokenService } from './verified-token-service.ts'; const test = { for: describeFeature }; + vi.mock('./verified-token-service.ts', () => ({ VerifiedTokenService: vi.fn(), })); -// Mock console.log const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature(path.resolve(__dirname, 'index.feature')); +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function setPortalEnv( + prefix: string, + opts: { endpoint: string; audience: string; issuer: string; clockTolerance?: string; ignoreIssuer?: string }, +) { + vi.stubEnv(`${prefix}_OIDC_ENDPOINT`, opts.endpoint); + vi.stubEnv(`${prefix}_OIDC_AUDIENCE`, opts.audience); + vi.stubEnv(`${prefix}_OIDC_ISSUER`, opts.issuer); + if (opts.clockTolerance !== undefined) vi.stubEnv(`${prefix}_OIDC_CLOCK_TOLERANCE`, opts.clockTolerance); + if (opts.ignoreIssuer !== undefined) vi.stubEnv(`${prefix}_OIDC_IGNORE_ISSUER`, opts.ignoreIssuer); +} + +function makeBaseEnv(prefix: string) { + setPortalEnv(prefix, { + endpoint: `https://${prefix.toLowerCase()}.com/.well-known/jwks.json`, + audience: `${prefix.toLowerCase()}-aud`, + issuer: `https://${prefix.toLowerCase()}.com`, + }); +} + +function makeMockVerifiedTokenService() { + const mockGetVerifiedJwt = vi.fn(); + const mock = { + openIdConfigs: new Map(), + refreshInterval: 1000 * 60 * 5, + keyStoreCollection: new Map(), + refreshCollection: vi.fn(), + start: vi.fn(), + getVerifiedJwt: mockGetVerifiedJwt as unknown as VerifiedTokenService['getVerifiedJwt'], + timerInstance: undefined as NodeJS.Timeout | undefined, + } as VerifiedTokenService; + return { mock, mockGetVerifiedJwt }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + test.for(feature, ({ Scenario, BeforeEachScenario }) => { let service: ServiceTokenValidation; let mockVerifiedTokenService: VerifiedTokenService; - let mockGetVerifiedJwt: Mock<(bearerToken: string, configKey: string) => Promise> | null>>; - let originalEnv: NodeJS.ProcessEnv; + let mockGetVerifiedJwt: Mock; + let verifyJwtResult: Awaited> | undefined; + let thrownError: unknown; + let startUpResult: unknown; BeforeEachScenario(() => { - // Reset mocks vi.clearAllMocks(); + vi.unstubAllEnvs(); mockConsoleLog.mockClear(); + thrownError = undefined; + verifyJwtResult = undefined; + startUpResult = undefined; - // Store original environment - originalEnv = { ...process.env }; - - // Setup mock VerifiedTokenService - mockGetVerifiedJwt = vi.fn(); - mockVerifiedTokenService = { - openIdConfigs: new Map(), - refreshInterval: 1000 * 60 * 5, - keyStoreCollection: new Map(), - refreshCollection: vi.fn(), - start: vi.fn(), - getVerifiedJwt: mockGetVerifiedJwt as unknown as VerifiedTokenService['getVerifiedJwt'], - timerInstance: setInterval(() => undefined, 1000), - } as VerifiedTokenService; + const { mock, mockGetVerifiedJwt: getJwt } = makeMockVerifiedTokenService(); + mockVerifiedTokenService = mock; + mockGetVerifiedJwt = getJwt; vi.mocked(VerifiedTokenService).mockImplementation(function MockVerifiedTokenService() { return mockVerifiedTokenService; }); }); - afterEach(() => { - // Restore original environment - process.env = originalEnv; - }); + // ─── Constructor ────────────────────────────────────────────────────────── Scenario('Constructing ServiceTokenValidation with valid portal tokens', ({ Given, When, Then, And }) => { - Given('valid portal tokens mapping', () => { - // Set up environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ENDPOINT'] = 'https://portal2.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_AUDIENCE'] = 'audience2'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ISSUER'] = 'https://portal2.com'; + Given('valid portal tokens mapping for two portals', () => { + makeBaseEnv('PORTAL1'); + makeBaseEnv('PORTAL2'); }); - When('the ServiceTokenValidation is constructed with these tokens', () => { - // Ensure environment variables are set (they should be from Given, but let's be safe) - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ENDPOINT'] = 'https://portal2.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_AUDIENCE'] = 'audience2'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ISSUER'] = 'https://portal2.com'; - - const portalTokens = new Map([ - ['portal1', 'PORTAL1'], - ['portal2', 'PORTAL2'], - ]); - service = new ServiceTokenValidation(portalTokens); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1'], ['portal2', 'PORTAL2']])); }); - - Then('it should create OpenID configurations from environment variables', () => { - expect(VerifiedTokenService).toHaveBeenCalledWith( - expect.any(Map), - 1000 * 60 * 5, // default refresh interval - ); + Then('it should pass the configuration map to VerifiedTokenService', () => { + const [configs] = vi.mocked(VerifiedTokenService).mock.calls[0] as [Map]; + expect(configs).toBeInstanceOf(Map); + expect(configs.has('portal1')).toBe(true); + expect(configs.has('portal2')).toBe(true); }); - - And('it should initialize the VerifiedTokenService with the configurations', () => { - // biome-ignore lint:useLiteralKeys + And('it should pass the default refresh interval to VerifiedTokenService', () => { + expect(VerifiedTokenService).toHaveBeenCalledWith(expect.any(Map), 1000 * 60 * 5); + }); + And('it should store the VerifiedTokenService instance', () => { expect(service['tokenVerifier']).toBe(mockVerifiedTokenService); }); }); - Scenario('Constructing ServiceTokenValidation with missing optional environment variables', ({ Given, When, Then, And }) => { - Given('portal tokens mapping with missing optional environment variables', () => { - // Clear all environment variables first - // biome-ignore lint:useLiteralKeys - delete process.env['PORTAL1_OIDC_ENDPOINT']; - // biome-ignore lint:useLiteralKeys - delete process.env['PORTAL1_OIDC_AUDIENCE']; - // biome-ignore lint:useLiteralKeys - delete process.env['PORTAL1_OIDC_ISSUER']; - // biome-ignore lint:useLiteralKeys - delete process.env['PORTAL1_OIDC_CLOCK_TOLERANCE']; - // biome-ignore lint:useLiteralKeys - delete process.env['PORTAL1_OIDC_IGNORE_ISSUER']; - - // Set only required environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - // Explicitly don't set PORTAL1_OIDC_CLOCK_TOLERANCE and PORTAL1_OIDC_IGNORE_ISSUER + Scenario('Constructing ServiceTokenValidation with missing optional environment variables uses defaults', ({ Given, When, Then, And }) => { + Given('portal tokens mapping with only required environment variables set', () => { + // Only stub the required vars; optional vars intentionally absent + makeBaseEnv('PORTAL1'); }); - When('the ServiceTokenValidation is constructed with these tokens', () => { - // Don't reset environment variables here - use what's set in Given - const portalTokens = new Map([['portal1', 'PORTAL1']]); - service = new ServiceTokenValidation(portalTokens); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); }); - - Then('it should use default values for missing optional environment variables', () => { - expect(VerifiedTokenService).toHaveBeenCalledWith( - expect.any(Map), - 1000 * 60 * 5, // default refresh interval - ); - // Verify that the config was created with default values - const callArgs = vi.mocked(VerifiedTokenService).mock.calls[0]; - if (callArgs) { - const configs = callArgs[0] as Map; - const config = configs.get('portal1') as { clockTolerance: string; ignoreIssuer: boolean }; - expect(config.clockTolerance).toBe('5 minutes'); // default value - expect(config.ignoreIssuer).toBe(false); // 'false' === 'true' is false - console.log('Default values test passed - clockTolerance:', config.clockTolerance, 'ignoreIssuer:', config.ignoreIssuer); - } + Then('the config clockTolerance should default to "5 minutes"', () => { + const [configs] = vi.mocked(VerifiedTokenService).mock.calls[0] as unknown as [Map]; + expect(configs.get('portal1')?.clockTolerance).toBe('5 minutes'); + }); + And('the config ignoreIssuer should default to false', () => { + const [configs] = vi.mocked(VerifiedTokenService).mock.calls[0] as unknown as [Map]; + expect(configs.get('portal1')?.ignoreIssuer).toBe(false); }); + }); - And('it should initialize the VerifiedTokenService with default configurations', () => { - // biome-ignore lint:useLiteralKeys - expect(service['tokenVerifier']).toBe(mockVerifiedTokenService); + Scenario('Constructing ServiceTokenValidation when ignoreIssuer is explicitly set to true', ({ Given, When, Then }) => { + Given('portal tokens mapping with OIDC_IGNORE_ISSUER set to "true"', () => { + makeBaseEnv('PORTAL1'); + vi.stubEnv('PORTAL1_OIDC_IGNORE_ISSUER', 'true'); + }); + When('the ServiceTokenValidation is constructed with these tokens', () => { + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); + }); + Then('the config ignoreIssuer should be true', () => { + const [configs] = vi.mocked(VerifiedTokenService).mock.calls[0] as unknown as [Map]; + expect(configs.get('portal1')?.ignoreIssuer).toBe(true); }); }); - Scenario('Constructing ServiceTokenValidation with missing environment variables', ({ Given, When, Then }) => { - Given('portal tokens mapping with missing environment variables', () => { - // Don't set up environment variables - they should be missing + Scenario('Constructing ServiceTokenValidation with a custom refresh interval', ({ Given, When, Then }) => { + Given('valid portal tokens mapping for one portal', () => { + makeBaseEnv('PORTAL1'); + }); + When('the ServiceTokenValidation is constructed with a custom refresh interval of 30000', () => { + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']]), 30000); + }); + Then('it should pass the custom refresh interval 30000 to VerifiedTokenService', () => { + expect(VerifiedTokenService).toHaveBeenCalledWith(expect.any(Map), 30000); }); + }); + Scenario('Constructing ServiceTokenValidation with a missing required environment variable', ({ Given, When, Then }) => { + Given('portal tokens mapping that references a missing environment variable prefix', () => { + // MISSING_ prefix env vars are not stubbed — absence is the test condition + }); When('the ServiceTokenValidation is constructed', () => { - const portalTokens = new Map([['portal1', 'MISSING']]); - - expect(() => { - service = new ServiceTokenValidation(portalTokens); - }).toThrow('Environment variable MISSING_OIDC_ENDPOINT not set'); + try { + service = new ServiceTokenValidation(new Map([['portal1', 'MISSING']])); + } catch (e) { + thrownError = e; + } }); - - Then('it should throw an error for missing required environment variables', () => { - // Error is already thrown in When step + Then('it should throw an error indicating the environment variable is not set', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Environment variable MISSING_OIDC_ENDPOINT not set'); }); }); + // ─── startUp ───────────────────────────────────────────────────────────── + Scenario('Starting up the ServiceTokenValidation', ({ Given, When, Then, And }) => { Given('a ServiceTokenValidation instance with valid configuration', () => { - // Set up environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - - const portalTokens = new Map([['portal1', 'PORTAL1']]); - service = new ServiceTokenValidation(portalTokens); + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); }); - When('startUp is called', async () => { - const result = await service.startUp(); - expect(result).toBe(service); + startUpResult = await service.startUp(); }); - - Then('it should start the underlying VerifiedTokenService', () => { - expect(mockVerifiedTokenService.start).toHaveBeenCalled(); + Then('it should call start on the underlying VerifiedTokenService', () => { + expect(mockVerifiedTokenService.start).toHaveBeenCalledOnce(); }); - - And('it should return the service instance', () => { - // Result check is in When step + And('it should resolve with the service instance itself', () => { + expect(startUpResult).toBe(service); }); }); - Scenario('Verifying JWT with ServiceTokenValidation', ({ Given, When, Then, And }) => { - Given('a ServiceTokenValidation instance that is started', () => { - // Set up environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ENDPOINT'] = 'https://portal2.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_AUDIENCE'] = 'audience2'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ISSUER'] = 'https://portal2.com'; - - const portalTokens = new Map([ - ['portal1', 'PORTAL1'], - ['portal2', 'PORTAL2'], - ]); - service = new ServiceTokenValidation(portalTokens); + // ─── verifyJwt ──────────────────────────────────────────────────────────── - // Mock successful verification on second attempt - mockGetVerifiedJwt - .mockResolvedValueOnce(null) // First provider fails - .mockResolvedValueOnce({ - // Second provider succeeds - payload: { sub: 'user123', aud: 'audience2' }, - protectedHeader: { alg: 'RS256' }, - key: {} as never, - }); + Scenario('verifyJwt succeeds on the second provider after a retryable error on the first', ({ Given, And, When, Then }) => { + Given('a ServiceTokenValidation instance configured with two portals', () => { + makeBaseEnv('PORTAL1'); + makeBaseEnv('PORTAL2'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1'], ['portal2', 'PORTAL2']])); }); - - And('a valid JWT token', () => { - // Token is provided in When step + And('the first portal raises a retryable JWSSignatureVerificationFailed error', () => { + mockGetVerifiedJwt.mockRejectedValueOnce( + Object.assign(new Error('signature mismatch'), { name: 'JWSSignatureVerificationFailed' }), + ); }); - - When('verifyJwt is called with the token', async () => { - const result = await service.verifyJwt('valid.jwt.token'); - expect(result).toEqual({ - verifiedJwt: { sub: 'user123', aud: 'audience2' }, - openIdConfigKey: 'portal2', + And('the second portal resolves with a valid JWT payload', () => { + mockGetVerifiedJwt.mockResolvedValueOnce({ + payload: { sub: 'user123', aud: 'portal2-aud' }, + protectedHeader: { alg: 'RS256' }, + key: {} as never, }); }); - - Then('it should try verification with each configured provider', () => { + When('verifyJwt is called with a bearer token', async () => { + verifyJwtResult = await service.verifyJwt('test.jwt.token'); + }); + Then('it should call getVerifiedJwt for both portal1 and portal2', () => { expect(mockVerifiedTokenService.getVerifiedJwt).toHaveBeenCalledTimes(2); - expect(mockVerifiedTokenService.getVerifiedJwt).toHaveBeenCalledWith('valid.jwt.token', 'portal1'); - expect(mockVerifiedTokenService.getVerifiedJwt).toHaveBeenCalledWith('valid.jwt.token', 'portal2'); + expect(mockVerifiedTokenService.getVerifiedJwt).toHaveBeenCalledWith('test.jwt.token', 'portal1'); + expect(mockVerifiedTokenService.getVerifiedJwt).toHaveBeenCalledWith('test.jwt.token', 'portal2'); }); - - And('it should return the verification result when successful', () => { - // Result check is in When step + And('it should return the verifiedJwt and openIdConfigKey from the second portal', () => { + expect(verifyJwtResult).toEqual({ + verifiedJwt: { sub: 'user123', aud: 'portal2-aud' }, + openIdConfigKey: 'portal2', + }); }); }); - Scenario('Verifying invalid JWT with ServiceTokenValidation', ({ Given, When, Then, And }) => { - Given('a ServiceTokenValidation instance that is started', () => { - // Set up environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - - const portalTokens = new Map([['portal1', 'PORTAL1']]); - service = new ServiceTokenValidation(portalTokens); - - // Mock verification failure - mockGetVerifiedJwt.mockResolvedValue(null); + Scenario('verifyJwt propagates a non-retryable error', ({ Given, And, When, Then }) => { + Given('a ServiceTokenValidation instance configured with one portal', () => { + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); }); - - And('an invalid JWT token', () => { - // Token is provided in When step + And('the portal raises a non-retryable TypeError', () => { + mockGetVerifiedJwt.mockRejectedValueOnce(new TypeError('unexpected failure')); }); + When('verifyJwt is called with a bearer token', async () => { + try { + verifyJwtResult = await service.verifyJwt('test.jwt.token'); + } catch (e) { + thrownError = e; + } + }); + Then('it should rethrow the non-retryable error', () => { + expect(thrownError).toBeInstanceOf(TypeError); + expect((thrownError as TypeError).message).toBe('unexpected failure'); + }); + }); - When('verifyJwt is called with the invalid token', async () => { - const result = await service.verifyJwt('invalid.jwt.token'); - expect(result).toBeNull(); + Scenario('verifyJwt returns null when a provider returns a result with no payload', ({ Given, And, When, Then }) => { + Given('a ServiceTokenValidation instance configured with one portal', () => { + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); }); + And('the portal resolves with a result that has no payload', () => { + mockGetVerifiedJwt.mockResolvedValueOnce({ + payload: undefined, + protectedHeader: { alg: 'RS256' }, + key: {} as never, + } as never); + }); + When('verifyJwt is called with a bearer token', async () => { + verifyJwtResult = await service.verifyJwt('test.jwt.token'); + }); + Then('it should return null', () => { + expect(verifyJwtResult).toBeNull(); + }); + }); - Then('it should return null indicating verification failed', () => { - // Result check is in When step + Scenario('verifyJwt returns null when all providers return null', ({ Given, And, When, Then }) => { + Given('a ServiceTokenValidation instance configured with one portal', () => { + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); + }); + And('the portal resolves with null', () => { + // biome-ignore lint/suspicious/noExplicitAny: simulating null return from mock + mockGetVerifiedJwt.mockResolvedValueOnce(null as any); + }); + When('verifyJwt is called with a bearer token', async () => { + verifyJwtResult = await service.verifyJwt('test.jwt.token'); + }); + Then('it should return null', () => { + expect(verifyJwtResult).toBeNull(); }); }); - Scenario('Shutting down the ServiceTokenValidation', ({ Given, When, Then, And }) => { - Given('a started ServiceTokenValidation instance', () => { - // Set up environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; + // ─── shutDown ───────────────────────────────────────────────────────────── - const portalTokens = new Map([['portal1', 'PORTAL1']]); - service = new ServiceTokenValidation(portalTokens); + Scenario('Shutting down when a timer is running clears the interval and logs', ({ Given, When, Then, And }) => { + Given('a ServiceTokenValidation instance with a running timer', () => { + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); + mockVerifiedTokenService.timerInstance = setInterval(() => undefined, 60_000); }); - When('shutDown is called', async () => { await service.shutDown(); }); - - Then('it should stop the underlying VerifiedTokenService', () => { - // The shutdown method clears the timer instance from VerifiedTokenService - expect(mockVerifiedTokenService.timerInstance).toBeDefined(); + Then('it should clear the timer interval', () => { + expect(mockConsoleLog).toHaveBeenCalledWith('ServiceTokenValidation stopped'); + }); + And('it should log "ServiceTokenValidation stopped"', () => { + expect(mockConsoleLog).toHaveBeenCalledOnce(); }); + }); - And('it should log that the service stopped', () => { + Scenario('Shutting down when no timer is running still logs', ({ Given, When, Then }) => { + Given('a ServiceTokenValidation instance with no timer running', () => { + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); + mockVerifiedTokenService.timerInstance = undefined; + }); + When('shutDown is called', async () => { + await service.shutDown(); + }); + Then('it should log "ServiceTokenValidation stopped"', () => { expect(mockConsoleLog).toHaveBeenCalledWith('ServiceTokenValidation stopped'); }); }); }); + diff --git a/packages/ocom/service-token-validation/src/index.ts b/packages/ocom/service-token-validation/src/index.ts index b002722c6..c8824fa05 100644 --- a/packages/ocom/service-token-validation/src/index.ts +++ b/packages/ocom/service-token-validation/src/index.ts @@ -39,12 +39,18 @@ export class ServiceTokenValidation implements ServiceBase { async verifyJwt(token: string): Promise | null> { // Try each config key for verification for (const configKey of this.tokenSettings.keys()) { - const result = await this.tokenVerifier.getVerifiedJwt(token, configKey); - if (result?.payload) { - return { - verifiedJwt: result.payload as ClaimsType, - openIdConfigKey: configKey, - }; + try { + const result = await this.tokenVerifier.getVerifiedJwt(token, configKey); + if (result?.payload) { + return { + verifiedJwt: result.payload as ClaimsType, + openIdConfigKey: configKey, + }; + } + } catch (error) { + if (!this.isRetryableVerificationError(error)) { + throw error; + } } } return null; @@ -74,4 +80,12 @@ export class ServiceTokenValidation implements ServiceBase { return defaultValue; } } + + private isRetryableVerificationError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return ['JWSSignatureVerificationFailed', 'JWTClaimValidationFailed', 'JWTExpired', 'JWTInvalid', 'JWSInvalid'].includes(error.name); + } } diff --git a/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx b/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx index 4930c0c3b..3fc91da04 100644 --- a/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx +++ b/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx @@ -80,6 +80,7 @@ const mockData = { export const Default: Story = { args: { data: mockData, + canCreateCommunity: true, } satisfies CommunityListProps, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx b/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx index c0f38203c..901ed7afc 100644 --- a/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx +++ b/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx @@ -16,7 +16,6 @@ export interface CommunityListProps { export const CommunityList: React.FC = (props) => { const [communityList, setCommunityList] = useState(props.data.communities); const navigate = useNavigate(); - const onChange = (event: ChangeEvent) => { const searchValue = event.target.value; if (searchValue === '') { @@ -120,12 +119,12 @@ export const CommunityList: React.FC = (props) => {

Navigate to a Community

- +
{ const pageLayouts: PageLayoutProps[] = [ { @@ -21,7 +27,10 @@ export const Admin: React.FC = () => { icon: , id: 2, parent: 'ROOT', - // hasPermissions: (member: Member) => member?.isAdmin ?? false + hasPermissions: (data: unknown) => { + const adminData = data as AdminMenuData; + return adminData?.member?.isAdmin ?? false; + }, }, { path: '/community/:communityId/admin/:memberId/settings/*', @@ -29,9 +38,10 @@ export const Admin: React.FC = () => { icon: , id: 3, parent: 'ROOT', - // Note: Permission check would be: - // hasPermissions: (member: Member) => member?.role?.permissions?.communityPermissions?.canManageCommunitySettings ?? false - // Currently schema doesn't include role/permissions, so we allow all admin users to access settings + hasPermissions: (data: unknown) => { + const adminData = data as AdminMenuData; + return adminData?.member?.isAdmin ?? false; + }, }, ]; diff --git a/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx b/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx index f446d084d..02fedcf53 100644 --- a/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx +++ b/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx @@ -2,7 +2,10 @@ import { useQuery } from '@apollo/client'; import { ComponentQueryLoader } from '@cellix/ui-core'; import type { PageLayoutProps } from '@ocom/ui-shared'; import { useParams } from 'react-router-dom'; -import { AdminSectionLayoutContainerMembersForCurrentEndUserDocument, type Member } from './generated.tsx'; +import { + AdminSectionLayoutContainerMembersForCurrentEndUserDocument, + type Member, +} from './generated.tsx'; import { SectionLayout } from './section-layout.tsx'; interface SectionLayoutContainerProps { @@ -14,6 +17,7 @@ export const SectionLayoutContainer: React.FC = (pr const { data: membersData, loading: membersLoading, error: membersError } = useQuery(AdminSectionLayoutContainerMembersForCurrentEndUserDocument); + return ( [ + { + path: '/community/:communityId/admin/:memberId', + title: 'Home', + icon: , + id: 'ROOT', + }, + { + path: '/community/:communityId/admin/:memberId/members/*', + title: 'Members', + icon: , + id: 2, + parent: 'ROOT', + hasPermissions: () => permissions?.canManageUsers ?? false, + }, + { + path: '/community/:communityId/admin/:memberId/settings/*', + title: 'Settings', + icon: , + id: 3, + parent: 'ROOT', + hasPermissions: () => permissions?.canManageCommunities ?? false, + }, +]; + +const meta: Meta = { + title: 'Admin/Layouts/SectionLayout', + component: SectionLayout, + decorators: [ + (Story) => ( + + + + } + /> + + + + ), + ], + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +export const AllPermissions: Story = { + args: { + pageLayouts: makePageLayouts(allPermissions), + memberData: mockMember, + staffSectionPermissions: allPermissions, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.getByText('Members')).toBeInTheDocument(); + expect(canvas.getByText('Settings')).toBeInTheDocument(); + }, +}; + +export const NoPermissions: Story = { + args: { + pageLayouts: makePageLayouts(noPermissions), + memberData: mockMember, + staffSectionPermissions: noPermissions, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; + +export const CommunityPermissionsOnly: Story = { + args: { + pageLayouts: makePageLayouts({ ...noPermissions, canManageCommunities: true }), + memberData: mockMember, + staffSectionPermissions: { ...noPermissions, canManageCommunities: true }, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.getByText('Settings')).toBeInTheDocument(); + }, +}; + +export const UserPermissionsOnly: Story = { + args: { + pageLayouts: makePageLayouts({ ...noPermissions, canManageUsers: true }), + memberData: mockMember, + staffSectionPermissions: { ...noPermissions, canManageUsers: true }, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.getByText('Members')).toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; + +export const NullPermissions: Story = { + args: { + pageLayouts: makePageLayouts(null), + memberData: mockMember, + staffSectionPermissions: null, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; diff --git a/packages/ocom/ui-community-route-admin/src/section-layout.tsx b/packages/ocom/ui-community-route-admin/src/section-layout.tsx index 85e655538..846dbfb20 100644 --- a/packages/ocom/ui-community-route-admin/src/section-layout.tsx +++ b/packages/ocom/ui-community-route-admin/src/section-layout.tsx @@ -21,6 +21,13 @@ const handleToggler = (isExpanded: boolean, setIsExpanded: (value: boolean) => v } }; +export interface AdminStaffSectionPermissions { + canManageCommunities: boolean; + canManageUsers: boolean; + canManageFinance: boolean; + canManageTechAdmin: boolean; +} + interface AdminSectionLayoutProps { pageLayouts: PageLayoutProps[]; memberData: Member; @@ -36,7 +43,7 @@ export const SectionLayout: React.FC = (props) => { const menuComponentProps: MenuComponentProps = { pageLayouts: props.pageLayouts, - memberData: props.memberData, + memberData: { member: props.memberData }, theme: 'light', mode: 'inline', }; diff --git a/packages/ocom/ui-staff-route-community-management/src/index.tsx b/packages/ocom/ui-staff-route-community-management/src/index.tsx index 90ceaf1cc..3bbe9a551 100644 --- a/packages/ocom/ui-staff-route-community-management/src/index.tsx +++ b/packages/ocom/ui-staff-route-community-management/src/index.tsx @@ -16,7 +16,7 @@ export const Root: React.FC = () => { } /> @@ -26,7 +26,7 @@ export const Root: React.FC = () => { } /> diff --git a/packages/ocom/ui-staff-route-finance/src/index.tsx b/packages/ocom/ui-staff-route-finance/src/index.tsx index c7116f4ad..fb0360d17 100644 --- a/packages/ocom/ui-staff-route-finance/src/index.tsx +++ b/packages/ocom/ui-staff-route-finance/src/index.tsx @@ -16,7 +16,7 @@ export const Root: React.FC = () => { } /> @@ -26,7 +26,7 @@ export const Root: React.FC = () => { } /> diff --git a/packages/ocom/ui-staff-route-user-management/.storybook/main.ts b/packages/ocom/ui-staff-route-user-management/.storybook/main.ts new file mode 100644 index 000000000..055dca4e5 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/.storybook/main.ts @@ -0,0 +1,16 @@ +import { dirname, join } from 'node:path'; +import type { StorybookConfig } from '@storybook/react-vite'; + +function getAbsolutePath(value: string) { + return dirname(require.resolve(join(value, 'package.json'))); +} + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [getAbsolutePath('@chromatic-com/storybook'), getAbsolutePath('@storybook/addon-docs'), getAbsolutePath('@storybook/addon-a11y'), getAbsolutePath('@storybook/addon-vitest')], + framework: { + name: getAbsolutePath('@storybook/react-vite'), + options: {}, + }, +}; +export default config; diff --git a/packages/ocom/ui-staff-route-user-management/.storybook/preview.tsx b/packages/ocom/ui-staff-route-user-management/.storybook/preview.tsx new file mode 100644 index 000000000..2f9935493 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/.storybook/preview.tsx @@ -0,0 +1,35 @@ +import { MockedProvider } from '@apollo/client/testing'; +import type { Decorator, Preview } from '@storybook/react'; +import { MemoryRouter } from 'react-router-dom'; +import 'antd/dist/reset.css'; + +export const decorators: Decorator[] = [ + (Story, context) => { + const initialEntries = context.parameters?.memoryRouter?.initialEntries ?? ['/']; + const apolloMocks = context.parameters?.apolloMocks ?? []; + + return ( + + + + + + ); + }, +]; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, +}; + +export default preview; diff --git a/packages/ocom/ui-staff-route-user-management/.storybook/vitest.setup.ts b/packages/ocom/ui-staff-route-user-management/.storybook/vitest.setup.ts new file mode 100644 index 000000000..f7590b108 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; +import { setProjectAnnotations } from '@storybook/react-vite'; +import * as projectAnnotations from './preview.tsx'; + +// This is an important step to apply the right configuration when testing your stories. +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); + \ No newline at end of file diff --git a/packages/ocom/ui-staff-route-user-management/package.json b/packages/ocom/ui-staff-route-user-management/package.json index 45f5ed339..d3fb714d7 100644 --- a/packages/ocom/ui-staff-route-user-management/package.json +++ b/packages/ocom/ui-staff-route-user-management/package.json @@ -10,13 +10,18 @@ "prebuild": "pnpm run lint", "build": "tsgo --noEmit", "lint": "biome lint", + "storybook": "storybook dev --port 6007", + "build-storybook": "storybook build", "test": "vitest run --silent --reporter=dot", "test:coverage": "vitest run --coverage --silent --reporter=dot", "test:watch": "vitest" }, "dependencies": { "@ant-design/icons": "catalog:", + "@apollo/client": "^3.13.9", + "@graphql-typed-document-node/core": "^3.2.0", "@ocom/ui-staff-shared": "workspace:*", + "antd": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-router-dom": "catalog:" @@ -24,9 +29,16 @@ "devDependencies": { "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", + "@chromatic-com/storybook": "^4.1.1", + "@storybook/react": "^9.1.9", + "@storybook/addon-a11y": "^9.1.3", + "@storybook/addon-docs": "^9.1.3", + "@storybook/addon-vitest": "^9.1.3", + "@storybook/react-vite": "^9.1.3", "@types/react": "^19.1.11", "@types/react-dom": "^19.1.6", "jsdom": "catalog:", + "storybook": "catalog:", "vite": "catalog:", "vitest": "catalog:", "typescript": "catalog:" diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.graphql b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.graphql new file mode 100644 index 000000000..6c4d46b7e --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.graphql @@ -0,0 +1,15 @@ +mutation StaffRoleCreate($input: StaffRoleCreateInput!) { + staffRoleCreate(input: $input) { + status { + success + errorMessage + } + staffRole { + id + roleName + enterpriseAppRole + createdAt + updatedAt + } + } +} diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.tsx new file mode 100644 index 000000000..2be9f968f --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.tsx @@ -0,0 +1,143 @@ +import { useMutation } from '@apollo/client'; +import { StaffAuthContext } from '@ocom/ui-staff-shared'; +import { App } from 'antd'; +import type React from 'react'; +import { useContext } from 'react'; +import { Navigate, useNavigate } from 'react-router-dom'; +import { StaffRoleCreateDocument, StaffRolesForSelectDocument, StaffRolesListDocument, type StaffRolesForSelectQuery, type StaffRolesListQuery } from '../generated.tsx'; +import { StaffRoleCreate, type StaffRoleFormValues } from './staff-role-create.tsx'; + +const EnterpriseAppRoleNames = { + CaseManager: 'Staff.CaseManager', + ServiceLineOwner: 'Staff.ServiceLineOwner', + Finance: 'Staff.Finance', + TechAdmin: 'Staff.TechAdmin', +} as const; + +function getAllowedEnterpriseAppRoles(enterpriseAppRole: string | undefined): string[] { + switch (enterpriseAppRole) { + case EnterpriseAppRoleNames.TechAdmin: + return Object.values(EnterpriseAppRoleNames); + case EnterpriseAppRoleNames.ServiceLineOwner: + return [EnterpriseAppRoleNames.ServiceLineOwner, EnterpriseAppRoleNames.CaseManager]; + case EnterpriseAppRoleNames.CaseManager: + return [EnterpriseAppRoleNames.CaseManager]; + case EnterpriseAppRoleNames.Finance: + return [EnterpriseAppRoleNames.Finance]; + default: + return []; + } +} + +export const StaffRoleCreateContainer: React.FC = () => { + const navigate = useNavigate(); + const { message } = App.useApp(); + const auth = useContext(StaffAuthContext); + const availableEnterpriseAppRoles = getAllowedEnterpriseAppRoles(auth?.enterpriseAppRole); + const showTechAdminPermissions = auth?.permissions?.canManageTechAdmin === true; + const canCreateRole = + auth?.permissions?.canAddRole === true || + auth?.permissions?.canManageStaffRolesAndPermissions === true || + auth?.permissions?.canManageTechAdmin === true; + + const [staffRoleCreate, { loading }] = useMutation(StaffRoleCreateDocument, { + update: (cache, { data }) => { + const newRole = data?.staffRoleCreate.staffRole; + if (!newRole) return; + const updateRolesList = (existing: StaffRolesListQuery | null): StaffRolesListQuery | null => { + if (!existing) return { staffRoles: [newRole] }; + if (existing.staffRoles.some((role) => String(role.id) === String(newRole.id))) return existing; + return { staffRoles: [...existing.staffRoles, newRole] }; + }; + const updateRolesSelect = (existing: StaffRolesForSelectQuery | null): StaffRolesForSelectQuery | null => { + if (!existing) return { staffRoles: [newRole] }; + if (existing.staffRoles.some((role) => String(role.id) === String(newRole.id))) return existing; + return { staffRoles: [...existing.staffRoles, newRole] }; + }; + cache.updateQuery({ query: StaffRolesListDocument }, updateRolesList); + cache.updateQuery({ query: StaffRolesForSelectDocument }, updateRolesSelect); + }, + }); + + if (!canCreateRole) { + return ( + + ); + } + + const handleSubmit = async (values: StaffRoleFormValues) => { + try { + const result = await staffRoleCreate({ + variables: { + input: { + roleName: values.roleName, + enterpriseAppRole: values.enterpriseAppRole || null, + permissions: { + communityPermissions: { + canManageCommunities: values.canManageCommunities, + canManageStaffRolesAndPermissions: values.canManageStaffRolesAndPermissions, + canManageAllCommunities: values.canManageAllCommunities, + canDeleteCommunities: values.canDeleteCommunities, + canChangeCommunityOwner: values.canChangeCommunityOwner, + canReIndexSearchCollections: values.canReIndexSearchCollections, + }, + userPermissions: { + canManageUsers: values.canManageUsers, + canAssignStaffRoles: values.canAssignStaffRoles, + canViewStaffUsers: values.canViewStaffUsers, + }, + staffRolePermissions: { + canViewRoles: values.canViewRoles, + canAddRole: values.canAddRole, + canEditRole: values.canEditRole, + canRemoveRole: values.canRemoveRole, + }, + financePermissions: { + canManageFinance: values.canManageFinance, + canViewGLBatchSummaries: values.canViewGLBatchSummaries, + canViewFinanceConfigs: values.canViewFinanceConfigs, + canCreateFinanceConfigs: values.canCreateFinanceConfigs, + }, + ...(showTechAdminPermissions + ? { + techAdminPermissions: { + canManageTechAdmin: values.canManageTechAdmin, + canViewDatabaseExplorer: values.canViewDatabaseExplorer, + canViewBlobExplorer: values.canViewBlobExplorer, + canViewQueueDashboard: values.canViewQueueDashboard, + canSendQueueMessages: values.canSendQueueMessages, + }, + } + : {}), + }, + }, + }, + }); + if (result.data?.staffRoleCreate.status.success) { + message.success('Role created successfully'); + navigate('..'); + } else { + message.error(result.data?.staffRoleCreate.status.errorMessage ?? 'Failed to create role'); + } + } catch (_err) { + message.error('Failed to create role'); + } + }; + + const handleCancel = () => { + navigate('..'); + }; + + return ( + + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.stories.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.stories.tsx new file mode 100644 index 000000000..256a5f219 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from 'storybook/test'; +import { StaffRoleCreate } from './staff-role-create.tsx'; + +const ALL_ENTERPRISE_APP_ROLES = ['Staff.TechAdmin', 'Staff.ServiceLineOwner', 'Staff.CaseManager', 'Staff.Finance']; + +const meta: Meta = { + title: 'UserManagement/Components/StaffRoleCreate', + component: StaffRoleCreate, + parameters: { layout: 'padded' }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + onSubmit: (values) => console.log('Submit:', values), + onCancel: () => console.log('Cancel clicked'), + availableEnterpriseAppRoles: ALL_ENTERPRISE_APP_ROLES, + showTechAdminPermissions: true, + }, +}; + +export const CaseManagerView: Story = { + args: { + onSubmit: (values) => console.log('Submit:', values), + onCancel: () => console.log('Cancel clicked'), + availableEnterpriseAppRoles: ['Staff.CaseManager'], + showTechAdminPermissions: false, + }, +}; + +export const ServiceLineOwnerView: Story = { + args: { + onSubmit: (values) => console.log('Submit:', values), + onCancel: () => console.log('Cancel clicked'), + availableEnterpriseAppRoles: ['Staff.ServiceLineOwner', 'Staff.CaseManager'], + showTechAdminPermissions: false, + }, +}; + +export const WithLoading: Story = { + args: { + onSubmit: (values) => console.log('Submit:', values), + onCancel: () => console.log('Cancel clicked'), + loading: true, + availableEnterpriseAppRoles: ALL_ENTERPRISE_APP_ROLES, + showTechAdminPermissions: true, + }, +}; + +export const PermissionHierarchy: Story = { + args: { + onSubmit: (values) => console.log('Submit:', values), + onCancel: () => console.log('Cancel clicked'), + availableEnterpriseAppRoles: ALL_ENTERPRISE_APP_ROLES, + showTechAdminPermissions: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const addRole = canvas.getByRole('checkbox', { name: /can add staff role/i }); + const viewRoles = canvas.getByRole('checkbox', { name: /can view staff roles/i }); + + await userEvent.click(addRole); + + expect(addRole).toBeChecked(); + expect(viewRoles).toBeChecked(); + }, +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx new file mode 100644 index 000000000..f4613977b --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx @@ -0,0 +1,234 @@ +import { Button, Checkbox, Divider, Form, Input, Select, Space, Typography } from 'antd'; +import type React from 'react'; + +const { Title } = Typography; + +export interface StaffRoleFormValues { + roleName: string; + enterpriseAppRole: string; + canManageCommunities: boolean; + canManageStaffRolesAndPermissions: boolean; + canManageAllCommunities: boolean; + canDeleteCommunities: boolean; + canChangeCommunityOwner: boolean; + canReIndexSearchCollections: boolean; + canManageUsers: boolean; + canAssignStaffRoles: boolean; + canViewStaffUsers: boolean; + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; +} + +interface StaffRoleCreateProps { + onSubmit: (values: StaffRoleFormValues) => void; + onCancel: () => void; + loading?: boolean; + availableEnterpriseAppRoles?: string[]; + showTechAdminPermissions?: boolean; + initialValues?: Partial; + mode?: 'create' | 'edit'; +} + +type PermissionFieldKey = keyof Omit; + +const PERMISSION_GROUPS: Array<{ + title: string; + techAdminOnly?: boolean; + topLevelKey: PermissionFieldKey; + fields: Array<{ key: PermissionFieldKey; label: string }>; +}> = [ + { + title: 'Community Permissions', + topLevelKey: 'canManageCommunities', + fields: [ + { key: 'canManageCommunities', label: 'Can Manage Communities' }, + { key: 'canManageStaffRolesAndPermissions', label: 'Can Manage Staff Roles and Permissions' }, + { key: 'canManageAllCommunities', label: 'Can Manage All Communities' }, + { key: 'canDeleteCommunities', label: 'Can Delete Communities' }, + { key: 'canChangeCommunityOwner', label: 'Can Change Community Owner' }, + { key: 'canReIndexSearchCollections', label: 'Can Reindex Search Collections' }, + ], + }, + { + title: 'User', + topLevelKey: 'canManageUsers', + fields: [ + { key: 'canManageUsers', label: 'Can Manage Users' }, + { key: 'canAssignStaffRoles', label: 'Can Assign Staff Roles' }, + { key: 'canViewStaffUsers', label: 'Can View Staff Users' }, + ], + }, + { + title: 'Staff Roles', + topLevelKey: 'canViewRoles', + fields: [ + { key: 'canViewRoles', label: 'Can View Staff Roles' }, + { key: 'canAddRole', label: 'Can Add Staff Role' }, + { key: 'canEditRole', label: 'Can Edit Staff Role' }, + { key: 'canRemoveRole', label: 'Can Remove Staff Role' }, + ], + }, + { + title: 'Finance', + topLevelKey: 'canManageFinance', + fields: [ + { key: 'canManageFinance', label: 'Can Manage Finance' }, + { key: 'canViewGLBatchSummaries', label: 'Can View GL Batch Summaries' }, + { key: 'canViewFinanceConfigs', label: 'Can View Finance Configs' }, + { key: 'canCreateFinanceConfigs', label: 'Can Create Finance Configs' }, + ], + }, + { + title: 'Tech Admin', + techAdminOnly: true, + topLevelKey: 'canManageTechAdmin', + fields: [ + { key: 'canManageTechAdmin', label: 'Can Manage Tech Admin' }, + { key: 'canViewDatabaseExplorer', label: 'Can View Database Explorer' }, + { key: 'canViewBlobExplorer', label: 'Can View Blob Explorer' }, + { key: 'canViewQueueDashboard', label: 'Can View Queue Dashboard' }, + { key: 'canSendQueueMessages', label: 'Can Send Queue Messages' }, + ], + }, +]; + +const normalizePermissionHierarchy = (values: StaffRoleFormValues): StaffRoleFormValues => { + const normalized = { ...values }; + + for (const group of PERMISSION_GROUPS) { + const shouldEnableTopLevel = group.fields.slice(1).some(({ key }) => normalized[key]); + if (shouldEnableTopLevel) { + normalized[group.topLevelKey] = true; + } + } + + return normalized; +}; + +const DEFAULT_VALUES: StaffRoleFormValues = { + roleName: '', + enterpriseAppRole: '', + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + canManageUsers: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, +}; + +export const StaffRoleCreate: React.FC = ({ onSubmit, onCancel, loading, availableEnterpriseAppRoles, showTechAdminPermissions, initialValues, mode = 'create' }) => { + const [form] = Form.useForm(); + + const defaultValues: StaffRoleFormValues = { + ...normalizePermissionHierarchy({ ...DEFAULT_VALUES, ...initialValues }), + }; + + const isEdit = mode === 'edit'; + const enterpriseAppRoleOptions = (availableEnterpriseAppRoles ?? []).map((r) => ({ value: r, label: r })); + + const handleValuesChange = (_changedValues: Partial, allValues: StaffRoleFormValues) => { + const normalizedValues = normalizePermissionHierarchy(allValues); + const hasHierarchyChange = PERMISSION_GROUPS.some((group) => { + const topLevelIsFalse = normalizedValues[group.topLevelKey] !== allValues[group.topLevelKey]; + const childSelected = group.fields.slice(1).some(({ key }) => allValues[key]); + return topLevelIsFalse && childSelected; + }); + + if (hasHierarchyChange) { + form.setFieldsValue(normalizedValues); + } + }; + + return ( + + {isEdit ? 'Edit Staff Role' : 'Create Staff Role'} +
+ + + + + ({ value: r.id, label: r.roleName }))} + placeholder={currentRoleName ? `${currentRoleName}` : 'No role assigned'} + /> + + {isEditingOwnRole && ( +
+
+ )} + +
+ + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.graphql b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.graphql new file mode 100644 index 000000000..a87b8d325 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.graphql @@ -0,0 +1,16 @@ +query StaffUsersList { + staffUsers { + ...StaffUsersListFields + } +} + +fragment StaffUsersListFields on StaffUser { + id + displayName + email + createdAt + role { + id + roleName + } +} diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.tsx new file mode 100644 index 000000000..217c8efe3 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.tsx @@ -0,0 +1,40 @@ +import { useQuery } from '@apollo/client'; +import { StaffAuthContext } from '@ocom/ui-staff-shared'; +import type React from 'react'; +import { useContext } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { StaffUsersListDocument } from '../generated.tsx'; +import { StaffUsersList } from './staff-users-list.tsx'; + +export const StaffUsersListContainer: React.FC = () => { + const navigate = useNavigate(); + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canEdit = + perms?.canAssignStaffRoles === true || + perms?.canManageUsers === true || + perms?.canManageStaffRolesAndPermissions === true || + perms?.canManageTechAdmin === true; + const { data, loading } = useQuery(StaffUsersListDocument, { + fetchPolicy: 'cache-and-network', + }); + + const handleEdit = (id: string) => { + navigate(id); + }; + + return ( + ({ + id: String(u.id), + displayName: u.displayName, + email: u.email, + role: u.role ? { id: String(u.role.id), roleName: u.role.roleName } : null, + createdAt: u.createdAt ? String(u.createdAt) : '', + }) )} + onEdit={handleEdit} + loading={loading} + canEdit={canEdit} + /> + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.stories.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.stories.tsx new file mode 100644 index 000000000..7687f8a38 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, within } from 'storybook/test'; +import { type StaffUser, StaffUsersList } from './staff-users-list.tsx'; + +const mockUsers: StaffUser[] = [ + { + id: '1', + displayName: 'Alice Admin', + email: 'alice@example.com', + role: { id: 'r1', roleName: 'Tech Admin' }, + createdAt: '2024-01-01T00:00:00Z', + }, + { + id: '2', + displayName: 'Bob Manager', + email: 'bob@example.com', + role: null, + createdAt: '2024-02-01T00:00:00Z', + }, +]; + +const meta: Meta = { + title: 'UserManagement/Components/StaffUsersList', + component: StaffUsersList, + parameters: { layout: 'padded' }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: mockUsers, + onEdit: (id) => console.log('Edit user:', id), + canEdit: true, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText('Staff Users (2)')).toBeInTheDocument(); + expect(canvas.getByText('Alice Admin')).toBeInTheDocument(); + expect(canvas.getByText('Bob Manager')).toBeInTheDocument(); + expect(canvas.getByText('Tech Admin')).toBeInTheDocument(); + expect(canvas.getByText('No Role')).toBeInTheDocument(); + }, +}; + +export const EmptyState: Story = { + args: { + data: [], + onEdit: (id) => console.log('Edit user:', id), + canEdit: true, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText('Staff Users (0)')).toBeInTheDocument(); + }, +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.tsx new file mode 100644 index 000000000..443bfa2f4 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.tsx @@ -0,0 +1,71 @@ +import type { TableColumnsType } from 'antd'; +import { Button, Space, Table, Typography } from 'antd'; +import type React from 'react'; + +const { Title } = Typography; + +export interface StaffUser { + id: string; + displayName: string; + email: string; + role?: { id: string; roleName: string } | null; + createdAt: string; +} + +interface StaffUsersListProps { + data: StaffUser[]; + onEdit: (id: string) => void; + canEdit?: boolean; + loading?: boolean; +} + +export const StaffUsersList: React.FC = ({ data, onEdit, canEdit = false, loading }) => { + const columns: TableColumnsType = [ + { title: 'Display Name', dataIndex: 'displayName', key: 'displayName' }, + { title: 'Email', dataIndex: 'email', key: 'email' }, + { + title: 'Role', + key: 'role', + render: (_: unknown, record: StaffUser) => record.role?.roleName ?? 'No Role', + }, + { + title: 'Created At', + dataIndex: 'createdAt', + key: 'createdAt', + render: (date: string) => (date ? new Date(date).toLocaleDateString() : 'N/A'), + }, + { + title: 'Action', + key: 'action', + render: (_: unknown, record: StaffUser) => ( + canEdit ? ( + + ) : null + ), + }, + ]; + + return ( + +
+ Staff Users ({data.length}) +
+ + + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/index.tsx b/packages/ocom/ui-staff-route-user-management/src/index.tsx index 33b2a3f38..1c60eb065 100644 --- a/packages/ocom/ui-staff-route-user-management/src/index.tsx +++ b/packages/ocom/ui-staff-route-user-management/src/index.tsx @@ -1,6 +1,6 @@ -import { PlaceholderPage } from '@ocom/ui-staff-shared'; import type React from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { UserManagementPage } from './pages/user-management.tsx'; import { SectionLayout } from './section-layout.tsx'; export const Root: React.FC = () => { @@ -13,22 +13,15 @@ export const Root: React.FC = () => { } /> - } + element={} /> diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.stories.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.stories.tsx new file mode 100644 index 000000000..f7ffbaeb9 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Route, Routes } from 'react-router-dom'; +import { StaffRoleCreateDocument, StaffRolesListDocument } from '../generated.tsx'; +import { StaffRolesPage } from './staff-roles.tsx'; + +const mockStaffRoles = [ + { id: 'r1', roleName: 'Case Manager', enterpriseAppRole: 'Staff.CaseManager', createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-15T00:00:00.000Z' }, + { id: 'r2', roleName: 'Finance', enterpriseAppRole: 'Staff.Finance', createdAt: '2024-01-02T00:00:00.000Z', updatedAt: '2024-01-15T00:00:00.000Z' }, +]; + +const listMock = { + request: { query: StaffRolesListDocument }, + result: { data: { staffRoles: mockStaffRoles } }, +}; + +const createMock = { + request: { query: StaffRoleCreateDocument, variables: { input: { roleName: 'New Role' } } }, + result: { data: { staffRoleCreate: { status: { success: true, errorMessage: null }, staffRole: { id: 'r3', roleName: 'New Role', enterpriseAppRole: '' } } } }, +}; + +const meta: Meta = { + title: 'UserManagement/Pages/StaffRolesPage', + component: StaffRolesPage, + parameters: { + layout: 'padded', + memoryRouter: { + initialEntries: ['/'], + }, + apolloMocks: [listMock, createMock], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + } + /> + + ), +}; + +export const CreateView: Story = { + parameters: { + memoryRouter: { + initialEntries: ['/create'], + }, + apolloMocks: [listMock, createMock], + }, + render: () => ( + + } + /> + + ), +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.tsx new file mode 100644 index 000000000..35f60fd6b --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.tsx @@ -0,0 +1,46 @@ +import type React from 'react'; +import { useContext } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { StaffAuthContext } from '@ocom/ui-staff-shared'; +import { StaffRoleCreateContainer } from '../components/staff-role-create.container.tsx'; +import { StaffRoleEditContainer } from '../components/staff-role-edit.container.tsx'; +import { StaffRolesListContainer } from '../components/staff-roles-list.container.tsx'; + +export const StaffRolesPage: React.FC = () => { + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canViewRoles = + perms?.canViewRoles === true || + perms?.canAddRole === true || + perms?.canEditRole === true || + perms?.canRemoveRole === true || + perms?.canManageStaffRolesAndPermissions === true || + perms?.canManageTechAdmin === true; + const canViewStaffUsers = perms?.canViewStaffUsers === true || perms?.canManageUsers === true || perms?.canManageTechAdmin === true; + + if (!canViewRoles) { + return ( + + ); + } + + return ( + + } + /> + } + /> + } + /> + + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.stories.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.stories.tsx new file mode 100644 index 000000000..b48aad2f6 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Route, Routes } from 'react-router-dom'; +import { StaffUsersListDocument } from '../generated.tsx'; +import { StaffUsersPage } from './staff-users.tsx'; + +const mockStaffUsers = [ + { id: '1', displayName: 'Alice Admin', email: 'alice@example.com', createdAt: '2024-01-01T00:00:00.000Z', role: { id: 'r1', roleName: 'Case Manager' } }, + { id: '2', displayName: 'Bob Staff', email: 'bob@example.com', createdAt: '2024-02-01T00:00:00.000Z', role: null }, +]; + +const meta: Meta = { + title: 'UserManagement/Pages/StaffUsersPage', + component: StaffUsersPage, + parameters: { + layout: 'padded', + memoryRouter: { + initialEntries: ['/'], + }, + apolloMocks: [ + { + request: { query: StaffUsersListDocument }, + result: { data: { staffUsers: mockStaffUsers } }, + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + } + /> + + ), +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.tsx new file mode 100644 index 000000000..7f7980e68 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.tsx @@ -0,0 +1,41 @@ +import type React from 'react'; +import { useContext } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { StaffAuthContext } from '@ocom/ui-staff-shared'; +import { StaffUserDetailContainer } from '../components/staff-user-detail.container.tsx'; +import { StaffUsersListContainer } from '../components/staff-users-list.container.tsx'; + +export const StaffUsersPage: React.FC = () => { + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canViewStaffUsers = perms?.canViewStaffUsers === true || perms?.canManageUsers === true || perms?.canManageTechAdmin === true; + const canViewRoles = + perms?.canViewRoles === true || + perms?.canAddRole === true || + perms?.canEditRole === true || + perms?.canRemoveRole === true || + perms?.canManageStaffRolesAndPermissions === true || + perms?.canManageTechAdmin === true; + + if (!canViewStaffUsers) { + return ( + + ); + } + + return ( + + } + /> + } + /> + + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/user-management.stories.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/user-management.stories.tsx new file mode 100644 index 000000000..56ea7b59e --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/user-management.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Route, Routes } from 'react-router-dom'; +import { UserManagementPage } from './user-management.tsx'; + +const meta: Meta = { + title: 'UserManagement/Pages/UserManagementPage', + component: UserManagementPage, + parameters: { + layout: 'padded', + memoryRouter: { + initialEntries: ['/staff-users'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const StaffUsersTab: Story = { + render: () => ( + + } + /> + + ), +}; + +export const StaffRolesTab: Story = { + parameters: { + memoryRouter: { + initialEntries: ['/staff-roles'], + }, + }, + render: () => ( + + } + /> + + ), +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/user-management.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/user-management.tsx new file mode 100644 index 000000000..230a35c30 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/user-management.tsx @@ -0,0 +1,66 @@ +import { SafetyOutlined, UserOutlined } from '@ant-design/icons'; +import { SubPageLayout, VerticalTabs } from '@ocom/ui-staff-shared'; +import { StaffAuthContext } from '@ocom/ui-staff-shared'; +import type React from 'react'; +import { useContext } from 'react'; +import { Navigate } from 'react-router-dom'; +import { StaffRolesPage } from './staff-roles.tsx'; +import { StaffUsersPage } from './staff-users.tsx'; + +export const UserManagementPage: React.FC = () => { + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canViewStaffUsers = perms?.canViewStaffUsers === true || perms?.canManageUsers === true || perms?.canManageStaffRolesAndPermissions === true || perms?.canManageTechAdmin === true; + const canViewRoles = + perms?.canViewRoles === true || + perms?.canAddRole === true || + perms?.canEditRole === true || + perms?.canRemoveRole === true || + perms?.canManageStaffRolesAndPermissions === true || + perms?.canManageTechAdmin === true; + + const pages = [ + ...(canViewStaffUsers + ? [ + { + id: 'staff-users', + link: 'staff-users', + path: 'staff-users/*', + title: 'Staff Users', + icon: , + element: , + }, + ] + : []), + ...(canViewRoles + ? [ + { + id: 'staff-roles', + link: 'staff-roles', + path: 'staff-roles/*', + title: 'Staff Roles', + icon: , + element: , + }, + ] + : []), + ]; + + if (pages.length === 0) { + return ( + + ); + } + + return ( + User Management} + > + + + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/vitest.config.ts b/packages/ocom/ui-staff-route-user-management/vitest.config.ts index 17bec4371..0b6d005dc 100644 --- a/packages/ocom/ui-staff-route-user-management/vitest.config.ts +++ b/packages/ocom/ui-staff-route-user-management/vitest.config.ts @@ -1,12 +1,10 @@ -import { baseConfig } from '@cellix/config-vitest'; -import { defineConfig, mergeConfig } from 'vitest/config'; +import { createStorybookVitestConfig, getDirnameFromImportMetaUrl } from '@cellix/config-vitest'; +import { defineConfig } from 'vitest/config'; -export default mergeConfig( - baseConfig, - defineConfig({ - test: { - environment: 'jsdom', - passWithNoTests: true, - }, +const dirname = getDirnameFromImportMetaUrl(import.meta.url); + +export default defineConfig( + createStorybookVitestConfig(dirname, { + additionalCoverageExclude: ['src/index.tsx'], }), ); diff --git a/packages/ocom/ui-staff-shared/package.json b/packages/ocom/ui-staff-shared/package.json index 7d5f8cdc6..8b29ada7c 100644 --- a/packages/ocom/ui-staff-shared/package.json +++ b/packages/ocom/ui-staff-shared/package.json @@ -16,7 +16,10 @@ "test:watch": "vitest" }, "dependencies": { + "@apollo/client": "^3.13.9", "@ant-design/icons": "catalog:", + "@cellix/ui-core": "workspace:*", + "@graphql-typed-document-node/core": "^3.2.0", "@ocom/ui-shared": "workspace:*", "@graphql-typed-document-node/core": "^3.2.0", "react": "catalog:", @@ -30,6 +33,7 @@ "@types/react": "^19.1.11", "@types/react-dom": "^19.1.6", "jsdom": "catalog:", + "react-dom": "^19.1.1", "vite": "catalog:", "vitest": "catalog:", "typescript": "catalog:" diff --git a/packages/ocom/ui-staff-shared/src/index.tsx b/packages/ocom/ui-staff-shared/src/index.tsx index ede529df0..d28d20e4c 100644 --- a/packages/ocom/ui-staff-shared/src/index.tsx +++ b/packages/ocom/ui-staff-shared/src/index.tsx @@ -2,7 +2,10 @@ import React, { createElement, type FC } from 'react'; import { SectionLayout } from './section-layout.tsx'; export { VerticalTabs } from '@ocom/ui-shared'; +export { RequireRole, type RequireRoleProps } from './require-role.tsx'; export { SectionLayout, type SectionLayoutProps } from './section-layout.tsx'; +export { SectionLayoutContainer } from './section-layout.container.tsx'; +export { extractRoles, type StaffAppRole, StaffAppRoles, staffRouteRoles } from './staff-app-roles.ts'; export { type StaffAuth, StaffAuthContext, StaffAuthProvider, StaffRouteShell, type StaffRouteShellProps } from './staff-route-shell.tsx'; export { SubPageLayout } from './sub-page-layout.tsx'; @@ -19,20 +22,13 @@ import { StaffAuthContext } from './staff-route-shell.tsx'; export const PlaceholderPage: React.FC = ({ sectionName, description, expectedRoles, explicitRoles }) => { const auth = React.useContext(StaffAuthContext); - const resolvedRoles = React.useMemo(() => { + const resolvedPermissions = React.useMemo(() => { if (explicitRoles && explicitRoles.length > 0) return explicitRoles; - if (auth) { - const a = auth as StaffAuth; - if (Array.isArray(a.roles) && a.roles.length > 0) return a.roles as string[]; - type RawProfile = { roles?: unknown; role?: unknown }; - const raw = a.raw as RawProfile | undefined; - if (raw) { - const maybe = raw.roles ?? raw.role ?? undefined; - if (Array.isArray(maybe)) return maybe as string[]; - if (typeof maybe === 'string') return [maybe]; - } - } - return []; + const perms = auth?.permissions; + if (!perms) return []; + return Object.entries(perms) + .filter(([, isEnabled]) => isEnabled === true) + .map(([permKey]) => permKey); }, [auth, explicitRoles]); const identitySummary = React.useMemo<{ displayName: string; identifier: string | undefined } | null>(() => { @@ -70,11 +66,11 @@ export const PlaceholderPage: React.FC = ({ sectionName, descr )}
-
Resolved Roles
- {resolvedRoles && resolvedRoles.length > 0 ? ( +
Resolved Permissions
+ {resolvedPermissions.length > 0 ? (
    - {resolvedRoles.map((r) => ( -
  • {r}
  • + {resolvedPermissions.map((permission) => ( +
  • {permission}
  • ))}
) : ( diff --git a/packages/ocom/ui-staff-shared/src/require-role.test.tsx b/packages/ocom/ui-staff-shared/src/require-role.test.tsx new file mode 100644 index 000000000..1353ab8d1 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/require-role.test.tsx @@ -0,0 +1,132 @@ +import type * as React from 'react'; +import { renderToString } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RequireRole } from './require-role.tsx'; +import { StaffAuthProvider } from './staff-route-shell.tsx'; + +const useQueryMock = vi.fn(); +vi.mock('@apollo/client', () => ({ + gql: (strings: TemplateStringsArray, ...values: unknown[]) => String.raw({ raw: strings }, ...values), + useQuery: (...args: unknown[]) => useQueryMock(...args), +})); + +const Protected: React.FC = () =>
protected content
; + +describe('RequireRole', () => { + beforeEach(() => { + useQueryMock.mockReset(); + }); + + it('renders children when the permission key is true', () => { + useQueryMock.mockReturnValue({ + loading: false, + error: undefined, + data: { + staffUserCurrent: { + role: { + permissions: { + communityPermissions: { canManageCommunities: false, canManageStaffRolesAndPermissions: false }, + userPermissions: { canManageUsers: false, canAssignStaffRoles: false, canViewStaffUsers: false }, + staffRolePermissions: { canViewRoles: false, canAddRole: false, canEditRole: false, canRemoveRole: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: true }, + }, + }, + }, + }, + }); + const identity = {}; + const html = renderToString( + + + + + + + , + ); + expect(html).toContain('protected content'); + }); + + it('redirects to /unauthorized when the permission key is false', () => { + useQueryMock.mockReturnValue({ + loading: false, + error: undefined, + data: { + staffUserCurrent: { + role: { + permissions: { + communityPermissions: { canManageCommunities: false, canManageStaffRolesAndPermissions: false }, + userPermissions: { canManageUsers: false, canAssignStaffRoles: false, canViewStaffUsers: false }, + staffRolePermissions: { canViewRoles: false, canAddRole: false, canEditRole: false, canRemoveRole: false }, + financePermissions: { canManageFinance: true }, + techAdminPermissions: { canManageTechAdmin: false }, + }, + }, + }, + }, + }); + const identity = {}; + const html = renderToString( + + + + + + + , + ); + expect(html).not.toContain('protected content'); + }); + + it('redirects to /unauthorized when query returns an error', () => { + useQueryMock.mockReturnValue({ + loading: false, + error: new Error('network error'), + data: undefined, + }); + const identity = {}; + const html = renderToString( + + + + + + + , + ); + expect(html).not.toContain('protected content'); + }); + + it('does not render protected content while loading', () => { + useQueryMock.mockReturnValue({ + loading: true, + error: undefined, + data: undefined, + }); + const identity = {}; + const html = renderToString( + + + + + + + , + ); + expect(html).not.toContain('protected content'); + }); +}); diff --git a/packages/ocom/ui-staff-shared/src/require-role.tsx b/packages/ocom/ui-staff-shared/src/require-role.tsx new file mode 100644 index 000000000..ae363ad57 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/require-role.tsx @@ -0,0 +1,98 @@ +import { gql, useQuery } from '@apollo/client'; +import type { FC, ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import type { StaffAuth } from './staff-route-shell.tsx'; + +export interface RequireRoleProps { + /** Deprecated. Frontend authorization must use backend permission flags. */ + roles: readonly string[]; + /** Gate by backend permission flag. */ + permKey?: keyof NonNullable; + children: ReactNode; +} + +const STAFF_USER_CURRENT_QUERY = gql` + query RequireRoleStaffUserCurrent { + staffUserCurrent: currentStaffUserAndCreateIfNotExists { + role { + permissions { + communityPermissions { + canManageCommunities + canManageStaffRolesAndPermissions + } + userPermissions { + canManageUsers + canAssignStaffRoles + canViewStaffUsers + } + staffRolePermissions { + canViewRoles + canAddRole + canEditRole + canRemoveRole + } + financePermissions { + canManageFinance + } + techAdminPermissions { + canManageTechAdmin + } + } + } + } + } +`; + +interface StaffUserCurrentQueryResult { + staffUserCurrent: { + role?: { + permissions: { + communityPermissions: { canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean }; + userPermissions: { canManageUsers: boolean; canAssignStaffRoles: boolean; canViewStaffUsers: boolean }; + staffRolePermissions: { canViewRoles: boolean; canAddRole: boolean; canEditRole: boolean; canRemoveRole: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + }; + }; + }; +} + +export const RequireRole: FC = ({ roles, permKey, children }) => { + void roles; + const { data, loading, error } = useQuery(STAFF_USER_CURRENT_QUERY, { + fetchPolicy: 'cache-first', + }); + + if (loading) { + return null; + } + + const rolePermissions = data?.staffUserCurrent?.role?.permissions; + const permissions: NonNullable | undefined = rolePermissions + ? { + canManageCommunities: rolePermissions.communityPermissions.canManageCommunities, + canManageStaffRolesAndPermissions: rolePermissions.communityPermissions.canManageStaffRolesAndPermissions, + canManageUsers: rolePermissions.userPermissions.canManageUsers, + canAssignStaffRoles: rolePermissions.userPermissions.canAssignStaffRoles, + canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers, + canManageFinance: rolePermissions.financePermissions.canManageFinance, + canManageTechAdmin: rolePermissions.techAdminPermissions.canManageTechAdmin, + canViewRoles: rolePermissions.staffRolePermissions.canViewRoles, + canAddRole: rolePermissions.staffRolePermissions.canAddRole, + canEditRole: rolePermissions.staffRolePermissions.canEditRole, + canRemoveRole: rolePermissions.staffRolePermissions.canRemoveRole, + } + : undefined; + const isAuthorized = permKey !== undefined && permissions?.[permKey] === true; + + if (error || !isAuthorized) { + return ( + + ); + } + + return <>{children}; +}; diff --git a/packages/ocom/ui-staff-shared/src/section-layout-header.graphql b/packages/ocom/ui-staff-shared/src/section-layout-header.graphql new file mode 100644 index 000000000..e744d3fde --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/section-layout-header.graphql @@ -0,0 +1,13 @@ +query SectionLayoutHeaderCurrentStaffUser { + currentStaffUserAndCreateIfNotExists { + ...SectionLayoutHeaderStaffUserFields + } +} + +fragment SectionLayoutHeaderStaffUserFields on StaffUser { + id + displayName + firstName + lastName + email +} diff --git a/packages/ocom/ui-staff-shared/src/section-layout.container.tsx b/packages/ocom/ui-staff-shared/src/section-layout.container.tsx new file mode 100644 index 000000000..4209b71d6 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/section-layout.container.tsx @@ -0,0 +1,50 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import type { PageLayoutProps } from '@ocom/ui-shared'; +import type React from 'react'; +import { SectionLayoutHeaderCurrentStaffUserDocument } from './generated.tsx'; +import { SectionLayout } from './section-layout.tsx'; + +interface SectionLayoutContainerProps { + pageLayouts: PageLayoutProps[]; +} + +export const SectionLayoutContainer: React.FC = (props) => { + const { data: staffUserData, loading: staffUserLoading, error: staffUserError } = useQuery( + SectionLayoutHeaderCurrentStaffUserDocument, + { + fetchPolicy: 'cache-first', + }, + ); + + const displayName = staffUserData?.currentStaffUserAndCreateIfNotExists?.displayName; + + // Debug logging to track displayName flow + if (typeof window !== 'undefined' && typeof window.location !== 'undefined') { + const href = window.location.href; + if (href.includes('dev') || href.includes('localhost')) { + console.debug('[SectionLayoutContainer] GraphQL query result:', { + loading: staffUserLoading, + error: staffUserError?.message, + staffUserData, + extractedDisplayName: displayName, + }); + } + } + + const sectionLayoutProps: React.ComponentProps = { + pageLayouts: props.pageLayouts, + // Always pass displayName (even if undefined) so the component can properly handle fallback chain + ...(displayName && { displayName }), + }; + + return ( + } + error={staffUserError} + /> + ); +}; + diff --git a/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx b/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx index bd43e89e1..b244861a1 100644 --- a/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx +++ b/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx @@ -18,36 +18,81 @@ const renderIntoDocument = (node: React.ReactNode) => { }; describe('SectionLayout merging behaviour', () => { - it('renders canonical staff navigation merged with consumer pageLayouts', async () => { - const consumerLayouts = [ - { - path: '/staff/community-management', - title: 'Community Management', - icon: , - id: 'ROOT', - }, - ]; + it('renders only the menu items the user has permission for', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Communities'); + expect(container.textContent).not.toContain('Users'); + expect(container.textContent).toContain('Finance'); + expect(container.textContent).not.toContain('Tech Admin'); + }); + it('shows no menu items when permissions are undefined (loading or no role assigned)', async () => { const container = renderIntoDocument( } + element={} /> , ); - // Wait a tick for ant design components to mount await new Promise((r) => setTimeout(r, 10)); - // Top-level menu items expected - expect(container.textContent).not.toContain('Home'); - expect(container.textContent).toContain('Communities'); + expect(container.textContent).not.toContain('Communities'); + expect(container.textContent).not.toContain('Users'); + expect(container.textContent).not.toContain('Finance'); + expect(container.textContent).not.toContain('Tech Admin'); + }); + + it('renders finance menu from JWT role when backend permissions are unavailable', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).not.toContain('Communities'); expect(container.textContent).toContain('Users'); expect(container.textContent).toContain('Finance'); - expect(container.textContent).toContain('Tech Admin'); + expect(container.textContent).not.toContain('Tech Admin'); }); it('preserves default parent when consumer entry omits parent field', async () => { @@ -63,12 +108,23 @@ describe('SectionLayout merging behaviour', () => { const container = renderIntoDocument( - - } - /> - + + + } + /> + + , ); @@ -142,3 +198,93 @@ describe('PlaceholderPage', () => { expect(container.textContent).toContain('m@example.com'); }); }); + +describe('SectionLayout with displayName prop', () => { + it('renders displayName from prop when provided', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Alice Johnson'); + expect(container.textContent).toContain('Log Out'); + }); + + it('falls back to auth context name when displayName prop is not provided', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Bob Smith'); + }); + + it('uses displayName prop over auth context when both are available', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Prop Name'); + expect(container.textContent).not.toContain('Auth Name'); + }); +}); diff --git a/packages/ocom/ui-staff-shared/src/section-layout.tsx b/packages/ocom/ui-staff-shared/src/section-layout.tsx index cd27b722f..400f31983 100644 --- a/packages/ocom/ui-staff-shared/src/section-layout.tsx +++ b/packages/ocom/ui-staff-shared/src/section-layout.tsx @@ -30,10 +30,27 @@ export interface SectionLayoutProps { headerContent?: React.ReactNode; /** Optional injected logged in user component (extension slot). */ loggedInUser?: React.ReactNode; + /** Optional displayName from container (e.g., from GraphQL query). When provided, takes priority over auth context. */ + displayName?: string; } export const SectionLayout: React.FC = (props) => { const auth = useContext(StaffAuthContext); + + // Debug logging to track displayName flow + if (typeof window !== 'undefined' && typeof window.location !== 'undefined') { + const href = window.location.href; + if (href.includes('dev') || href.includes('localhost')) { + console.debug('[SectionLayout] Component props & fallback chain:', { + propsDisplayName: props.displayName, + authName: auth?.name, + authUsername: auth?.username, + authEmail: auth?.email, + resolvedDisplayName: props.displayName || auth?.name || auth?.username || auth?.email || 'Staff User', + }); + } + } + // Guard access to localStorage so this component is safe during server-side rendering (no globalThis/localStorage) const [isExpanded, setIsExpanded] = useState(() => { if (typeof globalThis === 'undefined') return true; // default to expanded during SSR @@ -54,35 +71,48 @@ export const SectionLayout: React.FC = (props) => { // Merge canonical staff navigation with consumer-provided pageLayouts. // Defaults are added only when the consumer hasn't provided an entry with the same id. // Consumer-provided entries override defaults when ids conflict. - const defaultPageLayouts: PageLayoutProps[] = [ - { - path: '/staff/community-management', - title: 'Communities', - icon: , - id: 'ROOT', - }, - { - path: '/staff/user-management/*', - title: 'Users', - icon: , - id: 'users', - parent: 'ROOT', - }, - { - path: '/staff/finance/*', - title: 'Finance', - icon: , - id: 'finance', - parent: 'ROOT', - }, - { - path: '/staff/tech/*', - title: 'Tech Admin', - icon: , - id: 'tech', - parent: 'ROOT', - }, - ]; + // Build default page layouts from backend permissions. + const perms = auth?.permissions; + const canManageCommunities = perms?.canManageCommunities === true; + const canManageStaffRolesAndPermissions = perms?.canManageStaffRolesAndPermissions === true; + const canManageUsers = perms?.canManageUsers === true; + const canAssignStaffRoles = perms?.canAssignStaffRoles === true; + const canViewStaffUsers = perms?.canViewStaffUsers === true; + const canManageFinance = perms?.canManageFinance === true; + const canManageTechAdmin = perms?.canManageTechAdmin === true; + const canViewRoles = perms?.canViewRoles === true; + const canAddRole = perms?.canAddRole === true; + const canEditRole = perms?.canEditRole === true; + const canRemoveRole = perms?.canRemoveRole === true; + const nestedParentProps = canManageCommunities ? { parent: 'ROOT' as const } : {}; + const canAccessUserManagement = + canManageUsers || canAssignStaffRoles || canViewStaffUsers || canManageStaffRolesAndPermissions || canViewRoles || canAddRole || canEditRole || canRemoveRole || canManageTechAdmin; + + // Construct default page layouts ensuring a ROOT entry always exists so MenuComponent renders. + // If Communities is allowed, keep the historic behaviour: Communities is ROOT and others are its children. + // Otherwise, promote the first available section to ROOT so a finance-only user sees a single Finance item. + const defaultPageLayouts: PageLayoutProps[] = []; + + if (canManageCommunities) { + // Communities as canonical root, others as children + defaultPageLayouts.push({ path: '/staff/community-management', title: 'Communities', icon: , id: 'ROOT' }); + if (canAccessUserManagement) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', ...nestedParentProps }); + if (canManageFinance) defaultPageLayouts.push({ path: '/staff/finance/*', title: 'Finance', icon: , id: 'finance', ...nestedParentProps }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', ...nestedParentProps }); + } else { + // No Communities root. Promote the first available section to ROOT to render a single top-level item. + if (canManageFinance) { + defaultPageLayouts.push({ path: '/staff/finance/*', title: 'Finance', icon: , id: 'ROOT' }); + // add others as children if present + if (canAccessUserManagement) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', parent: 'ROOT' }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' }); + } else if (canAccessUserManagement) { + defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'ROOT' }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' }); + } else if (canManageTechAdmin) { + defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'ROOT' }); + } + } // Build a map from default entries, then overlay consumer entries so consumers can override defaults. // When consumers provide an entry with the same id, merge it with the default so that @@ -160,7 +190,7 @@ export const SectionLayout: React.FC = (props) => { marginLeft: 'auto', }} > - Staff User + {props.displayName || auth?.name || auth?.username || auth?.email || 'Staff User'}