diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index be7d5c1..9d94988 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -84,14 +84,18 @@ jobs: # Run tests test: - runs-on: ${{ matrix.os }} - permissions: - contents: read - strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: + - ubuntu-latest + # Windows runners don't support Linux Docker containers (needed for tests), + # so we currently cannot run tests on Windows. + # - windows-latest + + runs-on: ${{ matrix.os }} + permissions: + contents: read steps: - name: Checkout diff --git a/tests/Passwordless.Tests/ApiFactAttribute.cs b/tests/Passwordless.Tests/ApiFactAttribute.cs deleted file mode 100644 index 116486b..0000000 --- a/tests/Passwordless.Tests/ApiFactAttribute.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Uncomment line while running API in mock mode to run tests -// #define RUNNING_API - -namespace Passwordless.Tests; - -public class ApiFactAttribute : FactAttribute -{ - public ApiFactAttribute() - { -#if !RUNNING_API - Skip = "These tests are skipped unless you are running the API locally."; -#endif - } -} \ No newline at end of file diff --git a/tests/Passwordless.Tests/Fixtures/TestApiFixture.cs b/tests/Passwordless.Tests/Fixtures/TestApiFixture.cs new file mode 100644 index 0000000..37d7c61 --- /dev/null +++ b/tests/Passwordless.Tests/Fixtures/TestApiFixture.cs @@ -0,0 +1,172 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.MsSql; +using Xunit; + +namespace Passwordless.Tests.Fixtures; + +public class TestApiFixture : IAsyncLifetime +{ + private readonly HttpClient _http = new(); + + private readonly INetwork _network; + private readonly MsSqlContainer _databaseContainer; + private readonly IContainer _apiContainer; + + private readonly MemoryStream _databaseContainerStdOut = new(); + private readonly MemoryStream _databaseContainerStdErr = new(); + private readonly MemoryStream _apiContainerStdOut = new(); + private readonly MemoryStream _apiContainerStdErr = new(); + + private string PublicApiUrl => $"http://localhost:{_apiContainer.GetMappedPublicPort(80)}"; + + public TestApiFixture() + { + const string managementKey = "yourStrong(!)ManagementKey"; + const string databaseHost = "database"; + + _network = new NetworkBuilder() + .Build(); + + _databaseContainer = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .WithNetwork(_network) + .WithNetworkAliases(databaseHost) + .WithOutputConsumer( + Consume.RedirectStdoutAndStderrToStream(_databaseContainerStdOut, _databaseContainerStdErr) + ) + .Build(); + + _apiContainer = new ContainerBuilder() + // https://github.com/passwordless/passwordless-server/pkgs/container/passwordless-test-api + // TODO: replace with ':stable' after the next release of the server. + .WithImage("ghcr.io/passwordless/passwordless-test-api:latest") + .WithNetwork(_network) + // Run in development environment to execute migrations + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") + .WithEnvironment("ConnectionStrings__sqlite:api", "") + .WithEnvironment("ConnectionStrings__mssql:api", + $"Server={databaseHost},{MsSqlBuilder.MsSqlPort};" + + "Database=Passwordless;" + + $"User Id={MsSqlBuilder.DefaultUsername};" + + $"Password={MsSqlBuilder.DefaultPassword};" + + "Trust Server Certificate=true;" + + "Trusted_Connection=false;" + ) + .WithEnvironment("PasswordlessManagement__ManagementKey", managementKey) + .WithPortBinding(80, true) + // Wait until the API is launched, has performed migrations, and is ready to accept requests + .WithWaitStrategy(Wait + .ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => r + .ForPath("/") + .ForStatusCode(HttpStatusCode.OK) + ) + ) + .WithOutputConsumer( + Consume.RedirectStdoutAndStderrToStream(_apiContainerStdOut, _apiContainerStdErr) + ) + .Build(); + + _http.DefaultRequestHeaders.Add("ManagementKey", managementKey); + } + + public async Task InitializeAsync() + { + await _network.CreateAsync(); + await _databaseContainer.StartAsync(); + await _apiContainer.StartAsync(); + } + + public async Task CreateClientAsync() + { + using var response = await _http.PostAsJsonAsync( + $"{PublicApiUrl}/admin/apps/app{Guid.NewGuid():N}/create", + new { AdminEmail = "foo@bar.com", EventLoggingIsEnabled = true } + ); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Failed to create an app. " + + $"Status code: {(int)response.StatusCode}. " + + $"Response body: {await response.Content.ReadAsStringAsync()}." + ); + } + + var responseContent = await response.Content.ReadFromJsonAsync(); + var apiKey = responseContent.GetProperty("apiKey1").GetString(); + var apiSecret = responseContent.GetProperty("apiSecret1").GetString(); + + var services = new ServiceCollection(); + + services.AddPasswordlessSdk(options => + { + options.ApiUrl = PublicApiUrl; + options.ApiKey = apiKey; + options.ApiSecret = apiSecret ?? + throw new InvalidOperationException("Cannot extract API Secret from the response."); + }); + + return services.BuildServiceProvider().GetRequiredService(); + } + + public string GetLogs() + { + var databaseContainerStdOutText = Encoding.UTF8.GetString( + _databaseContainerStdOut.ToArray() + ); + + var databaseContainerStdErrText = Encoding.UTF8.GetString( + _databaseContainerStdErr.ToArray() + ); + + var apiContainerStdOutText = Encoding.UTF8.GetString( + _apiContainerStdOut.ToArray() + ); + + var apiContainerStdErrText = Encoding.UTF8.GetString( + _apiContainerStdErr.ToArray() + ); + + // API logs are typically more relevant, so put them first + return + $""" + # API container STDOUT: + + {apiContainerStdOutText} + + # API container STDERR: + + {apiContainerStdErrText} + + # Database container STDOUT: + + {databaseContainerStdOutText} + + # Database container STDERR: + + {databaseContainerStdErrText} + """; + } + + public async Task DisposeAsync() + { + await _apiContainer.DisposeAsync(); + await _databaseContainer.DisposeAsync(); + await _network.DisposeAsync(); + + _databaseContainerStdOut.Dispose(); + _databaseContainerStdErr.Dispose(); + _apiContainerStdOut.Dispose(); + _apiContainerStdErr.Dispose(); + + _http.Dispose(); + } +} \ No newline at end of file diff --git a/tests/Passwordless.Tests/Fixtures/TestApiFixtureCollection.cs b/tests/Passwordless.Tests/Fixtures/TestApiFixtureCollection.cs new file mode 100644 index 0000000..6b67083 --- /dev/null +++ b/tests/Passwordless.Tests/Fixtures/TestApiFixtureCollection.cs @@ -0,0 +1,8 @@ +using Xunit; + +namespace Passwordless.Tests.Fixtures; + +[CollectionDefinition(nameof(TestApiFixtureCollection))] +public class TestApiFixtureCollection : ICollectionFixture +{ +} \ No newline at end of file diff --git a/tests/Passwordless.Tests/Infra/ApiTestBase.cs b/tests/Passwordless.Tests/Infra/ApiTestBase.cs new file mode 100644 index 0000000..82701cf --- /dev/null +++ b/tests/Passwordless.Tests/Infra/ApiTestBase.cs @@ -0,0 +1,26 @@ +using Passwordless.Tests.Fixtures; +using Xunit; +using Xunit.Abstractions; + +namespace Passwordless.Tests.Infra; + +[Collection(nameof(TestApiFixtureCollection))] +public abstract class ApiTestBase : IDisposable +{ + protected TestApiFixture Api { get; } + + protected ITestOutputHelper TestOutput { get; } + + protected ApiTestBase(TestApiFixture api, ITestOutputHelper testOutput) + { + Api = api; + TestOutput = testOutput; + } + + public void Dispose() + { + // Ideally we should route the logs in realtime, but it's a bit tedious + // with the way the TestContainers library is designed. + TestOutput.WriteLine(Api.GetLogs()); + } +} \ No newline at end of file diff --git a/tests/Passwordless.Tests/Passwordless.Tests.csproj b/tests/Passwordless.Tests/Passwordless.Tests.csproj index d6552ed..25dfad3 100644 --- a/tests/Passwordless.Tests/Passwordless.Tests.csproj +++ b/tests/Passwordless.Tests/Passwordless.Tests.csproj @@ -4,18 +4,22 @@ true $([MSBuild]::IsOsPlatform('Windows')) - - - $(TargetFrameworks);net6.0;net7.0 $(TargetFrameworks);net462 $(TargetFrameworks);$(CurrentPreviewTfm) + + + + + + + diff --git a/tests/Passwordless.Tests/PasswordlessClientTests.cs b/tests/Passwordless.Tests/PasswordlessClientTests.cs deleted file mode 100644 index 1fa3990..0000000 --- a/tests/Passwordless.Tests/PasswordlessClientTests.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; - - -namespace Passwordless.Tests; - -public class PasswordlessClientTests -{ - 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(); - } - - [ApiFact] - public async Task CreateRegisterTokenAsync_ThrowsExceptionWhenBad() - { - var exception = await Assert.ThrowsAnyAsync( - async () => await _sut.CreateRegisterTokenAsync(new RegisterOptions(null!, null!))); - } - - [ApiFact] - public async Task VerifyTokenAsync_DoesNotThrowOnBadToken() - { - var verifiedUser = await _sut.VerifyTokenAsync("bad_token"); - - Assert.Null(verifiedUser); - } - - [ApiFact] - public async Task DeleteUserAsync_BadUserId_ThrowsException() - { - var exception = await Assert.ThrowsAnyAsync( - async () => await _sut.DeleteUserAsync(null!)); - } - - [ApiFact] - public async Task ListAsiasesAsync_BadUserId_ThrowsException() - { - var exception = await Assert.ThrowsAnyAsync( - async () => await _sut.ListAliasesAsync(null!)); - } - - [ApiFact] - public async Task ListCredentialsAsync_BadUserId_ThrowsException() - { - var exception = await Assert.ThrowsAnyAsync( - async () => await _sut.ListCredentialsAsync(null!)); - - var errorCode = Assert.Contains("errorCode", (IDictionary)exception.Details.Extensions); - Assert.Equal(JsonValueKind.String, errorCode.ValueKind); - Assert.Equal("missing_userid", errorCode.GetString()); - } - - [ApiFact] - public async Task CreateRegisterTokenAsync_Works() - { - var userId = Guid.NewGuid().ToString(); - - var response = await _sut.CreateRegisterTokenAsync(new RegisterOptions(userId, "test_username")); - - Assert.NotNull(response.Token); - Assert.StartsWith("register_", response.Token); - } - - [ApiFact] - public async Task VerifyTokenAsync_Works() - { - var user = await _sut.VerifyTokenAsync("verify_valid"); - - Assert.NotNull(user); - Assert.True(user.Success); - } - - [ApiFact] - public async Task ListUsersAsync_Works() - { - var users = await _sut.ListUsersAsync(); - - Assert.NotEmpty(users); - } - - [ApiFact] - public async Task ListAliasesAsync_Works() - { - // Act - var aliases = await _sut.ListAliasesAsync("has_aliases"); - - // Assert - Assert.NotEmpty(aliases); - } - - [ApiFact] - public async Task ListCredentialsAsync_Works() - { - var credentials = await _sut.ListCredentialsAsync("has_credentials"); - - Assert.NotEmpty(credentials); - } - - [ApiFact] - public async Task DeleteCredentialAsync_Works() - { - await _sut.DeleteCredentialAsync("can_delete"); - } - - [ApiFact] - public async Task GetUsersCountAsync_Works() - { - var usersCount = await _sut.GetUsersCountAsync(); - Assert.NotEqual(0, usersCount.Count); - } -} \ No newline at end of file diff --git a/tests/Passwordless.Tests/TokenTests.cs b/tests/Passwordless.Tests/TokenTests.cs new file mode 100644 index 0000000..59140e2 --- /dev/null +++ b/tests/Passwordless.Tests/TokenTests.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using Passwordless.Tests.Fixtures; +using Passwordless.Tests.Infra; +using Xunit; +using Xunit.Abstractions; + +namespace Passwordless.Tests; + +public class TokenTests : ApiTestBase +{ + public TokenTests(TestApiFixture api, ITestOutputHelper testOutput) + : base(api, testOutput) + { + } + + [Fact] + public async Task I_can_create_a_register_token() + { + // Arrange + var passwordless = await Api.CreateClientAsync(); + + // Act + var response = await passwordless.CreateRegisterTokenAsync( + new RegisterOptions("user123", "John Doe") + ); + + // Assert + response.Token.Should().NotBeNullOrWhiteSpace(); + response.Token.Should().StartWith("register_"); + } + + [Fact] + public async Task I_can_try_to_verify_a_poorly_formatted_signin_token_and_get_an_error() + { + // Arrange + var passwordless = await Api.CreateClientAsync(); + + // Act & assert + await Assert.ThrowsAnyAsync(async () => + await passwordless.VerifyTokenAsync("invalid") + ); + } + + [Fact(Skip = "Need to figure out a syntactically correct token that is invalid")] + public async Task I_can_try_to_verify_an_invalid_signin_token_and_get_a_null_response() + { + // Arrange + var passwordless = await Api.CreateClientAsync(); + + // Act + var response = await passwordless.VerifyTokenAsync("verify_foobar"); + + // Assert + response.Should().BeNull(); + } +} \ No newline at end of file diff --git a/tests/Passwordless.Tests/UserTests.cs b/tests/Passwordless.Tests/UserTests.cs new file mode 100644 index 0000000..ea2207b --- /dev/null +++ b/tests/Passwordless.Tests/UserTests.cs @@ -0,0 +1,30 @@ +using FluentAssertions; +using Passwordless.Tests.Fixtures; +using Passwordless.Tests.Infra; +using Xunit; +using Xunit.Abstractions; + +namespace Passwordless.Tests; + +public class UserTests : ApiTestBase +{ + public UserTests(TestApiFixture api, ITestOutputHelper testOutput) + : base(api, testOutput) + { + } + + [Fact] + public async Task I_can_list_users_and_get_an_empty_collection_if_there_are_not_any() + { + // Arrange + var passwordless = await Api.CreateClientAsync(); + + // Act + var userCountResponse = await passwordless.GetUsersCountAsync(); + var users = await passwordless.ListUsersAsync(); + + // Assert + userCountResponse.Count.Should().Be(users.Count); + users.Should().BeEmpty(); + } +} \ No newline at end of file diff --git a/tests/Passwordless.Tests/Usings.cs b/tests/Passwordless.Tests/Usings.cs deleted file mode 100644 index 8c927eb..0000000 --- a/tests/Passwordless.Tests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file diff --git a/tests/Passwordless.Tests/xunit.runner.json b/tests/Passwordless.Tests/xunit.runner.json new file mode 100644 index 0000000..5ab050c --- /dev/null +++ b/tests/Passwordless.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "methodDisplayOptions": "all", + "methodDisplay": "method" +} \ No newline at end of file