diff --git a/Passwordless-dotnet.sln b/Passwordless-dotnet.sln new file mode 100644 index 0000000..afbb48d --- /dev/null +++ b/Passwordless-dotnet.sln @@ -0,0 +1,30 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sdk.Tests", "Tests\Sdk.Tests\Sdk.Tests.csproj", "{F64C850E-9923-43F1-BC84-432AFBBA4425}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sdk", "src\Sdk\Sdk.csproj", "{A01503A8-6AB9-43A7-AC5A-4EAE091B07B6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F64C850E-9923-43F1-BC84-432AFBBA4425}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F64C850E-9923-43F1-BC84-432AFBBA4425}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F64C850E-9923-43F1-BC84-432AFBBA4425}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F64C850E-9923-43F1-BC84-432AFBBA4425}.Release|Any CPU.Build.0 = Release|Any CPU + {A01503A8-6AB9-43A7-AC5A-4EAE091B07B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A01503A8-6AB9-43A7-AC5A-4EAE091B07B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A01503A8-6AB9-43A7-AC5A-4EAE091B07B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A01503A8-6AB9-43A7-AC5A-4EAE091B07B6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection +EndGlobal diff --git a/src/Sdk/Helpers/Json.cs b/src/Sdk/Helpers/Json.cs new file mode 100644 index 0000000..9df7f21 --- /dev/null +++ b/src/Sdk/Helpers/Json.cs @@ -0,0 +1,8 @@ +using System.Text.Json; + +namespace Passwordless.Net.Helpers; + +internal static class Json +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); +} \ No newline at end of file diff --git a/src/Sdk/Models/AliasPointer.cs b/src/Sdk/Models/AliasPointer.cs new file mode 100644 index 0000000..c5be111 --- /dev/null +++ b/src/Sdk/Models/AliasPointer.cs @@ -0,0 +1,8 @@ +namespace Passwordless.Net; + +public class AliasPointer +{ + public string UserId { get; set; } + public string Alias { get; set; } + public string Plaintext { get; set; } +} \ No newline at end of file diff --git a/src/Sdk/Models/AuditLog.cs b/src/Sdk/Models/AuditLog.cs new file mode 100644 index 0000000..4920038 --- /dev/null +++ b/src/Sdk/Models/AuditLog.cs @@ -0,0 +1,9 @@ +namespace Passwordless.Net; + +public class AuditLog +{ + public DateTime Timestamp { get; set; } + public string Level { get; set; } + public string Message { get; set; } + public string Details { get; set; } +} \ No newline at end of file diff --git a/src/Sdk/Models/Credential.cs b/src/Sdk/Models/Credential.cs new file mode 100644 index 0000000..6db4422 --- /dev/null +++ b/src/Sdk/Models/Credential.cs @@ -0,0 +1,19 @@ +namespace Passwordless.Net; + +public class Credential +{ + public CredentialDescriptor Descriptor { get; set; } + public byte[] PublicKey { get; set; } + public byte[] UserHandle { get; set; } + public uint SignatureCounter { get; set; } + public string AttestationFmt { get; set; } + public DateTime CreatedAt { get; set; } + public Guid AaGuid { get; set; } + public DateTime LastUsedAt { get; set; } + public string RPID { get; set; } + public string Origin { get; set; } + public string Country { get; set; } + public string Device { get; set; } + public string Nickname { get; set; } + public string UserId { get; set; } +} \ No newline at end of file diff --git a/src/Sdk/Models/CredentialDescriptor.cs b/src/Sdk/Models/CredentialDescriptor.cs new file mode 100644 index 0000000..49ea626 --- /dev/null +++ b/src/Sdk/Models/CredentialDescriptor.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +using static Passwordless.Net.PasswordlessClient; + +namespace Passwordless.Net; + +public class CredentialDescriptor +{ + [JsonConverter(typeof(Base64UrlConverter))] + public byte[] Id { get; set; } +} \ No newline at end of file diff --git a/src/Sdk/Models/NewAppOptions.cs b/src/Sdk/Models/NewAppOptions.cs new file mode 100644 index 0000000..f4baed5 --- /dev/null +++ b/src/Sdk/Models/NewAppOptions.cs @@ -0,0 +1,7 @@ +namespace Passwordless.Net; + +public class NewAppOptions +{ + public string AppId { get; set; } = ""; + public string AdminEmail { get; set; } = ""; +} \ No newline at end of file diff --git a/src/Sdk/Models/NewAppResponse.cs b/src/Sdk/Models/NewAppResponse.cs new file mode 100644 index 0000000..d1321ad --- /dev/null +++ b/src/Sdk/Models/NewAppResponse.cs @@ -0,0 +1,10 @@ +namespace Passwordless.Net; + +public class NewAppResponse +{ + public string Message { get; set; } + public string ApiKey1 { get; set; } + public string ApiKey2 { get; set; } + public string ApiSecret1 { get; set; } + public string ApiSecret2 { get; set; } +} \ No newline at end of file diff --git a/src/Sdk/Models/PasswordlessUserSummary.cs b/src/Sdk/Models/PasswordlessUserSummary.cs new file mode 100644 index 0000000..6667db5 --- /dev/null +++ b/src/Sdk/Models/PasswordlessUserSummary.cs @@ -0,0 +1,10 @@ +namespace Passwordless.Net; + +public class PasswordlessUserSummary +{ + public string UserId { get; set; } + public List Aliases { get; set; } + public int CredentialsCount { get; set; } + public int AliasCount { get; set; } + public DateTime LastUsedAt { get; set; } +} \ No newline at end of file diff --git a/src/Sdk/Models/RegisterOptions.cs b/src/Sdk/Models/RegisterOptions.cs new file mode 100644 index 0000000..c030789 --- /dev/null +++ b/src/Sdk/Models/RegisterOptions.cs @@ -0,0 +1,16 @@ +namespace Passwordless.Net; + +public class RegisterOptions +{ + public required string UserId { get; set; } + public string? DisplayName { get; set; } + public required string Username { get; set; } + public string Attestation { get; set; } + public string AuthenticatorType { get; set; } + public bool Discoverable { get; set; } + public string UserVerification { get; set; } + public HashSet? Aliases { get; set; } + public bool AliasHashing { get; set; } + + public DateTime ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/src/Sdk/Models/UsersCount.cs b/src/Sdk/Models/UsersCount.cs new file mode 100644 index 0000000..73f039f --- /dev/null +++ b/src/Sdk/Models/UsersCount.cs @@ -0,0 +1,6 @@ +namespace Passwordless.Net; + +public class UsersCount +{ + public int Count { get; set; } +} \ No newline at end of file diff --git a/src/Sdk/Models/VerifiedUser.cs b/src/Sdk/Models/VerifiedUser.cs new file mode 100644 index 0000000..ba4c38a --- /dev/null +++ b/src/Sdk/Models/VerifiedUser.cs @@ -0,0 +1,17 @@ +namespace Passwordless.Net; + +public class VerifiedUser +{ + public string UserId { get; set; } + public byte[] CredentialId { get; set; } + public bool Success { get; set; } + public DateTime Timestamp { get; set; } + public string RpId { get; set; } + public string Origin { get; set; } + public string Device { get; set; } + public string Country { get; set; } + public string Nickname { get; set; } + public DateTime ExpiresAt { get; set; } + public Guid TokenId { get; set; } + public string Type { get; set; } +} \ No newline at end of file diff --git a/src/Sdk/PasswordlessApiException.cs b/src/Sdk/PasswordlessApiException.cs new file mode 100644 index 0000000..486afb6 --- /dev/null +++ b/src/Sdk/PasswordlessApiException.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Passwordless.Net; + +public sealed class PasswordlessApiException : HttpRequestException +{ + public ProblemDetails Details { get; } + + public PasswordlessApiException(ProblemDetails problemDetails) : base(problemDetails.Title) + { + Details = problemDetails; + } +} + +public class ProblemDetails +{ + // TODO: Make immutable + // TODO: Include errorCode as a property once it's more common + public string Type { get; set; } = null!; + public string Title { get; set; } = null!; + public int Status { get; set; } + public string? Detail { get; set; } + public string? Instance { get; set; } + + [JsonExtensionData] + public Dictionary Extensions { get; set; } +} \ No newline at end of file diff --git a/src/Sdk/PasswordlessClient.cs b/src/Sdk/PasswordlessClient.cs new file mode 100644 index 0000000..9725f2f --- /dev/null +++ b/src/Sdk/PasswordlessClient.cs @@ -0,0 +1,283 @@ +using System.Buffers; +using System.Buffers.Text; +using System.Diagnostics; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Passwordless.Net; + +public interface IPasswordlessClient +{ + Task CreateRegisterToken(RegisterOptions registerOptions); + Task DeleteCredential(string id); + Task DeleteCredential(byte[] id); + Task> ListAliases(string userId); + Task> ListCredentials(string userId); + Task?> ListUsers(); + Task VerifyToken(string verifyToken); + Task DeleteUserAsync(string userId); +} + +public class RegisterTokenResponse +{ + public string Token { get; set; } +} + +[DebuggerDisplay("{DebuggerToString()}")] +public class PasswordlessClient : IPasswordlessClient +{ + private readonly HttpClient _client; + + public static PasswordlessClient Create(PasswordlessOptions options, IHttpClientFactory factory) + { + var client = factory.CreateClient(); + client.BaseAddress = new Uri(options.ApiUrl); + client.DefaultRequestHeaders.Add("ApiSecret", options.ApiSecret); + return new PasswordlessClient(client); + } + + public PasswordlessClient(HttpClient client) + { + _client = client; + } + + public async Task CreateRegisterToken(RegisterOptions registerOptions) + { + var res = await _client.PostAsJsonAsync("register/token", registerOptions); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync())!; + } + + public async Task VerifyToken(string verifyToken) + { + var request = new HttpRequestMessage(HttpMethod.Post, "signin/verify") + { + Content = JsonContent.Create(new + { + token = verifyToken, + }), + }; + + // We just want to return null if there is a problem. + request.SkipErrorHandling(); + var response = await _client.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + var res = await response.Content.ReadFromJsonAsync(); + return res; + } + + return null; + } + + public async Task DeleteUserAsync(string userId) + { + await _client.PostAsJsonAsync("users/delete", new { UserId = userId }); + } + + public async Task?> ListUsers() + { + var response = await _client.GetFromJsonAsync>("users/list"); + return response!.Values; + } + + public async Task> ListAliases(string userId) + { + var response = await _client.GetFromJsonAsync>($"alias/list?userid={userId}"); + return response!.Values; + } + + + public async Task> ListCredentials(string userId) + { + var response = await _client.GetFromJsonAsync>($"credentials/list?userid={userId}"); + return response!.Values; + } + + public async Task DeleteCredential(string id) + { + await _client.PostAsJsonAsync("credentials/delete", new { CredentialId = id }); + } + + public async Task DeleteCredential(byte[] id) + { + await DeleteCredential(Base64Url.Encode(id)); + } + + public async Task GetUsersCount() + { + return (await _client.GetFromJsonAsync("users/count"))!; + } + + private string DebuggerToString() + { + var sb = new StringBuilder(); + sb.Append("ApiUrl = "); + sb.Append(_client.BaseAddress); + if (_client.DefaultRequestHeaders.TryGetValues("ApiSecret", out var values)) + { + var apiSecret = values.First(); + if (apiSecret.Length > 5) + { + sb.Append(' '); + sb.Append("ApiSecret = "); + sb.Append("***"); + sb.Append(apiSecret.AsSpan(apiSecret.Length - 4)); + } + } + else + { + sb.Append(' '); + sb.Append("ApiSecret = (null)"); + } + + return sb.ToString(); + } + + public class ListResponse + { + public List Values { get; set; } = null!; + } + + public sealed class Base64UrlConverter : JsonConverter + { + public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (!reader.HasValueSequence) + { + return Base64Url.DecodeUtf8(reader.ValueSpan); + } + return Base64Url.Decode(reader.GetString()); + } + + public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) + { + writer.WriteStringValue(Base64Url.Encode(value)); + } + } + + public static class Base64Url + { + /// + /// Converts arg data to a Base64Url encoded string. + /// + public static string Encode(ReadOnlySpan arg) + { + int minimumLength = (int)(((long)arg.Length + 2L) / 3 * 4); + char[] array = ArrayPool.Shared.Rent(minimumLength); + Convert.TryToBase64Chars(arg, array, out var charsWritten); + Span span = array.AsSpan(0, charsWritten); + for (int i = 0; i < span.Length; i++) + { + ref char reference = ref span[i]; + switch (reference) + { + case '+': + reference = '-'; + break; + case '/': + reference = '_'; + break; + } + } + int num = span.IndexOf('='); + if (num > -1) + { + span = span.Slice(0, num); + } + string result = new string(span); + ArrayPool.Shared.Return(array, clearArray: true); + return result; + } + + /// + /// Decodes a Base64Url encoded string to its raw bytes. + /// + public static byte[] Decode(ReadOnlySpan text) + { + int num = (text.Length % 4) switch + { + 2 => 2, + 3 => 1, + _ => 0, + }; + int num2 = text.Length + num; + char[] array = ArrayPool.Shared.Rent(num2); + text.CopyTo(array); + for (int i = 0; i < text.Length; i++) + { + ref char reference = ref array[i]; + switch (reference) + { + case '-': + reference = '+'; + break; + case '_': + reference = '/'; + break; + } + } + switch (num) + { + case 1: + array[num2 - 1] = '='; + break; + case 2: + array[num2 - 1] = '='; + array[num2 - 2] = '='; + break; + } + byte[] result = Convert.FromBase64CharArray(array, 0, num2); + ArrayPool.Shared.Return(array, clearArray: true); + return result; + } + + /// + /// Decodes a Base64Url encoded string to its raw bytes. + /// + public static byte[] DecodeUtf8(ReadOnlySpan text) + { + int num = (text.Length % 4) switch + { + 2 => 2, + 3 => 1, + _ => 0, + }; + int num2 = text.Length + num; + byte[] array = ArrayPool.Shared.Rent(num2); + text.CopyTo(array); + for (int i = 0; i < text.Length; i++) + { + ref byte reference = ref array[i]; + switch (reference) + { + case 45: + reference = 43; + break; + case 95: + reference = 47; + break; + } + } + switch (num) + { + case 1: + array[num2 - 1] = 61; + break; + case 2: + array[num2 - 1] = 61; + array[num2 - 2] = 61; + break; + } + Base64.DecodeFromUtf8InPlace(array.AsSpan(0, num2), out var bytesWritten); + byte[] result = array.AsSpan(0, bytesWritten).ToArray(); + ArrayPool.Shared.Return(array, clearArray: true); + return result; + } + + + } +} \ No newline at end of file diff --git a/src/Sdk/PasswordlessDelegatingHandler.cs b/src/Sdk/PasswordlessDelegatingHandler.cs new file mode 100644 index 0000000..2b3510d --- /dev/null +++ b/src/Sdk/PasswordlessDelegatingHandler.cs @@ -0,0 +1,30 @@ +using System.Net.Http.Json; +using Passwordless.Net.Helpers; +using static Passwordless.Net.PasswordlessHttpRequestExtensions; + +namespace Passwordless.Net; + +internal class PasswordlessDelegatingHandler : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = await base.SendAsync(request, cancellationToken); + + if (request.Options.TryGetValue(SkipErrorHandlingOption, out var doNotErrorHandler) && doNotErrorHandler) + { + return response; + } + + if (!response.IsSuccessStatusCode + && string.Equals(response.Content.Headers.ContentType?.MediaType, "application/problem+json", StringComparison.OrdinalIgnoreCase)) + { + // Attempt to read problem details + var problemDetails = await response.Content.ReadFromJsonAsync(Json.Options, cancellationToken: cancellationToken); + + // Throw exception + throw new PasswordlessApiException(problemDetails!); + } + + return response; + } +} \ No newline at end of file diff --git a/src/Sdk/PasswordlessExtensions.cs b/src/Sdk/PasswordlessExtensions.cs new file mode 100644 index 0000000..250ff6d --- /dev/null +++ b/src/Sdk/PasswordlessExtensions.cs @@ -0,0 +1,9 @@ +namespace Passwordless.Net; + +public static class PasswordlessExtensions +{ + public static string ToBase64Url(this byte[] bytes) + { + return PasswordlessClient.Base64Url.Encode(bytes); + } +} \ No newline at end of file diff --git a/src/Sdk/PasswordlessHttpRequestExtensions.cs b/src/Sdk/PasswordlessHttpRequestExtensions.cs new file mode 100644 index 0000000..391e7dd --- /dev/null +++ b/src/Sdk/PasswordlessHttpRequestExtensions.cs @@ -0,0 +1,12 @@ +namespace Passwordless.Net; + +internal static class PasswordlessHttpRequestExtensions +{ + internal static HttpRequestOptionsKey SkipErrorHandlingOption = new(nameof(SkipErrorHandling)); + + internal static HttpRequestMessage SkipErrorHandling(this HttpRequestMessage request, bool skip = true) + { + request.Options.Set(SkipErrorHandlingOption, skip); + return request; + } +} \ No newline at end of file diff --git a/src/Sdk/PasswordlessOptions.cs b/src/Sdk/PasswordlessOptions.cs new file mode 100644 index 0000000..2ccecf9 --- /dev/null +++ b/src/Sdk/PasswordlessOptions.cs @@ -0,0 +1,33 @@ +namespace Passwordless.Net; + +/// +/// Represents all the options you can use to configure a backend Passwordless system. +/// +public class PasswordlessOptions +{ + /// + /// Passwordless Cloud Url + /// + public const string CloudApiUrl = "https://v4.passwordless.dev"; + + /// + /// Gets or sets the url to use for Passwordless operations. + /// + /// + /// Defaults to . + /// + public string ApiUrl { get; set; } = CloudApiUrl; + + /// + /// Gets or sets the secret API key used to authenticate with the Passwordless API. + /// + public string ApiSecret { get; set; } = default!; + + /// + /// Gets or sets the public API key used to interact with the Passwordless API. + /// + /// + /// Optional: Only used for frontend operations by the JS Client. E.g: Useful if you're using MVC/Razor pages + /// + public string? ApiKey { get; set; } +} \ No newline at end of file diff --git a/src/Sdk/Sdk.csproj b/src/Sdk/Sdk.csproj new file mode 100644 index 0000000..7bc2ce1 --- /dev/null +++ b/src/Sdk/Sdk.csproj @@ -0,0 +1,28 @@ + + + + net7.0 + enable + enable + Passwordless.Net + Passwordless.Net + 0.0.10 + + + + + + + + + + + + + + + + + + + diff --git a/src/Sdk/ServiceCollectionExtensions.cs b/src/Sdk/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d5839bd --- /dev/null +++ b/src/Sdk/ServiceCollectionExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Options; +using Passwordless.Net; + +// This is a trick to always show up in a class when people are registering services +namespace Microsoft.Extensions.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddPasswordlessSdk(this IServiceCollection services, Action configureOptions) + { + services.AddOptions() + .Configure(configureOptions) + .PostConfigure(options => options.ApiUrl ??= PasswordlessOptions.CloudApiUrl) + .Validate(options => !string.IsNullOrEmpty(options.ApiSecret), "Passwordless: Missing ApiSecret"); + + services.AddPasswordlessClientCore((sp, client) => + { + var options = sp.GetRequiredService>().Value; + + client.BaseAddress = new Uri(options.ApiUrl); + client.DefaultRequestHeaders.Add("ApiSecret", options.ApiSecret); + }); + + // TODO: Get rid of this service, all consumers should use the interface + services.AddTransient(sp => (PasswordlessClient)sp.GetRequiredService()); + + return services; + } + + /// + /// Helper method for making custom typed HttpClient implementations that also have + /// the inner handler for throwing fancy exceptions. Not intended for public use, + /// hence the hiding of it in IDE's. + /// + /// + /// This method signature is subject to change without major version bump/announcement. + /// + internal static IServiceCollection AddPasswordlessClientCore(this IServiceCollection services, Action configureClient) + where TClient : class + where TImplementation : class, TClient + { + services.AddTransient(); + + services + .AddHttpClient(configureClient) + .AddHttpMessageHandler(); + + return services; + } +} \ No newline at end of file diff --git a/tests/Sdk.Tests/PasswordlessClientTests.cs b/tests/Sdk.Tests/PasswordlessClientTests.cs new file mode 100644 index 0000000..4a12129 --- /dev/null +++ b/tests/Sdk.Tests/PasswordlessClientTests.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Passwordless.Net.Tests; + +public class PasswordlessClientTests +{ + + private const string SkipReason = "Manual test, you must be running the Api project."; + // private const string SkipReason = null; + + private readonly PasswordlessClient _sut; + + public PasswordlessClientTests() + { + var services = new ServiceCollection(); + + services.AddPasswordlessSdk(options => + { + options.ApiUrl = "https://localhost:7002"; + options.ApiSecret = "test:secret:a679563b331846c79c20b114a4f56d02"; + }); + + var provider = services.BuildServiceProvider(); + + _sut = (PasswordlessClient)provider.GetRequiredService(); + } + + [Fact(Skip = SkipReason)] + public async Task CreateRegisterToken_ThrowsExceptionWhenBad() + { + var exception = await Assert.ThrowsAnyAsync(async () => await _sut.CreateRegisterToken(new RegisterOptions + { + UserId = null!, + Username = null!, + })); + } + + [Fact(Skip = SkipReason)] + public async Task VerifyToken_DoesNotThrowOnBadToken() + { + var verifiedUser = await _sut.VerifyToken("bad_token"); + + Assert.Null(verifiedUser); + } + + [Fact(Skip = SkipReason)] + public async Task DeleteUserAsync_BadUserId_ThrowsException() + { + var exception = await Assert.ThrowsAnyAsync( + async () => await _sut.DeleteUserAsync(null!)); + } + + [Fact(Skip = SkipReason)] + public async Task ListAsiases_BadUserId_ThrowsException() + { + var exception = await Assert.ThrowsAnyAsync( + async () => await _sut.ListAliases(null!)); + } + + [Fact(Skip = SkipReason)] + public async Task ListCredentials_BadUserId_ThrowsException() + { + var exception = await Assert.ThrowsAnyAsync( + async () => await _sut.ListCredentials(null!)); + } +} \ No newline at end of file diff --git a/tests/Sdk.Tests/Sdk.Tests.csproj b/tests/Sdk.Tests/Sdk.Tests.csproj new file mode 100644 index 0000000..16ef210 --- /dev/null +++ b/tests/Sdk.Tests/Sdk.Tests.csproj @@ -0,0 +1,30 @@ + + + + net7.0 + enable + enable + + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/Sdk.Tests/Usings.cs b/tests/Sdk.Tests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/tests/Sdk.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file