diff --git a/AS400API.Tests/AS400API.Tests.csproj b/AS400API.Tests/AS400API.Tests.csproj new file mode 100644 index 0000000..21733cb --- /dev/null +++ b/AS400API.Tests/AS400API.Tests.csproj @@ -0,0 +1,18 @@ + + + net9.0 + enable + enable + false + + + + + + + + + + + + diff --git a/AS400API.Tests/ApiIntegrationTests.cs b/AS400API.Tests/ApiIntegrationTests.cs new file mode 100644 index 0000000..81563c0 --- /dev/null +++ b/AS400API.Tests/ApiIntegrationTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using AS400API.Auth; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace AS400API.Tests; + +public sealed class ApiIntegrationTests : IClassFixture> +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + private readonly WebApplicationFactory _factory; + + public ApiIntegrationTests(WebApplicationFactory factory) + { + _factory = factory.WithWebHostBuilder(builder => + { + builder.UseSetting("DOTNET_ENVIRONMENT", "Development"); + }); + } + + [Fact] + public async Task GetRoot_ReturnsExpectedPayload() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/"); + var body = await response.Content.ReadAsStringAsync(); + + Assert.True(response.IsSuccessStatusCode, FormatFailure("GET /", response.StatusCode, body)); + + var payload = JsonSerializer.Deserialize(body, JsonOptions); + Assert.NotNull(payload); + Assert.Equal("AS400API", payload!.Name); + Assert.Equal("ok", payload.Status); + } + + [Fact] + public async Task Login_WithValidCredentials_ReturnsToken() + { + var client = _factory.CreateClient(); + var request = new LoginRequest("admin", "Pass@123"); + + var response = await client.PostAsJsonAsync("/api/v1/auth/login", request, JsonOptions); + var body = await response.Content.ReadAsStringAsync(); + + Assert.True(response.IsSuccessStatusCode, FormatFailure("POST /api/v1/auth/login", response.StatusCode, body)); + + var payload = JsonSerializer.Deserialize(body, JsonOptions); + Assert.NotNull(payload); + Assert.Equal("Bearer", payload!.TokenType); + Assert.True(payload.ExpiresIn > 0); + Assert.False(string.IsNullOrWhiteSpace(payload.AccessToken)); + Assert.Contains("Admin", payload.Roles); + } + + [Fact] + public async Task Login_WithInvalidCredentials_ReturnsUnauthorized() + { + var client = _factory.CreateClient(); + var request = new LoginRequest("admin", "wrong-password"); + + var response = await client.PostAsJsonAsync("/api/v1/auth/login", request, JsonOptions); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.True(string.IsNullOrEmpty(body) || body.Contains("error", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task UsersMe_WithValidToken_ReturnsProfile() + { + var client = _factory.CreateClient(); + var loginResponse = await client.PostAsJsonAsync("/api/v1/auth/login", new LoginRequest("operator", "Pass@123"), JsonOptions); + var loginBody = await loginResponse.Content.ReadAsStringAsync(); + Assert.True(loginResponse.IsSuccessStatusCode, FormatFailure("POST /api/v1/auth/login", loginResponse.StatusCode, loginBody)); + + var tokenPayload = JsonSerializer.Deserialize(loginBody, JsonOptions); + Assert.NotNull(tokenPayload); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenPayload!.AccessToken); + + var profileResponse = await client.GetAsync("/api/v1/users/me"); + var profileBody = await profileResponse.Content.ReadAsStringAsync(); + Assert.True(profileResponse.IsSuccessStatusCode, FormatFailure("GET /api/v1/users/me", profileResponse.StatusCode, profileBody)); + + var profile = JsonSerializer.Deserialize(profileBody, JsonOptions); + Assert.NotNull(profile); + Assert.Equal("operator", profile!.Username); + Assert.Contains("Operator", profile.Roles); + } + + private static string FormatFailure(string action, HttpStatusCode status, string body) => + $"{action} failed with status {(int)status} ({status}). Body: {body}"; + + private sealed record RootResponse(string Name, string Status); + + private sealed record LoginRequest(string Username, string Password); + + private sealed record UserProfileResponse(string Username, string[] Roles); +} diff --git a/AS400API.csproj b/AS400API.csproj index 96f19c1..6dd969a 100644 --- a/AS400API.csproj +++ b/AS400API.csproj @@ -14,4 +14,7 @@ + + + diff --git a/AS400API.sln b/AS400API.sln index 53f84e0..94e2d91 100644 --- a/AS400API.sln +++ b/AS400API.sln @@ -1,19 +1,46 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AS400API", "AS400API.csproj", "{40CB5B53-77B8-B1FD-458C-805350CB09E8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AS400API.Tests", "AS400API.Tests\AS400API.Tests.csproj", "{37A21E3B-3EEB-4538-B085-0FD6BA85DE70}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|x64.ActiveCfg = Debug|Any CPU + {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|x64.Build.0 = Debug|Any CPU + {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|x86.Build.0 = Debug|Any CPU {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|Any CPU.ActiveCfg = Release|Any CPU {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|Any CPU.Build.0 = Release|Any CPU + {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|x64.ActiveCfg = Release|Any CPU + {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|x64.Build.0 = Release|Any CPU + {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|x86.ActiveCfg = Release|Any CPU + {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|x86.Build.0 = Release|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|x64.ActiveCfg = Debug|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|x64.Build.0 = Debug|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|x86.ActiveCfg = Debug|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|x86.Build.0 = Debug|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|Any CPU.Build.0 = Release|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|x64.ActiveCfg = Release|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|x64.Build.0 = Release|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|x86.ActiveCfg = Release|Any CPU + {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Program.cs b/Program.cs index bae89c2..c7f4ed5 100644 --- a/Program.cs +++ b/Program.cs @@ -131,3 +131,5 @@ app.Run(); // env DOTNET_ENVIRONMENT=Development dotnet run // docker run -d --name sonarqube -p 9000:9000 sonarqube:lts-community + +public partial class Program { }