From d09167a0bdb45318703a6ecd4fbc4d9d309e03b2 Mon Sep 17 00:00:00 2001 From: Michael Reichenbach <755327+Silthus@users.noreply.github.com> Date: Fri, 9 Feb 2024 05:34:35 +0100 Subject: [PATCH] feat: add editor role requirement to import endpoint --- src/__tests__/import-example.json | 129 ++++++++++++++++++ src/__tests__/import.test.ts | 106 ++++++++++++++ src/auth/rules.ts | 11 +- .../import/json/__tests__/import-json.test.ts | 27 ++-- src/db/import/json/import-json.ts | 86 ++++++------ src/db/import/json/request-handler.ts | 2 +- src/model/AreaDataSource.ts | 15 +- src/model/MutableAreaDataSource.ts | 2 +- src/server.ts | 2 + src/utils/testUtils.ts | 26 ++-- 10 files changed, 341 insertions(+), 65 deletions(-) create mode 100644 src/__tests__/import-example.json create mode 100644 src/__tests__/import.test.ts diff --git a/src/__tests__/import-example.json b/src/__tests__/import-example.json new file mode 100644 index 00000000..1546849a --- /dev/null +++ b/src/__tests__/import-example.json @@ -0,0 +1,129 @@ +{ + "areas": [ + { + "areaName": "Utah", + "countryCode": "us", + "children": [ + { + "areaName": "Southeast Utah", + "children": [ + { + "areaName": "Indian Creek", + "children": [ + { + "areaName": "Supercrack Buttress", + "climbs": [ + { + "name": "The Key Flake", + "grade": "5.10", + "fa": "unknown", + "type": { + "trad": true + }, + "safety": "UNSPECIFIED", + "metadata": { + "lnglat": { + "type": "Point", + "coordinates": [ + -109.54552, + 38.03635 + ] + }, + "left_right_index": 1 + }, + "content": { + "description": "Cool off-width that requires off-width and face skills.", + "protection": "Anchors hidden up top. Need 80m to make it all the way down.", + "location": "Opposite keyhole flake. Obvious right leaning offwidth that starts atop 20 ft boulder." + } + }, + { + "name": "Incredible Hand Crack", + "grade": "5.10", + "fa": "Rich Perch, John Bragg, Doug Snively, and Anne Tarver, 1978", + "type": { + "trad": true + }, + "safety": "UNSPECIFIED", + "metadata": { + "lnglat": { + "type": "Point", + "coordinates": [ + -109.54552, + 38.03635 + ] + }, + "left_right_index": 2 + }, + "content": { + "description": "Route starts at the top of the trail from the parking lot to Supercrack Buttress.", + "protection": "Cams from 2-2.5\". Heavy on 2.5\" (#2 Camalot)", + "location": "" + }, + "pitches": [ + { + "pitchNumber": 1, + "grade": "5.10", + "disciplines": { + "trad": true + }, + "length": 100, + "boltsCount": 0, + "description": "A classic hand crack that widens slightly towards the top. Requires a range of cam sizes. Sustained and excellent quality." + }, + { + "pitchNumber": 2, + "grade": "5.9", + "disciplines": { + "trad": true + }, + "length": 30, + "boltsCount": 0, + "description": "Easier climbing with good protection. Features a mix of crack sizes. Shorter than the first pitch but equally enjoyable." + } + ] + } + ], + "gradeContext": "US", + "metadata": { + "isBoulder": false, + "isDestination": false, + "leaf": true, + "lnglat": { + "type": "Point", + "coordinates": [ + -109.54552, + 38.03635 + ] + }, + "bbox": [ + -109.54609091005857, + 38.03590033981814, + -109.54494908994141, + 38.03679966018186 + ] + }, + "content": { + "description": "" + } + } + ], + "metadata": { + "lnglat": { + "type": "Point", + "coordinates": [ + -109.5724044642857, + 38.069429035714286 + ] + } + }, + "content": { + "description": "Indian Creek is a crack climbing mecca in the southeastern region of Utah, USA. Located within the [Bears Ears National Monument](https://en.wikipedia.org/wiki/Bears_Ears_National_Monument)." + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/__tests__/import.test.ts b/src/__tests__/import.test.ts new file mode 100644 index 00000000..755ba6e3 --- /dev/null +++ b/src/__tests__/import.test.ts @@ -0,0 +1,106 @@ +import {ApolloServer} from "apollo-server-express"; +import muuid from "uuid-mongodb"; +import express from "express"; +import {InMemoryDB} from "../utils/inMemoryDB.js"; +import {queryAPI, setUpServer} from "../utils/testUtils.js"; +import {muuidToString} from "../utils/helpers.js"; +import MutableAreaDataSource from "../model/MutableAreaDataSource.js"; +import exampleImportData from './import-example.json' assert {type: 'json'}; +import {AreaType} from "../db/AreaTypes.js"; +import {BulkImportResult} from "../db/import/json/import-json"; + +describe('/import', () => { + const endpoint = '/import' + let server: ApolloServer + let user: muuid.MUUID + let userUuid: string + let app: express.Application + let inMemoryDB: InMemoryDB + let testArea: AreaType + + let areas: MutableAreaDataSource + + beforeAll(async () => { + ({server, inMemoryDB, app} = await setUpServer()) + // Auth0 serializes uuids in "relaxed" mode, resulting in this hex string format + // "59f1d95a-627d-4b8c-91b9-389c7424cb54" instead of base64 "WfHZWmJ9S4yRuTicdCTLVA==". + user = muuid.mode('relaxed').v4() + userUuid = muuidToString(user) + }) + + beforeEach(async () => { + await inMemoryDB.clear() + areas = MutableAreaDataSource.getInstance() + await areas.addCountry('usa') + testArea = await areas.addArea(user, "Test Area", null, "us") + }) + + afterAll(async () => { + await server.stop() + await inMemoryDB.close() + }) + + it('should return 403 if no user', async () => { + const res = await queryAPI({ + app, + endpoint, + body: exampleImportData + }) + expect(res.status).toBe(403) + expect(res.text).toBe('Forbidden') + }) + + it('should return 403 if user is not an editor', async () => { + const res = await queryAPI({ + app, + endpoint, + userUuid, + body: exampleImportData + }) + expect(res.status).toBe(403) + expect(res.text).toBe('Forbidden') + }) + + it('should return 200 if user is an editor', async () => { + const res = await queryAPI({ + app, + endpoint, + userUuid, + roles: ['editor'], + body: exampleImportData + }) + expect(res.status).toBe(200) + }) + + it('should import data', async () => { + const res = await queryAPI({ + app, + endpoint, + userUuid, + roles: ['editor'], + body: { + areas: [ + ...exampleImportData.areas, + { + id: testArea.metadata.area_id.toUUID().toString(), + areaName: "Updated Test Area", + } + ], + }, + }); + expect(res.status).toBe(200) + + const result = res.body as BulkImportResult + expect(result.addedAreaIds.length).toBe(4) + + const committedAreas = await Promise.all(result.addedAreaIds.map((areaId: string) => areas.findOneAreaByUUID(muuid.from(areaId)))); + expect(committedAreas.length).toBe(4); + + const committedClimbs = await Promise.all(result.climbIds.map((id: string) => areas.findOneClimbByUUID(muuid.from(id)))); + expect(committedClimbs.length).toBe(2); + + const updatedAreas = await Promise.all(result.updatedAreaIds.map((areaId: any) => areas.findOneAreaByUUID(muuid.from(areaId)))); + expect(updatedAreas.length).toBe(1); + expect(updatedAreas[0].area_name).toBe("Updated Test Area"); + }) +}); \ No newline at end of file diff --git a/src/auth/rules.ts b/src/auth/rules.ts index f5ecdafe..68de82de 100644 --- a/src/auth/rules.ts +++ b/src/auth/rules.ts @@ -1,4 +1,4 @@ -import { rule, inputRule } from 'graphql-shield' +import { inputRule, rule } from 'graphql-shield' import MediaDataSource from '../model/MutableMediaDataSource.js' import { MediaObjectGQLInput } from '../db/MediaObjectTypes.js' @@ -7,6 +7,15 @@ export const isEditor = rule()(async (parent, args, ctx, info) => { return _hasUserUuid(ctx) && ctx.user.roles.includes('editor') }) +export const hasEditorRoleMiddleware = async (req, res, next): Promise => { + const roles: string[] = req.user?.roles ?? [] + if (_hasUserUuid(req) && roles.includes('editor')) { + next() + } else { + res.status(403).send('Forbidden') + } +} + export const isUserAdmin = rule()(async (parent, args, ctx, info) => { return _hasUserUuid(ctx) && ctx.user.roles.includes('user_admin') }) diff --git a/src/db/import/json/__tests__/import-json.test.ts b/src/db/import/json/__tests__/import-json.test.ts index f4903b7c..0b03f80e 100644 --- a/src/db/import/json/__tests__/import-json.test.ts +++ b/src/db/import/json/__tests__/import-json.test.ts @@ -8,9 +8,11 @@ import {ClimbType} from '../../../ClimbTypes.js'; import streamListener from '../../../edit/streamListener.js'; import {AreaJson, bulkImportJson, BulkImportResult} from '../import-json.js'; import inMemoryDB from "../../../../utils/inMemoryDB.js"; +import {isFulfilled, isRejected} from "../../../../utils/testUtils.js"; type TestResult = BulkImportResult & { addedAreas: Partial[]; + updatedAreas: Partial[]; addedClimbs: Partial[]; }; @@ -27,15 +29,14 @@ describe('bulk import e2e', () => { areas, }); - const isFulfilled = ( - p: PromiseSettledResult - ): p is PromiseFulfilledResult => p.status === 'fulfilled'; - const isRejected = ( - p: PromiseSettledResult - ): p is PromiseRejectedResult => p.status === 'rejected'; - const committedAreas = await Promise.allSettled( - result.addedAreas.map((area) => - areas.findOneAreaByUUID(area.metadata.area_id) + const addedAreas = await Promise.allSettled( + result.addedAreaIds.map((areaId) => + areas.findOneAreaByUUID(muuid.from(areaId)) + ) + ); + const updatedAreas = await Promise.allSettled( + result.updatedAreaIds.map((areaId) => + areas.findOneAreaByUUID(muuid.from(areaId)) ) ); const committedClimbs = await Promise.allSettled( @@ -46,10 +47,12 @@ describe('bulk import e2e', () => { ...result, errors: [ ...result.errors, - ...committedAreas.filter(isRejected).map((p) => p.reason), + ...addedAreas.filter(isRejected).map((p) => p.reason), ...committedClimbs.filter(isRejected).map((p) => p.reason), + ...updatedAreas.filter(isRejected).map((p) => p.reason), ], - addedAreas: committedAreas.filter(isFulfilled).map((p) => p.value), + addedAreas: addedAreas.filter(isFulfilled).map((p) => p.value), + updatedAreas: updatedAreas.filter(isFulfilled).map((p) => p.value), addedClimbs: committedClimbs .filter(isFulfilled) .map((p) => p.value as Partial), @@ -241,7 +244,7 @@ describe('bulk import e2e', () => { }) ).resolves.toMatchObject({ errors: [], - addedAreas: [{area_name: 'New Name'}], + updatedAreas: [{area_name: 'New Name'}], }); }); }); diff --git a/src/db/import/json/import-json.ts b/src/db/import/json/import-json.ts index 3fcc624d..5362eca2 100644 --- a/src/db/import/json/import-json.ts +++ b/src/db/import/json/import-json.ts @@ -1,11 +1,12 @@ import { Point } from '@turf/helpers' import mongoose from 'mongoose' -import { MUUID } from 'uuid-mongodb' +import muuid, { MUUID } from 'uuid-mongodb' import { logger } from '../../../logger.js' import MutableAreaDataSource from '../../../model/MutableAreaDataSource.js' import MutableClimbDataSource from '../../../model/MutableClimbDataSource.js' import { AreaType } from '../../AreaTypes.js' import { ClimbChangeInputType, ClimbType } from '../../ClimbTypes.js' +import { withTransaction } from '../../../utils/helpers' export type AreaJson = Partial< Omit @@ -33,7 +34,8 @@ export interface BulkImportOptions { } export interface BulkImportResult { - addedAreas: AreaType[] + addedAreaIds: string[] + updatedAreaIds: string[] climbIds: string[] errors: Error[] } @@ -50,20 +52,19 @@ export async function bulkImportJson ({ areas = MutableAreaDataSource.getInstance(), climbs = MutableClimbDataSource.getInstance() }: BulkImportOptions): Promise { - let result: BulkImportResult = { - addedAreas: [], + const result: BulkImportResult = { + addedAreaIds: [], + updatedAreaIds: [], climbIds: [], errors: [] } logger.debug('starting bulk import session...') const session = _session ?? (await mongoose.startSession()) try { - await session.withTransaction(async () => { + return await withTransaction(session, async () => { logger.info('starting bulk import...', json) - result = await _bulkImportJson({ user, json, areas, climbs, session }) - logger.info('bulk import successful', result) - return result - }) + return await _bulkImportJson({ user, json, areas, climbs, session }) + }) ?? result } catch (e) { logger.error('bulk import failed', e) result.errors.push(e) @@ -80,17 +81,20 @@ async function _bulkImportJson ({ areas = MutableAreaDataSource.getInstance(), climbs = MutableClimbDataSource.getInstance() }: BulkImportOptions): Promise { - const addedAreas: AreaType[] = [] - const climbIds: string[] = [] - - const addArea = async ( + const addOrUpdateArea = async ( node: AreaJson, parentUuid?: MUUID - ): Promise => { - const result: AreaType[] = [] - let area: AreaType + ): Promise => { + const result: BulkImportResult = { + addedAreaIds: [], + updatedAreaIds: [], + climbIds: [], + errors: [] + } + let area: AreaType | null if (node.id !== undefined && node.id !== null) { - area = (await areas.updateArea(user, node.id, node, session)) as AreaType + area = await areas?.updateAreaWith({ user, areaUuid: muuid.from(node.id), document: node, session }) + if (area != null) { result.updatedAreaIds.push(area.metadata.area_id.toUUID().toString()) } } else if (node.areaName !== undefined) { area = await areas.addAreaWith({ user, @@ -99,36 +103,40 @@ async function _bulkImportJson ({ parentUuid, session }) + result.addedAreaIds.push(area?.metadata.area_id.toUUID().toString()) } else { throw new Error('areaName or id is required') } - result.push(area) - if (node.children != null) { + if ((node.children != null) && (area != null)) { for (const child of node.children) { - result.push(...(await addArea(child, area.metadata.area_id))) + const childResult = await addOrUpdateArea(child, area.metadata.area_id) + result.updatedAreaIds.push(...childResult.updatedAreaIds) + result.addedAreaIds.push(...childResult.addedAreaIds) + result.climbIds.push(...childResult.climbIds) } } - if (node.climbs != null) { - climbIds.push( - ...(await climbs.addOrUpdateClimbsWith({ - userId: user, - parentId: area.metadata.area_id, - changes: [...node.climbs], - session - })) - ) + if ((node.climbs != null) && (area != null)) { + const climbIds = await climbs.addOrUpdateClimbsWith({ + userId: user, + parentId: area.metadata.area_id, + changes: [...node.climbs ?? []], + session + }) + result.climbIds.push(...climbIds) } - return [...result] + return result } - for (const node of json?.areas ?? []) { - // fails fast and throws errors up the chain - addedAreas.push(...(await addArea(node))) - } - - return { - addedAreas: [...addedAreas], - climbIds, + const results = await Promise.all(json?.areas?.map(async (node) => await (addOrUpdateArea(node) ?? {})) ?? []) + return results.reduce((acc, r) => ({ + addedAreaIds: [...acc.addedAreaIds, ...r.addedAreaIds], + updatedAreaIds: [...acc.updatedAreaIds, ...r.updatedAreaIds], + climbIds: [...acc.climbIds, ...r.climbIds], + errors: [...acc.errors, ...r.errors] + }), { + addedAreaIds: [], + updatedAreaIds: [], + climbIds: [], errors: [] - } + }) } diff --git a/src/db/import/json/request-handler.ts b/src/db/import/json/request-handler.ts index ed505d13..98d45f68 100644 --- a/src/db/import/json/request-handler.ts +++ b/src/db/import/json/request-handler.ts @@ -9,7 +9,7 @@ export const importJsonRequestHandler = async (req, res): Promise => { res.status(500).send({ message: 'Error importing JSON', errors: result.errors.map((e) => e.toString()) }) } else { res.status(200).send(result) - logger.info(`Imported JSON: ${result.addedAreas.length} areas, ${result.climbIds.length} climbs`) + logger.info(`Imported JSON: ${result.addedAreaIds.length} areas created, ${result.updatedAreaIds.length} areas updated, ${result.climbIds.length} climbs updated or added`) } } catch (e) { logger.error(`Error importing JSON: ${e.toString() as string}`) diff --git a/src/model/AreaDataSource.ts b/src/model/AreaDataSource.ts index 2f4f3839..443c2aad 100644 --- a/src/model/AreaDataSource.ts +++ b/src/model/AreaDataSource.ts @@ -5,7 +5,16 @@ import bboxPolygon from '@turf/bbox-polygon' import { getAreaModel, getMediaObjectModel } from '../db/index.js' import { AreaType } from '../db/AreaTypes' -import { GQLFilter, AreaFilterParams, PathTokenParams, LeafStatusParams, ComparisonFilterParams, StatisticsType, CragsNear, BBoxType } from '../types' +import { + AreaFilterParams, + BBoxType, + ComparisonFilterParams, + CragsNear, + GQLFilter, + LeafStatusParams, + PathTokenParams, + StatisticsType +} from '../types' import { getClimbModel } from '../db/ClimbSchema.js' import { ClimbGQLQueryType } from '../db/ClimbTypes.js' import { logger } from '../logger.js' @@ -113,7 +122,7 @@ export default class AreaDataSource extends MongoDataSource { if (rs != null && rs.length === 1) { return rs[0] } - throw new Error(`Area ${uuid.toUUID().toString()} not found.`) + throw new Error(`Area ${uuid.toString()} not found.`) } async findManyClimbsByUuids (uuidList: muuid.MUUID[]): Promise { @@ -152,7 +161,7 @@ export default class AreaDataSource extends MongoDataSource { { $unwind: '$parent' }, // Previous stage returns as an array of 1 element. 'unwind' turn it into an object. { $set: { - // create aliases + // create aliases pathTokens: '$parent.pathTokens', ancestors: '$parent.ancestors' } diff --git a/src/model/MutableAreaDataSource.ts b/src/model/MutableAreaDataSource.ts index 2661003a..7beaa139 100644 --- a/src/model/MutableAreaDataSource.ts +++ b/src/model/MutableAreaDataSource.ts @@ -405,7 +405,7 @@ export default class MutableAreaDataSource extends AreaDataSource { if (isBoulder != null) { area.set({ 'metadata.isBoulder': isBoulder }) if (isBoulder) { - // boulfer == true implies leaf = true + // boulder == true implies leaf = true area.set({ 'metadata.leaf': true }) } } diff --git a/src/server.ts b/src/server.ts index c029bf13..e96bc43d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,6 +22,7 @@ import express from 'express' import * as http from 'http' import bodyParser from 'body-parser' import { importJsonRequestHandler } from './db/import/json/request-handler.js' +import { hasEditorRoleMiddleware } from './auth/rules' export async function createServer (): Promise<{ app: express.Application, server: ApolloServer }> { const schema = applyMiddleware( @@ -58,6 +59,7 @@ export async function createServer (): Promise<{ app: express.Application, serve app.post('/import', [ process.env.LOCAL_DEV_BYPASS_AUTH === 'true' ? localDevBypassAuthMiddleware : authMiddleware, + hasEditorRoleMiddleware, bodyParser.json(), importJsonRequestHandler ]) diff --git a/src/utils/testUtils.ts b/src/utils/testUtils.ts index 6f6957cc..b1bd8950 100644 --- a/src/utils/testUtils.ts +++ b/src/utils/testUtils.ts @@ -10,13 +10,15 @@ import express from 'express' const PORT = 4000 interface QueryAPIProps { - query: string + query?: string operationName?: string - variables: any - userUuid: string + variables?: any + userUuid?: string roles?: string[] port?: number + endpoint?: string app?: express.Application + body?: any } /* @@ -27,10 +29,12 @@ export const queryAPI = async ({ query, operationName, variables, - userUuid, + userUuid = '', roles = [], app, - port = PORT + endpoint = '/', + port = PORT, + body = { query, operationName, variables } }: QueryAPIProps): Promise => { // Avoid needing to pass in actual signed tokens. const jwtSpy = jest.spyOn(jwt, 'verify') @@ -42,10 +46,9 @@ export const queryAPI = async ({ } }) - const queryObj = { query, operationName, variables } return await request(app ?? `http://localhost:${port}`) - .post('/') - .send(queryObj) + .post(endpoint) + .send(body) .set('Authorization', 'Bearer placeholder-jwt-see-SpyOn') } @@ -63,3 +66,10 @@ export const setUpServer = async (): Promise => { const { app, server } = await createServer() return { app, server, inMemoryDB } } + +export const isFulfilled = ( + p: PromiseSettledResult +): p is PromiseFulfilledResult => p.status === 'fulfilled' +export const isRejected = ( + p: PromiseSettledResult +): p is PromiseRejectedResult => p.status === 'rejected'