From 99200485bf6347727dfa5fb7c8ea457033f3336d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Alberto=20Leiva=20Obando?= <56046999+jleiva-gap@users.noreply.github.com> Date: Mon, 13 Nov 2023 13:28:55 -0600 Subject: [PATCH] [RND-651] Ability to loosely allow overposted material for parity (#314) --- .../src/validation/QueryStringValidator.ts | 2 +- .../validation/ResourceSchemaValidation.ts | 19 +- .../meadowlark-core/test/TestHelper.ts | 4 + .../test/integration/handler/GetById.test.ts | 635 ++++++++++++++++++ .../test/integration/handler/Query.test.ts | 182 +++++ .../test/integration/handler/Update.test.ts | 273 ++++++++ .../test/integration/handler/Upsert.test.ts | 248 +++++++ ...QueryStringValidatorForOverposting.test.ts | 54 +- 8 files changed, 1393 insertions(+), 24 deletions(-) create mode 100644 Meadowlark-js/packages/meadowlark-core/test/integration/handler/GetById.test.ts create mode 100644 Meadowlark-js/packages/meadowlark-core/test/integration/handler/Query.test.ts create mode 100644 Meadowlark-js/packages/meadowlark-core/test/integration/handler/Update.test.ts create mode 100644 Meadowlark-js/packages/meadowlark-core/test/integration/handler/Upsert.test.ts diff --git a/Meadowlark-js/packages/meadowlark-core/src/validation/QueryStringValidator.ts b/Meadowlark-js/packages/meadowlark-core/src/validation/QueryStringValidator.ts index ad7af770..70c115ad 100644 --- a/Meadowlark-js/packages/meadowlark-core/src/validation/QueryStringValidator.ts +++ b/Meadowlark-js/packages/meadowlark-core/src/validation/QueryStringValidator.ts @@ -19,7 +19,7 @@ export type QueryStringValidationResult = { * Validates that those queryParameters which are present are actually fields on the API resource */ export function validateQueryParameters(resourceSchema: ResourceSchema, queryParameters: FrontendQueryParameters): string[] { - const { queryValidator } = getSchemaValidatorsFor(resourceSchema); + const { queryValidator } = getSchemaValidatorsFor(resourceSchema, true); let errors: string[] = []; diff --git a/Meadowlark-js/packages/meadowlark-core/src/validation/ResourceSchemaValidation.ts b/Meadowlark-js/packages/meadowlark-core/src/validation/ResourceSchemaValidation.ts index c2b6457b..1969f537 100644 --- a/Meadowlark-js/packages/meadowlark-core/src/validation/ResourceSchemaValidation.ts +++ b/Meadowlark-js/packages/meadowlark-core/src/validation/ResourceSchemaValidation.ts @@ -18,8 +18,9 @@ export type ResourceSchemaValidators = { updateValidator: ValidateFunction; }; -function initializeAjv(): Ajv { - const removeAdditional = false; // TODO: replace on merge with RND-651 +function initializeAjv(isQueryParameterValidator: boolean): Ajv { + // A query parameter validator cannot have additional properties + const removeAdditional = isQueryParameterValidator ? false : getBooleanFromEnvironment('ALLOW_OVERPOSTING', false); const coerceTypes = getBooleanFromEnvironment('ALLOW_TYPE_COERCION', false); const ajv = new Ajv({ allErrors: true, coerceTypes, removeAdditional }); @@ -34,16 +35,19 @@ let ajv: Ajv | null = null; // simple cache implementation, see: https://rewind.io/blog/simple-caching-in-aws-lambda-functions/ /** This is a cache mapping ResourceSchema objects to compiled ajv JSON Schema validators for the API resource */ const validatorCache: Map = new Map(); +const queryValidatorCache: Map = new Map(); /** * Returns the API resource JSON Schema validator functions for the given ResourceSchema. Caches results. */ -export function getSchemaValidatorsFor(resourceSchema: ResourceSchema): ResourceSchemaValidators { - if (ajv == null) ajv = initializeAjv(); - - const cachedValidators: ResourceSchemaValidators | undefined = validatorCache.get(resourceSchema); +export function getSchemaValidatorsFor( + resourceSchema: ResourceSchema, + isQueryParameterValidator: boolean = false, +): ResourceSchemaValidators { + const validatorCacheObject = isQueryParameterValidator ? queryValidatorCache : validatorCache; + const cachedValidators: ResourceSchemaValidators | undefined = validatorCacheObject.get(resourceSchema); if (cachedValidators != null) return cachedValidators; - + ajv = initializeAjv(isQueryParameterValidator); const resourceValidators: ResourceSchemaValidators = { insertValidator: ajv.compile(resourceSchema.jsonSchemaForInsert), queryValidator: ajv.compile(resourceSchema.jsonSchemaForQuery), @@ -58,4 +62,5 @@ export function getSchemaValidatorsFor(resourceSchema: ResourceSchema): Resource */ export function clearAllValidatorCache(): void { validatorCache.clear(); + queryValidatorCache.clear(); } diff --git a/Meadowlark-js/packages/meadowlark-core/test/TestHelper.ts b/Meadowlark-js/packages/meadowlark-core/test/TestHelper.ts index 4e0b2ff1..3781ebef 100644 --- a/Meadowlark-js/packages/meadowlark-core/test/TestHelper.ts +++ b/Meadowlark-js/packages/meadowlark-core/test/TestHelper.ts @@ -21,3 +21,7 @@ export function apiSchemaFrom(metaEd: MetaEdEnvironment): ApiSchema { const pluginEnvironment = metaEd.plugin.get('edfiApiSchema') as PluginEnvironment; return (pluginEnvironment.data as PluginEnvironmentEdfiApiSchema).apiSchema; } + +export function restoreSpies(spies: jest.SpyInstance[]) { + spies.forEach((spy) => spy.mockRestore()); +} diff --git a/Meadowlark-js/packages/meadowlark-core/test/integration/handler/GetById.test.ts b/Meadowlark-js/packages/meadowlark-core/test/integration/handler/GetById.test.ts new file mode 100644 index 00000000..1f3252c2 --- /dev/null +++ b/Meadowlark-js/packages/meadowlark-core/test/integration/handler/GetById.test.ts @@ -0,0 +1,635 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +import { Config, Environment, initializeLogging, getBooleanFromEnvironment } from '@edfi/meadowlark-utilities'; +import { FrontendRequest, newFrontendRequest, newFrontendRequestMiddleware } from '../../../src/handler/FrontendRequest'; +import { FrontendResponse } from '../../../src/handler/FrontendResponse'; +import { getById, upsert, update } from '../../../src/handler/FrontendFacade'; +import * as Publish from '../../../src/plugin/listener/Publish'; +import { NoDocumentStorePlugin } from '../../../src/plugin/backend/NoDocumentStorePlugin'; +import * as PluginLoader from '../../../src/plugin/PluginLoader'; +import { MiddlewareModel } from '../../../src/middleware/MiddlewareModel'; +import * as AuthorizationMiddleware from '../../../src/middleware/AuthorizationMiddleware'; +import * as ParsePathMiddleware from '../../../src/middleware/ParsePathMiddleware'; +import { setupMockConfiguration } from '../../ConfigHelper'; +import { UpsertResult } from '../../../src/message/UpsertResult'; +import { clearAllValidatorCache } from '../../../src/validation/ResourceSchemaValidation'; +import { GetResult } from '../../../src/message/GetResult'; +import { DocumentUuid } from '../../../src/model/IdTypes'; +import { restoreSpies } from '../../TestHelper'; +import { UpdateResult } from '../../../src/message/UpdateResult'; +import { EndpointName } from '../../../src/model/api-schema/EndpointName'; +import { ProjectNamespace } from '../../../src/model/api-schema/ProjectNamespace'; +import { ProjectShortVersion } from '../../../src/model/ProjectShortVersion'; + +let upsertResponse: FrontendResponse; +let updateResponse: FrontendResponse; +let getResponse: FrontendResponse; +let mockDocumentStore: jest.SpyInstance; +let mockAuthorizationMiddleware: jest.SpyInstance; +let documentUuid: DocumentUuid; +const originalGetBooleanFromEnvironment = getBooleanFromEnvironment; +const baseRequestBody = { + weekIdentifier: '123456', + schoolReference: { + schoolId: 123, + }, + beginDate: '2023-10-30', + endDate: '2023-10-30', + totalInstructionalDays: 10, +}; +const updateRequestBody = { + ...baseRequestBody, +}; +const updateResponseBody = { + ...updateRequestBody, + _lastModifiedDate: '1970-01-01T00:00:00.000Z', +}; +const requestBodyAdditionalProperties = { + ...updateRequestBody, + extraneousProperty: 'LoremIpsum', + secondExtraneousProperty: 'Second additional', +}; +const frontendRequest: FrontendRequest = { + ...newFrontendRequest(), + path: '/v3.3b/ed-fi/academicWeeks/', + body: JSON.stringify(baseRequestBody), + middleware: { + ...newFrontendRequestMiddleware(), + pathComponents: { + endpointName: 'academicWeeks' as unknown as EndpointName, + projectNamespace: 'ed-fi' as unknown as ProjectNamespace, + projectShortVersion: 'v3.3b' as unknown as ProjectShortVersion, + }, + }, +}; +const frontendRequestAdditionalProperties: FrontendRequest = { + ...frontendRequest, + body: JSON.stringify(requestBodyAdditionalProperties), +}; + +const upsertResult: UpsertResult = { + response: 'INSERT_SUCCESS', + newDocumentUuid: '6b48af60-afe7-4df2-b783-dc614ec9bb64', + failureMessage: null, +} as unknown as UpsertResult; + +const updateResult: UpdateResult = { + response: 'UPDATE_SUCCESS', + failureMessage: null, +} as unknown as UpdateResult; + +const getResult: GetResult = { + response: 'GET_SUCCESS', + edfiDoc: undefined as unknown as object, + documentUuid: '6b48af60-afe7-4df2-b783-dc614ec9bb64' as DocumentUuid, + lastModifiedDate: 0, +}; + +describe('given a get with ALLOW_OVERPOSTING equals to false', () => { + beforeAll(async () => { + clearAllValidatorCache(); + setupMockConfiguration(); + initializeLogging(); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue(NoDocumentStorePlugin); + jest.spyOn(Environment, 'getBooleanFromEnvironment').mockImplementation((key: Config.ConfigKeys) => { + if (key === 'ALLOW_OVERPOSTING') { + return false; + } + return originalGetBooleanFromEnvironment(key, false); + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + afterEach(() => { + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + }); + + describe('when getting a document after invoke an upsert without extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest, + frontendResponse: null, + }; + mockDocumentStore = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + upsertDocument: async () => Promise.resolve(upsertResult), + }); + jest.spyOn(Publish, 'afterUpsertDocument').mockImplementation(async () => Promise.resolve()); + mockAuthorizationMiddleware = jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + // Act + upsertResponse = await upsert(frontendRequest); + documentUuid = ( + (upsertResponse?.headers?.Location.split('/').pop() ?? (undefined as unknown as DocumentUuid)) + ); + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + // Finally, we want to get the document we just created. + // Prepare request to invoke get. + const FrontEndGetRequest: FrontendRequest = { + ...frontendRequestAdditionalProperties, + path: `${frontendRequestAdditionalProperties.path}${documentUuid}`, + middleware: { + ...frontendRequestAdditionalProperties.middleware, + pathComponents: { + ...frontendRequestAdditionalProperties.middleware.pathComponents, + documentUuid, + }, + }, + }; + const getModel: MiddlewareModel = { + frontendRequest: FrontEndGetRequest, + frontendResponse: null, + }; + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(getModel); + jest.spyOn(Publish, 'afterGetDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + getDocumentById: async () => + Promise.resolve({ + ...getResult, + edfiDoc: frontendRequestAdditionalProperties.middleware.parsedBody, + }), + }); + // Act + getResponse = await getById(FrontEndGetRequest); + }); + + it('returns status 201 on Upsert', () => { + expect(upsertResponse.statusCode).toEqual(201); + }); + + it('returns status 200 on Get', () => { + expect(getResponse.statusCode).toEqual(200); + }); + + it('should not return extraneous properties', () => { + expect(getResponse.body).toEqual( + expect.objectContaining({ + ...baseRequestBody, + id: documentUuid, + }), + ); + }); + }); + + describe('when getting a document after invoke an upsert that fails with extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest: frontendRequestAdditionalProperties, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpsertDocument').mockImplementation(async () => Promise.resolve()); + mockAuthorizationMiddleware = jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + mockDocumentStore = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + upsertDocument: async () => Promise.resolve(upsertResult), + }); + // Act + upsertResponse = await upsert(frontendRequestAdditionalProperties); + documentUuid = ( + (upsertResponse?.headers?.Location?.split('/').pop() ?? (undefined as unknown as DocumentUuid)) + ); + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + // Finally, we want to get the document we just created. + // Prepare request to invoke get. + const FrontEndGetRequest: FrontendRequest = { + ...frontendRequestAdditionalProperties, + path: `${frontendRequestAdditionalProperties.path}${documentUuid}`, + middleware: { + ...frontendRequestAdditionalProperties.middleware, + pathComponents: { + ...frontendRequestAdditionalProperties.middleware.pathComponents, + documentUuid, + }, + }, + }; + const getModel: MiddlewareModel = { + frontendRequest: FrontEndGetRequest, + frontendResponse: null, + }; + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(getModel); + jest.spyOn(Publish, 'afterGetDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + getDocumentById: async () => + Promise.resolve({ + ...getResult, + edfiDoc: frontendRequestAdditionalProperties.middleware.parsedBody, + }), + }); + // Act + getResponse = await getById(FrontEndGetRequest); + }); + + afterAll(() => { + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + }); + + it('returns status 400 on Upsert', () => { + expect(upsertResponse.statusCode).toEqual(400); + }); + + it('returns error on Upsert for extraneous properties', () => { + expect(upsertResponse.body).toMatchInlineSnapshot(` + { + "error": [ + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'extraneousProperty' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'weekIdentifier'?", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'secondExtraneousProperty' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'schoolReference'?", + }, + ], + } + `); + }); + + it('returns status 404 on Get', () => { + expect(getResponse.statusCode).toEqual(404); + }); + }); + + describe('when getting a document after invoke an update that fails with extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpsertDocument').mockImplementation(async () => Promise.resolve()); + mockAuthorizationMiddleware = jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + mockDocumentStore = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + upsertDocument: async () => Promise.resolve(upsertResult), + }); + // Act + upsertResponse = await upsert(frontendRequest); + documentUuid = ( + (upsertResponse?.headers?.Location?.split('/').pop() ?? (undefined as unknown as DocumentUuid)) + ); + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + const FrontEndUpdateRequest: FrontendRequest = { + ...frontendRequestAdditionalProperties, + path: `${frontendRequestAdditionalProperties.path}${documentUuid}`, + body: JSON.stringify({ + ...requestBodyAdditionalProperties, + id: documentUuid, + }), + middleware: { + ...frontendRequestAdditionalProperties.middleware, + pathComponents: { + ...frontendRequestAdditionalProperties.middleware.pathComponents, + documentUuid, + }, + }, + }; + const getUpdateModel: MiddlewareModel = { + frontendRequest: FrontEndUpdateRequest, + frontendResponse: null, + }; + mockAuthorizationMiddleware = jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(getUpdateModel); + jest.spyOn(Publish, 'afterUpdateDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(ParsePathMiddleware, 'parsePath').mockResolvedValue(getUpdateModel); + mockDocumentStore = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + updateDocumentById: async () => Promise.resolve(updateResult), + }); + // Act + updateResponse = await update(FrontEndUpdateRequest); + documentUuid = ( + (updateResponse?.headers?.Location?.split('/').pop() ?? (undefined as unknown as DocumentUuid)) + ); + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + // Finally, we want to get the document we just created. + // Prepare request to invoke get. + const FrontEndGetRequest: FrontendRequest = { + ...frontendRequestAdditionalProperties, + path: `${frontendRequestAdditionalProperties.path}${documentUuid}`, + middleware: { + ...FrontEndUpdateRequest.middleware, + pathComponents: { + ...FrontEndUpdateRequest.middleware.pathComponents, + documentUuid, + }, + }, + }; + const getModel: MiddlewareModel = { + frontendRequest: FrontEndGetRequest, + frontendResponse: null, + }; + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(getModel); + jest.spyOn(Publish, 'afterGetDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + getDocumentById: async () => + Promise.resolve({ + ...getResult, + edfiDoc: frontendRequestAdditionalProperties.middleware.parsedBody, + }), + }); + // Act + getResponse = await getById(FrontEndGetRequest); + }); + + afterAll(() => { + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + }); + + it('returns status 201 on Upsert', () => { + expect(upsertResponse.statusCode).toEqual(201); + }); + + it('returns errors on Update for extraneous properties', () => { + expect(updateResponse.body).toMatchInlineSnapshot(` + { + "error": [ + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'extraneousProperty' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'weekIdentifier'?", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'secondExtraneousProperty' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'schoolReference'?", + }, + ], + } + `); + }); + + it('returns status 404 on Get', () => { + expect(getResponse.statusCode).toEqual(404); + }); + }); +}); + +describe('given a get with ALLOW_OVERPOSTING equals to true', () => { + beforeAll(async () => { + clearAllValidatorCache(); + setupMockConfiguration(); + initializeLogging(); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue(NoDocumentStorePlugin); + jest.spyOn(Environment, 'getBooleanFromEnvironment').mockImplementation((key: Config.ConfigKeys) => { + if (key === 'ALLOW_OVERPOSTING') { + return true; + } + return originalGetBooleanFromEnvironment(key, false); + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('when getting a document after invoke upserting without extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpsertDocument').mockImplementation(async () => Promise.resolve()); + mockAuthorizationMiddleware = jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + mockDocumentStore = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + upsertDocument: async () => Promise.resolve(upsertResult), + }); + // Act upsert + upsertResponse = await upsert(frontendRequest); + documentUuid = ( + (upsertResponse?.headers?.Location.split('/').pop() ?? (undefined as unknown as DocumentUuid)) + ); + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + // Finally, we want to get the document we just created. + // Prepare request to invoke get. + const FrontEndGetRequest: FrontendRequest = { + ...frontendRequest, + path: `${frontendRequestAdditionalProperties.path}${documentUuid}`, + middleware: { + ...frontendRequest.middleware, + pathComponents: { + ...frontendRequest.middleware.pathComponents, + documentUuid, + }, + }, + }; + const getModel: MiddlewareModel = { + frontendRequest: FrontEndGetRequest, + frontendResponse: null, + }; + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(getModel); + jest.spyOn(Publish, 'afterGetDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + getDocumentById: async () => + Promise.resolve({ + ...getResult, + edfiDoc: frontendRequestAdditionalProperties.middleware.parsedBody, + }), + }); + // Act + getResponse = await getById(FrontEndGetRequest); + }); + + it('returns status 200', () => { + expect(getResponse.statusCode).toEqual(200); + }); + + it('should not return extraneous properties', () => { + expect(getResponse.body).toEqual( + expect.objectContaining({ + ...baseRequestBody, + id: documentUuid, + }), + ); + }); + }); + // when getting a document after invoke upserting it with extraneous properties + describe('when getting a document after invoke upserting with extraneous properties', () => { + beforeAll(async () => { + // First, we need to create a insert a document with extraneous properties. + const model: MiddlewareModel = { + frontendRequest: frontendRequestAdditionalProperties, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpsertDocument').mockImplementation(async () => Promise.resolve()); + mockAuthorizationMiddleware = jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + mockDocumentStore = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + upsertDocument: async () => Promise.resolve(upsertResult), + }); + // Act upsert + upsertResponse = await upsert(frontendRequestAdditionalProperties); + documentUuid = ( + (upsertResponse?.headers?.Location.split('/').pop() ?? (undefined as unknown as DocumentUuid)) + ); + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + // Finally, we want to get the document we just created. + // Prepare request to invoke get. + const FrontEndGetRequest: FrontendRequest = { + ...frontendRequestAdditionalProperties, + path: `${frontendRequestAdditionalProperties.path}${documentUuid}`, + middleware: { + ...frontendRequestAdditionalProperties.middleware, + pathComponents: { + ...frontendRequestAdditionalProperties.middleware.pathComponents, + documentUuid, + }, + }, + }; + const getModel: MiddlewareModel = { + frontendRequest: FrontEndGetRequest, + frontendResponse: null, + }; + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(getModel); + jest.spyOn(Publish, 'afterGetDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + getDocumentById: async () => + Promise.resolve({ + ...getResult, + edfiDoc: frontendRequestAdditionalProperties.middleware.parsedBody, + }), + }); + // Act + getResponse = await getById(FrontEndGetRequest); + }); + + it('returns status 200', () => { + expect(getResponse.statusCode).toEqual(200); + }); + + it('should not return extraneous properties', () => { + expect(getResponse.body).toEqual( + expect.objectContaining({ + ...updateResponseBody, + id: documentUuid, + }), + ); + }); + }); + + describe('when getting a document after invoke an update with extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpsertDocument').mockImplementation(async () => Promise.resolve()); + mockAuthorizationMiddleware = jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + mockDocumentStore = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + upsertDocument: async () => Promise.resolve(upsertResult), + }); + // Act + upsertResponse = await upsert(frontendRequest); + documentUuid = ( + (upsertResponse?.headers?.Location?.split('/').pop() ?? (undefined as unknown as DocumentUuid)) + ); + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + const FrontEndUpdateRequest: FrontendRequest = { + ...frontendRequestAdditionalProperties, + path: `${frontendRequestAdditionalProperties.path}${documentUuid}`, + body: JSON.stringify({ + ...requestBodyAdditionalProperties, + id: documentUuid, + }), + middleware: { + ...frontendRequestAdditionalProperties.middleware, + pathComponents: { + ...frontendRequestAdditionalProperties.middleware.pathComponents, + documentUuid, + }, + }, + }; + const getUpdateModel: MiddlewareModel = { + frontendRequest: FrontEndUpdateRequest, + frontendResponse: null, + }; + mockAuthorizationMiddleware = jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(getUpdateModel); + jest.spyOn(Publish, 'afterUpdateDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(ParsePathMiddleware, 'parsePath').mockResolvedValue(getUpdateModel); + mockDocumentStore = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + updateDocumentById: async () => Promise.resolve(updateResult), + }); + // Act + updateResponse = await update(FrontEndUpdateRequest); + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + // Finally, we want to get the document we just created. + // Prepare request to invoke get. + const FrontEndGetRequest: FrontendRequest = { + ...frontendRequestAdditionalProperties, + path: `${frontendRequestAdditionalProperties.path}${documentUuid}`, + middleware: { + ...FrontEndUpdateRequest.middleware, + pathComponents: { + ...FrontEndUpdateRequest.middleware.pathComponents, + documentUuid, + }, + }, + }; + const getModel: MiddlewareModel = { + frontendRequest: FrontEndGetRequest, + frontendResponse: null, + }; + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(getModel); + jest.spyOn(Publish, 'afterGetDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + getDocumentById: async () => + Promise.resolve({ + ...getResult, + edfiDoc: frontendRequestAdditionalProperties.middleware.parsedBody, + }), + }); + // Act + getResponse = await getById(FrontEndGetRequest); + }); + + afterAll(() => { + restoreSpies([mockDocumentStore, mockAuthorizationMiddleware]); + }); + + it('returns status 201 on Upsert', () => { + expect(upsertResponse.statusCode).toEqual(201); + }); + + it('returns 204 on Update for extraneous properties', () => { + expect(updateResponse.statusCode).toEqual(204); + }); + + it('returns status 200 on Get', () => { + expect(getResponse.statusCode).toEqual(200); + }); + + it('get should not return extraneous properties', () => { + expect(getResponse.body).toEqual( + expect.objectContaining({ + ...updateRequestBody, + id: documentUuid, + }), + ); + }); + }); +}); diff --git a/Meadowlark-js/packages/meadowlark-core/test/integration/handler/Query.test.ts b/Meadowlark-js/packages/meadowlark-core/test/integration/handler/Query.test.ts new file mode 100644 index 00000000..5d1e50b7 --- /dev/null +++ b/Meadowlark-js/packages/meadowlark-core/test/integration/handler/Query.test.ts @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +import { Config, Environment, initializeLogging, getBooleanFromEnvironment } from '@edfi/meadowlark-utilities'; +import { clearAllValidatorCache } from '../../../src/validation/ResourceSchemaValidation'; +import { FrontendRequest, newFrontendRequest, newFrontendRequestMiddleware } from '../../../src/handler/FrontendRequest'; +import { FrontendResponse } from '../../../src/handler/FrontendResponse'; +import { QueryResult } from '../../../src/message/QueryResult'; +import { EndpointName } from '../../../src/model/api-schema/EndpointName'; +import { ProjectNamespace } from '../../../src/model/api-schema/ProjectNamespace'; +import { ProjectShortVersion } from '../../../src/model/ProjectShortVersion'; +import { NoDocumentStorePlugin } from '../../../src/plugin/backend/NoDocumentStorePlugin'; +import { setupMockConfiguration } from '../../ConfigHelper'; +import { query } from '../../../src/handler/FrontendFacade'; +import * as PluginLoader from '../../../src/plugin/PluginLoader'; +import { MiddlewareModel } from '../../../src/middleware/MiddlewareModel'; +import * as AuthorizationMiddleware from '../../../src/middleware/AuthorizationMiddleware'; + +const originalGetBooleanFromEnvironment = getBooleanFromEnvironment; +const frontendRequest: FrontendRequest = { + ...newFrontendRequest(), + queryParameters: { + weekIdentifier: 'weekIdentifier', + }, + body: '{}', + middleware: { + ...newFrontendRequestMiddleware(), + pathComponents: { + endpointName: 'academicWeeks' as EndpointName, + projectNamespace: 'ed-fi' as ProjectNamespace, + projectShortVersion: 'v3.3b' as ProjectShortVersion, + }, + }, +}; + +const frontendRequestAdditionalProperties: FrontendRequest = { + ...frontendRequest, + queryParameters: { + ...frontendRequest.queryParameters, + extraneousProperty1: 'extraneousProperty1', + extraneousProperty2: 'extraneousProperty2', + }, + body: '{}', + middleware: { + ...newFrontendRequestMiddleware(), + pathComponents: { + endpointName: 'academicWeeks' as EndpointName, + projectNamespace: 'ed-fi' as ProjectNamespace, + projectShortVersion: 'v3.3b' as ProjectShortVersion, + }, + }, +}; + +// eslint-disable-next-line no-template-curly-in-string +describe.each([true, false])('given a query with ALLOW_OVERPOSTING: %p', (allowOverposting) => { + beforeAll(async () => { + clearAllValidatorCache(); + setupMockConfiguration(); + initializeLogging(); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue(NoDocumentStorePlugin); + jest.spyOn(Environment, 'getBooleanFromEnvironment').mockImplementation((key: Config.ConfigKeys) => { + if (key === 'ALLOW_OVERPOSTING') { + return allowOverposting; + } + return originalGetBooleanFromEnvironment(key, false); + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('when querying a document without additional properties', () => { + let response: FrontendResponse; + let mockQueryHandler: any; + const goodResult: object = { goodResult: 'result' }; + const headers: object = [{ totalCount: '1' }]; + + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest, + frontendResponse: null, + }; + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + mockQueryHandler = jest.spyOn(PluginLoader, 'getQueryHandler').mockReturnValue({ + ...NoDocumentStorePlugin, + queryDocuments: async () => + Promise.resolve({ + response: 'QUERY_SUCCESS', + documents: [goodResult], + Headers: headers, + } as unknown as QueryResult), + }); + + // Act + response = await query(frontendRequest); + }); + + afterAll(() => { + mockQueryHandler.mockRestore(); + }); + + it('returns status 200', () => { + expect(response.statusCode).toEqual(200); + }); + + it('returns total count of 1', () => { + expect(headers[0].totalCount).toEqual('1'); + }); + + it('returns 1 result', () => { + expect(response.body).toHaveLength(1); + }); + + it('returns expected object', () => { + expect(response.body).toMatchInlineSnapshot( + ` + [ + { + "goodResult": "result", + }, + ] + `, + ); + }); + }); + + describe('when querying a document with additional properties', () => { + let response: FrontendResponse; + let mockQueryHandler: jest.SpyInstance; + const goodResult: object = { goodResult: 'result' }; + const headers: object = [{ totalCount: '1' }]; + + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest: frontendRequestAdditionalProperties, + frontendResponse: null, + }; + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + mockQueryHandler = jest.spyOn(PluginLoader, 'getQueryHandler').mockReturnValue({ + ...NoDocumentStorePlugin, + queryDocuments: async () => + Promise.resolve({ + response: 'QUERY_SUCCESS', + documents: [goodResult], + Headers: headers, + } as unknown as QueryResult), + }); + + // Act + response = await query(frontendRequestAdditionalProperties); + }); + + afterAll(() => { + mockQueryHandler.mockRestore(); + }); + + it('returns status 400', () => { + expect(response.statusCode).toEqual(400); + }); + + it('returns total count of 1', () => { + expect(headers[0].totalCount).toEqual('1'); + }); + + it('returns error object', () => { + expect(response.body).toMatchInlineSnapshot( + ` + { + "error": "The request is invalid.", + "modelState": { + "AcademicWeek does not include property 'extraneousProperty1'": "Invalid property", + "AcademicWeek does not include property 'extraneousProperty2'": "Invalid property", + }, + } + `, + ); + }); + }); +}); diff --git a/Meadowlark-js/packages/meadowlark-core/test/integration/handler/Update.test.ts b/Meadowlark-js/packages/meadowlark-core/test/integration/handler/Update.test.ts new file mode 100644 index 00000000..6b8f819b --- /dev/null +++ b/Meadowlark-js/packages/meadowlark-core/test/integration/handler/Update.test.ts @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +import { Config, Environment, initializeLogging, getBooleanFromEnvironment } from '@edfi/meadowlark-utilities'; +import { FrontendRequest, newFrontendRequest, newFrontendRequestMiddleware } from '../../../src/handler/FrontendRequest'; +import { FrontendResponse } from '../../../src/handler/FrontendResponse'; +import { update } from '../../../src/handler/FrontendFacade'; +import * as Publish from '../../../src/plugin/listener/Publish'; +import { NoDocumentStorePlugin } from '../../../src/plugin/backend/NoDocumentStorePlugin'; +import * as PluginLoader from '../../../src/plugin/PluginLoader'; +import { MiddlewareModel } from '../../../src/middleware/MiddlewareModel'; +import * as AuthorizationMiddleware from '../../../src/middleware/AuthorizationMiddleware'; +import { setupMockConfiguration } from '../../ConfigHelper'; +import { UpdateResult } from '../../../src/message/UpdateResult'; +import { DocumentUuid } from '../../../src/model/IdTypes'; +import { clearAllValidatorCache } from '../../../src/validation/ResourceSchemaValidation'; +import { EndpointName } from '../../../src/model/api-schema/EndpointName'; +import { ProjectNamespace } from '../../../src/model/api-schema/ProjectNamespace'; +import { ProjectShortVersion } from '../../../src/model/ProjectShortVersion'; + +let updateResponse: FrontendResponse; +let mockUpdate: any; + +const documentUuid: DocumentUuid = '6b48af60-afe7-4df2-b783-dc614ec9bb64' as DocumentUuid; +const requestPath: string = '/v3.3b/ed-fi/academicWeeks/'; +const originalGetBooleanFromEnvironment = getBooleanFromEnvironment; +const baseRequestBody = { + weekIdentifier: '123456', + schoolReference: { + schoolId: 123, + }, + beginDate: '2023-10-30', + endDate: '2023-10-30', + totalInstructionalDays: 10, +}; + +const frontendRequest: FrontendRequest = { + ...newFrontendRequest(), + path: requestPath, + body: JSON.stringify(baseRequestBody), + middleware: { + ...newFrontendRequestMiddleware(), + pathComponents: { + endpointName: 'academicWeeks' as unknown as EndpointName, + projectNamespace: 'ed-fi' as unknown as ProjectNamespace, + projectShortVersion: 'v3.3b' as unknown as ProjectShortVersion, + }, + }, +}; + +const frontendRequestUpdate: FrontendRequest = { + ...frontendRequest, + path: `${requestPath}${documentUuid}`, + body: JSON.stringify({ + ...baseRequestBody, + id: documentUuid, + }), + middleware: { + ...newFrontendRequestMiddleware(), + pathComponents: { + endpointName: 'academicWeeks' as unknown as EndpointName, + projectNamespace: 'ed-fi' as unknown as ProjectNamespace, + projectShortVersion: 'v3.3b' as unknown as ProjectShortVersion, + documentUuid: documentUuid as DocumentUuid, + }, + }, +}; + +const frontendRequestAdditionalProperties: FrontendRequest = { + ...newFrontendRequest(), + body: JSON.stringify({ + ...baseRequestBody, + extraneousProperty: 'LoremIpsum', + secondExtraneousProperty: 'Second additional', + }), + middleware: { + ...newFrontendRequestMiddleware(), + pathComponents: { + endpointName: 'academicWeeks' as unknown as EndpointName, + projectNamespace: 'ed-fi' as unknown as ProjectNamespace, + projectShortVersion: 'v3.3b' as unknown as ProjectShortVersion, + }, + }, +}; +const frontendRequestUpdateAdditionalProperties: FrontendRequest = { + ...frontendRequestAdditionalProperties, + path: `${requestPath}${documentUuid}`, + body: JSON.stringify({ + ...baseRequestBody, + extraneousProperty: 'LoremIpsum', + secondExtraneousProperty: 'Second additional', + id: documentUuid, + }), + middleware: { + ...newFrontendRequestMiddleware(), + pathComponents: { + endpointName: 'academicWeeks' as unknown as EndpointName, + projectNamespace: 'ed-fi' as unknown as ProjectNamespace, + projectShortVersion: 'v3.3b' as unknown as ProjectShortVersion, + documentUuid: documentUuid as DocumentUuid, + }, + }, +}; + +const updateResult: UpdateResult = { + response: 'UPDATE_SUCCESS', + failureMessage: null, +} as unknown as UpdateResult; + +describe('given an update with Allow Overposting equals to false', () => { + beforeAll(async () => { + clearAllValidatorCache(); + setupMockConfiguration(); + initializeLogging(); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue(NoDocumentStorePlugin); + jest.spyOn(Environment, 'getBooleanFromEnvironment').mockImplementation((key: Config.ConfigKeys) => { + if (key === 'ALLOW_OVERPOSTING') { + return false; + } + return originalGetBooleanFromEnvironment(key, false); + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('when updating a document without extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest: frontendRequestUpdate, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpdateDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + mockUpdate = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + updateDocumentById: async () => Promise.resolve(updateResult), + }); + // Act + updateResponse = await update(frontendRequestUpdate); + }); + + afterAll(() => { + mockUpdate.mockRestore(); + }); + + it('returns status 204', () => { + expect(updateResponse.statusCode).toEqual(204); + }); + }); + + describe('when updating a document with extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest: frontendRequestUpdateAdditionalProperties, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpdateDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + mockUpdate = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + updateDocumentById: async () => Promise.resolve(updateResult), + }); + // Act + updateResponse = await update(frontendRequestUpdateAdditionalProperties); + }); + + afterAll(() => { + mockUpdate.mockRestore(); + }); + + it('returns status 400', () => { + expect(updateResponse.statusCode).toEqual(400); + }); + + it('returns error on update for extraneous properties', () => { + expect(updateResponse.body).toMatchInlineSnapshot(` + { + "error": [ + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'extraneousProperty' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'weekIdentifier'?", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'secondExtraneousProperty' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'schoolReference'?", + }, + ], + } + `); + }); + }); +}); + +describe('given an update with Allow Overposting equals to true', () => { + beforeAll(async () => { + clearAllValidatorCache(); + setupMockConfiguration(); + initializeLogging(); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue(NoDocumentStorePlugin); + jest.spyOn(Environment, 'getBooleanFromEnvironment').mockImplementation((key: Config.ConfigKeys) => { + if (key === 'ALLOW_OVERPOSTING') { + return true; + } + return originalGetBooleanFromEnvironment(key, false); + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('when updating a document without extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest: frontendRequestUpdate, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpdateDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + mockUpdate = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + updateDocumentById: async () => Promise.resolve(updateResult), + }); + // Act + updateResponse = await update(frontendRequestUpdate); + }); + + afterAll(() => { + mockUpdate.mockRestore(); + }); + + it('returns status 204', () => { + expect(updateResponse.statusCode).toEqual(204); + }); + }); + + describe('when updating a document with extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest: frontendRequestUpdateAdditionalProperties, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpdateDocumentById').mockImplementation(async () => Promise.resolve()); + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + mockUpdate = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + updateDocumentById: async () => Promise.resolve(updateResult), + }); + // Act + updateResponse = await update(frontendRequestUpdateAdditionalProperties); + }); + + afterAll(() => { + mockUpdate.mockRestore(); + }); + + it('returns status 204', () => { + expect(updateResponse.statusCode).toEqual(204); + }); + }); +}); diff --git a/Meadowlark-js/packages/meadowlark-core/test/integration/handler/Upsert.test.ts b/Meadowlark-js/packages/meadowlark-core/test/integration/handler/Upsert.test.ts new file mode 100644 index 00000000..bacaebf5 --- /dev/null +++ b/Meadowlark-js/packages/meadowlark-core/test/integration/handler/Upsert.test.ts @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +import { Config, Environment, initializeLogging, getBooleanFromEnvironment } from '@edfi/meadowlark-utilities'; +import { FrontendRequest, newFrontendRequest, newFrontendRequestMiddleware } from '../../../src/handler/FrontendRequest'; +import { FrontendResponse } from '../../../src/handler/FrontendResponse'; +import { upsert } from '../../../src/handler/FrontendFacade'; +import * as Publish from '../../../src/plugin/listener/Publish'; +import { NoDocumentStorePlugin } from '../../../src/plugin/backend/NoDocumentStorePlugin'; +import * as PluginLoader from '../../../src/plugin/PluginLoader'; +import { MiddlewareModel } from '../../../src/middleware/MiddlewareModel'; +import * as AuthorizationMiddleware from '../../../src/middleware/AuthorizationMiddleware'; +import * as ParsePathMiddleware from '../../../src/middleware/ParsePathMiddleware'; +import { setupMockConfiguration } from '../../ConfigHelper'; +import { UpsertResult } from '../../../src/message/UpsertResult'; +import { clearAllValidatorCache } from '../../../src/validation/ResourceSchemaValidation'; +import { EndpointName } from '../../../src/model/api-schema/EndpointName'; +import { ProjectNamespace } from '../../../src/model/api-schema/ProjectNamespace'; +import { ProjectShortVersion } from '../../../src/model/ProjectShortVersion'; + +let upsertResponse: FrontendResponse; +let mockUpsert: any; + +const originalGetBooleanFromEnvironment = getBooleanFromEnvironment; + +const frontendRequest: FrontendRequest = { + ...newFrontendRequest(), + body: `{ + "weekIdentifier": "123456", + "schoolReference": { + "schoolId": 123 + }, + "beginDate": "2023-10-30", + "endDate": "2023-10-30", + "totalInstructionalDays": 10 + }`, + middleware: { + ...newFrontendRequestMiddleware(), + pathComponents: { + endpointName: 'academicWeeks' as unknown as EndpointName, + projectNamespace: 'ed-fi' as unknown as ProjectNamespace, + projectShortVersion: 'v3.3b' as unknown as ProjectShortVersion, + }, + }, +}; + +const frontendRequestAdditionalProperties: FrontendRequest = { + ...newFrontendRequest(), + body: `{ + "weekIdentifier": "123456", + "schoolReference": { + "schoolId": 123 + }, + "beginDate": "2023-10-30", + "endDate": "2023-10-30", + "extraneousProperty": "LoremIpsum", + "totalInstructionalDays": 10 + }`, + middleware: { + ...newFrontendRequestMiddleware(), + pathComponents: { + endpointName: 'academicWeeks' as unknown as EndpointName, + projectNamespace: 'ed-fi' as unknown as ProjectNamespace, + projectShortVersion: 'v3.3b' as unknown as ProjectShortVersion, + }, + }, +}; + +describe('given an upsert with Allow Overposting equals to false', () => { + beforeAll(async () => { + clearAllValidatorCache(); + setupMockConfiguration(); + initializeLogging(); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue(NoDocumentStorePlugin); + jest.spyOn(Environment, 'getBooleanFromEnvironment').mockImplementation((key: Config.ConfigKeys) => { + if (key === 'ALLOW_OVERPOSTING') { + return false; + } + return originalGetBooleanFromEnvironment(key, false); + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('when upserting a document without extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpsertDocument').mockImplementation(async () => Promise.resolve()); + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + jest.spyOn(ParsePathMiddleware, 'parsePath').mockResolvedValue(model); + mockUpsert = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + upsertDocument: async () => + Promise.resolve({ + response: 'INSERT_SUCCESS', + newDocumentUuid: '6b48af60-afe7-4df2-b783-dc614ec9bb64', + failureMessage: null, + } as unknown as UpsertResult), + }); + // Act + upsertResponse = await upsert(frontendRequest); + }); + + afterAll(() => { + mockUpsert.mockRestore(); + }); + + it('returns status 201', () => { + expect(upsertResponse.statusCode).toEqual(201); + }); + }); + + describe('when upserting a document with extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest: frontendRequestAdditionalProperties, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpsertDocument').mockImplementation(async () => Promise.resolve()); + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + jest.spyOn(ParsePathMiddleware, 'parsePath').mockResolvedValue(model); + mockUpsert = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + upsertDocument: async () => + Promise.resolve({ + response: 'INSERT_SUCCESS', + newDocumentUuid: '6b48af60-afe7-4df2-b783-dc614ec9bb64', + failureMessage: null, + } as unknown as UpsertResult), + }); + // Act + upsertResponse = await upsert(frontendRequestAdditionalProperties); + }); + + afterAll(() => { + mockUpsert.mockRestore(); + }); + + it('returns status 400', () => { + expect(upsertResponse.statusCode).toEqual(400); + }); + + it('returns error on Upsert for extraneous properties', () => { + expect(upsertResponse.body).toMatchInlineSnapshot(` + { + "error": [ + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'extraneousProperty' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'weekIdentifier'?", + }, + ], + } + `); + }); + }); +}); + +describe('given an upsert with Allow Overposting equals to true', () => { + beforeAll(async () => { + clearAllValidatorCache(); + setupMockConfiguration(); + initializeLogging(); + jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue(NoDocumentStorePlugin); + jest.spyOn(Environment, 'getBooleanFromEnvironment').mockImplementation((key: Config.ConfigKeys) => { + if (key === 'ALLOW_OVERPOSTING') { + return true; + } + return originalGetBooleanFromEnvironment(key, false); + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('when upserting a document without extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpsertDocument').mockImplementation(async () => Promise.resolve()); + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + jest.spyOn(ParsePathMiddleware, 'parsePath').mockResolvedValue(model); + mockUpsert = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + upsertDocument: async () => + Promise.resolve({ + response: 'INSERT_SUCCESS', + newDocumentUuid: '6b48af60-afe7-4df2-b783-dc614ec9bb64', + failureMessage: null, + } as unknown as UpsertResult), + }); + // Act + upsertResponse = await upsert(frontendRequest); + }); + + afterAll(() => { + mockUpsert.mockRestore(); + }); + + it('returns status 201', () => { + expect(upsertResponse.statusCode).toEqual(201); + }); + }); + + describe('when upserting a document with extraneous properties', () => { + beforeAll(async () => { + const model: MiddlewareModel = { + frontendRequest: frontendRequestAdditionalProperties, + frontendResponse: null, + }; + jest.spyOn(Publish, 'afterUpsertDocument').mockImplementation(async () => Promise.resolve()); + jest.spyOn(AuthorizationMiddleware, 'authorize').mockResolvedValue(model); + jest.spyOn(ParsePathMiddleware, 'parsePath').mockResolvedValue(model); + mockUpsert = jest.spyOn(PluginLoader, 'getDocumentStore').mockReturnValue({ + ...NoDocumentStorePlugin, + upsertDocument: async () => + Promise.resolve({ + response: 'INSERT_SUCCESS', + newDocumentUuid: '6b48af60-afe7-4df2-b783-dc614ec9bb64', + failureMessage: null, + } as unknown as UpsertResult), + }); + // Act + upsertResponse = await upsert(frontendRequestAdditionalProperties); + }); + + afterAll(() => { + mockUpsert.mockRestore(); + }); + + it('returns status 201', () => { + expect(upsertResponse.statusCode).toEqual(201); + }); + }); +}); diff --git a/Meadowlark-js/packages/meadowlark-core/test/validation/QueryStringValidatorForOverposting.test.ts b/Meadowlark-js/packages/meadowlark-core/test/validation/QueryStringValidatorForOverposting.test.ts index 7d22e494..b7bbaacf 100644 --- a/Meadowlark-js/packages/meadowlark-core/test/validation/QueryStringValidatorForOverposting.test.ts +++ b/Meadowlark-js/packages/meadowlark-core/test/validation/QueryStringValidatorForOverposting.test.ts @@ -41,7 +41,7 @@ function createResourceSchema(): ResourceSchema { }; } -describe.skip('given query parameters with allow overposting is false have two invalid properties and a valid one', () => { +describe('given query parameters with allow overposting is false have two invalid properties and a valid one', () => { let validationResult: string[]; beforeAll(() => { @@ -65,15 +65,15 @@ describe.skip('given query parameters with allow overposting is false have two i }); it('should contain property `one`', () => { - expect(validationResult).toContain("Student does not include property 'one'"); + expect(validationResult).toContain(" does not include property 'one'"); }); it('should contain property `two`', () => { - expect(validationResult).toContain("Student does not include property 'two'"); + expect(validationResult).toContain(" does not include property 'two'"); }); }); -describe.skip('given query parameters with allow overposting is true have two extraneous properties and a valid one', () => { +describe('given query parameters with allow overposting is true have two extraneous properties and a valid one', () => { let validationResult: string[]; beforeAll(() => { @@ -91,8 +91,16 @@ describe.skip('given query parameters with allow overposting is true have two ex jest.restoreAllMocks(); }); - it('should not have errors', () => { - expect(validationResult).toHaveLength(0); + it('should have two errors', () => { + expect(validationResult).toHaveLength(2); + }); + + it('should contain property `one`', () => { + expect(validationResult).toContain(" does not include property 'one'"); + }); + + it('should contain property `two`', () => { + expect(validationResult).toContain(" does not include property 'two'"); }); }); @@ -254,7 +262,7 @@ describe('given a bad decimal query parameter value', () => { }); }); -describe.skip('given body insert with allow overposting is false have two invalid properties and a valid one', () => { +describe('given body insert with allow overposting is false have two invalid properties and a valid one', () => { let validationResult: ValidationFailure | null; beforeAll(() => { @@ -273,13 +281,20 @@ describe.skip('given body insert with allow overposting is false have two invali jest.restoreAllMocks(); }); - it('should have two errors', () => { - expect(validationResult).toHaveLength(2); + it('should have three errors', () => { + expect(validationResult?.error).toHaveLength(3); }); it('should return validation errors', () => { - expect(validationResult).toMatchInlineSnapshot(` + expect(validationResult?.error).toMatchInlineSnapshot(` [ + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'uniqueId' property is not expected to be here", + "path": "{requestBody}", + }, { "context": { "errorType": "additionalProperties", @@ -299,7 +314,7 @@ describe.skip('given body insert with allow overposting is false have two invali }); }); -describe.skip('given body update with allow overposting is false have two invalid properties and a valid one', () => { +describe('given body update with allow overposting is false have two invalid properties and a valid one', () => { let validationResult: ValidationFailure | null; beforeAll(() => { @@ -318,13 +333,20 @@ describe.skip('given body update with allow overposting is false have two invali jest.restoreAllMocks(); }); - it('should have two errors', () => { - expect(validationResult).toHaveLength(2); + it('should have three errors', () => { + expect(validationResult?.error).toHaveLength(3); }); it('should return validation errors', () => { - expect(validationResult).toMatchInlineSnapshot(` + expect(validationResult?.error).toMatchInlineSnapshot(` [ + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'uniqueId' property is not expected to be here", + "path": "{requestBody}", + }, { "context": { "errorType": "additionalProperties", @@ -344,7 +366,7 @@ describe.skip('given body update with allow overposting is false have two invali }); }); -describe.skip('given body insert with allow overposting is true have two invalid properties and a valid one', () => { +describe('given body insert with allow overposting is true have two invalid properties and a valid one', () => { let validationResult: ValidationFailure | null; beforeAll(() => { @@ -368,7 +390,7 @@ describe.skip('given body insert with allow overposting is true have two invalid }); }); -describe.skip('given body update with allow overposting is true have two invalid properties and a valid one', () => { +describe('given body update with allow overposting is true have two invalid properties and a valid one', () => { let validationResult: ValidationFailure | null; beforeAll(() => {