From c80e79d3f75120f264a443bb3637057f262e1f43 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Fri, 8 Jul 2022 18:41:59 +0200 Subject: [PATCH] feat: restrict event creation (#1160) * refactor: remove unused cypress plugins file * refactor(cypress): move event creation to chapter The subpath is relative to the chapter dashboard, so it seems like a better fit. * refactor: rename helper * refactor(cypress): get response from createEvent * fix(test): seed and login before creating events * test: only let authorized members create events * feat: restrict event creation to auth'd users * fix(seed): only generate one admin for one chapter Allows us to test if they can affect other chapters. * feat: restrict event creation * fix(test): match new graphql schema * refactor: use Permission * fix(test): pass chapterId to cy.createEvent * refactor: remove comment * fix: typo Co-authored-by: Krzysztof G. <60067306+gikf@users.noreply.github.com> * refactor: clarify comment * refactor: DRY out assertions Co-authored-by: Krzysztof G. <60067306+gikf@users.noreply.github.com> Co-authored-by: Krzysztof G. <60067306+gikf@users.noreply.github.com> --- client/graphql.schema.json | 32 ++-- client/src/generated/graphql.tsx | 8 +- .../dashboard/Events/graphql/mutations.ts | 4 +- .../dashboard/Events/pages/NewEventPage.tsx | 3 +- .../chapters/[chapterId]/index.cy.js | 146 ++++++++++++++++++ cypress/e2e/dashboard/events/index.cy.js | 103 +----------- cypress/plugins/index.js | 84 ---------- cypress/support/commands.js | 13 +- server/prisma/generator/setupRoles.ts | 24 +-- server/src/controllers/Events/inputs.ts | 3 - server/src/controllers/Events/resolver.ts | 9 +- 11 files changed, 198 insertions(+), 231 deletions(-) delete mode 100644 cypress/plugins/index.js diff --git a/client/graphql.schema.json b/client/graphql.schema.json index beb22b9b88..b80803c005 100644 --- a/client/graphql.schema.json +++ b/client/graphql.schema.json @@ -884,22 +884,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "chapter_id", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "description", "description": null, @@ -2950,6 +2934,22 @@ "name": "createEvent", "description": null, "args": [ + { + "name": "chapterId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "data", "description": null, diff --git a/client/src/generated/graphql.tsx b/client/src/generated/graphql.tsx index 4d74139f5d..c925ac61f8 100644 --- a/client/src/generated/graphql.tsx +++ b/client/src/generated/graphql.tsx @@ -102,7 +102,6 @@ export type CreateChapterInputs = { export type CreateEventInputs = { capacity: Scalars['Float']; - chapter_id: Scalars['Int']; description: Scalars['String']; ends_at: Scalars['DateTime']; image_url: Scalars['String']; @@ -330,6 +329,7 @@ export type MutationCreateChapterArgs = { }; export type MutationCreateEventArgs = { + chapterId: Scalars['Int']; data: CreateEventInputs; }; @@ -867,6 +867,7 @@ export type ChapterRolesQuery = { }; export type CreateEventMutationVariables = Exact<{ + chapterId: Scalars['Int']; data: CreateEventInputs; }>; @@ -2277,8 +2278,8 @@ export type ChapterRolesQueryResult = Apollo.QueryResult< ChapterRolesQueryVariables >; export const CreateEventDocument = gql` - mutation createEvent($data: CreateEventInputs!) { - createEvent(data: $data) { + mutation createEvent($chapterId: Int!, $data: CreateEventInputs!) { + createEvent(chapterId: $chapterId, data: $data) { id name canceled @@ -2313,6 +2314,7 @@ export type CreateEventMutationFn = Apollo.MutationFunction< * @example * const [createEventMutation, { data, loading, error }] = useCreateEventMutation({ * variables: { + * chapterId: // value for 'chapterId' * data: // value for 'data' * }, * }); diff --git a/client/src/modules/dashboard/Events/graphql/mutations.ts b/client/src/modules/dashboard/Events/graphql/mutations.ts index f48457f6de..01b6f28906 100644 --- a/client/src/modules/dashboard/Events/graphql/mutations.ts +++ b/client/src/modules/dashboard/Events/graphql/mutations.ts @@ -1,8 +1,8 @@ import { gql } from '@apollo/client'; export const createEvent = gql` - mutation createEvent($data: CreateEventInputs!) { - createEvent(data: $data) { + mutation createEvent($chapterId: Int!, $data: CreateEventInputs!) { + createEvent(chapterId: $chapterId, data: $data) { id name canceled diff --git a/client/src/modules/dashboard/Events/pages/NewEventPage.tsx b/client/src/modules/dashboard/Events/pages/NewEventPage.tsx index cb335bc427..1ac625162e 100644 --- a/client/src/modules/dashboard/Events/pages/NewEventPage.tsx +++ b/client/src/modules/dashboard/Events/pages/NewEventPage.tsx @@ -49,10 +49,9 @@ export const NewEventPage: NextPage = () => { streaming_url: isOnline(data.venue_type) ? data.streaming_url : null, tags: tagsArray, sponsor_ids: sponsorArray, - chapter_id: chapterId, }; const event = await createEvent({ - variables: { data: { ...eventData } }, + variables: { chapterId, data: { ...eventData } }, }); if (event.data) { diff --git a/cypress/e2e/dashboard/chapters/[chapterId]/index.cy.js b/cypress/e2e/dashboard/chapters/[chapterId]/index.cy.js index e14f6611ff..2317a45049 100644 --- a/cypress/e2e/dashboard/chapters/[chapterId]/index.cy.js +++ b/cypress/e2e/dashboard/chapters/[chapterId]/index.cy.js @@ -1,6 +1,152 @@ +import { expectToBeRejected } from '../../../../support/util'; + +const testEvent = { + title: 'Test Event', + description: 'Test Description', + url: 'https://test.event.org', + streamingUrl: 'https://test.event.org/video', + capacity: '10', + tags: 'Test, Event, Tag', + startAt: '2022-01-01T00:01', + endAt: '2022-01-02T00:02', + venueId: '1', + imageUrl: 'https://test.event.org/image', +}; + +// TODO: Consolidate fixtures. +const eventData = { + venue_id: 1, + sponsor_ids: [], + name: 'Other Event', + description: 'Test Description', + url: 'https://test.event.org', + venue_type: 'PhysicalAndOnline', + capacity: 10, + image_url: 'https://test.event.org/image', + streaming_url: 'https://test.event.org/video', + start_at: '2022-01-01T00:01', + ends_at: '2022-01-02T00:02', + tags: 'Test, Event, Tag', +}; + describe('chapter dashboard', () => { + beforeEach(() => { + cy.exec('npm run db:seed'); + cy.login(); + }); + it('should have link to add event for chapter', () => { cy.visit('/dashboard/chapters/1'); cy.get('a[href="/dashboard/chapters/1/new_event"').should('be.visible'); }); + + it('emails interested users when an event is created', () => { + createEventViaUI(1); + cy.location('pathname').should('match', /^\/dashboard\/events\/\d+$/); + // confirm that the test data appears in the new event + cy.wrap(Object.entries(testEvent)).each(([key, value]) => { + // TODO: simplify this conditional when tags and dates are handled + // properly. + if (!['tags', 'startAt', 'endAt', 'venueId'].includes(key)) { + cy.contains(value); + } + }); + // check that the title we selected is in the event we created. + cy.get('@venueTitle').then((venueTitle) => { + cy.contains(venueTitle); + }); + + // check that the subscribed users have been emailed + cy.waitUntilMail('allMail'); + + cy.get('@allMail').mhFirst().as('invitation'); + // TODO: select chapter during event creation and use that here (much like @venueTitle + // ) i.e. remove the hardcoding. + cy.getChapterMembers(1).then((members) => { + const subscriberEmails = members + .filter(({ subscribed }) => subscribed) + .map(({ user: { email } }) => email); + cy.get('@invitation') + .mhGetRecipients() + .should('have.members', subscriberEmails); + }); + cy.get('@invitation').then((mail) => { + cy.checkBcc(mail).should('eq', true); + }); + }); + + it('prevents members and admins from other chapters from creating events', () => { + let chapterId = 2; + // normal member + cy.register(); + cy.login(Cypress.env('JWT_TEST_USER')); + cy.createEvent(chapterId, eventData).then(expectToBeRejected); + + // admin of a different chapter + cy.login(Cypress.env('JWT_ADMIN_USER')); + cy.createEvent(2, eventData).then(expectToBeRejected); + + // switch the chapterId to match the admin's chapter + chapterId = 1; + cy.createEvent(chapterId, { + ...eventData, + name: 'Created by Admin', + }).then((response) => { + expect(response.status).to.eq(200); + expect(response.body.errors).not.to.exist; + + cy.visit(`/dashboard/events/`); + cy.contains('Created by Admin'); + cy.contains(eventData.name).should('not.exist'); + }); + }); + + function createEventViaUI(chapterId) { + cy.visit(`/dashboard/chapters/${chapterId}`); + cy.get(`a[href="/dashboard/chapters/${chapterId}/new_event"]`).click(); + cy.findByRole('textbox', { name: 'Event title' }).type(testEvent.title); + cy.findByRole('textbox', { name: 'Description' }).type( + testEvent.description, + ); + cy.findByRole('textbox', { name: 'Event Image Url' }).type( + testEvent.imageUrl, + ); + cy.findByRole('textbox', { name: 'Url' }).type(testEvent.url); + cy.findByRole('spinbutton', { name: 'Capacity' }).type(testEvent.capacity); + cy.findByRole('textbox', { name: 'Tags (separated by a comma)' }).type( + 'Test, Event, Tag', + ); + + cy.findByLabelText(/^Start at/) + .clear() + .type(testEvent.startAt) + .type('{esc}'); + cy.findByLabelText(/^End at/) + .clear() + .type(testEvent.endAt) + .type('{esc}'); + + // TODO: figure out why cypress thinks this is covered. + // cy.findByRole('checkbox', { name: 'Invite only' }).click(); + cy.get('[data-cy="invite-only-checkbox"]').click(); + // TODO: I thought would be a listbox - does it matter that it's a - // combobox? - cy.findByRole('combobox', { name: 'Venue' }) - .as('venueSelect') - .select(testEvent.venueId); - cy.get('@venueSelect') - .find(`option[value=${testEvent.venueId}]`) - .invoke('text') - .as('venueTitle'); - cy.findByRole('textbox', { name: 'Streaming URL' }).type( - testEvent.streamingUrl, - ); - - cy.findByRole('form', { name: 'Add event' }) - .findByRole('button', { - name: 'Add event', - }) - .click(); - cy.location('pathname').should('match', /^\/dashboard\/events\/\d+$/); - } - it('has a button to email attendees', () => { cy.visit('/dashboard/events/1'); // sending to confirmed first @@ -203,7 +106,6 @@ describe('events dashboard', () => { it("emails the users when an event's venue is changed", () => { const eventData = { venue_id: 1, - chapter_id: 1, sponsor_ids: [], name: 'Event Venue change test', description: 'Test Description', @@ -218,7 +120,8 @@ describe('events dashboard', () => { }; const newVenueId = 2; - cy.createEvent(eventData).then((eventId) => { + cy.createEvent(1, eventData).then((response) => { + const eventId = response.body.data.createEvent.id; cy.visit(`/dashboard/events/${eventId}/edit`); cy.findByRole('combobox', { name: 'Venue' }) .select(newVenueId) diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js deleted file mode 100644 index e3f20cd950..0000000000 --- a/cypress/plugins/index.js +++ /dev/null @@ -1,84 +0,0 @@ -/// -// *********************************************************** -// This example plugins/index.js can be used to load plugins -// -// You can change the location of this file or turn off loading -// the plugins file with the 'pluginsFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/plugins-guide -// *********************************************************** - -// This function is called when a project is opened or re-opened (e.g. due to -// the project's config changing) - -/** - * @type {Cypress.PluginConfig} - */ - -const { execSync } = require('child_process'); -require('dotenv').config(); - -const jwt = require('jsonwebtoken'); - -// eslint-disable-next-line no-unused-vars -module.exports = (on, config) => { - // `on` is used to hook into various events Cypress emits - // `config` is the resolved Cypress config - config.env = config.env || {}; - // TODO: ideally the email address should have a common source (since it's - // used in the db generator, too) - config.env.JWT = jwt.sign({ email: 'foo@bar.com' }, process.env.JWT_SECRET, { - expiresIn: '120min', - }); - config.env.JWT_TEST_USER = jwt.sign( - { email: 'test@user.org' }, - process.env.JWT_SECRET, - { - expiresIn: '120min', - }, - ); - - config.env.JWT_ADMIN_USER = jwt.sign( - { email: 'admin@of.a.chapter' }, - process.env.JWT_SECRET, - { - expiresIn: '120min', - }, - ); - - config.env.JWT_BANNED_ADMIN_USER = jwt.sign( - { email: 'banned@chapter.admin' }, - process.env.JWT_SECRET, - { - expiresIn: '120min', - }, - ); - - config.env.TOKEN_DELETED_USER = jwt.sign({ id: -1 }, process.env.JWT_SECRET, { - expiresIn: '120min', - }); - - config.env.JWT_EXPIRED = jwt.sign( - { email: 'foo@bar.com' }, - process.env.JWT_SECRET, - { - expiresIn: '1', - }, - ); - - // Standard JWT (with id, exp etc.), but with the signature removed: - config.env.JWT_UNSIGNED = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNjM1ODYzNjQ2LCJleHAiOjE2Mzg1NDIwNDZ9.'; - - config.env.JWT_MALFORMED = 'not-a-valid-format'; - - // This makes sure the db is populated before running any tests. Without this, - // it's difficult (when running docker-compose up) to guarantee that both the - // docker container is running and that the db has been seeded. - on('before:run', () => { - execSync('npm run db:reset'); - }); - require('@cypress/code-coverage/task')(on, config); - return config; -}; diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 87d0beb2f5..48afd7e34b 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -159,14 +159,15 @@ Cypress.Commands.add('waitUntilMail', (alias) => { ); }); -Cypress.Commands.add('createEvent', (data) => { +Cypress.Commands.add('createEvent', (chapterId, data) => { const eventMutation = { operationName: 'createEvent', variables: { - data: { ...data }, + chapterId, + data, }, - query: `mutation createEvent($data: CreateEventInputs!) { - createEvent(data: $data) { + query: `mutation createEvent($chapterId: Int!, $data: CreateEventInputs!) { + createEvent(chapterId: $chapterId, data: $data) { id } }`, @@ -176,9 +177,7 @@ Cypress.Commands.add('createEvent', (data) => { url: 'http://localhost:5000/graphql', body: eventMutation, }; - return cy.authedRequest(requestOptions).then((response) => { - return response.body.data.createEvent.id; - }); + return cy.authedRequest(requestOptions); }); Cypress.Commands.add('createChapter', (data) => { diff --git a/server/prisma/generator/setupRoles.ts b/server/prisma/generator/setupRoles.ts index be8f49a40a..f69ee11588 100644 --- a/server/prisma/generator/setupRoles.ts +++ b/server/prisma/generator/setupRoles.ts @@ -20,6 +20,20 @@ const setupRoles = async ( ): Promise => { const usersData: Prisma.chapter_usersCreateManyInput[] = []; const subscribeIterator = makeBooleanIterator(); + + // We create one admin for chapter 1, but none for the other chapters. That + // means we can test requests from that admin to the other chapters and + // confirm that the requests are rejected. + const adminData: Prisma.chapter_usersCreateManyInput = { + joined_date: new Date(), + chapter_id: 1, + user_id: adminId, + chapter_role_id: chapterRoles.administrator.id, + subscribed: true, + }; + + usersData.push(adminData); + for (const chapterId of chapterIds) { const ownerData: Prisma.chapter_usersCreateManyInput = { joined_date: new Date(), @@ -35,16 +49,6 @@ const setupRoles = async ( usersData.push(ownerData); - const adminData: Prisma.chapter_usersCreateManyInput = { - joined_date: new Date(), - chapter_id: chapterId, - user_id: adminId, - chapter_role_id: chapterRoles.administrator.id, - subscribed: true, - }; - - usersData.push(adminData); - const bannedAdminData: Prisma.chapter_usersCreateManyInput = { joined_date: new Date(), chapter_id: chapterId, diff --git a/server/src/controllers/Events/inputs.ts b/server/src/controllers/Events/inputs.ts index 5ab3cbd91e..06ccf2d375 100644 --- a/server/src/controllers/Events/inputs.ts +++ b/server/src/controllers/Events/inputs.ts @@ -34,9 +34,6 @@ export class CreateEventInputs { @Field(() => Int, { nullable: true }) venue_id: number; - @Field(() => Int) - chapter_id: number; - @Field(() => Boolean, { nullable: true }) invite_only: boolean; diff --git a/server/src/controllers/Events/resolver.ts b/server/src/controllers/Events/resolver.ts index e6234fdf26..3874fe1146 100644 --- a/server/src/controllers/Events/resolver.ts +++ b/server/src/controllers/Events/resolver.ts @@ -402,22 +402,23 @@ export class EventResolver { return true; } + @Authorized(Permission.EventCreate) @Mutation(() => Event) async createEvent( + @Arg('chapterId', () => Int) chapterId: number, @Arg('data') data: CreateEventInputs, - @Ctx() ctx: GQLCtx, + @Ctx() ctx: Required, ): Promise { - if (!ctx.user) throw Error('User must be logged in to create events'); let venue; if (data.venue_id) { venue = await prisma.venues.findUnique({ where: { id: data.venue_id } }); } const chapter = await prisma.chapters.findUnique({ - where: { id: data.chapter_id }, + where: { id: chapterId }, }); const userChapter = ctx.user.user_chapters.find( - ({ chapter_id }) => chapter_id === data.chapter_id, + ({ chapter_id }) => chapter_id === chapterId, ); const eventSponsorsData: Prisma.event_sponsorsCreateManyEventsInput[] =