diff --git a/azure-functions-language-worker-protobuf/src/proto/FunctionRpc.proto b/azure-functions-language-worker-protobuf/src/proto/FunctionRpc.proto index d9d4e4a4..3ed1f058 100644 --- a/azure-functions-language-worker-protobuf/src/proto/FunctionRpc.proto +++ b/azure-functions-language-worker-protobuf/src/proto/FunctionRpc.proto @@ -431,11 +431,12 @@ message RpcException { // Http cookie type. Note that only name and value are used for Http requests message RpcHttpCookie { - // Enum that lets servers require that a cookie shouoldn't be sent with cross-site requests + // Enum that lets servers require that a cookie shouldn't be sent with cross-site requests enum SameSite { None = 0; Lax = 1; Strict = 2; + ExplicitNone = 3; } // Cookie name diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 682697f5..29a5d5ca 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -92,6 +92,7 @@ jobs: FUNCTIONS_WORKER_RUNTIME: 'node' languageWorkers:node:workerDirectory: $(System.DefaultWorkingDirectory) - task: PublishTestResults@2 + condition: always() inputs: testRunner: VSTest testResultsFiles: '**/*.trx' diff --git a/src/converters/RpcHttpConverters.ts b/src/converters/RpcHttpConverters.ts index 52f14328..0ad9575f 100644 --- a/src/converters/RpcHttpConverters.ts +++ b/src/converters/RpcHttpConverters.ts @@ -101,10 +101,13 @@ function toRpcHttpCookie(inputCookie: Cookie): rpc.IRpcHttpCookie { // Resolve SameSite enum, a one-off let rpcSameSite: rpc.RpcHttpCookie.SameSite = rpc.RpcHttpCookie.SameSite.None; if (inputCookie && inputCookie.sameSite) { - if (inputCookie.sameSite.toLocaleLowerCase() === "lax") { - rpcSameSite = rpc.RpcHttpCookie.SameSite.Lax; - } else if (inputCookie.sameSite.toLocaleLowerCase() === "strict") { - rpcSameSite = rpc.RpcHttpCookie.SameSite.Strict; + let sameSite = inputCookie.sameSite.toLocaleLowerCase(); + if (sameSite === "lax") { + rpcSameSite = rpc.RpcHttpCookie.SameSite.Lax; + } else if (sameSite === "strict") { + rpcSameSite = rpc.RpcHttpCookie.SameSite.Strict; + } else if (sameSite === "none") { + rpcSameSite = rpc.RpcHttpCookie.SameSite.ExplicitNone; } } diff --git a/src/public/Interfaces.ts b/src/public/Interfaces.ts index d1f0d42f..2d66b0a8 100644 --- a/src/public/Interfaces.ts +++ b/src/public/Interfaces.ts @@ -137,7 +137,7 @@ export interface Cookie { httpOnly?: boolean; /** Can restrict the cookie to not be sent with cross-site requests */ - sameSite?: "Strict" | "Lax" | undefined; + sameSite?: "Strict" | "Lax" | "None" | undefined; /** Number of seconds until the cookie expires. A zero or negative number will expire the cookie immediately. */ maxAge?: number; diff --git a/test/RpcHttpConverters.ts b/test/RpcHttpConverters.ts index f75e39a6..2ed3ff26 100644 --- a/test/RpcHttpConverters.ts +++ b/test/RpcHttpConverters.ts @@ -43,7 +43,45 @@ describe('Rpc Converters', () => { expect((rpcCookies[2].expires).value.seconds).to.equal(819199440); }); - it('throws on invalid cookie input', () => { + it('converts http cookie SameSite', () => { + let cookieInputs: Cookie[] = + [ + { + name: "none-cookie", + value: "myvalue", + sameSite: "None" + }, + { + name: "lax-cookie", + value: "myvalue", + sameSite: "Lax" + }, + { + name: "strict-cookie", + value: "myvalue", + sameSite: "Strict" + }, + { + name: "default-cookie", + value: "myvalue" + } + ]; + + let rpcCookies = toRpcHttpCookieList(cookieInputs); + expect(rpcCookies[0].name).to.equal("none-cookie"); + expect(rpcCookies[0].sameSite).to.equal(rpc.RpcHttpCookie.SameSite.ExplicitNone); + + expect(rpcCookies[1].name).to.equal("lax-cookie"); + expect(rpcCookies[1].sameSite).to.equal(rpc.RpcHttpCookie.SameSite.Lax); + + expect(rpcCookies[2].name).to.equal("strict-cookie"); + expect(rpcCookies[2].sameSite).to.equal(rpc.RpcHttpCookie.SameSite.Strict); + + expect(rpcCookies[3].name).to.equal("default-cookie"); + expect(rpcCookies[3].sameSite).to.equal(rpc.RpcHttpCookie.SameSite.None); + }); + + it('throws on invalid input', () => { expect(() => { let cookieInputs = [ { diff --git a/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/Helpers/HttpHelpers.cs b/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/Helpers/HttpHelpers.cs new file mode 100644 index 00000000..356f52d8 --- /dev/null +++ b/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/Helpers/HttpHelpers.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +namespace Azure.Functions.NodeJs.Tests.E2E +{ + class HttpHelpers + { + public static async Task InvokeHttpTrigger(string functionName, string queryString = "") + { + // Basic http request + HttpRequestMessage request = GetTestRequest(functionName, queryString); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain")); + return await GetResponseMessage(request); + } + + public static async Task InvokeHttpTriggerWithBody(string functionName, string body, HttpStatusCode expectedStatusCode, string mediaType, int expectedCode = 0) + { + HttpRequestMessage request = GetTestRequest(functionName); + request.Content = new StringContent(body); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(mediaType)); + return await GetResponseMessage(request); + } + + private static HttpRequestMessage GetTestRequest(string functionName, string queryString = "") + { + return new HttpRequestMessage + { + RequestUri = new Uri($"{Constants.FunctionsHostUrl}/api/{functionName}{queryString}"), + Method = HttpMethod.Post + }; + } + + private static async Task GetResponseMessage(HttpRequestMessage request) + { + HttpResponseMessage response = null; + using (var httpClient = new HttpClient()) + { + response = await httpClient.SendAsync(request); + } + + return response; + } + } +} \ No newline at end of file diff --git a/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/HttpEndToEndTests.cs b/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/HttpEndToEndTests.cs index 2febadbe..888de3bf 100644 --- a/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/HttpEndToEndTests.cs +++ b/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/HttpEndToEndTests.cs @@ -1,7 +1,13 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using System.Net; +using System.Net.Http; +using System.Text; using System.Threading.Tasks; using Xunit; @@ -25,7 +31,15 @@ public HttpEndToEndTests(FunctionAppFixture fixture) public async Task HttpTriggerTests(string functionName, string queryString, HttpStatusCode expectedStatusCode, string expectedMessage) { // TODO: Verify exception on 500 after https://github.com/Azure/azure-functions-host/issues/3589 - Assert.True(await Utilities.InvokeHttpTrigger(functionName, queryString, expectedStatusCode, expectedMessage)); + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger(functionName, queryString); + string actualMessage = await response.Content.ReadAsStringAsync(); + + Assert.Equal(expectedStatusCode, response.StatusCode); + + if (!string.IsNullOrEmpty(expectedMessage)) { + Assert.False(string.IsNullOrEmpty(actualMessage)); + Assert.True(actualMessage.Contains(expectedMessage)); + } } [Theory] @@ -34,15 +48,65 @@ public async Task HttpTriggerTests(string functionName, string queryString, Http [InlineData("HttpTriggerBodyAndRawBody", "{\"a\":1}", "application/octet-stream", HttpStatusCode.OK)] [InlineData("HttpTriggerBodyAndRawBody", "abc", "text/plain", HttpStatusCode.OK)] - public async Task HttpTriggerTestsWithCustomMediaType(string functionName, string queryString, string mediaType, HttpStatusCode expectedStatusCode) + public async Task HttpTriggerTestsWithCustomMediaType(string functionName, string body, string mediaType, HttpStatusCode expectedStatusCode) { - Assert.True(await Utilities.InvokeHttpTriggerWithBody(functionName, queryString, expectedStatusCode, mediaType)); + HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody(functionName, body, expectedStatusCode, mediaType); + JObject responseBody = JObject.Parse(await response.Content.ReadAsStringAsync()); + + Assert.Equal(expectedStatusCode, response.StatusCode); + VerifyBodyAndRawBody(responseBody, body, mediaType); } - [Fact(Skip = "Not yet enabled.")] + [Fact] public async Task HttpTriggerWithCookieTests() { - Assert.True(await Utilities.InvokeHttpTrigger("HttpTriggerSetsCookie", "", HttpStatusCode.OK, "mycookie=myvalue, mycookie2=myvalue2")); + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("HttpTriggerSetsCookie"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + List cookies = response.Headers.SingleOrDefault(header => header.Key == "Set-Cookie").Value.ToList(); + Assert.Equal(5, cookies.Count); + Assert.Equal("mycookie=myvalue; max-age=200000; path=/", cookies[0]); + Assert.Equal("mycookie2=myvalue; max-age=200000; path=/", cookies[1]); + Assert.Equal("mycookie3-expires=myvalue3-expires; max-age=0; path=/", cookies[2]); + Assert.Equal("mycookie4-samesite-lax=myvalue; path=/; samesite=lax", cookies[3]); + Assert.Equal("mycookie5-samesite-strict=myvalue; path=/; samesite=strict", cookies[4]); + // Assert.Equal("mycookie4-samesite-none=myvalue; path=/; samesite=none", cookies[5]); + } + + private static void VerifyBodyAndRawBody(JObject result, string input, string mediaType) + { + if (mediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase)) + { + try + { + Assert.Equal(input, (string)result["reqRawBody"]); + Assert.True(JToken.DeepEquals((JObject)result["reqBody"], JObject.Parse(input))); + } + catch (InvalidCastException) // Invalid JSON + { + Assert.Equal(input, (string)result["reqRawBody"]); + Assert.Equal(input, (string)result["reqBody"]); + } + } + else if (IsMediaTypeOctetOrMultipart(mediaType)) + { + JObject reqBody = (JObject)result["reqBody"]; + byte[] responseBytes = reqBody["data"].ToObject(); + Assert.True(responseBytes.SequenceEqual(Encoding.UTF8.GetBytes(input))); + Assert.Equal(input, (string)result["reqRawBody"]); + } + else if (mediaType.Equals("text/plain", StringComparison.OrdinalIgnoreCase)) + { + Assert.Equal(input, (string)result["reqRawBody"]); + Assert.Equal(input, (string)result["reqBody"]); + } else { + Assert.Equal("Supported media types are 'text/plain' 'application/octet-stream', 'multipart/*', and 'application/json'", $"Found mediaType '{mediaType}'"); + } + } + + private static bool IsMediaTypeOctetOrMultipart(string mediaType) + { + return mediaType != null && (string.Equals(mediaType, "application/octet-stream", StringComparison.OrdinalIgnoreCase) + || mediaType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0); } } } \ No newline at end of file diff --git a/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/Utilities.cs b/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/Utilities.cs index ad90d432..f152ad71 100644 --- a/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/Utilities.cs +++ b/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/Utilities.cs @@ -4,12 +4,6 @@ using System; using System.Diagnostics; using System.Threading.Tasks; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using Newtonsoft.Json.Linq; -using System.Text; -using System.Linq; namespace Azure.Functions.NodeJs.Tests.E2E { @@ -34,88 +28,5 @@ public static async Task RetryAsync(Func> condition, int timeout = 60 } } } - - public static async Task InvokeHttpTrigger(string functionName, string queryString, HttpStatusCode expectedStatusCode, string expectedMessage, int expectedCode = 0) - { - string uri = $"api/{functionName}{queryString}"; - HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain")); - - var httpClient = new HttpClient(); - httpClient.BaseAddress = new Uri(Constants.FunctionsHostUrl); - var response = await httpClient.SendAsync(request); - if (expectedStatusCode != response.StatusCode && expectedCode != (int)response.StatusCode) - { - return false; - } - - if (!string.IsNullOrEmpty(expectedMessage)) - { - string actualMessage = await response.Content.ReadAsStringAsync(); - return actualMessage.Contains(expectedMessage); - } - return true; - } - - public static async Task InvokeHttpTriggerWithBody(string functionName, string body, HttpStatusCode expectedStatusCode, string mediaType, int expectedCode = 0) - { - // Arrange - HttpRequestMessage request = new HttpRequestMessage - { - RequestUri = new Uri($"{Constants.FunctionsHostUrl}/api/{functionName}"), - Method = HttpMethod.Post, - Content = new StringContent(body), - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(mediaType)); - - // Act - HttpResponseMessage response = null; - using (var httpClient = new HttpClient()) - { - response = await httpClient.SendAsync(request); - } - - // Verify - if (expectedStatusCode != response.StatusCode && expectedCode != (int)response.StatusCode) - { - return false; - } - - return VerifyBodyAndRawBody(JObject.Parse(await response.Content.ReadAsStringAsync()), body, mediaType); - } - - private static bool VerifyBodyAndRawBody(JObject result, string input, string mediaType) - { - if (mediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase)) - { - try - { - return ((string)result["reqRawBody"]).Equals(input) && (JToken.DeepEquals((JObject)result["reqBody"], JObject.Parse(input))); - } - catch (InvalidCastException) // Invalid JSON - { - return ((string)result["reqRawBody"]).Equals(input) && ((string)result["reqBody"]).Equals(input); - } - } - else if(IsMediaTypeOctetOrMultipart(mediaType)) - { - JObject reqBody = (JObject)result["reqBody"]; - byte[] responseBytes = reqBody["data"].ToObject(); - return responseBytes.SequenceEqual(Encoding.UTF8.GetBytes(input)) && ((string)result["reqRawBody"]).Equals(input); - } - else if(mediaType.Equals("text/plain", StringComparison.OrdinalIgnoreCase)) - { - return ((string)result["reqRawBody"]).Equals(input) && ((string)result["reqBody"]).Equals(input); - } - - return false; - } - - private static bool IsMediaTypeOctetOrMultipart(string mediaType) - { - return mediaType != null && (string.Equals(mediaType, "application/octet-stream", StringComparison.OrdinalIgnoreCase) || - mediaType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0); - } } } \ No newline at end of file diff --git a/test/end-to-end/testFunctionApp/HttpTriggerSetsCookie/index.js b/test/end-to-end/testFunctionApp/HttpTriggerSetsCookie/index.js index c89a76fc..f95e5794 100644 --- a/test/end-to-end/testFunctionApp/HttpTriggerSetsCookie/index.js +++ b/test/end-to-end/testFunctionApp/HttpTriggerSetsCookie/index.js @@ -1,7 +1,13 @@ module.exports = async function (context, req) { context.log('JavaScript HTTP trigger function processed a request.'); - - return { + // TODO: Add this scenario + // { + // name: "mycookie6-samesite-none", + // value: "myvalue", + // sameSite: "None" + // }, + context.res = { + status: 200, cookies: [ { name: "mycookie", @@ -10,7 +16,7 @@ module.exports = async function (context, req) { }, { name: "mycookie2", - value: "myvalue2", + value: "myvalue", path: "/", maxAge: "200000" }, @@ -18,8 +24,17 @@ module.exports = async function (context, req) { name: "mycookie3-expires", value: "myvalue3-expires", maxAge: 0 + }, + { + name: "mycookie4-samesite-lax", + value: "myvalue", + sameSite: "Lax" + }, + { + name: "mycookie5-samesite-strict", + value: "myvalue", + sameSite: "Strict" } - ], - body: JSON.stringify(req.headers["cookie"]) + ] } }; \ No newline at end of file diff --git a/types/public/Interfaces.d.ts b/types/public/Interfaces.d.ts index 351ffb18..81f2809f 100644 --- a/types/public/Interfaces.d.ts +++ b/types/public/Interfaces.d.ts @@ -137,7 +137,7 @@ export interface Cookie { /** Sets the cookie to be inaccessible to JavaScript's Document.cookie API */ httpOnly?: boolean; /** Can restrict the cookie to not be sent with cross-site requests */ - sameSite?: "Strict" | "Lax" | undefined; + sameSite?: "Strict" | "Lax" | "None" | undefined; /** Number of seconds until the cookie expires. A zero or negative number will expire the cookie immediately. */ maxAge?: number; }