diff --git a/README.md b/README.md index f0241cd2..c8567055 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,64 @@ -# Azure Functions Node.js E2E Tests - -This repo contains end-to-end tests for Node.js on Azure Functions. These are automated tests designed to run regularly against prerelease versions of all the various pieces that make up the Node.js experience on Azure Functions, including: - -- [Azure Functions Host](https://github.com/Azure/azure-functions-host) -- [Azure Functions Core Tools](https://github.com/Azure/azure-functions-core-tools) -- [Node.js Worker](https://github.com/Azure/azure-functions-nodejs-worker) -- [Node.js Library](https://github.com/Azure/azure-functions-nodejs-library) - -## Pipeline - -Here is the general flow of the pipeline: - -1. Install node modules and build both the tests themselves and the test apps -2. Emulate several resources in Azure that will be used for testing different bindings -3. Run the tests. A few notes: - 1. These are run in parallel by OS, but in serial by Node.js version and programming model version. Theoretically every combination could be run in parallel, but that would use a ton of Azure Pipelines agents - 2. The primary method of validation is to run core tools against the test app and validate the output -4. Shutdown the emulated Azure resources (automatic) -5. Upload test results - -## Code of Conduct - -This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. +# Azure Functions Node.js E2E Tests + +This repo contains end-to-end tests for Node.js on Azure Functions. These are automated tests designed to run regularly against prerelease versions of all the various pieces that make up the Node.js experience on Azure Functions, including: + +- [Azure Functions Host](https://github.com/Azure/azure-functions-host) +- [Azure Functions Core Tools](https://github.com/Azure/azure-functions-core-tools) +- [Node.js Worker](https://github.com/Azure/azure-functions-nodejs-worker) +- [Node.js Library](https://github.com/Azure/azure-functions-nodejs-library) + +## Pipeline + +Here is the general flow of the pipeline: + +1. Install node modules and build both the tests themselves and the test apps +2. Emulate several resources in Azure that will be used for testing different bindings +3. Run the tests. A few notes: + 1. These are run in parallel by OS, but in serial by Node.js version and programming model version. Theoretically every combination could be run in parallel, but that would use a ton of Azure Pipelines agents + 2. The primary method of validation is to run core tools against the test app and validate the output +4. Shutdown the emulated Azure resources (automatic) +5. Upload test results + +## Security posture for resource-backed HTTP routes + +This repository is a localhost/emulator end-to-end harness. The test pipeline runs Azure Functions Core Tools on `127.0.0.1` and exercises bindings against local or emulated dependencies instead of deploying a public Azure Function App from this repo. + +That operating model lowers immediate exposure in this repository, but the reported findings are still actionable for any Azure-hosted deployment of the same functions. The affected routes are wired to real Table, Cosmos DB, SQL, Storage Queue, and Service Bus bindings, so anonymous access in a hosted app can still read from or write to backing resources. + +Some of those binding-coverage routes originally used anonymous auth as a historical convenience so the E2E tests could call them with simple `fetch` requests while coverage was being expanded. That was never meant to be a recommended production posture. + +Sensitive resource-backed HTTP routes now require function-level auth. This is a deliberate breaking change for deployed callers that previously invoked those routes anonymously. Hosted callers must now send a function key, and should follow the tightened contract of using `GET` for read endpoints and `POST` for write endpoints. + +Azure Functions Core Tools does not enforce function-key auth for local runs, so local regression protection comes primarily from static route checks and validation tests rather than from a local auth challenge. + +### Hosted invocation examples + +For Azure-hosted runs, include the function key either as the `code` query string parameter: + +```bash +curl "https://.azurewebsites.net/api/httpTriggerTableInput/?code=" +``` + +or as the `x-functions-key` header: + +```bash +curl -H "x-functions-key: " "https://.azurewebsites.net/api/httpTriggerTableInput/" +``` + +The same requirement applies to hosted `POST` requests for the resource-backed output routes. + +### Automated test helper + +The test helper `getFuncUrl()` automatically appends `?code=` to every URL when the `FUNCTIONS_TEST_KEY` environment variable is set. This means that the same test suite runs against both local Core Tools (no key needed—auth is not enforced locally) and hosted Azure Function Apps (key is injected via the env var) without any code changes: + +```bash +# Local: no env var needed — Core Tools ignores auth +npm run testAllExceptServiceBus + +# Hosted: set FUNCTIONS_TEST_KEY to your function's default key +FUNCTIONS_TEST_KEY= npm run testAllExceptServiceBus +``` + +## Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/app/v3-oldConfig/httpTriggerCosmosDBInput/function.json b/app/v3-oldConfig/httpTriggerCosmosDBInput/function.json index 58d66595..1fec5e86 100644 --- a/app/v3-oldConfig/httpTriggerCosmosDBInput/function.json +++ b/app/v3-oldConfig/httpTriggerCosmosDBInput/function.json @@ -1,11 +1,13 @@ { "bindings": [ { - "authLevel": "anonymous", + "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", - "methods": ["get", "post"] + "methods": [ + "get" + ] }, { "type": "http", diff --git a/app/v3-oldConfig/httpTriggerCosmosDBOutput/function.json b/app/v3-oldConfig/httpTriggerCosmosDBOutput/function.json index 0d1d8bbd..4874aa8a 100644 --- a/app/v3-oldConfig/httpTriggerCosmosDBOutput/function.json +++ b/app/v3-oldConfig/httpTriggerCosmosDBOutput/function.json @@ -1,11 +1,13 @@ { "bindings": [ { - "authLevel": "anonymous", + "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", - "methods": ["get", "post"] + "methods": [ + "post" + ] }, { "type": "http", diff --git a/app/v3/httpTriggerCosmosDBInput/function.json b/app/v3/httpTriggerCosmosDBInput/function.json index a34f360a..177ab52c 100644 --- a/app/v3/httpTriggerCosmosDBInput/function.json +++ b/app/v3/httpTriggerCosmosDBInput/function.json @@ -1,11 +1,13 @@ { "bindings": [ { - "authLevel": "anonymous", + "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", - "methods": ["get", "post"] + "methods": [ + "get" + ] }, { "type": "http", diff --git a/app/v3/httpTriggerCosmosDBInput/index.ts b/app/v3/httpTriggerCosmosDBInput/index.ts index d99e123a..9976d68c 100644 --- a/app/v3/httpTriggerCosmosDBInput/index.ts +++ b/app/v3/httpTriggerCosmosDBInput/index.ts @@ -1,15 +1,18 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { AzureFunction, Context, HttpRequest } from '@azure/functions'; - -const httpTriggerCosmosDBInput: AzureFunction = async function ( - context: Context, - _request: HttpRequest -): Promise { - context.res = { - body: context.bindings.inputDoc.testData, - }; -}; - -export default httpTriggerCosmosDBInput; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { AzureFunction, Context } from '@azure/functions'; +import { isMissingReadResult } from '../utils/httpValidation'; + +const httpTriggerCosmosDBInput: AzureFunction = async function (context: Context): Promise { + if (isMissingReadResult(context.bindings.inputDoc)) { + context.res = { status: 404 }; + return; + } + + context.res = { + body: context.bindings.inputDoc.testData, + }; +}; + +export default httpTriggerCosmosDBInput; diff --git a/app/v3/httpTriggerCosmosDBOutput/function.json b/app/v3/httpTriggerCosmosDBOutput/function.json index b2c45cc4..b7faec67 100644 --- a/app/v3/httpTriggerCosmosDBOutput/function.json +++ b/app/v3/httpTriggerCosmosDBOutput/function.json @@ -1,11 +1,13 @@ { "bindings": [ { - "authLevel": "anonymous", + "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", - "methods": ["get", "post"] + "methods": [ + "post" + ] }, { "type": "http", diff --git a/app/v3/httpTriggerCosmosDBOutput/index.ts b/app/v3/httpTriggerCosmosDBOutput/index.ts index 9745ee1c..a27a91c4 100644 --- a/app/v3/httpTriggerCosmosDBOutput/index.ts +++ b/app/v3/httpTriggerCosmosDBOutput/index.ts @@ -1,14 +1,21 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { AzureFunction, Context, HttpRequest } from '@azure/functions'; - -const httpTriggerCosmosDBOutput: AzureFunction = async function ( - context: Context, - request: HttpRequest -): Promise { - context.bindings.outputDoc = request.body; - context.res = { body: 'done' }; -}; - -export default httpTriggerCosmosDBOutput; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { AzureFunction, Context, HttpRequest } from '@azure/functions'; +import { getJsonBody, hasItemsWithRequiredStringFields } from '../utils/httpValidation'; + +const httpTriggerCosmosDBOutput: AzureFunction = async function ( + context: Context, + request: HttpRequest +): Promise { + const body = getJsonBody(request); + if (!hasItemsWithRequiredStringFields(body, ['id', 'testData'])) { + context.res = { status: 400 }; + return; + } + + context.bindings.outputDoc = body; + context.res = { body: 'done' }; +}; + +export default httpTriggerCosmosDBOutput; diff --git a/app/v3/httpTriggerServiceBusOutput/function.json b/app/v3/httpTriggerServiceBusOutput/function.json index c14d1fb2..79364030 100644 --- a/app/v3/httpTriggerServiceBusOutput/function.json +++ b/app/v3/httpTriggerServiceBusOutput/function.json @@ -1,11 +1,13 @@ { "bindings": [ { - "authLevel": "anonymous", + "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", - "methods": ["get", "post"] + "methods": [ + "post" + ] }, { "type": "http", diff --git a/app/v3/httpTriggerServiceBusOutput/index.ts b/app/v3/httpTriggerServiceBusOutput/index.ts index 1dee7499..5d249579 100644 --- a/app/v3/httpTriggerServiceBusOutput/index.ts +++ b/app/v3/httpTriggerServiceBusOutput/index.ts @@ -1,14 +1,21 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { AzureFunction, Context, HttpRequest } from '@azure/functions'; - -const httpTriggerServiceBusOutput: AzureFunction = async function ( - context: Context, - request: HttpRequest -): Promise { - context.bindings.outputMsg = request.body.output; - context.res = { body: 'done' }; -}; - -export default httpTriggerServiceBusOutput; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { AzureFunction, Context, HttpRequest } from '@azure/functions'; +import { getJsonBody, hasValidOutputEnvelope } from '../utils/httpValidation'; + +const httpTriggerServiceBusOutput: AzureFunction = async function ( + context: Context, + request: HttpRequest +): Promise { + const body = getJsonBody(request); + if (!hasValidOutputEnvelope(body)) { + context.res = { status: 400 }; + return; + } + + context.bindings.outputMsg = body.output; + context.res = { body: 'done' }; +}; + +export default httpTriggerServiceBusOutput; diff --git a/app/v3/httpTriggerSqlInput/function.json b/app/v3/httpTriggerSqlInput/function.json index 986c8027..e417916b 100644 --- a/app/v3/httpTriggerSqlInput/function.json +++ b/app/v3/httpTriggerSqlInput/function.json @@ -1,11 +1,13 @@ { "bindings": [ { - "authLevel": "anonymous", + "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", - "methods": ["get", "post"] + "methods": [ + "get" + ] }, { "type": "http", @@ -23,4 +25,4 @@ } ], "scriptFile": "../dist/httpTriggerSqlInput/index.js" -} \ No newline at end of file +} diff --git a/app/v3/httpTriggerSqlInput/index.ts b/app/v3/httpTriggerSqlInput/index.ts index eb1de547..97aba4b9 100644 --- a/app/v3/httpTriggerSqlInput/index.ts +++ b/app/v3/httpTriggerSqlInput/index.ts @@ -1,13 +1,20 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { AzureFunction, Context, HttpRequest } from '@azure/functions'; - -const httpTriggerSqlInput: AzureFunction = async function (context: Context, _request: HttpRequest): Promise { - context.log(`httpTriggerSqlInput was triggered`); - context.res = { - body: context.bindings.inputItem, - }; -}; - -export default httpTriggerSqlInput; \ No newline at end of file +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { AzureFunction, Context } from '@azure/functions'; +import { isMissingReadResult } from '../utils/httpValidation'; + +const httpTriggerSqlInput: AzureFunction = async function (context: Context): Promise { + context.log(`httpTriggerSqlInput was triggered`); + + if (isMissingReadResult(context.bindings.inputItem)) { + context.res = { status: 404 }; + return; + } + + context.res = { + body: context.bindings.inputItem, + }; +}; + +export default httpTriggerSqlInput; diff --git a/app/v3/httpTriggerSqlOutput/function.json b/app/v3/httpTriggerSqlOutput/function.json index 76edda65..6588e292 100644 --- a/app/v3/httpTriggerSqlOutput/function.json +++ b/app/v3/httpTriggerSqlOutput/function.json @@ -1,11 +1,13 @@ { "bindings": [ { - "authLevel": "anonymous", + "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", - "methods": ["get", "post"] + "methods": [ + "post" + ] }, { "type": "http", @@ -21,4 +23,4 @@ } ], "scriptFile": "../dist/httpTriggerSqlOutput/index.js" -} \ No newline at end of file +} diff --git a/app/v3/httpTriggerSqlOutput/index.ts b/app/v3/httpTriggerSqlOutput/index.ts index f62e0c72..2d09098a 100644 --- a/app/v3/httpTriggerSqlOutput/index.ts +++ b/app/v3/httpTriggerSqlOutput/index.ts @@ -1,14 +1,21 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { AzureFunction, Context, HttpRequest } from '@azure/functions'; - -const httpTriggerSqlOutput: AzureFunction = async function (context: Context, request: HttpRequest): Promise { - context.log(`httpTriggerSqlOutput was triggered`); - context.bindings.outputItem = request.body; - context.res = { - status: 201, - }; -}; - -export default httpTriggerSqlOutput; \ No newline at end of file +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { AzureFunction, Context, HttpRequest } from '@azure/functions'; +import { getJsonBody, hasItemsWithRequiredStringFields } from '../utils/httpValidation'; + +const httpTriggerSqlOutput: AzureFunction = async function (context: Context, request: HttpRequest): Promise { + const body = getJsonBody(request); + if (!hasItemsWithRequiredStringFields(body, ['id', 'testData'])) { + context.res = { status: 400 }; + return; + } + + context.log(`httpTriggerSqlOutput was triggered`); + context.bindings.outputItem = body; + context.res = { + status: 201, + }; +}; + +export default httpTriggerSqlOutput; diff --git a/app/v3/httpTriggerStorageQueueOutput/function.json b/app/v3/httpTriggerStorageQueueOutput/function.json index 6aa22d36..df792d75 100644 --- a/app/v3/httpTriggerStorageQueueOutput/function.json +++ b/app/v3/httpTriggerStorageQueueOutput/function.json @@ -1,11 +1,13 @@ { "bindings": [ { - "authLevel": "anonymous", + "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", - "methods": ["get", "post"] + "methods": [ + "post" + ] }, { "type": "http", diff --git a/app/v3/httpTriggerStorageQueueOutput/index.ts b/app/v3/httpTriggerStorageQueueOutput/index.ts index 3f3252c0..cc3fe1d1 100644 --- a/app/v3/httpTriggerStorageQueueOutput/index.ts +++ b/app/v3/httpTriggerStorageQueueOutput/index.ts @@ -1,14 +1,21 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { AzureFunction, Context, HttpRequest } from '@azure/functions'; - -const httpTriggerStorageQueueOutput: AzureFunction = async function ( - context: Context, - request: HttpRequest -): Promise { - context.bindings.outputMsg = request.body.output; - context.res = { body: 'done' }; -}; - -export default httpTriggerStorageQueueOutput; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { AzureFunction, Context, HttpRequest } from '@azure/functions'; +import { getJsonBody, hasValidOutputEnvelope } from '../utils/httpValidation'; + +const httpTriggerStorageQueueOutput: AzureFunction = async function ( + context: Context, + request: HttpRequest +): Promise { + const body = getJsonBody(request); + if (!hasValidOutputEnvelope(body)) { + context.res = { status: 400 }; + return; + } + + context.bindings.outputMsg = body.output; + context.res = { body: 'done' }; +}; + +export default httpTriggerStorageQueueOutput; diff --git a/app/v3/httpTriggerTableInput/function.json b/app/v3/httpTriggerTableInput/function.json index 3f2b383d..9604a14b 100644 --- a/app/v3/httpTriggerTableInput/function.json +++ b/app/v3/httpTriggerTableInput/function.json @@ -1,11 +1,13 @@ { "bindings": [ { - "authLevel": "anonymous", + "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", - "methods": ["get", "post"], + "methods": [ + "get" + ], "route": "httpTriggerTableInput/{rowKey}" }, { diff --git a/app/v3/httpTriggerTableInput/index.ts b/app/v3/httpTriggerTableInput/index.ts index 8e01167c..946e2443 100644 --- a/app/v3/httpTriggerTableInput/index.ts +++ b/app/v3/httpTriggerTableInput/index.ts @@ -1,13 +1,26 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { AzureFunction, Context, HttpRequest } from '@azure/functions'; - -const httpTriggerTableInput: AzureFunction = async function (context: Context, request: HttpRequest): Promise { - context.log(`httpTriggerTableInput was triggered`); - context.res = { - body: context.bindings.inputItem, - }; -}; - -export default httpTriggerTableInput; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { AzureFunction, Context, HttpRequest } from '@azure/functions'; +import { getRouteParam, isMissingReadResult } from '../utils/httpValidation'; + +const httpTriggerTableInput: AzureFunction = async function (context: Context, request: HttpRequest): Promise { + const rowKey = getRouteParam(request, 'rowKey'); + if (!rowKey) { + context.res = { status: 400 }; + return; + } + + context.log(`httpTriggerTableInput was triggered`); + + if (isMissingReadResult(context.bindings.inputItem)) { + context.res = { status: 404 }; + return; + } + + context.res = { + body: context.bindings.inputItem, + }; +}; + +export default httpTriggerTableInput; diff --git a/app/v3/httpTriggerTableOutput/function.json b/app/v3/httpTriggerTableOutput/function.json index ecf32235..2c2c0e00 100644 --- a/app/v3/httpTriggerTableOutput/function.json +++ b/app/v3/httpTriggerTableOutput/function.json @@ -1,11 +1,13 @@ { "bindings": [ { - "authLevel": "anonymous", + "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", - "methods": ["get", "post"] + "methods": [ + "post" + ] }, { "type": "http", diff --git a/app/v3/httpTriggerTableOutput/index.ts b/app/v3/httpTriggerTableOutput/index.ts index 022d757c..7897d479 100644 --- a/app/v3/httpTriggerTableOutput/index.ts +++ b/app/v3/httpTriggerTableOutput/index.ts @@ -1,14 +1,21 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { AzureFunction, Context, HttpRequest } from '@azure/functions'; - -const httpTriggerTableOutput: AzureFunction = async function (context: Context, request: HttpRequest): Promise { - context.log(`httpTriggerTableOutput was triggered`); - context.bindings.outputItem = request.body; - context.res = { - status: 201, - }; -}; - -export default httpTriggerTableOutput; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { AzureFunction, Context, HttpRequest } from '@azure/functions'; +import { getJsonBody, hasItemsWithRequiredStringFields } from '../utils/httpValidation'; + +const httpTriggerTableOutput: AzureFunction = async function (context: Context, request: HttpRequest): Promise { + const body = getJsonBody(request); + if (!hasItemsWithRequiredStringFields(body, ['PartitionKey', 'RowKey'])) { + context.res = { status: 400 }; + return; + } + + context.log(`httpTriggerTableOutput was triggered`); + context.bindings.outputItem = body; + context.res = { + status: 201, + }; +}; + +export default httpTriggerTableOutput; diff --git a/app/v3/utils/httpValidation.ts b/app/v3/utils/httpValidation.ts new file mode 100644 index 00000000..855da7af --- /dev/null +++ b/app/v3/utils/httpValidation.ts @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { HttpRequest } from '@azure/functions'; + +export function getRouteParam(request: HttpRequest, name: string): string | undefined { + return getRequiredString(request.params?.[name]); +} + +export function getQueryParam(request: HttpRequest, name: string): string | undefined { + return getRequiredString(request.query?.[name]); +} + +export function getJsonBody(request: HttpRequest): unknown { + let body = request.body; + if (body === undefined || body === null || body === '') { + body = request.rawBody ?? request.bufferBody; + } + + if (Buffer.isBuffer(body)) { + body = body.toString(); + } + + if (typeof body === 'string') { + const trimmedBody = body.trim(); + if (!trimmedBody) { + return undefined; + } + + try { + body = JSON.parse(trimmedBody); + } catch { + return undefined; + } + } + + return body === null || body === undefined ? undefined : body; +} + +export function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +export function hasRequiredStringFields(item: unknown, fieldNames: string[]): item is Record { + return isObject(item) && fieldNames.every((fieldName) => isNonEmptyString(item[fieldName])); +} + +export function hasValidOutputEnvelope(body: unknown): body is { output: string | string[] } { + return isObject(body) && isStringOrStringArray(body.output); +} + +export function hasItemsWithRequiredStringFields( + body: unknown, + fieldNames: string[] +): body is Record | Record[] { + return Array.isArray(body) + ? body.length > 0 && body.every((item) => hasRequiredStringFields(item, fieldNames)) + : hasRequiredStringFields(body, fieldNames); +} + +export function isMissingReadResult(value: unknown): boolean { + return value === null || value === undefined || (Array.isArray(value) && value.length === 0); +} + +function getRequiredString(value: unknown): string | undefined { + return isNonEmptyString(value) ? value.trim() : undefined; +} + +function isStringOrStringArray(value: unknown): value is string | string[] { + return isNonEmptyString(value) || (Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString)); +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/app/v4-oldConfig/src/functions/httpTriggerCosmosDBInput.ts b/app/v4-oldConfig/src/functions/httpTriggerCosmosDBInput.ts index ae50436c..a0f28c93 100644 --- a/app/v4-oldConfig/src/functions/httpTriggerCosmosDBInput.ts +++ b/app/v4-oldConfig/src/functions/httpTriggerCosmosDBInput.ts @@ -1,27 +1,32 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { app, HttpRequest, HttpResponseInit, input, InvocationContext } from '@azure/functions'; - -const cosmosInput = input.cosmosDB({ - databaseName: 'e2eTestCosmosDB', - collectionName: 'e2eTestContainerTriggerAndOutput', - id: '{Query.id}', - partitionKey: 'testPartKey', - connectionStringSetting: 'CosmosDBConnection', -}); - -export async function httpTriggerCosmosDBInput( - _request: HttpRequest, - context: InvocationContext -): Promise { - const doc = context.extraInputs.get(cosmosInput); - return { body: (doc).testData }; -} - -app.http('httpTriggerCosmosDBInput', { - methods: ['GET', 'POST'], - authLevel: 'anonymous', - extraInputs: [cosmosInput], - handler: httpTriggerCosmosDBInput, -}); +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { app, HttpRequest, HttpResponseInit, input, InvocationContext } from '@azure/functions'; +import { isMissingResult, notFound } from '../utils/httpValidation'; + +const cosmosInput = input.cosmosDB({ + databaseName: 'e2eTestCosmosDB', + collectionName: 'e2eTestContainerTriggerAndOutput', + id: '{Query.id}', + partitionKey: 'testPartKey', + connectionStringSetting: 'CosmosDBConnection', +}); + +export async function httpTriggerCosmosDBInput( + request: HttpRequest, + context: InvocationContext +): Promise { + const doc = context.extraInputs.get(cosmosInput); + if (isMissingResult(doc)) { + return notFound(`No Cosmos DB document was found for id "${request.query.get('id')}".`); + } + + return { body: (doc as { testData?: string }).testData }; +} + +app.http('httpTriggerCosmosDBInput', { + methods: ['GET'], + authLevel: 'function', + extraInputs: [cosmosInput], + handler: httpTriggerCosmosDBInput, +}); diff --git a/app/v4-oldConfig/src/functions/httpTriggerCosmosDBOutput.ts b/app/v4-oldConfig/src/functions/httpTriggerCosmosDBOutput.ts index 95b24fec..0e64120a 100644 --- a/app/v4-oldConfig/src/functions/httpTriggerCosmosDBOutput.ts +++ b/app/v4-oldConfig/src/functions/httpTriggerCosmosDBOutput.ts @@ -1,26 +1,40 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { HttpRequest, HttpResponseInit, InvocationContext, app, output } from '@azure/functions'; - -const cosmosOutput = output.cosmosDB({ - databaseName: 'e2eTestCosmosDB', - collectionName: 'e2eTestContainerTrigger', - connectionStringSetting: 'CosmosDBConnection', -}); - -export async function httpTriggerCosmosDBOutput( - request: HttpRequest, - context: InvocationContext -): Promise { - const body = await request.json(); - context.extraOutputs.set(cosmosOutput, body); - return { body: 'done' }; -} - -app.http('httpTriggerCosmosDBOutput', { - methods: ['GET', 'POST'], - authLevel: 'anonymous', - extraOutputs: [cosmosOutput], - handler: httpTriggerCosmosDBOutput, -}); +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { HttpRequest, HttpResponseInit, InvocationContext, app, output } from '@azure/functions'; +import { getRequiredJsonBody, hasRequiredStringFields, validateObjectOrArray } from '../utils/httpValidation'; + +const cosmosOutput = output.cosmosDB({ + databaseName: 'e2eTestCosmosDB', + collectionName: 'e2eTestContainerTrigger', + connectionStringSetting: 'CosmosDBConnection', +}); + +export async function httpTriggerCosmosDBOutput( + request: HttpRequest, + context: InvocationContext +): Promise { + const bodyResult = await getRequiredJsonBody(request); + if ('response' in bodyResult) { + return bodyResult.response; + } + + const validationError = validateObjectOrArray( + bodyResult.value, + (item) => hasRequiredStringFields(item, ['id', 'testData']), + 'Request body must include Cosmos DB documents with non-empty "id" and "testData" values.' + ); + if (validationError) { + return validationError; + } + + context.extraOutputs.set(cosmosOutput, bodyResult.value); + return { body: 'done' }; +} + +app.http('httpTriggerCosmosDBOutput', { + methods: ['POST'], + authLevel: 'function', + extraOutputs: [cosmosOutput], + handler: httpTriggerCosmosDBOutput, +}); diff --git a/app/v4-oldConfig/src/utils/httpValidation.ts b/app/v4-oldConfig/src/utils/httpValidation.ts new file mode 100644 index 00000000..159e15f0 --- /dev/null +++ b/app/v4-oldConfig/src/utils/httpValidation.ts @@ -0,0 +1,124 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { HttpRequest, HttpResponseInit } from '@azure/functions'; + +interface ValidationSuccess { + value: T; +} + +interface ValidationFailure { + response: HttpResponseInit; +} + +export type ValidationResult = ValidationSuccess | ValidationFailure; + +type JsonRecord = Record; + +export function badRequest(message: string): HttpResponseInit { + return { + status: 400, + jsonBody: { error: message }, + }; +} + +export function notFound(message: string): HttpResponseInit { + return { + status: 404, + jsonBody: { error: message }, + }; +} + +export function getRequiredQueryParam(request: HttpRequest, name: string): ValidationResult { + const value = request.query.get(name); + if (!isNonEmptyString(value)) { + return { response: badRequest(`Missing or invalid query parameter "${name}".`) }; + } + + return { value }; +} + +export function getRequiredRouteParam(request: HttpRequest, name: string): ValidationResult { + const value = request.params[name]; + if (!isNonEmptyString(value)) { + return { response: badRequest(`Missing or invalid route parameter "${name}".`) }; + } + + return { value }; +} + +export async function getRequiredJsonBody(request: HttpRequest): Promise> { + try { + const body = await request.json(); + if (body === undefined || body === null) { + return { response: badRequest('Missing or invalid request body.') }; + } + + return { value: body }; + } catch { + return { response: badRequest('Missing or invalid request body.') }; + } +} + +export function validateObject( + payload: unknown, + isValidItem: (item: JsonRecord) => boolean, + errorMessage: string +): HttpResponseInit | undefined { + if (!isJsonRecord(payload) || !isValidItem(payload)) { + return badRequest(errorMessage); + } + + return undefined; +} + +export function validateObjectOrArray( + payload: unknown, + isValidItem: (item: JsonRecord) => boolean, + errorMessage: string +): HttpResponseInit | undefined { + if (Array.isArray(payload)) { + if (payload.length === 0) { + return badRequest(errorMessage); + } + + for (const item of payload) { + if (!isJsonRecord(item) || !isValidItem(item)) { + return badRequest(errorMessage); + } + } + + return undefined; + } + + if (!isJsonRecord(payload) || !isValidItem(payload)) { + return badRequest(errorMessage); + } + + return undefined; +} + +export function hasRequiredStringFields(item: JsonRecord, fieldNames: string[]): boolean { + return fieldNames.every((fieldName) => isNonEmptyString(item[fieldName])); +} + +export function hasDefinedField(item: JsonRecord, fieldName: string): boolean { + return Object.prototype.hasOwnProperty.call(item, fieldName) && item[fieldName] !== undefined; +} + +export function hasValidOutputEnvelope(item: JsonRecord): boolean { + const output = item['output']; + return isNonEmptyString(output) || (Array.isArray(output) && output.length > 0 && output.every(isNonEmptyString)); +} + +export function isMissingResult(value: unknown): boolean { + return Array.isArray(value) ? value.length === 0 : value === undefined || value === null; +} + +function isJsonRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/app/v4/src/functions/eventHubOneTrigger.ts b/app/v4/src/functions/eventHubOneTrigger.ts index ed95cb06..1f5b71d3 100644 --- a/app/v4/src/functions/eventHubOneTrigger.ts +++ b/app/v4/src/functions/eventHubOneTrigger.ts @@ -18,4 +18,4 @@ app.eventHub('eventHubOneTrigger', { cardinality: 'one', consumerGroup: 'cg1', handler: eventHubOneTrigger, -}); \ No newline at end of file +}); diff --git a/app/v4/src/functions/httpTriggerCosmosDBInput.ts b/app/v4/src/functions/httpTriggerCosmosDBInput.ts index 255e6553..5ea7a316 100644 --- a/app/v4/src/functions/httpTriggerCosmosDBInput.ts +++ b/app/v4/src/functions/httpTriggerCosmosDBInput.ts @@ -1,27 +1,32 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { app, HttpRequest, HttpResponseInit, input, InvocationContext } from '@azure/functions'; - -const cosmosInput = input.cosmosDB({ - databaseName: 'e2eTestCosmosDB', - containerName: 'e2eTestContainerTriggerAndOutput', - id: '{Query.id}', - partitionKey: 'testPartKey', - connection: 'CosmosDBConnection', -}); - -export async function httpTriggerCosmosDBInput( - _request: HttpRequest, - context: InvocationContext -): Promise { - const doc = context.extraInputs.get(cosmosInput); - return { body: (doc).testData }; -} - -app.http('httpTriggerCosmosDBInput', { - methods: ['GET', 'POST'], - authLevel: 'anonymous', - extraInputs: [cosmosInput], - handler: httpTriggerCosmosDBInput, -}); +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { app, HttpRequest, HttpResponseInit, input, InvocationContext } from '@azure/functions'; +import { isMissingResult, notFound } from '../utils/httpValidation'; + +const cosmosInput = input.cosmosDB({ + databaseName: 'e2eTestCosmosDB', + containerName: 'e2eTestContainerTriggerAndOutput', + id: '{Query.id}', + partitionKey: 'testPartKey', + connection: 'CosmosDBConnection', +}); + +export async function httpTriggerCosmosDBInput( + request: HttpRequest, + context: InvocationContext +): Promise { + const doc = context.extraInputs.get(cosmosInput); + if (isMissingResult(doc)) { + return notFound(`No Cosmos DB document was found for id "${request.query.get('id')}".`); + } + + return { body: (doc as { testData?: string }).testData }; +} + +app.http('httpTriggerCosmosDBInput', { + methods: ['GET'], + authLevel: 'function', + extraInputs: [cosmosInput], + handler: httpTriggerCosmosDBInput, +}); diff --git a/app/v4/src/functions/httpTriggerCosmosDBOutput.ts b/app/v4/src/functions/httpTriggerCosmosDBOutput.ts index 4d9fc06c..f6684156 100644 --- a/app/v4/src/functions/httpTriggerCosmosDBOutput.ts +++ b/app/v4/src/functions/httpTriggerCosmosDBOutput.ts @@ -1,26 +1,40 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { app, HttpRequest, HttpResponseInit, InvocationContext, output } from '@azure/functions'; - -const cosmosOutput = output.cosmosDB({ - databaseName: 'e2eTestCosmosDB', - containerName: 'e2eTestContainerTrigger', - connection: 'CosmosDBConnection', -}); - -export async function httpTriggerCosmosDBOutput( - request: HttpRequest, - context: InvocationContext -): Promise { - const body = await request.json(); - context.extraOutputs.set(cosmosOutput, body); - return { body: 'done' }; -} - -app.http('httpTriggerCosmosDBOutput', { - methods: ['GET', 'POST'], - authLevel: 'anonymous', - extraOutputs: [cosmosOutput], - handler: httpTriggerCosmosDBOutput, -}); +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { app, HttpRequest, HttpResponseInit, InvocationContext, output } from '@azure/functions'; +import { getRequiredJsonBody, hasRequiredStringFields, validateObjectOrArray } from '../utils/httpValidation'; + +const cosmosOutput = output.cosmosDB({ + databaseName: 'e2eTestCosmosDB', + containerName: 'e2eTestContainerTrigger', + connection: 'CosmosDBConnection', +}); + +export async function httpTriggerCosmosDBOutput( + request: HttpRequest, + context: InvocationContext +): Promise { + const bodyResult = await getRequiredJsonBody(request); + if ('response' in bodyResult) { + return bodyResult.response; + } + + const validationError = validateObjectOrArray( + bodyResult.value, + (item) => hasRequiredStringFields(item, ['id', 'testData']), + 'Request body must include Cosmos DB documents with non-empty "id" and "testData" values.' + ); + if (validationError) { + return validationError; + } + + context.extraOutputs.set(cosmosOutput, bodyResult.value); + return { body: 'done' }; +} + +app.http('httpTriggerCosmosDBOutput', { + methods: ['POST'], + authLevel: 'function', + extraOutputs: [cosmosOutput], + handler: httpTriggerCosmosDBOutput, +}); diff --git a/app/v4/src/functions/httpTriggerServiceBusOutput.ts b/app/v4/src/functions/httpTriggerServiceBusOutput.ts index 9505a930..9807a1a7 100644 --- a/app/v4/src/functions/httpTriggerServiceBusOutput.ts +++ b/app/v4/src/functions/httpTriggerServiceBusOutput.ts @@ -1,25 +1,39 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { app, HttpRequest, HttpResponseInit, InvocationContext, output } from '@azure/functions'; - -const serviceBusOutput = output.serviceBusQueue({ - connection: 'ServiceBusConnection', - queueName: 'e2e-test-queue-one-trigger', -}); - -export async function httpTriggerServiceBusOutput( - request: HttpRequest, - context: InvocationContext -): Promise { - const body = <{ output: any }>await request.json(); - context.extraOutputs.set(serviceBusOutput, body.output); - return { body: 'done' }; -} - -app.http('httpTriggerServiceBusOutput', { - methods: ['GET', 'POST'], - authLevel: 'anonymous', - extraOutputs: [serviceBusOutput], - handler: httpTriggerServiceBusOutput, -}); +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { app, HttpRequest, HttpResponseInit, InvocationContext, output } from '@azure/functions'; +import { getRequiredJsonBody, hasValidOutputEnvelope, validateObject } from '../utils/httpValidation'; + +const serviceBusOutput = output.serviceBusQueue({ + connection: 'ServiceBusConnection', + queueName: 'e2e-test-queue-one-trigger', +}); + +export async function httpTriggerServiceBusOutput( + request: HttpRequest, + context: InvocationContext +): Promise { + const bodyResult = await getRequiredJsonBody(request); + if ('response' in bodyResult) { + return bodyResult.response; + } + + const validationError = validateObject( + bodyResult.value, + (item) => hasValidOutputEnvelope(item), + 'Request body must include an "output" value.' + ); + if (validationError) { + return validationError; + } + + context.extraOutputs.set(serviceBusOutput, (bodyResult.value as { output: unknown }).output); + return { body: 'done' }; +} + +app.http('httpTriggerServiceBusOutput', { + methods: ['POST'], + authLevel: 'function', + extraOutputs: [serviceBusOutput], + handler: httpTriggerServiceBusOutput, +}); diff --git a/app/v4/src/functions/httpTriggerSqlInput.ts b/app/v4/src/functions/httpTriggerSqlInput.ts index 1f68f86f..06267b1c 100644 --- a/app/v4/src/functions/httpTriggerSqlInput.ts +++ b/app/v4/src/functions/httpTriggerSqlInput.ts @@ -1,24 +1,30 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { app, HttpRequest, HttpResponseInit, input, InvocationContext } from '@azure/functions'; - -const sqlInput = input.sql({ - connectionStringSetting: 'SqlConnection', - commandText: 'select * from dbo.e2eSqlNonTriggerTable where id = @id', - commandType: 'Text', - parameters: '@id={Query.id}', -}); - -export async function httpTriggerSqlInput(request: HttpRequest, context: InvocationContext): Promise { - context.log(`httpTriggerSqlInput was triggered`); - const items = context.extraInputs.get(sqlInput); - return { jsonBody: items }; -} - -app.http('httpTriggerSqlInput', { - methods: ['GET', 'POST'], - authLevel: 'anonymous', - extraInputs: [sqlInput], - handler: httpTriggerSqlInput, -}); \ No newline at end of file +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { app, HttpRequest, HttpResponseInit, input, InvocationContext } from '@azure/functions'; +import { isMissingResult, notFound } from '../utils/httpValidation'; + +const sqlInput = input.sql({ + connectionStringSetting: 'SqlConnection', + commandText: 'select * from dbo.e2eSqlNonTriggerTable where id = @id', + commandType: 'Text', + parameters: '@id={Query.id}', +}); + +export async function httpTriggerSqlInput(request: HttpRequest, context: InvocationContext): Promise { + context.log(`httpTriggerSqlInput was triggered`); + + const items = context.extraInputs.get(sqlInput); + if (isMissingResult(items)) { + return notFound(`No SQL rows were found for id "${request.query.get('id')}".`); + } + + return { jsonBody: items }; +} + +app.http('httpTriggerSqlInput', { + methods: ['GET'], + authLevel: 'function', + extraInputs: [sqlInput], + handler: httpTriggerSqlInput, +}); diff --git a/app/v4/src/functions/httpTriggerSqlOutput.ts b/app/v4/src/functions/httpTriggerSqlOutput.ts index 60cd1306..82e7b8a3 100644 --- a/app/v4/src/functions/httpTriggerSqlOutput.ts +++ b/app/v4/src/functions/httpTriggerSqlOutput.ts @@ -1,26 +1,41 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { app, HttpRequest, HttpResponseInit, InvocationContext, output } from '@azure/functions'; - -const sqlOutput = output.sql({ - connectionStringSetting: 'SqlConnection', - commandText: 'dbo.e2eSqlNonTriggerTable', -}); - -export async function httpTriggerSqlOutput( - request: HttpRequest, - context: InvocationContext -): Promise { - context.log(`httpTriggerSqlOutput was triggered`); - const body = await request.json(); - context.extraOutputs.set(sqlOutput, body); - return { status: 201 }; -} - -app.http('httpTriggerSqlOutput', { - methods: ['GET', 'POST'], - authLevel: 'anonymous', - extraOutputs: [sqlOutput], - handler: httpTriggerSqlOutput, -}); \ No newline at end of file +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { app, HttpRequest, HttpResponseInit, InvocationContext, output } from '@azure/functions'; +import { getRequiredJsonBody, hasRequiredStringFields, validateObjectOrArray } from '../utils/httpValidation'; + +const sqlOutput = output.sql({ + connectionStringSetting: 'SqlConnection', + commandText: 'dbo.e2eSqlNonTriggerTable', +}); + +export async function httpTriggerSqlOutput( + request: HttpRequest, + context: InvocationContext +): Promise { + context.log(`httpTriggerSqlOutput was triggered`); + + const bodyResult = await getRequiredJsonBody(request); + if ('response' in bodyResult) { + return bodyResult.response; + } + + const validationError = validateObjectOrArray( + bodyResult.value, + (item) => hasRequiredStringFields(item, ['id', 'testData']), + 'Request body must include SQL rows with non-empty "id" and "testData" values.' + ); + if (validationError) { + return validationError; + } + + context.extraOutputs.set(sqlOutput, bodyResult.value); + return { status: 201 }; +} + +app.http('httpTriggerSqlOutput', { + methods: ['POST'], + authLevel: 'function', + extraOutputs: [sqlOutput], + handler: httpTriggerSqlOutput, +}); diff --git a/app/v4/src/functions/httpTriggerStorageQueueOutput.ts b/app/v4/src/functions/httpTriggerStorageQueueOutput.ts index 5b853fd8..7ba6ab16 100644 --- a/app/v4/src/functions/httpTriggerStorageQueueOutput.ts +++ b/app/v4/src/functions/httpTriggerStorageQueueOutput.ts @@ -1,25 +1,39 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { app, HttpRequest, HttpResponseInit, InvocationContext, output } from '@azure/functions'; - -const storageOutput = output.storageQueue({ - queueName: 'e2e-test-queue-trigger', - connection: 'AzureWebJobsStorage', -}); - -export async function httpTriggerStorageQueueOutput( - request: HttpRequest, - context: InvocationContext -): Promise { - const body = <{ output: any }>await request.json(); - context.extraOutputs.set(storageOutput, body.output); - return { body: 'done' }; -} - -app.http('httpTriggerStorageQueueOutput', { - methods: ['POST'], - authLevel: 'anonymous', - extraOutputs: [storageOutput], - handler: httpTriggerStorageQueueOutput, -}); +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { app, HttpRequest, HttpResponseInit, InvocationContext, output } from '@azure/functions'; +import { getRequiredJsonBody, hasValidOutputEnvelope, validateObject } from '../utils/httpValidation'; + +const storageOutput = output.storageQueue({ + queueName: 'e2e-test-queue-trigger', + connection: 'AzureWebJobsStorage', +}); + +export async function httpTriggerStorageQueueOutput( + request: HttpRequest, + context: InvocationContext +): Promise { + const bodyResult = await getRequiredJsonBody(request); + if ('response' in bodyResult) { + return bodyResult.response; + } + + const validationError = validateObject( + bodyResult.value, + (item) => hasValidOutputEnvelope(item), + 'Request body must include an "output" value.' + ); + if (validationError) { + return validationError; + } + + context.extraOutputs.set(storageOutput, (bodyResult.value as { output: unknown }).output); + return { body: 'done' }; +} + +app.http('httpTriggerStorageQueueOutput', { + methods: ['POST'], + authLevel: 'function', + extraOutputs: [storageOutput], + handler: httpTriggerStorageQueueOutput, +}); diff --git a/app/v4/src/functions/httpTriggerTableInput.ts b/app/v4/src/functions/httpTriggerTableInput.ts index 50d28b1e..332c3a84 100644 --- a/app/v4/src/functions/httpTriggerTableInput.ts +++ b/app/v4/src/functions/httpTriggerTableInput.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { app, HttpRequest, HttpResponseInit, input, InvocationContext } from '@azure/functions'; +import { getRequiredRouteParam, isMissingResult, notFound } from '../utils/httpValidation'; const tableInput = input.table({ tableName: 'e2etesttable', @@ -15,13 +16,23 @@ export async function httpTriggerTableInput( context: InvocationContext ): Promise { context.log(`httpTriggerTableInput was triggered`); + + const rowKeyResult = getRequiredRouteParam(request, 'rowKey'); + if ('response' in rowKeyResult) { + return rowKeyResult.response; + } + const items = context.extraInputs.get(tableInput); + if (isMissingResult(items)) { + return notFound(`No table entities were found for rowKey "${rowKeyResult.value}".`); + } + return { jsonBody: items }; } app.http('httpTriggerTableInput', { - methods: ['GET', 'POST'], - authLevel: 'anonymous', + methods: ['GET'], + authLevel: 'function', route: 'httpTriggerTableInput/{rowKey}', extraInputs: [tableInput], handler: httpTriggerTableInput, diff --git a/app/v4/src/functions/httpTriggerTableOutput.ts b/app/v4/src/functions/httpTriggerTableOutput.ts index bf9bb2a3..162a85f4 100644 --- a/app/v4/src/functions/httpTriggerTableOutput.ts +++ b/app/v4/src/functions/httpTriggerTableOutput.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { app, HttpRequest, HttpResponseInit, InvocationContext, output } from '@azure/functions'; +import { getRequiredJsonBody, hasRequiredStringFields, validateObjectOrArray } from '../utils/httpValidation'; const tableOutput = output.table({ tableName: 'e2etesttable', @@ -13,14 +14,28 @@ export async function httpTriggerTableOutput( context: InvocationContext ): Promise { context.log(`httpTriggerTableOutput was triggered`); - const body = await request.json(); - context.extraOutputs.set(tableOutput, body); + + const bodyResult = await getRequiredJsonBody(request); + if ('response' in bodyResult) { + return bodyResult.response; + } + + const validationError = validateObjectOrArray( + bodyResult.value, + (item) => hasRequiredStringFields(item, ['PartitionKey', 'RowKey']), + 'Request body must include table entities with non-empty "PartitionKey" and "RowKey" values.' + ); + if (validationError) { + return validationError; + } + + context.extraOutputs.set(tableOutput, bodyResult.value); return { status: 201 }; } app.http('httpTriggerTableOutput', { - methods: ['GET', 'POST'], - authLevel: 'anonymous', + methods: ['POST'], + authLevel: 'function', extraOutputs: [tableOutput], handler: httpTriggerTableOutput, }); diff --git a/app/v4/src/functions/sqlTrigger.ts b/app/v4/src/functions/sqlTrigger.ts index 8998cf3d..050b6c38 100644 --- a/app/v4/src/functions/sqlTrigger.ts +++ b/app/v4/src/functions/sqlTrigger.ts @@ -28,4 +28,4 @@ app.sql('sqlTrigger', { tableName: 'dbo.e2eSqlTriggerTable', connectionStringSetting: 'SqlConnection', handler: sqlTrigger, -}); \ No newline at end of file +}); diff --git a/app/v4/src/utils/httpValidation.ts b/app/v4/src/utils/httpValidation.ts new file mode 100644 index 00000000..159e15f0 --- /dev/null +++ b/app/v4/src/utils/httpValidation.ts @@ -0,0 +1,124 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { HttpRequest, HttpResponseInit } from '@azure/functions'; + +interface ValidationSuccess { + value: T; +} + +interface ValidationFailure { + response: HttpResponseInit; +} + +export type ValidationResult = ValidationSuccess | ValidationFailure; + +type JsonRecord = Record; + +export function badRequest(message: string): HttpResponseInit { + return { + status: 400, + jsonBody: { error: message }, + }; +} + +export function notFound(message: string): HttpResponseInit { + return { + status: 404, + jsonBody: { error: message }, + }; +} + +export function getRequiredQueryParam(request: HttpRequest, name: string): ValidationResult { + const value = request.query.get(name); + if (!isNonEmptyString(value)) { + return { response: badRequest(`Missing or invalid query parameter "${name}".`) }; + } + + return { value }; +} + +export function getRequiredRouteParam(request: HttpRequest, name: string): ValidationResult { + const value = request.params[name]; + if (!isNonEmptyString(value)) { + return { response: badRequest(`Missing or invalid route parameter "${name}".`) }; + } + + return { value }; +} + +export async function getRequiredJsonBody(request: HttpRequest): Promise> { + try { + const body = await request.json(); + if (body === undefined || body === null) { + return { response: badRequest('Missing or invalid request body.') }; + } + + return { value: body }; + } catch { + return { response: badRequest('Missing or invalid request body.') }; + } +} + +export function validateObject( + payload: unknown, + isValidItem: (item: JsonRecord) => boolean, + errorMessage: string +): HttpResponseInit | undefined { + if (!isJsonRecord(payload) || !isValidItem(payload)) { + return badRequest(errorMessage); + } + + return undefined; +} + +export function validateObjectOrArray( + payload: unknown, + isValidItem: (item: JsonRecord) => boolean, + errorMessage: string +): HttpResponseInit | undefined { + if (Array.isArray(payload)) { + if (payload.length === 0) { + return badRequest(errorMessage); + } + + for (const item of payload) { + if (!isJsonRecord(item) || !isValidItem(item)) { + return badRequest(errorMessage); + } + } + + return undefined; + } + + if (!isJsonRecord(payload) || !isValidItem(payload)) { + return badRequest(errorMessage); + } + + return undefined; +} + +export function hasRequiredStringFields(item: JsonRecord, fieldNames: string[]): boolean { + return fieldNames.every((fieldName) => isNonEmptyString(item[fieldName])); +} + +export function hasDefinedField(item: JsonRecord, fieldName: string): boolean { + return Object.prototype.hasOwnProperty.call(item, fieldName) && item[fieldName] !== undefined; +} + +export function hasValidOutputEnvelope(item: JsonRecord): boolean { + const output = item['output']; + return isNonEmptyString(output) || (Array.isArray(output) && output.length > 0 && output.every(isNonEmptyString)); +} + +export function isMissingResult(value: unknown): boolean { + return Array.isArray(value) ? value.length === 0 : value === undefined || value === null; +} + +function isJsonRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/azure-pipelines/templates/build.yml b/azure-pipelines/templates/build.yml index e6074d88..35e8992a 100644 --- a/azure-pipelines/templates/build.yml +++ b/azure-pipelines/templates/build.yml @@ -11,3 +11,5 @@ steps: displayName: 'build e2e tests' - script: npm run lint displayName: 'lint e2e tests' + - script: npm run testSecurityRegression + displayName: 'check sensitive HTTP route auth/methods' diff --git a/package.json b/package.json index 0c905d0d..b5917d04 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "lint": "eslint . --fix", "watch": "tsc -w", "createCombinedApps": "node out/createCombinedApps.js", + "testSecurityRegression": "node scripts/check-sensitive-http-routes.js", "testOldConfig": "npm run testV3OldConfig && npm run testV4OldConfig", "testV3OldConfig": "node out/index.js --model v3 --oldConfig", "testV4OldConfig": "node out/index.js --model v4 --oldConfig", diff --git a/scripts/check-sensitive-http-routes.js b/scripts/check-sensitive-http-routes.js new file mode 100644 index 00000000..48238ebb --- /dev/null +++ b/scripts/check-sensitive-http-routes.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..'); + +const routeExpectations = [ + { kind: 'json', path: 'app/v3/httpTriggerTableInput/function.json', authLevel: 'function', methods: ['get'] }, + { kind: 'json', path: 'app/v3/httpTriggerTableOutput/function.json', authLevel: 'function', methods: ['post'] }, + { kind: 'json', path: 'app/v3/httpTriggerServiceBusOutput/function.json', authLevel: 'function', methods: ['post'] }, + { kind: 'json', path: 'app/v3/httpTriggerStorageQueueOutput/function.json', authLevel: 'function', methods: ['post'] }, + { kind: 'json', path: 'app/v3/httpTriggerCosmosDBInput/function.json', authLevel: 'function', methods: ['get'] }, + { kind: 'json', path: 'app/v3/httpTriggerCosmosDBOutput/function.json', authLevel: 'function', methods: ['post'] }, + { kind: 'json', path: 'app/v3/httpTriggerSqlInput/function.json', authLevel: 'function', methods: ['get'] }, + { kind: 'json', path: 'app/v3/httpTriggerSqlOutput/function.json', authLevel: 'function', methods: ['post'] }, + { kind: 'json', path: 'app/v3-oldConfig/httpTriggerCosmosDBInput/function.json', authLevel: 'function', methods: ['get'] }, + { kind: 'json', path: 'app/v3-oldConfig/httpTriggerCosmosDBOutput/function.json', authLevel: 'function', methods: ['post'] }, + { kind: 'ts', path: 'app/v4/src/functions/httpTriggerTableInput.ts', authLevel: 'function', methods: ['get'] }, + { kind: 'ts', path: 'app/v4/src/functions/httpTriggerTableOutput.ts', authLevel: 'function', methods: ['post'] }, + { kind: 'ts', path: 'app/v4/src/functions/httpTriggerServiceBusOutput.ts', authLevel: 'function', methods: ['post'] }, + { kind: 'ts', path: 'app/v4/src/functions/httpTriggerStorageQueueOutput.ts', authLevel: 'function', methods: ['post'] }, + { kind: 'ts', path: 'app/v4/src/functions/httpTriggerCosmosDBInput.ts', authLevel: 'function', methods: ['get'] }, + { kind: 'ts', path: 'app/v4/src/functions/httpTriggerCosmosDBOutput.ts', authLevel: 'function', methods: ['post'] }, + { kind: 'ts', path: 'app/v4/src/functions/httpTriggerSqlInput.ts', authLevel: 'function', methods: ['get'] }, + { kind: 'ts', path: 'app/v4/src/functions/httpTriggerSqlOutput.ts', authLevel: 'function', methods: ['post'] }, + { kind: 'ts', path: 'app/v4-oldConfig/src/functions/httpTriggerCosmosDBInput.ts', authLevel: 'function', methods: ['get'] }, + { kind: 'ts', path: 'app/v4-oldConfig/src/functions/httpTriggerCosmosDBOutput.ts', authLevel: 'function', methods: ['post'] }, +]; + +const failures = []; +for (const expectation of routeExpectations) { + const filePath = path.join(repoRoot, expectation.path); + try { + if (expectation.kind === 'json') { + validateJsonRoute(filePath, expectation); + } else { + validateTsRoute(filePath, expectation); + } + } catch (error) { + failures.push(`${expectation.path}: ${error.message}`); + } +} + +if (failures.length > 0) { + console.error('Sensitive HTTP route regression check failed:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log(`Sensitive HTTP route regression check passed for ${routeExpectations.length} files.`); + +function validateJsonRoute(filePath, expectation) { + const source = fs.readFileSync(filePath, 'utf8'); + const config = JSON.parse(source); + const httpTrigger = config.bindings.find((binding) => binding.type === 'httpTrigger'); + if (!httpTrigger) { + throw new Error('Missing httpTrigger binding.'); + } + + assertEqual(filePath, 'authLevel', normalizeString(httpTrigger.authLevel), expectation.authLevel); + assertMethods(filePath, normalizeMethods(httpTrigger.methods), expectation.methods); +} + +function validateTsRoute(filePath, expectation) { + const source = fs.readFileSync(filePath, 'utf8'); + const authLevelMatch = source.match(/authLevel:\s*['"`]([^'"`]+)['"`]/); + const methodsMatch = source.match(/methods:\s*\[([^\]]*)\]/); + + if (!authLevelMatch) { + throw new Error('Missing authLevel declaration.'); + } + if (!methodsMatch) { + throw new Error('Missing methods declaration.'); + } + + const methods = Array.from(methodsMatch[1].matchAll(/['"`]([^'"`]+)['"`]/g)).map((match) => normalizeString(match[1])); + assertEqual(filePath, 'authLevel', normalizeString(authLevelMatch[1]), expectation.authLevel); + assertMethods(filePath, methods, expectation.methods); +} + +function assertEqual(filePath, label, actual, expected) { + if (actual !== expected) { + throw new Error(`Expected ${label} "${expected}" but found "${actual}" in ${path.relative(repoRoot, filePath)}.`); + } +} + +function assertMethods(filePath, actual, expected) { + const actualSet = new Set(actual); + const expectedSet = new Set(expected); + if (actualSet.size !== expectedSet.size || [...expectedSet].some((method) => !actualSet.has(method))) { + throw new Error( + `Expected methods [${expected.join(', ')}] but found [${actual.join(', ')}] in ${path.relative( + repoRoot, + filePath + )}.` + ); + } +} + +function normalizeMethods(methods) { + if (!Array.isArray(methods)) { + return []; + } + + return methods.map(normalizeString); +} + +function normalizeString(value) { + return String(value).trim().toLowerCase(); +} diff --git a/src/constants.ts b/src/constants.ts index ff651c7a..d2459c5d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,6 +7,7 @@ export namespace EnvVarNames { export const eventHub = 'EventHubConnection'; export const serviceBus = 'ServiceBusConnection'; export const sql = 'SqlConnection'; + export const functionKey = 'FUNCTIONS_TEST_KEY'; } export namespace CosmosDB { @@ -55,6 +56,37 @@ export const defaultTimeout = 3 * 60 * 1000; export const combinedFolder = 'combined'; export const oldConfigSuffix = '-oldConfig'; -export function getFuncUrl(routeSuffix: string): string { - return `http://127.0.0.1:7071/api/${routeSuffix}`; +export const jsonContentTypeHeaders = { + 'content-type': 'application/json', +}; + +type QueryParamValue = string | number | boolean; +type QueryParamInput = QueryParamValue | QueryParamValue[] | undefined; + +export function getFuncUrl(routeSuffix: string, queryParams?: Record): string { + const url = new URL(`http://127.0.0.1:7071/api/${routeSuffix}`); + const functionKey = process.env[EnvVarNames.functionKey]; + if (functionKey) { + url.searchParams.set('code', functionKey); + } + + if (queryParams) { + for (const name in queryParams) { + if (!Object.prototype.hasOwnProperty.call(queryParams, name)) { + continue; + } + + const value = queryParams[name]; + if (value === undefined) { + continue; + } + + const values = Array.isArray(value) ? value : [value]; + for (const item of values) { + url.searchParams.append(name, String(item)); + } + } + } + + return url.toString(); } diff --git a/src/cosmosDB.test.ts b/src/cosmosDB.test.ts index 33572a25..d0d655d4 100644 --- a/src/cosmosDB.test.ts +++ b/src/cosmosDB.test.ts @@ -4,7 +4,7 @@ import { CosmosClient } from '@azure/cosmos'; import { expect } from 'chai'; import { default as fetch } from 'node-fetch'; -import { getFuncUrl, CosmosDB } from './constants'; +import { CosmosDB, getFuncUrl, jsonContentTypeHeaders } from './constants'; import { waitForOutput } from './global.test'; import { cosmosDBConnectionString } from './utils/connectionStrings'; import { getRandomTestData } from './utils/getRandomTestData'; @@ -21,7 +21,7 @@ describe('cosmosDB', () => { await waitForOutput(`cosmosDBTrigger processed 1 documents`); await waitForOutput(`cosmosDBTrigger was triggered by "${testData}"`); - const url = `${getFuncUrl('httpTriggerCosmosDBInput')}?id=${createdItem.item.id}`; + const url = getFuncUrl('httpTriggerCosmosDBInput', { id: createdItem.item.id }); const response = await fetch(url); const body = await response.text(); expect(body).to.equal(testData); @@ -37,7 +37,7 @@ describe('cosmosDB', () => { // single doc const singleDoc = getDoc(); - await fetch(url, { method: 'POST', body: JSON.stringify(singleDoc) }); + await fetch(url, { method: 'POST', headers: jsonContentTypeHeaders, body: JSON.stringify(singleDoc) }); await waitForOutput(`cosmosDBTrigger was triggered by "${singleDoc.testData}"`); // bulk docs @@ -45,9 +45,23 @@ describe('cosmosDB', () => { for (let i = 0; i < 5; i++) { bulkDocs.push(getDoc()); } - await fetch(url, { method: 'POST', body: JSON.stringify(bulkDocs) }); + await fetch(url, { method: 'POST', headers: jsonContentTypeHeaders, body: JSON.stringify(bulkDocs) }); for (const doc of bulkDocs) { await waitForOutput(`cosmosDBTrigger was triggered by "${doc.testData}"`); } }); -}); \ No newline at end of file + + it('input and output reject invalid payloads and missing resources', async () => { + // Binding-backed input routes intentionally do not assert omitted-id 400s because + // the host resolves {Query.id} before the function code can validate it. + const missingDocResponse = await fetch(getFuncUrl('httpTriggerCosmosDBInput', { id: getRandomTestData() })); + expect(missingDocResponse.status).to.equal(404); + + const invalidWriteResponse = await fetch(getFuncUrl('httpTriggerCosmosDBOutput'), { + method: 'POST', + headers: jsonContentTypeHeaders, + body: JSON.stringify({ testData: getRandomTestData() }), + }); + expect(invalidWriteResponse.status).to.equal(400); + }); +}); diff --git a/src/eventHub.test.ts b/src/eventHub.test.ts index c68ea8e6..7d2274b9 100644 --- a/src/eventHub.test.ts +++ b/src/eventHub.test.ts @@ -2,10 +2,10 @@ // Licensed under the MIT License. import { EventHubProducerClient } from '@azure/event-hubs'; +import { EventHub } from './constants'; import { isOldConfig, waitForOutput } from './global.test'; import { eventHubConnectionString } from './utils/connectionStrings'; import { getRandomTestData } from './utils/getRandomTestData'; -import { EventHub } from './constants'; describe('eventHub', () => { let clientOneTriggerAndOutput: EventHubProducerClient; @@ -13,7 +13,6 @@ describe('eventHub', () => { let clientManyTriggerAndOutput: EventHubProducerClient; let clientManyTrigger: EventHubProducerClient; - before(function (this: Mocha.Context) { // Old config (Exts bundles < 4.0.0) cannot use EventHub emulator // Microsoft.Azure.Functions.Worker.Extensions.EventHubs bundle must be >= 6.3.0 @@ -21,9 +20,15 @@ describe('eventHub', () => { if (isOldConfig) { this.skip(); } - clientOneTriggerAndOutput = new EventHubProducerClient(eventHubConnectionString, EventHub.eventHubOneTriggerAndOutput); + clientOneTriggerAndOutput = new EventHubProducerClient( + eventHubConnectionString, + EventHub.eventHubOneTriggerAndOutput + ); clientOneTrigger = new EventHubProducerClient(eventHubConnectionString, EventHub.eventHubOneTrigger); - clientManyTriggerAndOutput = new EventHubProducerClient(eventHubConnectionString, EventHub.eventHubManyTriggerAndOutput); + clientManyTriggerAndOutput = new EventHubProducerClient( + eventHubConnectionString, + EventHub.eventHubManyTriggerAndOutput + ); clientManyTrigger = new EventHubProducerClient(eventHubConnectionString, EventHub.eventHubManyTrigger); }); diff --git a/src/http.test.ts b/src/http.test.ts index 9cf7f841..2ae01adc 100644 --- a/src/http.test.ts +++ b/src/http.test.ts @@ -46,7 +46,7 @@ describe('http', () => { }); it('hello world name in query', async () => { - const response = await fetch(`${helloWorldUrl}?name=testName`); + const response = await fetch(getFuncUrl('helloWorld', { name: 'testName' })); const body = await response.text(); expect(body).to.equal('Hello, testName!'); expect(response.status).to.equal(200); @@ -61,8 +61,7 @@ describe('http', () => { // Related: https://github.com/Azure/azure-functions-nodejs-library/issues/285 it('route parameters', async () => { - const funcUrl = getFuncUrl('httpTriggerRouteParams'); - const response = await fetch(`${funcUrl}/testName/5`); + const response = await fetch(getFuncUrl('httpTriggerRouteParams/testName/5')); const body = await response.json(); expect(body).to.deep.equal({ name: 'testName', id: '5' }); expect(response.status).to.equal(200); @@ -84,8 +83,7 @@ describe('http', () => { }); it('query', async () => { - const funcUrl = getFuncUrl('httpTriggerQuery'); - const response = await fetch(`${funcUrl}?name=testName&dupe=1&dupe=2`); + const response = await fetch(getFuncUrl('httpTriggerQuery', { name: 'testName', dupe: ['1', '2'] })); const body = await response.json(); if (isOldConfig || model === 'v3') { // NOTE: more info on dupe query behavior here: @@ -206,8 +204,7 @@ describe('http', () => { }); it(`receive stream ${lengthInMb}mb`, async () => { - const funcUrl = getFuncUrl('httpTriggerSendStream'); - const response = await fetch(`${funcUrl}?lengthInMb=${lengthInMb}`, { method: 'GET' }); + const response = await fetch(getFuncUrl('httpTriggerSendStream', { lengthInMb }), { method: 'GET' }); const bytesReceived = await receiveStreamWithProgress(response.body); expect(bytesReceived).to.equal(convertMbToB(lengthInMb)); diff --git a/src/index.ts b/src/index.ts index 487923aa..4c774a93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,12 +33,12 @@ export async function run(): Promise { let files: string[] = await globby('**/*.test.js', { cwd: __dirname }); const { only, exclude } = getTestFileFilter(); if (only) { - files = files.filter(f => f.endsWith(only)); + files = files.filter((f) => f.endsWith(only)); } else if (exclude) { - files = files.filter(f => !f.endsWith(exclude)); + files = files.filter((f) => !f.endsWith(exclude)); } - files = files.filter(f => path.resolve(__dirname, f) !== globalTestPath); + files = files.filter((f) => path.resolve(__dirname, f) !== globalTestPath); files.forEach((f) => mocha.addFile(path.resolve(__dirname, f))); const failures = await new Promise((resolve) => mocha.run(resolve)); diff --git a/src/serviceBus.test.ts b/src/serviceBus.test.ts index 74d2f5ee..a585d21f 100644 --- a/src/serviceBus.test.ts +++ b/src/serviceBus.test.ts @@ -2,19 +2,19 @@ // Licensed under the MIT License. import { ServiceBusClient } from '@azure/service-bus'; +import { expect } from 'chai'; import { default as fetch } from 'node-fetch'; -import { getFuncUrl } from './constants'; +import { getFuncUrl, jsonContentTypeHeaders, ServiceBus } from './constants'; import { isOldConfig, waitForOutput } from './global.test'; -import { getRandomTestData } from './utils/getRandomTestData'; import { serviceBusConnectionString } from './utils/connectionStrings'; -import { ServiceBus } from './constants'; +import { getRandomTestData } from './utils/getRandomTestData'; describe('serviceBus', () => { let client: ServiceBusClient; before(function (this: Mocha.Context) { if (isOldConfig) { - this.skip(); + this.skip(); } client = new ServiceBusClient(serviceBusConnectionString); }); @@ -65,7 +65,11 @@ describe('serviceBus', () => { // single const message = getRandomTestData(); - await fetch(url, { method: 'POST', body: JSON.stringify({ output: message }) }); + await fetch(url, { + method: 'POST', + headers: jsonContentTypeHeaders, + body: JSON.stringify({ output: message }), + }); await waitForOutput(`serviceBusQueueTrigger was triggered by "${message}"`); // bulk @@ -73,9 +77,23 @@ describe('serviceBus', () => { for (let i = 0; i < 5; i++) { bulkMsgs.push(getRandomTestData()); } - await fetch(url, { method: 'POST', body: JSON.stringify({ output: bulkMsgs }) }); + await fetch(url, { + method: 'POST', + headers: jsonContentTypeHeaders, + body: JSON.stringify({ output: bulkMsgs }), + }); for (const msg of bulkMsgs) { await waitForOutput(`serviceBusQueueTrigger was triggered by "${msg}"`); } }); -}); \ No newline at end of file + + it('extra output rejects malformed payloads', async () => { + const response = await fetch(getFuncUrl('httpTriggerServiceBusOutput'), { + method: 'POST', + headers: jsonContentTypeHeaders, + body: JSON.stringify({}), + }); + + expect(response.status).to.equal(400); + }); +}); diff --git a/src/sql.test.ts b/src/sql.test.ts index 6bbac192..901b55a3 100644 --- a/src/sql.test.ts +++ b/src/sql.test.ts @@ -2,14 +2,14 @@ // Licensed under the MIT License. import { expect } from 'chai'; +import { ConnectionPool } from 'mssql'; import { default as fetch } from 'node-fetch'; import { v4 as uuid } from 'uuid'; -import { Sql, getFuncUrl } from './constants'; +import { getFuncUrl, jsonContentTypeHeaders, Sql } from './constants'; import { isOldConfig, waitForOutput } from './global.test'; +import { sqlTestConnectionString } from './utils/connectionStrings'; import { getRandomTestData } from './utils/getRandomTestData'; -import { ConnectionPool } from 'mssql'; import { createPoolConnnection } from './utils/sql/setupSql'; -import { sqlTestConnectionString } from './utils/connectionStrings'; describe('sql', () => { let poolConnection: ConnectionPool | undefined; @@ -64,15 +64,34 @@ describe('sql', () => { testData: getRandomTestData(), }, ]; - const responseOut = await fetch(outputUrl, { method: 'POST', body: JSON.stringify(items) }); + const responseOut = await fetch(outputUrl, { + method: 'POST', + headers: jsonContentTypeHeaders, + body: JSON.stringify(items), + }); expect(responseOut.status).to.equal(201); await waitForOutput(`httpTriggerSqlOutput was triggered`); - const inputUrl = getFuncUrl('httpTriggerSqlInput'); - const responseIn = await fetch(`${inputUrl}?id=${id}`, { method: 'GET' }); + const responseIn = await fetch(getFuncUrl('httpTriggerSqlInput', { id }), { method: 'GET' }); expect(responseIn.status).to.equal(200); const result = await responseIn.json(); expect(result).to.deep.equal(items); await waitForOutput(`httpTriggerSqlInput was triggered`); }); -}); \ No newline at end of file + + it('input and output reject invalid payloads and missing resources', async () => { + const invalidWriteResponse = await fetch(getFuncUrl('httpTriggerSqlOutput'), { + method: 'POST', + headers: jsonContentTypeHeaders, + body: JSON.stringify([{ id: uuid() }]), + }); + expect(invalidWriteResponse.status).to.equal(400); + + // Binding-backed input routes intentionally do not assert omitted-id 400s because + // the host resolves {Query.id} before the function code can validate it. + const missingRowResponse = await fetch(getFuncUrl('httpTriggerSqlInput', { id: uuid() }), { + method: 'GET', + }); + expect(missingRowResponse.status).to.equal(404); + }); +}); diff --git a/src/storage.test.ts b/src/storage.test.ts index 562f0e8f..75cddc08 100644 --- a/src/storage.test.ts +++ b/src/storage.test.ts @@ -5,10 +5,10 @@ import { ContainerClient } from '@azure/storage-blob'; import { QueueClient } from '@azure/storage-queue'; import { expect } from 'chai'; import { default as fetch } from 'node-fetch'; -import { getFuncUrl } from './constants'; +import { getFuncUrl, jsonContentTypeHeaders } from './constants'; import { model, waitForOutput } from './global.test'; -import { getRandomTestData } from './utils/getRandomTestData'; import { storageConnectionString } from './utils/connectionStrings'; +import { getRandomTestData } from './utils/getRandomTestData'; describe('storage', () => { it('queue trigger and output', async () => { @@ -27,7 +27,11 @@ describe('storage', () => { // single const message = getRandomTestData(); - await fetch(url, { method: 'POST', body: JSON.stringify({ output: message }) }); + await fetch(url, { + method: 'POST', + headers: jsonContentTypeHeaders, + body: JSON.stringify({ output: message }), + }); await waitForOutput(`storageQueueTrigger was triggered by "${message}"`); // bulk @@ -35,12 +39,26 @@ describe('storage', () => { for (let i = 0; i < 5; i++) { bulkMsgs.push(getRandomTestData()); } - await fetch(url, { method: 'POST', body: JSON.stringify({ output: bulkMsgs }) }); + await fetch(url, { + method: 'POST', + headers: jsonContentTypeHeaders, + body: JSON.stringify({ output: bulkMsgs }), + }); for (const msg of bulkMsgs) { await waitForOutput(`storageQueueTrigger was triggered by "${msg}"`); } }); + it('queue extra output rejects malformed payloads', async () => { + const response = await fetch(getFuncUrl('httpTriggerStorageQueueOutput'), { + method: 'POST', + headers: jsonContentTypeHeaders, + body: JSON.stringify({}), + }); + + expect(response.status).to.equal(400); + }); + it('blob trigger and output', async () => { const containerName = 'e2e-test-container'; const client = new ContainerClient(storageConnectionString, containerName); @@ -72,6 +90,7 @@ describe('storage', () => { ]; const responseOut = await fetch(getFuncUrl('httpTriggerTableOutput'), { method: 'POST', + headers: jsonContentTypeHeaders, body: JSON.stringify(items), }); expect(responseOut.status).to.equal(201); @@ -84,6 +103,25 @@ describe('storage', () => { await waitForOutput(`httpTriggerTableInput was triggered`); }); + it('table input and output reject invalid requests', async () => { + const invalidWriteResponse = await fetch(getFuncUrl('httpTriggerTableOutput'), { + method: 'POST', + headers: jsonContentTypeHeaders, + body: JSON.stringify([{ PartitionKey: 'e2eTestPartKey' }]), + }); + expect(invalidWriteResponse.status).to.equal(400); + + const invalidReadResponse = await fetch(getFuncUrl(`httpTriggerTableInput/${encodeURIComponent(' ')}`), { + method: 'GET', + }); + expect(invalidReadResponse.status).to.equal(400); + + const missingRowResponse = await fetch(getFuncUrl(`httpTriggerTableInput/${getRandomTestData()}`), { + method: 'GET', + }); + expect(missingRowResponse.status).to.equal(404); + }); + // Test for bug https://github.com/Azure/azure-functions-nodejs-library/issues/179 it('Shared output bug', async function (this: Mocha.Context) { if (model === 'v3') { diff --git a/src/utils/connectionStrings.ts b/src/utils/connectionStrings.ts index 31b65015..02cdbadd 100644 --- a/src/utils/connectionStrings.ts +++ b/src/utils/connectionStrings.ts @@ -9,9 +9,11 @@ export let sqlConnectionString: string; export let sqlTestConnectionString: string; export async function initializeConnectionStrings(): Promise { - storageConnectionString = "UseDevelopmentStorage=true"; - cosmosDBConnectionString = "AccountEndpoint=http://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; - serviceBusConnectionString = eventHubConnectionString = "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; + storageConnectionString = 'UseDevelopmentStorage=true'; + cosmosDBConnectionString = + 'AccountEndpoint=http://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=='; + serviceBusConnectionString = eventHubConnectionString = + 'Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;'; sqlConnectionString = `Server=localhost,15433;Database=master;User Id=sa;Password=${process.env.SA_PASSWORD};Encrypt=false;TrustServerCertificate=true;`; sqlTestConnectionString = `Server=localhost,15433;Database=e2eTestDB;User Id=sa;Password=${process.env.SA_PASSWORD};Encrypt=false;TrustServerCertificate=true;`; } diff --git a/src/utils/cosmosdb/setupCosmosDB.ts b/src/utils/cosmosdb/setupCosmosDB.ts index 7f646a25..0014b451 100644 --- a/src/utils/cosmosdb/setupCosmosDB.ts +++ b/src/utils/cosmosdb/setupCosmosDB.ts @@ -6,22 +6,22 @@ // Microsoft.Azure.Cosmos.Client: This builder instance has already been used to build a processor. Create a new instance to build another. import { CosmosClient, PartitionKeyKind } from '@azure/cosmos'; -import { cosmosDBConnectionString } from '../connectionStrings'; import { CosmosDB } from '../../constants'; +import { cosmosDBConnectionString } from '../connectionStrings'; export async function setupCosmosDB() { const partitionKeyPath = `/${CosmosDB.partitionKey}`; if (!cosmosDBConnectionString) { - throw new Error('CosmosDB connection string is not set'); + throw new Error('CosmosDB connection string is not set'); } const client = new CosmosClient(cosmosDBConnectionString); await client.databases.createIfNotExists({ id: CosmosDB.triggerDatabaseName }); await client.database(CosmosDB.triggerDatabaseName).containers.createIfNotExists({ - id: CosmosDB.triggerContainerName, - partitionKey: { paths: [partitionKeyPath], kind: PartitionKeyKind.Hash } + id: CosmosDB.triggerContainerName, + partitionKey: { paths: [partitionKeyPath], kind: PartitionKeyKind.Hash }, }); await client.database(CosmosDB.triggerDatabaseName).containers.createIfNotExists({ - id: CosmosDB.triggerAndOutputContainerName, - partitionKey: { paths: [partitionKeyPath], kind: PartitionKeyKind.Hash } + id: CosmosDB.triggerAndOutputContainerName, + partitionKey: { paths: [partitionKeyPath], kind: PartitionKeyKind.Hash }, }); -} \ No newline at end of file +} diff --git a/src/utils/servicebus/setupServiceBus.ts b/src/utils/servicebus/setupServiceBus.ts index 5d69c909..0f2e4488 100644 --- a/src/utils/servicebus/setupServiceBus.ts +++ b/src/utils/servicebus/setupServiceBus.ts @@ -25,11 +25,7 @@ export async function setupServiceBus(): Promise { await createQueueIfNotExists(client, ServiceBus.serviceBusQueueManyTriggerAndOutput); // Create topics with subscriptions - await createTopicWithSubscriptionIfNotExists( - client, - ServiceBus.serviceBusTopicTrigger, - 'e2e-test-sub' - ); + await createTopicWithSubscriptionIfNotExists(client, ServiceBus.serviceBusTopicTrigger, 'e2e-test-sub'); await createTopicWithSubscriptionIfNotExists( client, ServiceBus.serviceBusTopicTriggerAndOutput, @@ -44,10 +40,7 @@ export async function setupServiceBus(): Promise { } } -async function createQueueIfNotExists( - client: ServiceBusAdministrationClient, - queueName: string -): Promise { +async function createQueueIfNotExists(client: ServiceBusAdministrationClient, queueName: string): Promise { try { await client.getQueue(queueName); } catch (err: unknown) { diff --git a/src/utils/sql/setupSql.ts b/src/utils/sql/setupSql.ts index 59b4c770..cac47395 100644 --- a/src/utils/sql/setupSql.ts +++ b/src/utils/sql/setupSql.ts @@ -3,8 +3,8 @@ import * as sql from 'mssql'; import retry from 'p-retry'; -import { sqlConnectionString, sqlTestConnectionString } from '../connectionStrings'; import { Sql } from '../../constants'; +import { sqlConnectionString, sqlTestConnectionString } from '../connectionStrings'; export async function runSqlSetupQueries() { // STEP 1: Create DB if not exists @@ -18,13 +18,12 @@ export async function runSqlSetupQueries() { // STEP 2: Retry ALTER DATABASE (change_tracking) try { - await retry(async (currentAttempt) => { - if (currentAttempt > 1) { - console.log( - `${new Date().toISOString()}: Retrying ALTER DATABASE. Attempt ${currentAttempt}` - ); - } - await pool.request().query(` + await retry( + async (currentAttempt) => { + if (currentAttempt > 1) { + console.log(`${new Date().toISOString()}: Retrying ALTER DATABASE. Attempt ${currentAttempt}`); + } + await pool.request().query(` IF NOT EXISTS ( SELECT 1 FROM sys.change_tracking_databases @@ -35,10 +34,12 @@ export async function runSqlSetupQueries() { SET CHANGE_TRACKING = ON (CHANGE_RETENTION = 2 DAYS, AUTO_CLEANUP = ON); END `); - }, { - retries: 5, - minTimeout: 5000 - }); + }, + { + retries: 5, + minTimeout: 5000, + } + ); } finally { await pool.close(); } @@ -86,4 +87,4 @@ export async function createPoolConnnection(connectionString: string): Promise