diff --git a/backend/src/db/graphql/groups.js b/backend/src/db/graphql/groups.js index 8486288ec..41780f7cd 100644 --- a/backend/src/db/graphql/groups.js +++ b/backend/src/db/graphql/groups.js @@ -50,6 +50,17 @@ export const joinGroupMutation = gql` } ` +export const switchGroupMemberRoleMutation = gql` + mutation ($id: ID!, $userId: ID!, $roleInGroup: GroupMemberRole!) { + SwitchGroupMemberRole(id: $id, userId: $userId, roleInGroup: $roleInGroup) { + id + name + slug + myRoleInGroup + } + } +` + // ------ queries export const groupQuery = gql` diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index afdc5501e..11ca956b6 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -79,10 +79,82 @@ const isAllowedSeeingMembersOfGroup = rule({ // Wolle: console.log('member: ', member) // console.log('group: ', group) return ( - group.groupType === 'public' || - (['closed', 'hidden'].includes(group.groupType) && - !!member && - ['usual', 'admin', 'owner'].includes(member.myRoleInGroup)) + !!group && + (group.groupType === 'public' || + (['closed', 'hidden'].includes(group.groupType) && + !!member && + ['usual', 'admin', 'owner'].includes(member.myRoleInGroup))) + ) + } catch (error) { + // Wolle: console.log('error: ', error) + throw new Error(error) + } finally { + session.close() + } +}) + +const isAllowedToSwitchGroupMemberRole = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!user) return false + const adminId = user.id + const { id: groupId, userId, roleInGroup } = args + // Wolle: + // console.log('adminId: ', adminId) + // console.log('groupId: ', groupId) + // console.log('userId: ', userId) + // console.log('roleInGroup: ', roleInGroup) + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (admin:User {id: $adminId})-[adminMembership:MEMBER_OF]->(group:Group {id: $groupId})<-[userMembership:MEMBER_OF]-(member:User {id: $userId}) + RETURN group {.*}, admin {.*, myRoleInGroup: adminMembership.role}, member {.*, myRoleInGroup: userMembership.role} + `, + { groupId, adminId, userId }, + ) + // Wolle: + // console.log( + // 'transactionResponse: ', + // transactionResponse, + // ) + // console.log( + // 'transaction admins: ', + // transactionResponse.records.map((record) => record.get('admin')), + // ) + // console.log( + // 'transaction groups: ', + // transactionResponse.records.map((record) => record.get('group')), + // ) + // console.log( + // 'transaction members: ', + // transactionResponse.records.map((record) => record.get('member')), + // ) + return { + admin: transactionResponse.records.map((record) => record.get('admin'))[0], + group: transactionResponse.records.map((record) => record.get('group'))[0], + member: transactionResponse.records.map((record) => record.get('member'))[0], + } + }) + try { + // Wolle: + // console.log('enter try !!!') + const { admin, group, member } = await readTxPromise + // Wolle: + // console.log('after !!!') + // console.log('admin: ', admin) + // console.log('group: ', group) + // console.log('member: ', member) + return ( + !!group && + !!admin && + !!member && + adminId !== userId && + ((['admin'].includes(admin.myRoleInGroup) && + !['owner'].includes(member.myRoleInGroup) && + ['pending', 'usual', 'admin'].includes(roleInGroup)) || + (['owner'].includes(admin.myRoleInGroup) && + ['pending', 'usual', 'admin', 'owner'].includes(roleInGroup))) ) } catch (error) { // Wolle: console.log('error: ', error) @@ -118,7 +190,7 @@ const isAuthor = rule({ const isDeletingOwnAccount = rule({ cache: 'no_cache', -})(async (parent, args, context, info) => { +})(async (parent, args, context, _info) => { return context.user.id === args.id }) @@ -183,7 +255,8 @@ export default shield( SignupVerification: allow, UpdateUser: onlyYourself, CreateGroup: isAuthenticated, - JoinGroup: isAuthenticated, + JoinGroup: isAuthenticated, // Wolle: can not be correct + SwitchGroupMemberRole: isAllowedToSwitchGroupMemberRole, CreatePost: isAuthenticated, UpdatePost: isAuthor, DeletePost: isAuthor, diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index c9a31fdc3..34959908d 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -160,6 +160,33 @@ export default { session.close() } }, + SwitchGroupMemberRole: async (_parent, params, context, _resolveInfo) => { + const { id: groupId, userId, roleInGroup } = params + // Wolle + // console.log('groupId: ', groupId) + // console.log('userId: ', groupId) + // console.log('roleInGroup: ', groupId) + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const joinGroupCypher = ` + MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) + SET + membership.updatedAt = toString(datetime()), + membership.role = $roleInGroup + RETURN member {.*, myRoleInGroup: membership.role} + ` + const result = await transaction.run(joinGroupCypher, { groupId, userId, roleInGroup }) + const [member] = await result.records.map((record) => record.get('member')) + return member + }) + try { + return await writeTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, }, Group: { ...Resolver('Group', { diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 87eb02dc0..e17e4827a 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -3,6 +3,7 @@ import Factory, { cleanDatabase } from '../../db/factories' import { createGroupMutation, joinGroupMutation, + switchGroupMemberRoleMutation, groupMemberQuery, groupQuery, } from '../../db/graphql/groups' @@ -13,8 +14,8 @@ import CONFIG from '../../config' const driver = getDriver() const neode = getNeode() -let query -let mutate +let isCleanDbAfterEach = true +let isSeedDb = true let authenticatedUser let user @@ -23,20 +24,20 @@ const descriptionAdditional100 = ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' let variables = {} +const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, +}) +const { query } = createTestClient(server) +const { mutate } = createTestClient(server) + beforeAll(async () => { await cleanDatabase() - - const { server } = createServer({ - context: () => { - return { - driver, - neode, - user: authenticatedUser, - } - }, - }) - query = createTestClient(server).query - mutate = createTestClient(server).mutate }) afterAll(async () => { @@ -44,50 +45,55 @@ afterAll(async () => { }) beforeEach(async () => { - variables = {} - user = await Factory.build( - 'user', - { - id: 'current-user', - name: 'TestUser', - }, - { - email: 'test@example.org', - password: '1234', - }, - ) - await Promise.all([ - neode.create('Category', { - id: 'cat9', - name: 'Democracy & Politics', - slug: 'democracy-politics', - icon: 'university', - }), - neode.create('Category', { - id: 'cat4', - name: 'Environment & Nature', - slug: 'environment-nature', - icon: 'tree', - }), - neode.create('Category', { - id: 'cat15', - name: 'Consumption & Sustainability', - slug: 'consumption-sustainability', - icon: 'shopping-cart', - }), - neode.create('Category', { - id: 'cat27', - name: 'Animal Protection', - slug: 'animal-protection', - icon: 'paw', - }), - ]) - authenticatedUser = null + // Wolle: find a better solution + if (isSeedDb) { + variables = {} + user = await Factory.build( + 'user', + { + id: 'current-user', + name: 'TestUser', + }, + { + email: 'test@example.org', + password: '1234', + }, + ) + await Promise.all([ + neode.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + slug: 'democracy-politics', + icon: 'university', + }), + neode.create('Category', { + id: 'cat4', + name: 'Environment & Nature', + slug: 'environment-nature', + icon: 'tree', + }), + neode.create('Category', { + id: 'cat15', + name: 'Consumption & Sustainability', + slug: 'consumption-sustainability', + icon: 'shopping-cart', + }), + neode.create('Category', { + id: 'cat27', + name: 'Animal Protection', + slug: 'animal-protection', + icon: 'paw', + }), + ]) + authenticatedUser = null + } }) // TODO: avoid database clean after each test in the future if possible for performance and flakyness reasons by filling the database step by step, see issue https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4543 afterEach(async () => { - await cleanDatabase() + if (isCleanDbAfterEach) { + await cleanDatabase() + } }) describe('CreateGroup', () => { @@ -558,6 +564,330 @@ describe('JoinGroup', () => { }) }) +describe('SwitchGroupMemberRole', () => { + describe('unauthenticated', () => { + it('throws authorization error', async () => { + variables = { + id: 'not-existing-group', + userId: 'current-user', + roleInGroup: 'pending', + } + const { errors } = await mutate({ mutation: switchGroupMemberRoleMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + describe('in building up mode', () => { + let usualMemberUser + let adminMemberUser + let ownerMemberUser + // let secondOwnerMemberUser + + beforeEach(async () => { + // Wolle: change this to beforeAll? + if (isSeedDb) { + // create users + usualMemberUser = await Factory.build( + 'user', + { + id: 'usual-member-user', + name: 'Usual Member TestUser', + }, + { + email: 'usual-member-user@example.org', + password: '1234', + }, + ) + adminMemberUser = await Factory.build( + 'user', + { + id: 'admin-member-user', + name: 'Admin Member TestUser', + }, + { + email: 'admin-member-user@example.org', + password: '1234', + }, + ) + ownerMemberUser = await Factory.build( + 'user', + { + id: 'owner-member-user', + name: 'Owner Member TestUser', + }, + { + email: 'owner-member-user@example.org', + password: '1234', + }, + ) + // secondOwnerMemberUser = + await Factory.build( + 'user', + { + id: 'second-owner-member-user', + name: 'Second Owner Member TestUser', + }, + { + email: 'second-owner-member-user@example.org', + password: '1234', + }, + ) + // create groups + authenticatedUser = await usualMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'public-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + authenticatedUser = await ownerMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'closed-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + authenticatedUser = await adminMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'hidden-group', + name: 'Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + // create additional memberships + // public-group + authenticatedUser = await usualMemberUser.toJson() + await mutate({ + mutation: joinGroupMutation, + variables: { + id: 'public-group', + userId: 'owner-of-closed-group', + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + id: 'public-group', + userId: 'owner-of-hidden-group', + }, + }) + // closed-group + authenticatedUser = await ownerMemberUser.toJson() + await mutate({ + mutation: joinGroupMutation, + variables: { + id: 'closed-group', + userId: 'usual-member-user', + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + id: 'closed-group', + userId: 'admin-member-user', + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + id: 'closed-group', + userId: 'second-owner-member-user', + }, + }) + // hidden-group + authenticatedUser = await adminMemberUser.toJson() + await mutate({ + mutation: joinGroupMutation, + variables: { + id: 'hidden-group', + userId: 'admin-member-user', + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + id: 'hidden-group', + userId: 'second-owner-member-user', + }, + }) + // Wolle + // function sleep(ms) { + // return new Promise(resolve => setTimeout(resolve, ms)); + // } + // await sleep(4 * 1000) + isCleanDbAfterEach = false + isSeedDb = false + } + }) + afterAll(async () => { + // Wolle: find a better solution + await cleanDatabase() + isCleanDbAfterEach = true + isSeedDb = true + }) + + describe('in all group types – here "closed-group" for example', () => { + beforeEach(async () => { + variables = { + id: 'closed-group', + } + }) + + describe('switch role', () => { + describe('of owner member "owner-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'owner-member-user', + } + }) + + describe('by owner themself "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: switchGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + }) + }) + + describe('of prospective admin member "admin-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'admin-member-user', + } + }) + + describe('by owner "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('has role admin', async () => { + // Wolle: + // const groups = await query({ query: groupQuery, variables: {} }) + // console.log('groups.data.Group: ', groups.data.Group) + // const groupMemberOfClosedGroup = await mutate({ + // mutation: groupMemberQuery, + // variables: { + // id: 'closed-group', + // }, + // }) + // console.log('groupMemberOfClosedGroup.data.GroupMember: ', groupMemberOfClosedGroup.data.GroupMember) + const expected = { + data: { + SwitchGroupMemberRole: { + id: 'admin-member-user', + myRoleInGroup: 'admin', + }, + }, + errors: undefined, + } + await expect( + mutate({ + mutation: switchGroupMemberRoleMutation, + variables, + }), + ).resolves.toMatchObject(expected) + }) + }) + }) + + describe('by still pending member "usual-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await usualMemberUser.toJson() + }) + + describe('degrade to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: switchGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + }) + + describe('by none member "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + describe('degrade to pending again', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'pending', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: switchGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + }) + }) + }) + }) + }) + }) +}) + describe('GroupMember', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index fad1b7e58..270f5c844 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -118,4 +118,10 @@ type Mutation { id: ID! userId: ID! ): User + + SwitchGroupMemberRole( + id: ID! + userId: ID! + roleInGroup: GroupMemberRole! + ): User }