Context
Steps 142–148 added a full permission system: group-role-based access (read/write/admin) computed from group membership + ancestor chains, an optional per-user ACL, two permission manager modes (owner vs group_admin), enforcement inside WorkflowEngine (requiredAccess) and DatabasePersistor (write guard), and 10-minute caching in EventProcessorWorker. No tests exist yet for any of this logic. This issue covers the files needed to give the system full coverage.
Files to Create
1. apps/api/src/app/services/permission.service.spec.ts — Unit tests
Uses MongoMemoryServer + Mongoose models directly. No HTTP layer.
getEffectiveGroupIds
- User with no memberships →
[]
- User in one group with no ancestors →
[groupId]
- User in group with a parent → includes both IDs
- User in group with grandparent chain → includes all three IDs
- User in two disjoint groups → includes all IDs from both trees
computeAccessLevel — owner mode (permissionManagerMode: 'owner')
doc.userId === userId → short-circuits to 'admin'
- Non-owner, empty ACL, no group perms →
'none'
- Non-owner with
userPermissions entry of 'read' → 'read'
- Non-owner with
userPermissions entry of 'write' → 'write'
- Non-owner with
userPermissions entry of 'admin' → 'admin'
- User in a group that has
'read' in doc.permissions, no user ACL → 'read'
- User ACL
'read', group 'write' → 'write' (max wins)
- User ACL
'write', group 'read' → 'write' (max wins)
- User in child group; parent group in
doc.permissions with 'write' → 'write' (ancestor inheritance)
computeAccessLevel — group_admin mode (permissionManagerMode: 'group_admin')
doc.userId === userId but mode is group_admin → NOT auto-admin; falls through to ACL check
- Owner with no ACL entry →
'none'
- Owner with
'write' in userPermissions → 'write'
- Unrelated user in doc's group with
'admin' → 'admin'
canManagePermissions — owner mode
doc.userId === userId → true
- Different user, no
doc.groupId → false
- Different user, in
doc.groupId group with role 'member' → false
- Different user, in
doc.groupId group with role 'admin' → true
- Different user, in
doc.groupId group with role 'owner' → true
canManagePermissions — group_admin mode
- Caller in
doc.groupId with 'admin' → true
- Caller in
doc.groupId with 'owner' → true
- Caller in
doc.groupId with 'member' → false
doc.userId === userId but caller not in group → false (NOT auto-manage)
doc.groupId is null → false
2. apps/api-e2e/src/permissions.spec.ts — E2E HTTP tests
Follows the same MongoMemoryServer + supertest pattern as users.spec.ts. Set up multiple users (owner, otherUser, groupAdmin) and real Group/Membership documents in beforeEach.
GET /api/documents/:id
- Owner → 200
- User with
'read' user ACL → 200
- User with group
'read' access → 200
- User with no permissions → 403
- Unauthenticated → 401
GET /api/documents — filtering
- Returns owned documents
- Returns documents where user has a user ACL entry
- Returns documents where user has group access
- Does NOT return documents where user has no access
POST /api/documents — group admin flow
- Group admin +
groupId + targetUserId → 201; response has permissionManagerMode: 'group_admin', group gets 'admin', targetUser gets 'write' in userPermissions
- Ancestor group gets
'read' in permissions
- Non-member with
targetUserId → 403
- Group member (role
'member') with targetUserId → 403
- Missing
groupId with targetUserId → 400
PATCH /api/documents/:id/user-permissions — grant permission
- Owner grants
'read' → 200, userPermissions updated
- Owner grants
'write' → 200
- Owner grants
'admin' → 200
- Granting same userId twice upserts (no duplicate) → 200
- Non-owner without manage rights → 403
- Owner cannot grant
'admin' when own level is only 'write' → 403 (Cannot grant access level higher than your own)
- Invalid
access value → 400
DELETE /api/documents/:id/user-permissions/:userId — revoke permission
- Owner removes existing entry → 204
- Non-owner without manage rights → 403
- Removing non-existent userId → 404
3. apps/api/src/app/websocket/access-level-cache.ts + access-level-cache.spec.ts
Extract the ACCESS_LEVEL_CACHE map and TTL logic from EventProcessorWorker.ts into a createAccessLevelCache(ttlMs) factory. No behavioral change — purely structural.
Cache tests use jest.useFakeTimers():
- First call invokes
compute once and returns the result
- Second call within TTL returns cached level,
compute NOT called again
- Call after TTL expires re-invokes
compute
- Different
userId or channel uses separate cache entries
Files to Modify
4. apps/api/src/app/websocket/WorkflowEngine.spec.ts — requiredAccess guard
Add handlers with requiredAccess: 'write' and requiredAccess: 'admin' to the test config. Extend makeContext to accept optional permissionLevel.
permissionLevel: 'read', requires 'write' → NOT executed
permissionLevel: 'write', requires 'write' → executed
permissionLevel: 'admin', requires 'write' → executed (admin ≥ write)
permissionLevel: 'write', requires 'admin' → NOT executed
permissionLevel absent, requires 'write' → NOT executed
- No
requiredAccess, any level → executed
5. apps/api/src/app/websocket/DatabasePersistor.spec.ts — write guard
permissionLevel: 'none' → DB write skipped
permissionLevel: 'read' → DB write skipped
permissionLevel: 'write' → DB write proceeds
permissionLevel: 'admin' → DB write proceeds
Context
Steps 142–148 added a full permission system: group-role-based access (read/write/admin) computed from group membership + ancestor chains, an optional per-user ACL, two permission manager modes (
ownervsgroup_admin), enforcement inside WorkflowEngine (requiredAccess) and DatabasePersistor (write guard), and 10-minute caching in EventProcessorWorker. No tests exist yet for any of this logic. This issue covers the files needed to give the system full coverage.Files to Create
1.
apps/api/src/app/services/permission.service.spec.ts— Unit testsUses MongoMemoryServer + Mongoose models directly. No HTTP layer.
getEffectiveGroupIds[][groupId]computeAccessLevel— owner mode (permissionManagerMode: 'owner')doc.userId === userId→ short-circuits to'admin''none'userPermissionsentry of'read'→'read'userPermissionsentry of'write'→'write'userPermissionsentry of'admin'→'admin''read'indoc.permissions, no user ACL →'read''read', group'write'→'write'(max wins)'write', group'read'→'write'(max wins)doc.permissionswith'write'→'write'(ancestor inheritance)computeAccessLevel— group_admin mode (permissionManagerMode: 'group_admin')doc.userId === userIdbut mode isgroup_admin→ NOT auto-admin; falls through to ACL check'none''write'inuserPermissions→'write''admin'→'admin'canManagePermissions— owner modedoc.userId === userId→truedoc.groupId→falsedoc.groupIdgroup with role'member'→falsedoc.groupIdgroup with role'admin'→truedoc.groupIdgroup with role'owner'→truecanManagePermissions— group_admin modedoc.groupIdwith'admin'→truedoc.groupIdwith'owner'→truedoc.groupIdwith'member'→falsedoc.userId === userIdbut caller not in group →false(NOT auto-manage)doc.groupIdis null →false2.
apps/api-e2e/src/permissions.spec.ts— E2E HTTP testsFollows the same MongoMemoryServer + supertest pattern as
users.spec.ts. Set up multiple users (owner, otherUser, groupAdmin) and real Group/Membership documents inbeforeEach.GET /api/documents/:id'read'user ACL → 200'read'access → 200GET /api/documents— filteringPOST /api/documents— group admin flowgroupId+targetUserId→ 201; response haspermissionManagerMode: 'group_admin', group gets'admin', targetUser gets'write'inuserPermissions'read'inpermissionstargetUserId→ 403'member') withtargetUserId→ 403groupIdwithtargetUserId→ 400PATCH /api/documents/:id/user-permissions— grant permission'read'→ 200,userPermissionsupdated'write'→ 200'admin'→ 200'admin'when own level is only'write'→ 403 (Cannot grant access level higher than your own)accessvalue → 400DELETE /api/documents/:id/user-permissions/:userId— revoke permission3.
apps/api/src/app/websocket/access-level-cache.ts+access-level-cache.spec.tsExtract the
ACCESS_LEVEL_CACHEmap and TTL logic fromEventProcessorWorker.tsinto acreateAccessLevelCache(ttlMs)factory. No behavioral change — purely structural.Cache tests use
jest.useFakeTimers():computeonce and returns the resultcomputeNOT called againcomputeuserIdorchanneluses separate cache entriesFiles to Modify
4.
apps/api/src/app/websocket/WorkflowEngine.spec.ts— requiredAccess guardAdd handlers with
requiredAccess: 'write'andrequiredAccess: 'admin'to the test config. ExtendmakeContextto accept optionalpermissionLevel.permissionLevel: 'read', requires'write'→ NOT executedpermissionLevel: 'write', requires'write'→ executedpermissionLevel: 'admin', requires'write'→ executed (admin ≥ write)permissionLevel: 'write', requires'admin'→ NOT executedpermissionLevelabsent, requires'write'→ NOT executedrequiredAccess, any level → executed5.
apps/api/src/app/websocket/DatabasePersistor.spec.ts— write guardpermissionLevel: 'none'→ DB write skippedpermissionLevel: 'read'→ DB write skippedpermissionLevel: 'write'→ DB write proceedspermissionLevel: 'admin'→ DB write proceeds