0

I'm working on a CustomClaimsProvider, although for the purposes of this question, all that matters is that it's an Azure Function with an HTTP trigger. The code here is based on the example code in the edit the function section of the get started page.

The issue is that the same code gives different responses across different implementations, and I want a unit test to verify that the response is correct.

Repro steps:

  1. In Visual Studio, create a new project, use "Azure Functions" (you need to have the Azure workload installed to do this) choose ".NET 6.0 (Long term support)" (this is critical, it gives an "in-process" implementation), but use the other default options
  2. Replace the content of Function1.cs with the following
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using System.Collections.Generic;

namespace FunctionApp6;

public class Function1
{
    [FunctionName("CustomClaimsProvider")]
    public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest request)
    {
        string requestBody = await new StreamReader(request.Body).ReadToEndAsync();

        // Claims to return to Azure AD
        ResponseContent r = new ResponseContent();
        r.data.actions[0].claims.ApiVersion = "1.0.0";
        r.data.actions[0].claims.DateOfBirth = "2000-01-01";
        r.data.actions[0].claims.CustomRoles.Add("Writer");
        r.data.actions[0].claims.CustomRoles.Add("Editor");
        return new OkObjectResult(r);
    }
}

public class ResponseContent
{
    public Data data { get; set; } = new();
}

public class Data
{
    public Data()
    {
        actions = new List<Action>();
        actions.Add(new Action());
    }

    [JsonProperty("@odata.type")]
    public string odatatype { get; set; } = "microsoft.graph.onTokenIssuanceStartResponseData";

    public List<Action> actions { get; set; }
}

public class Action
{
    [JsonProperty("@odata.type")]
    public string odatatype { get; set; } = "microsoft.graph.tokenIssuanceStart.provideClaimsForToken";

    public Claims claims { get; set; } = new();
}

public class Claims
{
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
    public string AnotherValue { get; set; }

    [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
    public string DateOfBirth { get; set; }

    public string ApiVersion { get; set; }

    public List<string> CustomRoles { get; set; } = new();
}
  1. Set that project as the start project and run the solution. When it runs, it will give a GET,POST URL... copy that URL into a browser to view the returned JSON. The output JSON looks like this, which is correct:
{
    "data": {
        "@odata.type": "microsoft.graph.onTokenIssuanceStartResponseData",
        "actions": [
            {
                "@odata.type": "microsoft.graph.tokenIssuanceStart.provideClaimsForToken",
                "claims": {
                    "dateOfBirth": "2000-01-01",
                    "apiVersion": "1.0.0",
                    "customRoles": [
                        "Writer",
                        "Editor"
                    ]
                }
            }
        ]
    }
}

However, if we repeat those steps, and just change the following things...

  • This time, select ".NET 6.0 Isolated (Long Term Support)"
  • Use the same code from above, but replace all the previous "using"s with:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Newtonsoft.Json;
  • Change the name of the attribute on the function from Function to FunctionName
  • Change the type of the 3 string properties within Claims to be string?

That's all. Now set this project to be the startup project and run it. The output now looks like this

{
    "data": {
        "odatatype": "microsoft.graph.onTokenIssuanceStartResponseData",
        "actions": [
            {
                "odatatype": "microsoft.graph.tokenIssuanceStart.provideClaimsForToken",
                "claims": {
                    "anotherValue": null,
                    "dateOfBirth": "2000-01-01",
                    "apiVersion": "1.0.0",
                    "customRoles": [
                        "Writer",
                        "Editor"
                    ]
                }
            }
        ]
    }
}

This output is different, even though the implementation is the same. It has been serialized with "System.Text.Json" rather than "Newtonsoft.Json". There is no visibility from the code of how this decision is being taken; but this difference is critical, as it breaks the implementation.

I tried starting to write a NUnit test, but realised I have no idea how the IActionResult becomes the JSON in the response body:

        [Test]
        public void Verify_json_data_is_serialized_correctly()
        {
            // Arrange.
            ResponseContent r = new ResponseContent();

            // Act.
            var actual = new OkObjectResult(r);

            // Assert.
            Assert.That(actual, Is.EqualTo("doesn't matter"));
        }

How can I unit test that the JSON response is being serialized correctly? (i.e. test what the method returns without needing the function to be running)

P.S. I know I can add System.Text.Json attributes to make it work properly, like [JsonPropertyName("@odata.type")]... that isn't the point. The point is the days that have been wasted trying to track down why a new custom claims provider wasn't working, even though it was based on code which is already working elsewhere. A unit test of what the JSON looks like would have shown the problem.

2 Answers 2

0

Caveat

I've not worked on any projects where we've needed Unit Tests to check the serialisation formats, so I'm not sure it's a valid problem to solve. (maybe integration tests would be better?) But you asked for help making it unit-testable so I'll give some thoughts.

Suggestions

I'll suggest three options. All are based on the principle of moving your logic out of the Azure Function, into a separate Service class. Unit Tests are best done away from your endpoint tech stack, so they just act on the domain logic.

Option 1 - serialise in the Unit Test

Remove the logic from the Azure Function, and put it in a Service class (probably in a separate project). You don't specify what information needs to come from the user's request body, so I can't guess that.

namespace MyServiceLayer;

public class MyCustomClaimsProvider
{
    public ResponseContent CreateClaimForUser(/*TODO: some user data*/)
    {
        ResponseContent r = new ResponseContent();
        r.data.actions[0].claims.ApiVersion = "1.0.0";
        r.data.actions[0].claims.DateOfBirth = "2000-01-01";
        r.data.actions[0].claims.CustomRoles.Add("Writer");
        r.data.actions[0].claims.CustomRoles.Add("Editor");

        return r;
    }
}

All your ResponseContent classes would then also go into the Service layer project, along with the above class.

Your Azure Function then injects this Service:

public class Function1
{
    private readonly MyCustomClaimsProvider _claimsProvider;

    public Function1(MyCustomClaimsProvider claimsProvider)
    {
        _claimsProvider = claimsProvider; // NB remember to set this up in DI.
    }

    [FunctionName("CustomClaimsProvider")]
    public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest request)
    {
        string requestBody = await new StreamReader(request.Body).ReadToEndAsync();

        // Claims to return to Azure AD
        ResponseContent r = _claimsProvider.CreateClaimForUser(/*TODO: some user data*/);

        return new OkObjectResult(r);
    }
}

Then, write your Unit Test so that it tests the MyCustomClaimsProvider. In the Unit Test, make sure you use the same Json serialiser as the default for your Azure Functions SDK. You might add some helper methods into the Unit Tests so that you don't repeat that code all the time.

I've omitted that detail here because I'm assuming you know already how to do that. If not, let me know and I'll add that too.

Option 2 - have the Service class return a string

This is a variant on the above. Have the Service method return an actual string as its output, instead of ResponseContent. I don't particularly like this, because it's mixing up two different things into one Service - and in fact into one layer of the application:

  • Domain logic (i.e. Services)
  • Endpoint logic (i.e. Serialisation)

So I prefer option 1.

Option 3 (a bit of an aside)

It feels like you should re-structure the application so that the user authentication code isn't in the main Function or even the main Service layer. It might be better to abstract your authentication logic entirely; perhaps bringing it in as a dependency and using some Middleware classes in the Azure Function to do it.

Basically authentication is a slightly different concern from either the endpoints or the application logic, so it probably sits separately too. This is out of scope for your question though.

Sign up to request clarification or add additional context in comments.

3 Comments

Option1: "In the Unit Test, make sure you use the same Json serialiser as the default for your Azure Functions SDK. ... I've omitted that detail here because I'm assuming you know already how to do that. If not, let me know and I'll add that too." This is exactly what I don't know how to do, as the approach to serialization is not specified anywhere in my code - as I said in the question, exactly the same code is resulting in different serialization.
"I've not worked on any projects where we've needed Unit Tests to check the serialisation formats, so I'm not sure it's a valid problem to solve" - well the first JSON satisfies the contract that Microsoft Entra requires the function to return, and the second doesn't (e.g. see "@odata.type", in the first JSON, where the second JSON has "odatatype"), so it's pretty important that it is done correctly.
Option 3: this code isn't to do with authentication, it's to do with authorization; and a CustomClaimsProvider is by definition separating that logic from the application.
0

Hm, they way it sounds to me you actually want to test the code/ configuration of your platform. These tests then are also more in the domain of integration tests rather than unit tests.

This difference somewhat matters here since the tests in mind need to pass or fail depending on the platform version. That means one would have to start-up the platform in a prod alike manner and run tests against it -which then brings all the trouble of idempotence, datastore and what not to you. You may be able to succeed with docker (as long as your system does not interact with other systems).

Maybe though you would be happy with a middle ground? What about testing the serialization settings of the platform only? Following that thought, you would have an arbitrary function, which returns an arbitrary object serving serialization demands only, such as

public class StandardSerializationModel 
{
  public DateTimeOffset? Timestamp => new DateTimeOffset(2024, 1, 20, 23, 59, 59, 999, TimeSpan.FromHours(2));

  public double DoubleValue => 21.02;

  public float FloatValue => 0.1f;
}

This, you can still only run against an environment (automatically after deployment), but it

  • would fail, if the serialization settings are changed intendedly or unintendedly
  • would not fail due to different data of environments
  • would not fail, if the instance you talk to depends on upstream/ downstream systems not present.
  • would not represent a risk for other environments (if not turned off)
  • may or may not have to impose credentials to run the service
  • assuming good coding practices, you would not have to test the return value of each service for json differences (they all belong into the StandardSerializationModel)

To actually execute such test (within the pipeline) you could simply use PowerShell with string compare (since the model returns constant values) or any API testing tool.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.