From f23104f1db3b65ba0643578f5212aecd38d3fe50 Mon Sep 17 00:00:00 2001 From: Johannes Tuerk <72355192+JoTiTu@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:15:20 +0100 Subject: [PATCH] add status list support (#235) * add status list support Signed-off-by: Johannes Tuerk * trigger pipe Signed-off-by: Johannes Tuerk * only allow https scheme for vct metadata retreival Signed-off-by: Johannes Tuerk * trigger pipe Signed-off-by: Johannes Tuerk * replace zlib with c# implementation Signed-off-by: Johannes Tuerk * remove virtual Signed-off-by: Johannes Tuerk --------- Signed-off-by: Johannes Tuerk --- .../CredentialSet/CredentialSetService.cs | 19 ++- .../Models/CredentialSetRecord.cs | 14 ++- .../Models/PresentationDefinition.cs | 2 +- .../SeviceCollectionExtensions.cs | 1 + .../Models/Records/SdJwtRecord.cs | 15 +++ .../Models/StatusList/Status.cs | 3 + .../Services/IStatusListService.cs | 10 ++ .../Services/StatusListService.cs | 114 ++++++++++++++++++ .../Services/VctMetadataService.cs | 2 +- .../SdJwtVcHolderServiceTests.cs | 2 +- .../StatusListTests.cs | 62 ++++++++++ 11 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 src/WalletFramework.SdJwtVc/Models/StatusList/Status.cs create mode 100644 src/WalletFramework.SdJwtVc/Services/IStatusListService.cs create mode 100644 src/WalletFramework.SdJwtVc/Services/StatusListService.cs create mode 100644 test/WalletFramework.SdJwtVc.Tests/StatusListTests.cs diff --git a/src/WalletFramework.Oid4Vc/CredentialSet/CredentialSetService.cs b/src/WalletFramework.Oid4Vc/CredentialSet/CredentialSetService.cs index feaf3094..76d1c587 100644 --- a/src/WalletFramework.Oid4Vc/CredentialSet/CredentialSetService.cs +++ b/src/WalletFramework.Oid4Vc/CredentialSet/CredentialSetService.cs @@ -8,6 +8,7 @@ using WalletFramework.Oid4Vc.CredentialSet.Models; using WalletFramework.Oid4Vc.Oid4Vci.Abstractions; using WalletFramework.SdJwtVc.Models.Records; +using WalletFramework.SdJwtVc.Services; using WalletFramework.SdJwtVc.Services.SdJwtVcHolderService; namespace WalletFramework.Oid4Vc.CredentialSet; @@ -16,6 +17,7 @@ public class CredentialSetService( IAgentProvider agentProvider, ISdJwtVcHolderService sdJwtVcHolderService, IMdocStorage mDocStorage, + IStatusListService statusListService, IWalletRecordService walletRecordService) : ICredentialSetService { @@ -101,7 +103,7 @@ public async Task>> ListAsync( return records; } - public virtual async Task> GetAsync(CredentialSetId credentialSetId) + public async Task> GetAsync(CredentialSetId credentialSetId) { var context = await agentProvider.GetContextAsync(); var record = await walletRecordService.GetAsync(context.Wallet, credentialSetId); @@ -127,10 +129,17 @@ public async Task RefreshCredentialSetState(CredentialSetRe if (expiresAt < DateTime.UtcNow) credentialSetRecord.State = CredentialState.Expired; }); - - //TODO: Implement revocation check (status List) -> Currently IsRevoked always returns false - if (credentialSetRecord.IsRevoked()) - credentialSetRecord.State = CredentialState.Revoked; + + await credentialSetRecord.StatusList.IfSomeAsync( + async statusList => + { + await statusListService.GetState(statusList).IfSomeAsync( + state => + { + if (state == CredentialState.Revoked) + credentialSetRecord.State = CredentialState.Revoked; + }); + }); if (oldState != credentialSetRecord.State) await UpdateAsync(credentialSetRecord); diff --git a/src/WalletFramework.Oid4Vc/CredentialSet/Models/CredentialSetRecord.cs b/src/WalletFramework.Oid4Vc/CredentialSet/Models/CredentialSetRecord.cs index 9947cfad..90532197 100644 --- a/src/WalletFramework.Oid4Vc/CredentialSet/Models/CredentialSetRecord.cs +++ b/src/WalletFramework.Oid4Vc/CredentialSet/Models/CredentialSetRecord.cs @@ -10,6 +10,7 @@ using WalletFramework.MdocVc; using WalletFramework.SdJwtVc.Models; using WalletFramework.SdJwtVc.Models.Records; +using WalletFramework.SdJwtVc.Models.StatusList; using CredentialState = WalletFramework.Core.Credentials.CredentialState; namespace WalletFramework.Oid4Vc.CredentialSet.Models; @@ -31,8 +32,7 @@ public sealed class CredentialSetRecord : RecordBase public Option ExpiresAt { get; set; } - //TODO: Add Status List - // public Option StatusList { get; } + public Option StatusList { get; set; } public Option RevokedAt { get; set; } @@ -53,6 +53,7 @@ public CredentialSetRecord( Option mDocCredentialType, Dictionary credentialAttributes, CredentialState credentialState, + Option statusList, Option expiresAt, Option issuedAt, Option notBefore, @@ -66,6 +67,7 @@ public CredentialSetRecord( MDocCredentialType = mDocCredentialType; CredentialAttributes = credentialAttributes; State = credentialState; + StatusList = statusList; ExpiresAt = expiresAt; IssuedAt = issuedAt; NotBefore = notBefore; @@ -119,6 +121,7 @@ public static class CredentialSetRecordExtensions private const string IssuerIdJsonKey = "issuer_id"; private const string CreatedAtJsonKey = "created_at"; private const string UpdatedAtJsonKey = "updated_at"; + private const string StatusListJsonKey = "status_list"; public static void AddSdJwtData( this CredentialSetRecord credentialSetRecord, @@ -131,6 +134,7 @@ public static void AddSdJwtData( credentialSetRecord.IssuedAt = sdJwtRecord.IssuedAt.ToOption(); credentialSetRecord.NotBefore = sdJwtRecord.NotBefore.ToOption(); credentialSetRecord.IssuerId = sdJwtRecord.IssuerId; + credentialSetRecord.StatusList = sdJwtRecord.Status; } public static void AddMDocData( @@ -200,6 +204,7 @@ public static JObject EncodeToJson(this CredentialSetRecord credentialSetRecord) credentialSetRecord.DeletedAt.IfSome(deletedAt => result.Add(DeletedAtJsonKey, deletedAt)); credentialSetRecord.CreatedAtUtc.IfSome(createdAtUtc => result.Add(CreatedAtJsonKey, createdAtUtc)); credentialSetRecord.UpdatedAtUtc.IfSome(updatedAtUtc => result.Add(UpdatedAtJsonKey, updatedAtUtc)); + credentialSetRecord.StatusList.IfSome(statusList => result.Add(StatusListJsonKey, JObject.FromObject(statusList))); result.Add(IssuerIdJsonKey, credentialSetRecord.IssuerId); return result; @@ -223,6 +228,10 @@ from docType in DocType.ValidDoctype(jToken).ToOption() var stateType = Enum.Parse(json[StateJsonKey]!.ToString()); + var statusListType = + from jToken in json.GetByKey(StatusListJsonKey).ToOption() + select jToken.ToObject(); + var expiresAtType = from jToken in json.GetByKey(ExpiresAtJsonKey).ToOption() select jToken.ToObject(); @@ -258,6 +267,7 @@ from jToken in json.GetByKey(UpdatedAtJsonKey).ToOption() mDocCredentialType, credentialAttributesType, stateType, + statusListType, expiresAtType, issuedAtType, notBeforeType, diff --git a/src/WalletFramework.Oid4Vc/Oid4Vp/PresentationExchange/Models/PresentationDefinition.cs b/src/WalletFramework.Oid4Vc/Oid4Vp/PresentationExchange/Models/PresentationDefinition.cs index f922016e..ce3a9f62 100644 --- a/src/WalletFramework.Oid4Vc/Oid4Vp/PresentationExchange/Models/PresentationDefinition.cs +++ b/src/WalletFramework.Oid4Vc/Oid4Vp/PresentationExchange/Models/PresentationDefinition.cs @@ -24,7 +24,7 @@ public class PresentationDefinition /// This MUST be a string. The string SHOULD provide a unique ID for the desired context. /// [JsonProperty("id", Required = Required.Always)] - public string Id { get; } + public string Id { get; } /// /// This SHOULD be a human-friendly string intended to constitute a distinctive designation of the Presentation diff --git a/src/WalletFramework.Oid4Vc/SeviceCollectionExtensions.cs b/src/WalletFramework.Oid4Vc/SeviceCollectionExtensions.cs index c55ee668..11c5eedf 100644 --- a/src/WalletFramework.Oid4Vc/SeviceCollectionExtensions.cs +++ b/src/WalletFramework.Oid4Vc/SeviceCollectionExtensions.cs @@ -52,6 +52,7 @@ public static IServiceCollection AddOpenIdServices(this IServiceCollection build builder.AddSingleton(); builder.AddSingleton(); builder.AddSingleton(); + builder.AddSingleton(); builder.AddSdJwtVcServices(); diff --git a/src/WalletFramework.SdJwtVc/Models/Records/SdJwtRecord.cs b/src/WalletFramework.SdJwtVc/Models/Records/SdJwtRecord.cs index a9774532..f9d2e763 100644 --- a/src/WalletFramework.SdJwtVc/Models/Records/SdJwtRecord.cs +++ b/src/WalletFramework.SdJwtVc/Models/Records/SdJwtRecord.cs @@ -9,6 +9,7 @@ using WalletFramework.Core.Functional; using WalletFramework.SdJwtVc.Models.Credential; using WalletFramework.SdJwtVc.Models.Credential.Attributes; +using WalletFramework.SdJwtVc.Models.StatusList; namespace WalletFramework.SdJwtVc.Models.Records; @@ -69,6 +70,11 @@ public sealed class SdJwtRecord : RecordBase, ICredential /// public DateTime? IssuedAt { get; set; } + /// + /// Tracks when the Sd-JWT was issued + /// + public Status? Status { get; set; } + /// /// Tracks when the Sd-JWT is valid from /// @@ -143,6 +149,7 @@ public SdJwtRecord() /// The Id of the issuer /// The Issuer-signed JWT part of the SD-JWT. /// The CredentialSetId. + /// The status list. /// The Expiration Date. /// The Issued at date. /// The valid after date. @@ -156,6 +163,7 @@ public SdJwtRecord( string issuerId, string encodedIssuerSignedJwt, string credentialSetId, + Status status, DateTime? expiresAt, DateTime? issuedAt, DateTime? notBefore, @@ -175,6 +183,7 @@ public SdJwtRecord( IssuerId = issuerId; CredentialSetId = credentialSetId; OneTimeUse = isOneTimeUse; + Status = status; } public SdJwtRecord( @@ -193,6 +202,9 @@ public SdJwtRecord( Claims = sdJwtDoc.GetAllSubjectClaims(); Display = display; DisplayedAttributes = displayedAttributes; + Status = sdJwtDoc.UnsecuredPayload.SelectToken("status")?.ToObject() is not null + ? sdJwtDoc.UnsecuredPayload.SelectToken("status")?.ToObject() + : null; CredentialSetId = credentialSetId; CredentialState = CredentialState.Active; @@ -229,6 +241,9 @@ public SdJwtRecord( Claims = sdJwtDoc.GetAllSubjectClaims(); Display = display; DisplayedAttributes = displayedAttributes; + Status = sdJwtDoc.UnsecuredPayload.SelectToken("status")?.ToObject() is not null + ? sdJwtDoc.UnsecuredPayload.SelectToken("status")?.ToObject() + : null; CredentialSetId = credentialSetId; CredentialState = CredentialState.Active; diff --git a/src/WalletFramework.SdJwtVc/Models/StatusList/Status.cs b/src/WalletFramework.SdJwtVc/Models/StatusList/Status.cs new file mode 100644 index 00000000..97105be5 --- /dev/null +++ b/src/WalletFramework.SdJwtVc/Models/StatusList/Status.cs @@ -0,0 +1,3 @@ +namespace WalletFramework.SdJwtVc.Models.StatusList; + +public record Status(int Idx, string Uri); diff --git a/src/WalletFramework.SdJwtVc/Services/IStatusListService.cs b/src/WalletFramework.SdJwtVc/Services/IStatusListService.cs new file mode 100644 index 00000000..0747f056 --- /dev/null +++ b/src/WalletFramework.SdJwtVc/Services/IStatusListService.cs @@ -0,0 +1,10 @@ +using LanguageExt; +using WalletFramework.Core.Credentials; +using WalletFramework.SdJwtVc.Models.StatusList; + +namespace WalletFramework.SdJwtVc.Services; + +public interface IStatusListService +{ + Task> GetState(Status status); +} diff --git a/src/WalletFramework.SdJwtVc/Services/StatusListService.cs b/src/WalletFramework.SdJwtVc/Services/StatusListService.cs new file mode 100644 index 00000000..acf51c26 --- /dev/null +++ b/src/WalletFramework.SdJwtVc/Services/StatusListService.cs @@ -0,0 +1,114 @@ +using System.Collections; +using System.IdentityModel.Tokens.Jwt; +using System.IO.Compression; +using LanguageExt; +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json.Linq; +using WalletFramework.Core.Credentials; +using WalletFramework.Core.Functional; +using WalletFramework.Core.Json; +using WalletFramework.SdJwtVc.Models.StatusList; + +namespace WalletFramework.SdJwtVc.Services; + +public class StatusListService(IHttpClientFactory httpClientFactory) : IStatusListService +{ + public async Task> GetState(Status status) + { + var client = httpClientFactory.CreateClient(); + var response = await client.GetAsync(status.Uri); + + if (!response.IsSuccessStatusCode) + return Option.None; + + var content = await response.Content.ReadAsStringAsync(); + + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(content); + + var statusListClaim = jwt.Claims.Find(claim => claim.Type == "status_list"); + return statusListClaim.Match( + Some: claim => + { + var json = JObject.Parse(claim.Value); + + var bits = from bitsJson in json.GetByKey("bits").ToOption() + select bitsJson.ToObject(); + + var list = from listJson in json.GetByKey("lst").ToOption() + select Base64UrlEncoder.DecodeBytes(listJson.ToObject()); + + return list.Match(bytes => + { + return bits.Match( + Some: bitSize => + { + var decompressedBytes = DecompressBytes(bytes); + + var statusPerByte = 8 / bitSize; + var relevantByteLocation = status.Idx / statusPerByte; + var relevantByte = decompressedBytes[relevantByteLocation]; + + var startPointByteIndex = status.Idx % statusPerByte; + var startPointBitIndex = startPointByteIndex * bitSize; + + var sum = 0; + for (int i = startPointBitIndex; i < startPointBitIndex + bitSize; i++) + { + var bit = new BitArray(new byte[]{relevantByte}).Get(i); + if (bit) + sum += 1 << (i % bitSize); + } + + return sum switch + { + 0x00 => CredentialState.Active, + 0x01 => CredentialState.Revoked, + _ => Option.None + }; + }, + None: () => Option.None + ); + }, + None: () => Option.None); + }, + None: () => Option.None); + } + + private static byte[] DecompressBytes(byte[] compressedData) + { + if (compressedData == null || compressedData.Length == 0) + throw new ArgumentException("Compressed data cannot be null or empty"); + + // Check the zlib header (2 bytes) + if (compressedData.Length < 6) + throw new InvalidDataException("Compressed data is too short."); + + var cmf = compressedData[0]; + var flg = compressedData[1]; + + // Validate compression method (CM) and compression info (CINFO) + if ((cmf & 0x0F) != 8 || (cmf >> 4) > 7) + throw new InvalidDataException("Unsupported zlib compression method or info."); + + // Validate the header checksum + if ((cmf * 256 + flg) % 31 != 0) + throw new InvalidDataException("Invalid zlib header checksum."); + + // Remove the zlib header (first 2 bytes) and Adler-32 checksum (last 4 bytes) + var deflateDataLength = compressedData.Length - 6; + if (deflateDataLength <= 0) + throw new InvalidDataException("No deflate-compressed data found."); + + var deflateData = new byte[deflateDataLength]; + Array.Copy(compressedData, 2, deflateData, 0, deflateDataLength); + + // Decompress using DeflateStream + using (var inputStream = new MemoryStream(deflateData)) + using (var deflateStream = new DeflateStream(inputStream, CompressionMode.Decompress)) + using (var outputStream = new MemoryStream()) + { + deflateStream.CopyTo(outputStream); + return outputStream.ToArray(); + } + } +} diff --git a/src/WalletFramework.SdJwtVc/Services/VctMetadataService.cs b/src/WalletFramework.SdJwtVc/Services/VctMetadataService.cs index 4e213cdd..ba9bd6f1 100644 --- a/src/WalletFramework.SdJwtVc/Services/VctMetadataService.cs +++ b/src/WalletFramework.SdJwtVc/Services/VctMetadataService.cs @@ -23,7 +23,7 @@ public VctMetadataService( public async Task> ProcessMetadata(Vct vct) { - if(!Uri.TryCreate(vct, UriKind.Absolute, out Uri vctUri)) + if(!Uri.TryCreate(vct, UriKind.Absolute, out Uri vctUri) || vctUri.Scheme != Uri.UriSchemeHttps) return Option.None; var baseEndpoint = new Uri(vctUri.GetLeftPart(UriPartial.Authority)); diff --git a/test/WalletFramework.SdJwtVc.Tests/SdJwtVcHolderServiceTests.cs b/test/WalletFramework.SdJwtVc.Tests/SdJwtVcHolderServiceTests.cs index 793329f2..9f6df3a4 100644 --- a/test/WalletFramework.SdJwtVc.Tests/SdJwtVcHolderServiceTests.cs +++ b/test/WalletFramework.SdJwtVc.Tests/SdJwtVcHolderServiceTests.cs @@ -14,7 +14,7 @@ namespace WalletFramework.SdJwtVc.Tests; public class SdJwtVcHolderServiceTests { - private readonly SdJwtVcHolderService _service; + private readonly SdJwtVcHolderService _service; public SdJwtVcHolderServiceTests() { diff --git a/test/WalletFramework.SdJwtVc.Tests/StatusListTests.cs b/test/WalletFramework.SdJwtVc.Tests/StatusListTests.cs new file mode 100644 index 00000000..f62b9206 --- /dev/null +++ b/test/WalletFramework.SdJwtVc.Tests/StatusListTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using Moq; +using Moq.Protected; +using WalletFramework.Core.Credentials; +using WalletFramework.SdJwtVc.Models.StatusList; +using WalletFramework.SdJwtVc.Services; + +namespace WalletFramework.SdJwtVc.Tests; + +public class StatusListTests +{ + private const string RequestUriResponseWithBitSize1 = + "eyJraWQiOiJkZjY5ODA1NDVlYWQwNTY3NTAwMGUyOGFiZDJhNTE0ZSIsInR5cCI6IkpXVCIsImFsZyI6IkVTMjU2In0.eyJzdWIiOiJodHRwczovL3NvbWUuaW8vc3RhdHVzLWxpc3RzP3JlZ2lzdHJ5SWQ9YmVlYTlmMTEtZGQ5ZC00MWUyLTljY2ItY2EzMGMxYjE5ZjRkIiwic3RhdHVzX2xpc3QiOnsiYml0cyI6MSwibHN0IjoiZUp4am1DakF5QUFBQW5zQW93PT0ifSwiaXNzIjoiaHR0cHM6Ly9zb21lLmlvIiwiZXhwIjoxNzMzMjE2MDY1LCJpYXQiOjE3MzMyMTI0NjV9.1osUmaMs9fBCNrpgVPNZa-Cr1f-ttYEpERQjen1xsxP2ePnoWzo44yUwPZZjojqDrgxRWaWC6lAtkk9xIZJhyg"; + + private const string RequestUriResponseWithBitSize2 = + "eyJraWQiOiJkZjY5ODA1NDVlYWQwNTY3NTAwMGUyOGFiZDJhNTE0ZSIsInR5cCI6IkpXVCIsImFsZyI6IkVTMjU2In0.eyJzdWIiOiJodHRwczovL3NvbWUuaW8vc3RhdHVzLWxpc3RzP3JlZ2lzdHJ5SWQ9YmVlYTlmMTEtZGQ5ZC00MWUyLTljY2ItY2EzMGMxYjE5ZjRkIiwic3RhdHVzX2xpc3QiOnsiYml0cyI6MiwibHN0IjoiZUp4am1DakF5QUFBQW5zQW93PT0ifSwiaXNzIjoiaHR0cHM6Ly9zb21lLmlvIiwiZXhwIjoxNzMzMjE2MDY1LCJpYXQiOjE3MzMyMTI0NjV9.SUHqhh69zvekBxtHo87p45ouYmOrpASqHkfgPisgLqqb7bo8_oXzvN_AS9huZpn5FA-wqTIoZgaBXBAKztIIag"; + + private const string RequestUriResponseWithBitSize4 = + "eyJraWQiOiJkZjY5ODA1NDVlYWQwNTY3NTAwMGUyOGFiZDJhNTE0ZSIsInR5cCI6IkpXVCIsImFsZyI6IkVTMjU2In0.eyJzdWIiOiJodHRwczovL3NvbWUuaW8vc3RhdHVzLWxpc3RzP3JlZ2lzdHJ5SWQ9YmVlYTlmMTEtZGQ5ZC00MWUyLTljY2ItY2EzMGMxYjE5ZjRkIiwic3RhdHVzX2xpc3QiOnsiYml0cyI6NCwibHN0IjoiZUp4am1DakF5QUFBQW5zQW93PT0ifSwiaXNzIjoiaHR0cHM6Ly9zb21lLmlvIiwiZXhwIjoxNzMzMjE2MDY1LCJpYXQiOjE3MzMyMTI0NjV9.XcKo7rYVWm0u6LyIUs3mSeutME_q5Qy1l_AHNA6UxgaywCR7n7MrCVd8OirSbRl68ImJabdisdpUP72hlQNw9g"; + + private const string RequestUriResponseWithBitSize8 = + "eyJraWQiOiJkZjY5ODA1NDVlYWQwNTY3NTAwMGUyOGFiZDJhNTE0ZSIsInR5cCI6IkpXVCIsImFsZyI6IkVTMjU2In0.eyJzdWIiOiJodHRwczovL3NvbWUuaW8vc3RhdHVzLWxpc3RzP3JlZ2lzdHJ5SWQ9YmVlYTlmMTEtZGQ5ZC00MWUyLTljY2ItY2EzMGMxYjE5ZjRkIiwic3RhdHVzX2xpc3QiOnsiYml0cyI6OCwibHN0IjoiZUp4am1DakF5QUFBQW5zQW93PT0ifSwiaXNzIjoiaHR0cHM6Ly9zb21lLmlvIiwiZXhwIjoxNzMzMjE2MDY1LCJpYXQiOjE3MzMyMTI0NjV9.Y8WWjgxkkUw7_UBXALxYPvenOXuPTyjTj09VKB-o5cGDtKZj-Lw66ZQFU0eOFvwXZrWzi8XL5ecfyNknzLzfOw"; + + private readonly Mock _httpMessageHandlerMock = new(); + private readonly Mock _httpClientFactoryMock = new(); + + [Theory] + [InlineData(6, RequestUriResponseWithBitSize1, CredentialState.Active)] + [InlineData(8, RequestUriResponseWithBitSize1, CredentialState.Revoked)] + [InlineData(5, RequestUriResponseWithBitSize2, CredentialState.Active)] + [InlineData(6, RequestUriResponseWithBitSize2, CredentialState.Revoked)] + [InlineData(4, RequestUriResponseWithBitSize4, CredentialState.Active)] + [InlineData(5, RequestUriResponseWithBitSize4, CredentialState.Revoked)] + [InlineData(4, RequestUriResponseWithBitSize8, CredentialState.Active)] + [InlineData(3, RequestUriResponseWithBitSize8, CredentialState.Revoked)] + public async Task CanCreateStatus(int statusListIntex, string httpResponseJwt, CredentialState expectedState) + { + // Arrange + var httpResponseMessage = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(httpResponseJwt) + }; + + _httpMessageHandlerMock.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => httpResponseMessage); + + var httpClient = new HttpClient(_httpMessageHandlerMock.Object); + _httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + var status = new Status(statusListIntex, "https://example.com"); + + // Act + var sut = await new StatusListService(_httpClientFactoryMock.Object).GetState(status); + + // Assert + Assert.True(sut.Match(state => state == expectedState, () => false)); + } +}