Skip to content

Add new SameSite property and tests (#286) #311

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ jobs:
FUNCTIONS_WORKER_RUNTIME: 'node'
languageWorkers:node:workerDirectory: $(System.DefaultWorkingDirectory)
- task: PublishTestResults@2
condition: always()
inputs:
testRunner: VSTest
testResultsFiles: '**/*.trx'
Expand Down
11 changes: 7 additions & 4 deletions src/converters/RpcHttpConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/public/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
40 changes: 39 additions & 1 deletion test/RpcHttpConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,45 @@ describe('Rpc Converters', () => {
expect((<any>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(<Cookie[]>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 = [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> 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<HttpResponseMessage> 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<HttpResponseMessage> GetResponseMessage(HttpRequestMessage request)
{
HttpResponseMessage response = null;
using (var httpClient = new HttpClient())
{
response = await httpClient.SendAsync(request);
}

return response;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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]
Expand All @@ -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<string> 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<byte[]>();
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -34,88 +28,5 @@ public static async Task RetryAsync(Func<Task<bool>> condition, int timeout = 60
}
}
}

public static async Task<bool> 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<bool> 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<byte[]>();
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);
}
}
}
25 changes: 20 additions & 5 deletions test/end-to-end/testFunctionApp/HttpTriggerSetsCookie/index.js
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -10,16 +16,25 @@ module.exports = async function (context, req) {
},
{
name: "mycookie2",
value: "myvalue2",
value: "myvalue",
path: "/",
maxAge: "200000"
},
{
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"])
]
}
};
Loading