1- import { AccessGroupEntity , UserEntity } from '@kleinkram/backend-common' ;
2- import { AccessGroupRights , AccessGroupType } from '@kleinkram/shared' ;
1+ import {
2+ AccessGroupEntity ,
3+ AccessGroupEventEntity ,
4+ GroupMembershipEntity ,
5+ UserEntity ,
6+ } from '@kleinkram/backend-common' ;
7+ import {
8+ AccessGroupEventType ,
9+ AccessGroupRights ,
10+ AccessGroupType ,
11+ } from '@kleinkram/shared' ;
312import {
413 createAccessGroupUsingPost ,
514 createProjectUsingPost ,
@@ -11,6 +20,31 @@ import { DEFAULT_URL, generateAndFetchDatabaseUser } from '../utilities';
1120
1221const DEFAULT_GROUP_UUID = '00000000-0000-0000-0000-000000000000' ;
1322
23+ async function pollForAuditEvent (
24+ groupUuid : string ,
25+ type : AccessGroupEventType ,
26+ maxRetries = 10 ,
27+ intervalMs = 50 ,
28+ ) : Promise < AccessGroupEventEntity > {
29+ const eventRepo = database . getRepository ( AccessGroupEventEntity ) ;
30+ for ( let index = 0 ; index < maxRetries ; index ++ ) {
31+ const events = await eventRepo . find ( {
32+ where : {
33+ accessGroup : { uuid : groupUuid } ,
34+ } ,
35+ order : { createdAt : 'DESC' } ,
36+ } ) ;
37+ const found = events . find ( ( event ) => event . type === type ) ;
38+ if ( found ) {
39+ return found ;
40+ }
41+ await new Promise ( ( resolve ) => setTimeout ( resolve , intervalMs ) ) ;
42+ }
43+ throw new Error (
44+ `Audit log event of type ${ type } was not found for group ${ groupUuid } after polling.` ,
45+ ) ;
46+ }
47+
1448/**
1549 * This test suite tests the access control of the application.
1650 *
@@ -1308,4 +1342,225 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => {
13081342 ) ;
13091343 expect ( response . status ) . toBe ( 403 ) ;
13101344 } ) ;
1345+
1346+ test ( 'if an editor can promote a group member to editor' , async ( ) => {
1347+ const { user : creator } = await generateAndFetchDatabaseUser (
1348+ 'internal' ,
1349+ 'user' ,
1350+ ) ;
1351+ const { user : member } = await generateAndFetchDatabaseUser (
1352+ 'internal' ,
1353+ 'user' ,
1354+ ) ;
1355+
1356+ const groupUuid = await createAccessGroupUsingPost (
1357+ { name : 'promote_test_group' } ,
1358+ creator ,
1359+ [ creator , member ] ,
1360+ ) ;
1361+
1362+ // Member is not an editor initially
1363+ const membershipRepo = database . getRepository ( GroupMembershipEntity ) ;
1364+ const initialMembership = await membershipRepo . findOneOrFail ( {
1365+ where : {
1366+ accessGroup : { uuid : groupUuid } ,
1367+ user : { uuid : member . uuid } ,
1368+ } ,
1369+ } ) ;
1370+ expect ( initialMembership . canEditGroup ) . toBe ( false ) ;
1371+
1372+ // Creator (editor) promotes member to editor
1373+ const headers = new HeaderCreator ( creator ) ;
1374+ headers . addHeader ( 'Content-Type' , 'application/json' ) ;
1375+ const response = await fetch (
1376+ `${ DEFAULT_URL } /access-groups/${ groupUuid } /users/${ member . uuid } /permissions` ,
1377+ {
1378+ method : 'PUT' ,
1379+ headers : headers . getHeaders ( ) ,
1380+ body : JSON . stringify ( { canEditGroup : true } ) ,
1381+ } ,
1382+ ) ;
1383+ expect ( response . status ) . toBeLessThan ( 300 ) ;
1384+
1385+ // Verify updated membership is returned
1386+ const responseJson = ( await response . json ( ) ) as {
1387+ canEditGroup : boolean ;
1388+ } ;
1389+ expect ( responseJson . canEditGroup ) . toBe ( true ) ;
1390+
1391+ // Verify in database
1392+ const updatedMembership = await membershipRepo . findOneOrFail ( {
1393+ where : {
1394+ accessGroup : { uuid : groupUuid } ,
1395+ user : { uuid : member . uuid } ,
1396+ } ,
1397+ } ) ;
1398+ expect ( updatedMembership . canEditGroup ) . toBe ( true ) ;
1399+
1400+ // Verify PROMOTE_USER event was logged in audit log
1401+ const promoteEvent = await pollForAuditEvent (
1402+ groupUuid ,
1403+ AccessGroupEventType . PROMOTE_USER ,
1404+ ) ;
1405+ expect ( promoteEvent ) . toBeDefined ( ) ;
1406+ expect ( promoteEvent . details . userUuid ) . toBe ( member . uuid ) ;
1407+ } ) ;
1408+
1409+ test ( 'if an editor can demote a group editor to a normal member' , async ( ) => {
1410+ const { user : creator } = await generateAndFetchDatabaseUser (
1411+ 'internal' ,
1412+ 'user' ,
1413+ ) ;
1414+ const { user : member } = await generateAndFetchDatabaseUser (
1415+ 'internal' ,
1416+ 'user' ,
1417+ ) ;
1418+
1419+ const groupUuid = await createAccessGroupUsingPost (
1420+ { name : 'demote_test_group' } ,
1421+ creator ,
1422+ [ creator , member ] ,
1423+ ) ;
1424+
1425+ // Promote member to editor via DB first
1426+ const membershipRepo = database . getRepository ( GroupMembershipEntity ) ;
1427+ const membership = await membershipRepo . findOneOrFail ( {
1428+ where : {
1429+ accessGroup : { uuid : groupUuid } ,
1430+ user : { uuid : member . uuid } ,
1431+ } ,
1432+ } ) ;
1433+ membership . canEditGroup = true ;
1434+ await membershipRepo . save ( membership ) ;
1435+
1436+ // Creator (editor) demotes member to normal user
1437+ const headers = new HeaderCreator ( creator ) ;
1438+ headers . addHeader ( 'Content-Type' , 'application/json' ) ;
1439+ const response = await fetch (
1440+ `${ DEFAULT_URL } /access-groups/${ groupUuid } /users/${ member . uuid } /permissions` ,
1441+ {
1442+ method : 'PUT' ,
1443+ headers : headers . getHeaders ( ) ,
1444+ body : JSON . stringify ( { canEditGroup : false } ) ,
1445+ } ,
1446+ ) ;
1447+ expect ( response . status ) . toBeLessThan ( 300 ) ;
1448+
1449+ const responseJson = ( await response . json ( ) ) as {
1450+ canEditGroup : boolean ;
1451+ } ;
1452+ expect ( responseJson . canEditGroup ) . toBe ( false ) ;
1453+
1454+ // Verify in database
1455+ const updatedMembership = await membershipRepo . findOneOrFail ( {
1456+ where : {
1457+ accessGroup : { uuid : groupUuid } ,
1458+ user : { uuid : member . uuid } ,
1459+ } ,
1460+ } ) ;
1461+ expect ( updatedMembership . canEditGroup ) . toBe ( false ) ;
1462+
1463+ // Verify DEMOTE_USER event was logged in audit log
1464+ const demoteEvent = await pollForAuditEvent (
1465+ groupUuid ,
1466+ AccessGroupEventType . DEMOTE_USER ,
1467+ ) ;
1468+ expect ( demoteEvent ) . toBeDefined ( ) ;
1469+ expect ( demoteEvent . details . userUuid ) . toBe ( member . uuid ) ;
1470+ } ) ;
1471+
1472+ test ( 'if demoting the last editor returns a conflict error' , async ( ) => {
1473+ const { user : creator } = await generateAndFetchDatabaseUser (
1474+ 'internal' ,
1475+ 'user' ,
1476+ ) ;
1477+
1478+ const groupUuid = await createAccessGroupUsingPost (
1479+ { name : 'demote_last_editor_test_group' } ,
1480+ creator ,
1481+ [ creator ] ,
1482+ ) ;
1483+
1484+ // Attempt to demote the creator (the only editor)
1485+ const headers = new HeaderCreator ( creator ) ;
1486+ headers . addHeader ( 'Content-Type' , 'application/json' ) ;
1487+ const response = await fetch (
1488+ `${ DEFAULT_URL } /access-groups/${ groupUuid } /users/${ creator . uuid } /permissions` ,
1489+ {
1490+ method : 'PUT' ,
1491+ headers : headers . getHeaders ( ) ,
1492+ body : JSON . stringify ( { canEditGroup : false } ) ,
1493+ } ,
1494+ ) ;
1495+ expect ( response . status ) . toBe ( 409 ) ;
1496+ } ) ;
1497+
1498+ test ( 'if a non-editor cannot promote or demote a user' , async ( ) => {
1499+ const { user : creator } = await generateAndFetchDatabaseUser (
1500+ 'internal' ,
1501+ 'user' ,
1502+ ) ;
1503+ const { user : member } = await generateAndFetchDatabaseUser (
1504+ 'internal' ,
1505+ 'user' ,
1506+ ) ;
1507+ const { user : unrelated } = await generateAndFetchDatabaseUser (
1508+ 'internal' ,
1509+ 'user' ,
1510+ ) ;
1511+
1512+ const groupUuid = await createAccessGroupUsingPost (
1513+ { name : 'non_editor_permission_test_group' } ,
1514+ creator ,
1515+ [ creator , member ] ,
1516+ ) ;
1517+
1518+ // Member (non-editor) tries to promote themselves
1519+ const memberHeaders = new HeaderCreator ( member ) ;
1520+ memberHeaders . addHeader ( 'Content-Type' , 'application/json' ) ;
1521+ const memberResponse = await fetch (
1522+ `${ DEFAULT_URL } /access-groups/${ groupUuid } /users/${ member . uuid } /permissions` ,
1523+ {
1524+ method : 'PUT' ,
1525+ headers : memberHeaders . getHeaders ( ) ,
1526+ body : JSON . stringify ( { canEditGroup : true } ) ,
1527+ } ,
1528+ ) ;
1529+ expect ( memberResponse . status ) . toBe ( 403 ) ;
1530+
1531+ // Member (non-editor) tries to demote the creator (editor)
1532+ const memberDemoteResponse = await fetch (
1533+ `${ DEFAULT_URL } /access-groups/${ groupUuid } /users/${ creator . uuid } /permissions` ,
1534+ {
1535+ method : 'PUT' ,
1536+ headers : memberHeaders . getHeaders ( ) ,
1537+ body : JSON . stringify ( { canEditGroup : false } ) ,
1538+ } ,
1539+ ) ;
1540+ expect ( memberDemoteResponse . status ) . toBe ( 403 ) ;
1541+
1542+ // Unrelated user tries to promote member
1543+ const unrelatedHeaders = new HeaderCreator ( unrelated ) ;
1544+ unrelatedHeaders . addHeader ( 'Content-Type' , 'application/json' ) ;
1545+ const unrelatedResponse = await fetch (
1546+ `${ DEFAULT_URL } /access-groups/${ groupUuid } /users/${ member . uuid } /permissions` ,
1547+ {
1548+ method : 'PUT' ,
1549+ headers : unrelatedHeaders . getHeaders ( ) ,
1550+ body : JSON . stringify ( { canEditGroup : true } ) ,
1551+ } ,
1552+ ) ;
1553+ expect ( unrelatedResponse . status ) . toBe ( 403 ) ;
1554+
1555+ // Unrelated user tries to demote the creator (editor)
1556+ const unrelatedDemoteResponse = await fetch (
1557+ `${ DEFAULT_URL } /access-groups/${ groupUuid } /users/${ creator . uuid } /permissions` ,
1558+ {
1559+ method : 'PUT' ,
1560+ headers : unrelatedHeaders . getHeaders ( ) ,
1561+ body : JSON . stringify ( { canEditGroup : false } ) ,
1562+ } ,
1563+ ) ;
1564+ expect ( unrelatedDemoteResponse . status ) . toBe ( 403 ) ;
1565+ } ) ;
13111566} ) ;
0 commit comments