diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d71e83c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +**/.git +**/.github +**/.vs +**/.vscode +**/bin +**/obj +**/logs +**/*.user +**/*.db +**/*.db-shm +**/*.db-wal +**/coverage +**/TestResults +tests/ +*.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f488ea2..a83cea2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,29 +7,29 @@ on: branches: [ main ] jobs: - build: + build-and-test: name: Build, Test & SonarCloud runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Java 21 (required by SonarScanner) - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - name: Set up .NET 10 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v4 with: dotnet-version: 10.x - name: Cache NuGet packages - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} @@ -58,9 +58,10 @@ jobs: - name: Build run: dotnet build --no-restore --configuration Release - - name: Run tests with coverage + - name: Run unit and integration tests run: | dotnet test --no-build --configuration Release \ + --filter "FullyQualifiedName!~E2eTests" \ --collect:"XPlat Code Coverage" \ --results-directory ./coverage \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover @@ -70,3 +71,37 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + + e2e: + name: E2E Tests (Docker) + runs-on: ubuntu-latest + needs: build-and-test + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.x + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} + restore-keys: nuget-${{ runner.os }}- + + - name: Restore dependencies + run: dotnet restore tests/Grimoire.E2eTests + + - name: Build Docker image + run: docker build -t grimoire-api:e2e . + + - name: Run E2E tests + env: + GRIMOIRE_TEST_IMAGE: grimoire-api:e2e + run: | + dotnet test tests/Grimoire.E2eTests --configuration Release \ + --logger "console;verbosity=normal" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5dda529 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY ["src/Grimoire.Core/Grimoire.Core.csproj", "src/Grimoire.Core/"] +COPY ["src/Grimoire.Infrastructure/Grimoire.Infrastructure.csproj", "src/Grimoire.Infrastructure/"] +COPY ["src/Grimoire.Api/Grimoire.Api.csproj", "src/Grimoire.Api/"] +RUN dotnet restore "src/Grimoire.Api/Grimoire.Api.csproj" + +COPY . . +WORKDIR "/src/src/Grimoire.Api" +RUN dotnet publish "Grimoire.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +RUN mkdir -p /data && chmod 777 /data + +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +ENV ConnectionStrings__Default="Data Source=/data/grimoire.db" + +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "Grimoire.Api.dll"] diff --git a/Grimoire.slnx b/Grimoire.slnx index 1d073f4..ee06684 100644 --- a/Grimoire.slnx +++ b/Grimoire.slnx @@ -6,6 +6,8 @@ + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5a4b77a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + image: grimoire-api:local + ports: + - "8080:8080" + environment: + ASPNETCORE_ENVIRONMENT: Development + Management__AdminApiKey: "change-me-in-production" + Encryption__MasterKey: "change-me-32-chars-minimum-key!!" + Cors__AllowedOrigins__0: "http://localhost:5173" + volumes: + - grimoire-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + +volumes: + grimoire-data: diff --git a/src/Grimoire.Api/Controllers/Management/ApplicationsController.cs b/src/Grimoire.Api/Controllers/Management/ApplicationsController.cs index a346e08..74776fa 100644 --- a/src/Grimoire.Api/Controllers/Management/ApplicationsController.cs +++ b/src/Grimoire.Api/Controllers/Management/ApplicationsController.cs @@ -157,8 +157,11 @@ public async Task RotateKey(string slug, CancellationToken ct) return Ok(new RotateKeyResponse(plainKey)); } - private static string GenerateApiKey() => - $"grm_{Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).Replace("+", "").Replace("/", "").Replace("=", "")[..40]}"; + private static string GenerateApiKey() + { + var bytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32); + return $"grm_{Convert.ToHexString(bytes).ToLowerInvariant()[..40]}"; + } private static ProblemDetails ProblemDetailsFor(string detail) => new() diff --git a/src/Grimoire.Api/Grimoire.Api.csproj b/src/Grimoire.Api/Grimoire.Api.csproj index 76e6d71..80d2ea1 100644 --- a/src/Grimoire.Api/Grimoire.Api.csproj +++ b/src/Grimoire.Api/Grimoire.Api.csproj @@ -1,4 +1,10 @@ + + net10.0 + enable + enable + + @@ -7,7 +13,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -15,10 +21,4 @@ - - - net8.0 - enable - enable - diff --git a/src/Grimoire.Api/Program.cs b/src/Grimoire.Api/Program.cs index 8f1b91e..18a6265 100644 --- a/src/Grimoire.Api/Program.cs +++ b/src/Grimoire.Api/Program.cs @@ -76,6 +76,7 @@ ); builder.Services.AddProblemDetails(); +builder.Services.AddHealthChecks(); var app = builder.Build(); @@ -102,5 +103,8 @@ app.UseMiddleware(); app.UseMiddleware(); app.MapControllers(); +app.MapHealthChecks("/health"); app.Run(); + +public partial class Program { } diff --git a/src/Grimoire.Consumer/Grimoire.Consumer.csproj b/src/Grimoire.Consumer/Grimoire.Consumer.csproj index 9684406..d1af24b 100644 --- a/src/Grimoire.Consumer/Grimoire.Consumer.csproj +++ b/src/Grimoire.Consumer/Grimoire.Consumer.csproj @@ -1,12 +1,12 @@ - + - net8.0 + net10.0 enable enable - - + + diff --git a/src/Grimoire.Core/Grimoire.Core.csproj b/src/Grimoire.Core/Grimoire.Core.csproj index d364a52..6c3a887 100644 --- a/src/Grimoire.Core/Grimoire.Core.csproj +++ b/src/Grimoire.Core/Grimoire.Core.csproj @@ -1,6 +1,6 @@ - + - net8.0 + net10.0 enable enable diff --git a/src/Grimoire.Infrastructure/Grimoire.Infrastructure.csproj b/src/Grimoire.Infrastructure/Grimoire.Infrastructure.csproj index 4b82a50..ed5f35f 100644 --- a/src/Grimoire.Infrastructure/Grimoire.Infrastructure.csproj +++ b/src/Grimoire.Infrastructure/Grimoire.Infrastructure.csproj @@ -1,21 +1,20 @@ - + + + net10.0 + enable + enable + + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + - - - net8.0 - enable - enable - diff --git a/src/Grimoire.Infrastructure/Persistence/Repositories/SecretRepository.cs b/src/Grimoire.Infrastructure/Persistence/Repositories/SecretRepository.cs index 04c5019..5171b10 100644 --- a/src/Grimoire.Infrastructure/Persistence/Repositories/SecretRepository.cs +++ b/src/Grimoire.Infrastructure/Persistence/Repositories/SecretRepository.cs @@ -40,22 +40,27 @@ public Task> GetVersionsAsync( .OrderByDescending(v => v.Version) .ToListAsync(ct); - public Task GetActiveVersionAsync( + public async Task GetActiveVersionAsync( Guid secretId, Guid environmentId, DateTimeOffset now, CancellationToken ct = default - ) => - db + ) + { + // Two-stage: filter indexable columns in SQL, then apply nullable DateTimeOffset comparisons in memory + // (EF Core 10 SQLite provider cannot translate nullable DateTimeOffset OR expressions) + var candidates = await db .SecretVersions.Where(v => - v.SecretId == secretId - && v.EnvironmentId == environmentId - && v.IsEnabled - && (v.NotBefore == null || v.NotBefore <= now) - && (v.ExpiresAt == null || v.ExpiresAt >= now) + v.SecretId == secretId && v.EnvironmentId == environmentId && v.IsEnabled ) .OrderByDescending(v => v.Version) - .FirstOrDefaultAsync(ct); + .ToListAsync(ct); + + return candidates.FirstOrDefault(v => + (v.NotBefore == null || v.NotBefore <= now) + && (v.ExpiresAt == null || v.ExpiresAt >= now) + ); + } public async Task GetNextVersionNumberAsync( Guid secretId, diff --git a/tests/Grimoire.E2eTests/E2eTests.cs b/tests/Grimoire.E2eTests/E2eTests.cs new file mode 100644 index 0000000..1fbfb4f --- /dev/null +++ b/tests/Grimoire.E2eTests/E2eTests.cs @@ -0,0 +1,161 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; + +namespace Grimoire.E2eTests; + +[Collection("E2E")] +public class E2eTests(GrimoireApiFixture fixture) : IClassFixture +{ + private static string UniqueName(string prefix = "e2e") => $"{prefix}-{Guid.NewGuid():N}"[..20]; + + [Fact] + public async Task HealthEndpoint_Returns200() + { + var response = await fixture.HttpClient.GetAsync("/health"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task FullLifecycle_CreateApp_SetSecret_ConsumeSecret() + { + var mgmt = fixture.CreateManagementClient(); + var appName = UniqueName("e2e-app"); + + // Create application + var createResp = await mgmt.PostAsJsonAsync( + "/api/management/applications", + new { name = appName, description = "E2E test" } + ); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var app = JsonNode.Parse(await createResp.Content.ReadAsStringAsync())!; + var slug = app["slug"]!.GetValue(); + var apiKey = app["plainApiKey"]!.GetValue(); + + // Create secret with value + var secretName = UniqueName("db-pass"); + await mgmt.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets", + new { name = secretName } + ); + var setVal = await mgmt.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets/{secretName}/values", + new[] + { + new + { + environmentSlug = "local", + value = "e2e-secret-value", + isEnabled = true, + }, + } + ); + Assert.Equal(HttpStatusCode.OK, setVal.StatusCode); + + // Consume secret via Consumer API + var consumer = fixture.CreateConsumerClient(apiKey); + var secretResp = await consumer.GetAsync( + $"/api/consumer/secrets/{secretName}?environment=local" + ); + Assert.Equal(HttpStatusCode.OK, secretResp.StatusCode); + var secretJson = JsonNode.Parse(await secretResp.Content.ReadAsStringAsync())!; + Assert.Equal("e2e-secret-value", secretJson["value"]!.GetValue()); + Assert.True(secretJson["properties"]!["enabled"]!.GetValue()); + } + + [Fact] + public async Task FullLifecycle_CreateApp_SetConfig_ConsumeConfig() + { + var mgmt = fixture.CreateManagementClient(); + var appName = UniqueName("cfg-app"); + + var createResp = await mgmt.PostAsJsonAsync( + "/api/management/applications", + new { name = appName } + ); + var app = JsonNode.Parse(await createResp.Content.ReadAsStringAsync())!; + var slug = app["slug"]!.GetValue(); + var apiKey = app["plainApiKey"]!.GetValue(); + + var key = UniqueName("Feature"); + await mgmt.PostAsJsonAsync( + $"/api/management/applications/{slug}/configurations", + new + { + environmentSlug = "local", + key, + value = "true", + } + ); + + var consumer = fixture.CreateConsumerClient(apiKey); + var cfgResp = await consumer.GetAsync( + $"/api/consumer/configurations/{key}?environment=local" + ); + Assert.Equal(HttpStatusCode.OK, cfgResp.StatusCode); + var cfgJson = JsonNode.Parse(await cfgResp.Content.ReadAsStringAsync())!; + Assert.Equal("true", cfgJson["value"]!.GetValue()); + Assert.Equal("local", cfgJson["label"]!.GetValue()); + } + + [Fact] + public async Task ManagementApi_RequiresAuthentication() + { + var response = await fixture.HttpClient.GetAsync("/api/management/applications"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ConsumerApi_RequiresApiKey() + { + var response = await fixture.HttpClient.GetAsync( + "/api/consumer/secrets/anything?environment=local" + ); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task RotateKey_OldKeyInvalid_NewKeyWorks() + { + var mgmt = fixture.CreateManagementClient(); + var (slug, oldKey) = await CreateApplicationAsync(mgmt); + + var rotateResp = await mgmt.PostAsync( + $"/api/management/applications/{slug}/rotate-key", + null + ); + var rotateJson = JsonNode.Parse(await rotateResp.Content.ReadAsStringAsync())!; + var newKey = rotateJson["plainApiKey"]!.GetValue(); + + // Old key should no longer work + var oldConsumer = fixture.CreateConsumerClient(oldKey); + var oldResp = await oldConsumer.GetAsync("/api/consumer/configurations?environment=local"); + Assert.Equal(HttpStatusCode.Unauthorized, oldResp.StatusCode); + + // New key should work + var newConsumer = fixture.CreateConsumerClient(newKey); + var newResp = await newConsumer.GetAsync("/api/consumer/configurations?environment=local"); + Assert.Equal(HttpStatusCode.OK, newResp.StatusCode); + } + + [Fact] + public async Task DeleteApplication_RemovesAccess() + { + var mgmt = fixture.CreateManagementClient(); + var (slug, _) = await CreateApplicationAsync(mgmt); + + await mgmt.DeleteAsync($"/api/management/applications/{slug}"); + + var getResp = await mgmt.GetAsync($"/api/management/applications/{slug}"); + Assert.Equal(HttpStatusCode.NotFound, getResp.StatusCode); + } + + private static async Task<(string slug, string apiKey)> CreateApplicationAsync(HttpClient mgmt) + { + var name = UniqueName("e2e-lifecycle"); + var resp = await mgmt.PostAsJsonAsync("/api/management/applications", new { name }); + resp.EnsureSuccessStatusCode(); + var json = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!; + return (json["slug"]!.GetValue(), json["plainApiKey"]!.GetValue()); + } +} diff --git a/tests/Grimoire.E2eTests/Grimoire.E2eTests.csproj b/tests/Grimoire.E2eTests/Grimoire.E2eTests.csproj new file mode 100644 index 0000000..f841cc3 --- /dev/null +++ b/tests/Grimoire.E2eTests/Grimoire.E2eTests.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + false + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/tests/Grimoire.E2eTests/GrimoireApiFixture.cs b/tests/Grimoire.E2eTests/GrimoireApiFixture.cs new file mode 100644 index 0000000..b254f67 --- /dev/null +++ b/tests/Grimoire.E2eTests/GrimoireApiFixture.cs @@ -0,0 +1,75 @@ +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Images; + +namespace Grimoire.E2eTests; + +public sealed class GrimoireApiFixture : IAsyncLifetime +{ + public const string AdminKey = "e2e-admin-key-secure"; + public const string MasterKey = "e2e-master-key-32-chars-minimum!!"; + + private IContainer? _container; + private IFutureDockerImage? _builtImage; + + public HttpClient HttpClient { get; private set; } = null!; + public string BaseUrl { get; private set; } = string.Empty; + + public async Task InitializeAsync() + { + var imageName = Environment.GetEnvironmentVariable("GRIMOIRE_TEST_IMAGE"); + + if (string.IsNullOrEmpty(imageName)) + { + var tag = $"grimoire-api:e2e-{Guid.NewGuid():N}"[..30]; + _builtImage = new ImageFromDockerfileBuilder() + .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty) + .WithDockerfile("Dockerfile") + .WithName(tag) + .Build(); + await _builtImage.CreateAsync(); + imageName = _builtImage.FullName; + } + + _container = new ContainerBuilder() + .WithImage(imageName) + .WithPortBinding(8080, true) + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") + .WithEnvironment("Management__AdminApiKey", AdminKey) + .WithEnvironment("Encryption__MasterKey", MasterKey) + .WithEnvironment("Cors__AllowedOrigins__0", "http://localhost:5173") + .WithWaitStrategy( + Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => r.ForPath("/health").ForPort(8080)) + ) + .Build(); + + await _container.StartAsync(); + + BaseUrl = $"http://{_container.Hostname}:{_container.GetMappedPublicPort(8080)}"; + HttpClient = new HttpClient { BaseAddress = new Uri(BaseUrl) }; + } + + public HttpClient CreateManagementClient() + { + var client = new HttpClient { BaseAddress = new Uri(BaseUrl) }; + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {AdminKey}"); + return client; + } + + public HttpClient CreateConsumerClient(string apiKey) + { + var client = new HttpClient { BaseAddress = new Uri(BaseUrl) }; + client.DefaultRequestHeaders.Add("X-Api-Key", apiKey); + return client; + } + + public async Task DisposeAsync() + { + HttpClient.Dispose(); + if (_container is not null) + await _container.DisposeAsync(); + if (_builtImage is not null) + await _builtImage.DisposeAsync(); + } +} diff --git a/tests/Grimoire.IntegrationTests/AuthenticationTests.cs b/tests/Grimoire.IntegrationTests/AuthenticationTests.cs new file mode 100644 index 0000000..a44fc5d --- /dev/null +++ b/tests/Grimoire.IntegrationTests/AuthenticationTests.cs @@ -0,0 +1,57 @@ +using System.Net; + +namespace Grimoire.IntegrationTests; + +public class AuthenticationTests(GrimoireWebApplicationFactory factory) + : IClassFixture +{ + [Fact] + public async Task ManagementApi_WithoutAuthHeader_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/management/applications"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ManagementApi_WithWrongBearerToken_Returns401() + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("Authorization", "Bearer wrong-key"); + var response = await client.GetAsync("/api/management/applications"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ManagementApi_WithCorrectBearerToken_Returns200() + { + var client = factory.CreateManagementClient(); + var response = await client.GetAsync("/api/management/applications"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task ConsumerApi_WithoutApiKeyHeader_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/consumer/secrets/anything?environment=local"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ConsumerApi_WithWrongApiKey_Returns401() + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Api-Key", "grm_wrongkey"); + var response = await client.GetAsync("/api/consumer/secrets/anything?environment=local"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task HealthEndpoint_Returns200_WithoutAuth() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/health"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/tests/Grimoire.IntegrationTests/Consumer/ConsumerConfigurationsTests.cs b/tests/Grimoire.IntegrationTests/Consumer/ConsumerConfigurationsTests.cs new file mode 100644 index 0000000..312a495 --- /dev/null +++ b/tests/Grimoire.IntegrationTests/Consumer/ConsumerConfigurationsTests.cs @@ -0,0 +1,105 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; + +namespace Grimoire.IntegrationTests.Consumer; + +public class ConsumerConfigurationsTests(GrimoireWebApplicationFactory factory) + : IClassFixture +{ + private HttpClient MgmtClient => factory.CreateManagementClient(); + + [Fact] + public async Task GetAllConfigurations_ReturnsItems_WithEnvironmentLabel() + { + var (slug, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + var key = TestHelpers.UniqueName("Feature"); + await MgmtClient.PostAsJsonAsync( + $"/api/management/applications/{slug}/configurations", + new + { + environmentSlug = "local", + key, + value = "true", + } + ); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync("/api/consumer/configurations?environment=local"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + var items = json["items"]!.AsArray(); + Assert.Contains( + items, + i => + i!["key"]!.GetValue() == key + && i["value"]!.GetValue() == "true" + && i["label"]!.GetValue() == "local" + ); + } + + [Fact] + public async Task GetAllConfigurations_ReturnsEmpty_WhenNoEntries() + { + var (_, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync("/api/consumer/configurations?environment=local"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Empty(json["items"]!.AsArray()); + } + + [Fact] + public async Task GetConfigurationByKey_Returns200_WithValue() + { + var (slug, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + var key = TestHelpers.UniqueName("conn-str"); + await MgmtClient.PostAsJsonAsync( + $"/api/management/applications/{slug}/configurations", + new + { + environmentSlug = "local", + key, + value = "Server=localhost", + } + ); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync( + $"/api/consumer/configurations/{key}?environment=local" + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal(key, json["key"]!.GetValue()); + Assert.Equal("Server=localhost", json["value"]!.GetValue()); + Assert.Equal("local", json["label"]!.GetValue()); + } + + [Fact] + public async Task GetConfigurationByKey_Returns404_WhenNotFound() + { + var (_, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync( + "/api/consumer/configurations/no-such-key?environment=local" + ); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetAllConfigurations_Returns404_ForUnknownEnvironment() + { + var (_, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync("/api/consumer/configurations?environment=no-env"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/Grimoire.IntegrationTests/Consumer/ConsumerSecretsTests.cs b/tests/Grimoire.IntegrationTests/Consumer/ConsumerSecretsTests.cs new file mode 100644 index 0000000..9f93975 --- /dev/null +++ b/tests/Grimoire.IntegrationTests/Consumer/ConsumerSecretsTests.cs @@ -0,0 +1,178 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; + +namespace Grimoire.IntegrationTests.Consumer; + +public class ConsumerSecretsTests(GrimoireWebApplicationFactory factory) + : IClassFixture +{ + private HttpClient MgmtClient => factory.CreateManagementClient(); + + [Fact] + public async Task GetSecret_Returns200_WithDecryptedValue() + { + var (slug, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + var secretName = TestHelpers.UniqueName("db-pass"); + await TestHelpers.CreateSecretWithValueAsync( + MgmtClient, + slug, + secretName, + "local", + "s3cr3tP@ss!" + ); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync( + $"/api/consumer/secrets/{secretName}?environment=local" + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal(secretName, json["name"]!.GetValue()); + Assert.Equal("s3cr3tP@ss!", json["value"]!.GetValue()); + Assert.NotNull(json["properties"]); + Assert.True(json["properties"]!["enabled"]!.GetValue()); + Assert.Equal(1, json["properties"]!["version"]!.GetValue()); + } + + [Fact] + public async Task GetSecret_Returns404_WhenNoActiveVersion() + { + var (slug, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + var secretName = TestHelpers.UniqueName("no-val"); + await MgmtClient.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets", + new { name = secretName } + ); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync( + $"/api/consumer/secrets/{secretName}?environment=local" + ); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetSecret_Returns404_WhenSecretDoesNotExist() + { + var (_, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync( + "/api/consumer/secrets/does-not-exist?environment=local" + ); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetSecret_Returns404_WhenEnvironmentNotFound() + { + var (slug, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + var secretName = TestHelpers.UniqueName("env-miss"); + await TestHelpers.CreateSecretWithValueAsync(MgmtClient, slug, secretName, "local", "val"); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync( + $"/api/consumer/secrets/{secretName}?environment=no-env" + ); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetSecret_Returns404_WhenVersionDisabled() + { + var (slug, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + var secretName = TestHelpers.UniqueName("disabled"); + + await MgmtClient.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets", + new { name = secretName } + ); + await MgmtClient.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets/{secretName}/values", + new[] + { + new + { + environmentSlug = "local", + value = "x", + isEnabled = false, + }, + } + ); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync( + $"/api/consumer/secrets/{secretName}?environment=local" + ); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetSecret_Returns404_WhenExpired() + { + var (slug, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + var secretName = TestHelpers.UniqueName("expired"); + var pastDate = DateTimeOffset.UtcNow.AddDays(-1); + + await MgmtClient.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets", + new { name = secretName } + ); + await MgmtClient.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets/{secretName}/values", + new[] + { + new + { + environmentSlug = "local", + value = "x", + isEnabled = true, + expiresAt = pastDate, + }, + } + ); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync( + $"/api/consumer/secrets/{secretName}?environment=local" + ); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetSecret_ReturnsLatestActiveVersion() + { + var (slug, apiKey) = await TestHelpers.CreateApplicationAsync(MgmtClient); + var secretName = TestHelpers.UniqueName("latest"); + await TestHelpers.CreateSecretWithValueAsync(MgmtClient, slug, secretName, "local", "v1"); + await MgmtClient.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets/{secretName}/values", + new[] + { + new + { + environmentSlug = "local", + value = "v2", + isEnabled = true, + }, + } + ); + + var consumer = factory.CreateConsumerClient(apiKey); + var response = await consumer.GetAsync( + $"/api/consumer/secrets/{secretName}?environment=local" + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal("v2", json["value"]!.GetValue()); + Assert.Equal(2, json["properties"]!["version"]!.GetValue()); + } +} diff --git a/tests/Grimoire.IntegrationTests/Grimoire.IntegrationTests.csproj b/tests/Grimoire.IntegrationTests/Grimoire.IntegrationTests.csproj new file mode 100644 index 0000000..1730481 --- /dev/null +++ b/tests/Grimoire.IntegrationTests/Grimoire.IntegrationTests.csproj @@ -0,0 +1,35 @@ + + + net10.0 + enable + enable + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + diff --git a/tests/Grimoire.IntegrationTests/GrimoireWebApplicationFactory.cs b/tests/Grimoire.IntegrationTests/GrimoireWebApplicationFactory.cs new file mode 100644 index 0000000..ad3ef96 --- /dev/null +++ b/tests/Grimoire.IntegrationTests/GrimoireWebApplicationFactory.cs @@ -0,0 +1,73 @@ +using Grimoire.Infrastructure.Persistence; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Grimoire.IntegrationTests; + +public sealed class GrimoireWebApplicationFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly string _dbPath = Path.Combine( + Path.GetTempPath(), + $"grimoire-test-{Guid.NewGuid():N}.db" + ); + + public const string AdminKey = "test-admin-key-integration"; + public const string MasterKey = "test-master-key-32-chars-minimum!"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureAppConfiguration( + (_, config) => + { + config.AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:Default"] = $"Data Source={_dbPath}", + ["Management:AdminApiKey"] = AdminKey, + ["Encryption:MasterKey"] = MasterKey, + ["Cors:AllowedOrigins:0"] = "http://localhost:5173", + } + ); + } + ); + } + + public HttpClient CreateManagementClient() + { + var client = CreateClient(); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {AdminKey}"); + return client; + } + + public HttpClient CreateConsumerClient(string apiKey) + { + var client = CreateClient(); + client.DefaultRequestHeaders.Add("X-Api-Key", apiKey); + return client; + } + + public Task InitializeAsync() => Task.CompletedTask; + + public new async Task DisposeAsync() + { + await base.DisposeAsync(); + TryDeleteDb(); + } + + private void TryDeleteDb() + { + foreach (var f in new[] { _dbPath, _dbPath + "-shm", _dbPath + "-wal" }) + try + { + File.Delete(f); + } + catch + { /* ignore */ + } + } +} diff --git a/tests/Grimoire.IntegrationTests/Management/ApplicationsTests.cs b/tests/Grimoire.IntegrationTests/Management/ApplicationsTests.cs new file mode 100644 index 0000000..35e069e --- /dev/null +++ b/tests/Grimoire.IntegrationTests/Management/ApplicationsTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; + +namespace Grimoire.IntegrationTests.Management; + +public class ApplicationsTests(GrimoireWebApplicationFactory factory) + : IClassFixture +{ + private HttpClient Client => factory.CreateManagementClient(); + + [Fact] + public async Task GetApplications_ReturnsEmptyListInitially() + { + var response = await Client.GetAsync("/api/management/applications"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray(); + Assert.NotNull(json); + } + + [Fact] + public async Task CreateApplication_Returns201_WithPlainApiKey() + { + var name = TestHelpers.UniqueName("create-app"); + var response = await Client.PostAsJsonAsync( + "/api/management/applications", + new { name, description = "Integration test app" } + ); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.NotNull(json["plainApiKey"]?.GetValue()); + Assert.StartsWith("grm_", json["plainApiKey"]!.GetValue()); + Assert.NotNull(json["slug"]?.GetValue()); + } + + [Fact] + public async Task CreateApplication_AutoGeneratesSlugFromName() + { + var response = await Client.PostAsJsonAsync( + "/api/management/applications", + new { name = "My Test Application", description = "" } + ); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal("my-test-application", json["slug"]!.GetValue()); + } + + [Fact] + public async Task GetApplicationBySlug_Returns200_WhenExists() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + + var response = await Client.GetAsync($"/api/management/applications/{slug}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal(slug, json["slug"]!.GetValue()); + } + + [Fact] + public async Task GetApplicationBySlug_Returns404_WhenNotFound() + { + var response = await Client.GetAsync("/api/management/applications/nonexistent-slug-xyz"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task UpdateApplication_Returns200_WithUpdatedName() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var newName = TestHelpers.UniqueName("updated"); + + var response = await Client.PutAsJsonAsync( + $"/api/management/applications/{slug}", + new { name = newName, description = "updated desc" } + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal(newName, json["name"]!.GetValue()); + } + + [Fact] + public async Task DeleteApplication_Returns204_AndThenNotFound() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + + var deleteResponse = await Client.DeleteAsync($"/api/management/applications/{slug}"); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + + var getResponse = await Client.GetAsync($"/api/management/applications/{slug}"); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } + + [Fact] + public async Task RotateKey_Returns200_WithNewPlainKey() + { + var (slug, originalKey) = await TestHelpers.CreateApplicationAsync(Client); + + var response = await Client.PostAsync( + $"/api/management/applications/{slug}/rotate-key", + null + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + var newKey = json["plainApiKey"]!.GetValue(); + Assert.NotNull(newKey); + Assert.NotEqual(originalKey, newKey); + } + + [Fact] + public async Task CreateApplication_SeedsLocalEnvironmentAutomatically() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + + var response = await Client.GetAsync($"/api/management/applications/{slug}/environments"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var envs = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray(); + Assert.Contains(envs, e => e!["slug"]!.GetValue() == "local"); + } + + [Fact] + public async Task CreateApplication_DuplicateName_Returns409() + { + var name = TestHelpers.UniqueName("dup-app"); + await Client.PostAsJsonAsync("/api/management/applications", new { name }); + + var response = await Client.PostAsJsonAsync("/api/management/applications", new { name }); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } +} diff --git a/tests/Grimoire.IntegrationTests/Management/ConfigurationsTests.cs b/tests/Grimoire.IntegrationTests/Management/ConfigurationsTests.cs new file mode 100644 index 0000000..b5455d5 --- /dev/null +++ b/tests/Grimoire.IntegrationTests/Management/ConfigurationsTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; + +namespace Grimoire.IntegrationTests.Management; + +public class ConfigurationsTests(GrimoireWebApplicationFactory factory) + : IClassFixture +{ + private HttpClient Client => factory.CreateManagementClient(); + + [Fact] + public async Task ListConfigurations_ReturnsEmpty_Initially() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + + var response = await Client.GetAsync($"/api/management/applications/{slug}/configurations"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var arr = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray(); + Assert.Empty(arr); + } + + [Fact] + public async Task CreateConfiguration_Returns201_WithKeyAndValue() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var key = TestHelpers.UniqueName("Feature"); + + var response = await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/configurations", + new + { + environmentSlug = "local", + key, + value = "true", + description = "flag", + } + ); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal(key, json["key"]!.GetValue()); + Assert.Equal("true", json["value"]!.GetValue()); + } + + [Fact] + public async Task CreateConfiguration_DuplicateKey_Returns409() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var key = TestHelpers.UniqueName("dup-key"); + + await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/configurations", + new + { + environmentSlug = "local", + key, + value = "v1", + } + ); + var response = await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/configurations", + new + { + environmentSlug = "local", + key, + value = "v2", + } + ); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task UpdateConfiguration_Returns200_WithNewValue() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var key = TestHelpers.UniqueName("upd-key"); + + await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/configurations", + new + { + environmentSlug = "local", + key, + value = "old", + } + ); + + var response = await Client.PutAsJsonAsync( + $"/api/management/applications/{slug}/configurations/local/{key}", + new { value = "new" } + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal("new", json["value"]!.GetValue()); + } + + [Fact] + public async Task DeleteConfiguration_Returns204() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var key = TestHelpers.UniqueName("del-key"); + + await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/configurations", + new + { + environmentSlug = "local", + key, + value = "v", + } + ); + + var response = await Client.DeleteAsync( + $"/api/management/applications/{slug}/configurations/local/{key}" + ); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task UpdateConfiguration_NotFound_Returns404() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var response = await Client.PutAsJsonAsync( + $"/api/management/applications/{slug}/configurations/local/no-key", + new { value = "x" } + ); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CreateConfiguration_ForNonexistentEnvironment_Returns404() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var response = await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/configurations", + new + { + environmentSlug = "no-env", + key = "k", + value = "v", + } + ); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/Grimoire.IntegrationTests/Management/EnvironmentsTests.cs b/tests/Grimoire.IntegrationTests/Management/EnvironmentsTests.cs new file mode 100644 index 0000000..a16967a --- /dev/null +++ b/tests/Grimoire.IntegrationTests/Management/EnvironmentsTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; + +namespace Grimoire.IntegrationTests.Management; + +public class EnvironmentsTests(GrimoireWebApplicationFactory factory) + : IClassFixture +{ + private HttpClient Client => factory.CreateManagementClient(); + + [Fact] + public async Task ListEnvironments_IncludesAutoCreatedLocal() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + + var response = await Client.GetAsync($"/api/management/applications/{slug}/environments"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var envs = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray(); + Assert.True(envs.Count >= 1); + Assert.Contains(envs, e => e!["slug"]!.GetValue() == "local"); + } + + [Fact] + public async Task CreateEnvironment_Returns201_WithGeneratedSlug() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + + var response = await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/environments", + new { name = "Production" } + ); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal("production", json["slug"]!.GetValue()); + } + + [Fact] + public async Task CreateEnvironment_DuplicateSlug_Returns409() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + + await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/environments", + new { name = "Staging" } + ); + var response = await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/environments", + new { name = "Staging" } + ); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task DeleteEnvironment_Returns204() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/environments", + new { name = "Temp-Env" } + ); + + var response = await Client.DeleteAsync( + $"/api/management/applications/{slug}/environments/temp-env" + ); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task ListEnvironments_ForNonExistentApp_Returns404() + { + var response = await Client.GetAsync( + "/api/management/applications/no-app-xyz/environments" + ); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DeleteEnvironment_NotFound_Returns404() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var response = await Client.DeleteAsync( + $"/api/management/applications/{slug}/environments/no-such-env" + ); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/Grimoire.IntegrationTests/Management/SecretsTests.cs b/tests/Grimoire.IntegrationTests/Management/SecretsTests.cs new file mode 100644 index 0000000..4930abe --- /dev/null +++ b/tests/Grimoire.IntegrationTests/Management/SecretsTests.cs @@ -0,0 +1,167 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; + +namespace Grimoire.IntegrationTests.Management; + +public class SecretsTests(GrimoireWebApplicationFactory factory) + : IClassFixture +{ + private HttpClient Client => factory.CreateManagementClient(); + + [Fact] + public async Task ListSecrets_ReturnsEmptyList_Initially() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + + var response = await Client.GetAsync($"/api/management/applications/{slug}/secrets"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var arr = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray(); + Assert.Empty(arr); + } + + [Fact] + public async Task CreateSecret_Returns201_WithRequiredEnvironments() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var secretName = TestHelpers.UniqueName("secret"); + + var response = await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets", + new { name = secretName, description = "test secret" } + ); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal(secretName, json["name"]!.GetValue()); + var requiredEnvs = json["requiredEnvironments"]!.AsArray(); + Assert.True(requiredEnvs.Count >= 1); + Assert.All(requiredEnvs, e => Assert.False(e!["valueProvided"]!.GetValue())); + } + + [Fact] + public async Task CreateSecret_Duplicate_Returns409() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var name = TestHelpers.UniqueName("dup-secret"); + + await Client.PostAsJsonAsync($"/api/management/applications/{slug}/secrets", new { name }); + var response = await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets", + new { name } + ); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task SetSecretValue_And_GetMetadata_Works() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var secretName = TestHelpers.UniqueName("sec"); + + await TestHelpers.CreateSecretWithValueAsync(Client, slug, secretName, "local", "s3cr3t!"); + + var response = await Client.GetAsync( + $"/api/management/applications/{slug}/secrets/{secretName}" + ); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal(secretName, json["name"]!.GetValue()); + } + + [Fact] + public async Task SetSecretValue_ForNonexistentEnvironment_Returns404() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var secretName = TestHelpers.UniqueName("sec"); + await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets", + new { name = secretName } + ); + + var response = await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets/{secretName}/values", + new[] + { + new + { + environmentSlug = "no-such-env", + value = "x", + isEnabled = true, + }, + } + ); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetVersionHistory_ReturnsVersions_WithoutValues() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var secretName = TestHelpers.UniqueName("ver"); + await TestHelpers.CreateSecretWithValueAsync(Client, slug, secretName, "local", "v1"); + + var response = await Client.GetAsync( + $"/api/management/applications/{slug}/secrets/{secretName}/versions/local" + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var arr = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray(); + Assert.Single(arr); + Assert.Equal(1, arr[0]!["version"]!.GetValue()); + Assert.Null(arr[0]!["value"]); + } + + [Fact] + public async Task GetVersionHistory_IncrementsVersion_OnMultipleValueSets() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var secretName = TestHelpers.UniqueName("multi"); + await TestHelpers.CreateSecretWithValueAsync(Client, slug, secretName, "local", "v1"); + + await Client.PostAsJsonAsync( + $"/api/management/applications/{slug}/secrets/{secretName}/values", + new[] + { + new + { + environmentSlug = "local", + value = "v2", + isEnabled = true, + }, + } + ); + + var response = await Client.GetAsync( + $"/api/management/applications/{slug}/secrets/{secretName}/versions/local" + ); + var arr = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray(); + Assert.Equal(2, arr.Count); + } + + [Fact] + public async Task DeleteSecret_Returns204_AndThenNotFound() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var name = TestHelpers.UniqueName("del"); + await Client.PostAsJsonAsync($"/api/management/applications/{slug}/secrets", new { name }); + + var del = await Client.DeleteAsync($"/api/management/applications/{slug}/secrets/{name}"); + Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); + + var get = await Client.GetAsync($"/api/management/applications/{slug}/secrets/{name}"); + Assert.Equal(HttpStatusCode.NotFound, get.StatusCode); + } + + [Fact] + public async Task GetSecret_NotFound_Returns404() + { + var (slug, _) = await TestHelpers.CreateApplicationAsync(Client); + var response = await Client.GetAsync( + $"/api/management/applications/{slug}/secrets/no-such-secret" + ); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/Grimoire.IntegrationTests/TestHelpers.cs b/tests/Grimoire.IntegrationTests/TestHelpers.cs new file mode 100644 index 0000000..0e6aee5 --- /dev/null +++ b/tests/Grimoire.IntegrationTests/TestHelpers.cs @@ -0,0 +1,74 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Grimoire.IntegrationTests; + +public static class TestHelpers +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + public static string UniqueName(string prefix = "test") => $"{prefix}-{Guid.NewGuid():N}"[..24]; + + public static async Task<(string slug, string apiKey)> CreateApplicationAsync( + HttpClient mgmtClient, + string? name = null + ) + { + name ??= UniqueName("app"); + var response = await mgmtClient.PostAsJsonAsync( + "/api/management/applications", + new { name, description = "Test app" } + ); + response.EnsureSuccessStatusCode(); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + return (json["slug"]!.GetValue(), json["plainApiKey"]!.GetValue()); + } + + public static async Task CreateEnvironmentAsync( + HttpClient mgmtClient, + string appSlug, + string envName = "staging" + ) + { + var response = await mgmtClient.PostAsJsonAsync( + $"/api/management/applications/{appSlug}/environments", + new { name = envName } + ); + response.EnsureSuccessStatusCode(); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + return json["slug"]!.GetValue(); + } + + public static async Task CreateSecretWithValueAsync( + HttpClient mgmtClient, + string appSlug, + string secretName, + string envSlug, + string value + ) + { + var create = await mgmtClient.PostAsJsonAsync( + $"/api/management/applications/{appSlug}/secrets", + new { name = secretName, description = "test" } + ); + create.EnsureSuccessStatusCode(); + + var setValue = await mgmtClient.PostAsJsonAsync( + $"/api/management/applications/{appSlug}/secrets/{secretName}/values", + new[] + { + new + { + environmentSlug = envSlug, + value, + isEnabled = true, + }, + } + ); + setValue.EnsureSuccessStatusCode(); + } + + public static async Task ReadAsAsync(HttpResponseMessage response) => + (await response.Content.ReadFromJsonAsync(JsonOpts))!; +} diff --git a/tests/Grimoire.Tests/Grimoire.Tests.csproj b/tests/Grimoire.Tests/Grimoire.Tests.csproj index 91a3f68..06b4276 100644 --- a/tests/Grimoire.Tests/Grimoire.Tests.csproj +++ b/tests/Grimoire.Tests/Grimoire.Tests.csproj @@ -1,23 +1,25 @@ - net8.0 + net10.0 enable enable - false true - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - + + + + @@ -28,5 +30,6 @@ + diff --git a/tests/Grimoire.Tests/SlugServiceTests.cs b/tests/Grimoire.Tests/SlugServiceTests.cs new file mode 100644 index 0000000..debd260 --- /dev/null +++ b/tests/Grimoire.Tests/SlugServiceTests.cs @@ -0,0 +1,45 @@ +using Grimoire.Infrastructure.Services; +using Xunit; + +namespace Grimoire.Tests; + +public class SlugServiceTests +{ + [Theory] + [InlineData("My Application", "my-application")] + [InlineData("Hello World", "hello-world")] + [InlineData("ALL CAPS", "all-caps")] + [InlineData("already-slug", "already-slug")] + [InlineData("Special!@#Chars", "specialchars")] + [InlineData("Multiple Spaces", "multiple-spaces")] + [InlineData(" Leading Trailing ", "leading-trailing")] + [InlineData("123 Numbers", "123-numbers")] + [InlineData("a---b---c", "a-b-c")] + public void Generate_ProducesExpectedSlug(string input, string expected) + { + var result = SlugService.Generate(input); + Assert.Equal(expected, result); + } + + [Fact] + public void Generate_LowercasesAllCharacters() + { + var result = SlugService.Generate("ABC DEF"); + Assert.Equal(result, result.ToLowerInvariant()); + } + + [Fact] + public void Generate_NoLeadingOrTrailingDashes() + { + var result = SlugService.Generate(" test "); + Assert.False(result.StartsWith('-')); + Assert.False(result.EndsWith('-')); + } + + [Fact] + public void Generate_NoDuplicateDashes() + { + var result = SlugService.Generate("a b c"); + Assert.DoesNotContain("--", result); + } +} diff --git a/tests/Grimoire.Tests/ValidatorTests.cs b/tests/Grimoire.Tests/ValidatorTests.cs new file mode 100644 index 0000000..f69f40b --- /dev/null +++ b/tests/Grimoire.Tests/ValidatorTests.cs @@ -0,0 +1,156 @@ +using FluentValidation; +using Grimoire.Api.DTOs.Management; +using Grimoire.Api.Validators.Management; +using Xunit; + +namespace Grimoire.Tests; + +public class ValidatorTests +{ + private static void AssertValid(AbstractValidator v, T model) + { + var result = v.Validate(model); + Assert.True(result.IsValid, string.Join("; ", result.Errors.Select(e => e.ErrorMessage))); + } + + private static void AssertInvalidFor(AbstractValidator v, T model, string propertyName) + { + var result = v.Validate(model); + Assert.False(result.IsValid); + Assert.Contains( + result.Errors, + e => e.PropertyName.Equals(propertyName, StringComparison.OrdinalIgnoreCase) + ); + } + + [Fact] + public void CreateApplicationRequest_ValidInput_Passes() => + AssertValid( + new CreateApplicationRequestValidator(), + new CreateApplicationRequest("My App", "desc") + ); + + [Fact] + public void CreateApplicationRequest_EmptyName_FailsOnName() => + AssertInvalidFor( + new CreateApplicationRequestValidator(), + new CreateApplicationRequest("", null), + "Name" + ); + + [Fact] + public void CreateApplicationRequest_NameTooLong_FailsOnName() => + AssertInvalidFor( + new CreateApplicationRequestValidator(), + new CreateApplicationRequest(new string('a', 201), null), + "Name" + ); + + [Fact] + public void UpdateApplicationRequest_ValidInput_Passes() => + AssertValid( + new UpdateApplicationRequestValidator(), + new UpdateApplicationRequest("New Name", "desc") + ); + + [Fact] + public void UpdateApplicationRequest_EmptyName_Fails() => + AssertInvalidFor( + new UpdateApplicationRequestValidator(), + new UpdateApplicationRequest("", null), + "Name" + ); + + [Fact] + public void CreateEnvironmentRequest_EmptyName_FailsOnName() => + AssertInvalidFor( + new CreateEnvironmentRequestValidator(), + new CreateEnvironmentRequest(""), + "Name" + ); + + [Fact] + public void CreateEnvironmentRequest_ValidName_Passes() => + AssertValid( + new CreateEnvironmentRequestValidator(), + new CreateEnvironmentRequest("Production") + ); + + [Fact] + public void CreateSecretRequest_EmptyName_FailsOnName() => + AssertInvalidFor( + new CreateSecretRequestValidator(), + new CreateSecretRequest("", null), + "Name" + ); + + [Fact] + public void SetSecretValueRequest_EmptyEnvironmentSlug_Fails() => + AssertInvalidFor( + new SetSecretValueRequestValidator(), + new SetSecretValueRequest("", "value"), + "EnvironmentSlug" + ); + + [Fact] + public void SetSecretValueRequest_EmptyValue_Fails() => + AssertInvalidFor( + new SetSecretValueRequestValidator(), + new SetSecretValueRequest("local", ""), + "Value" + ); + + [Fact] + public void SetSecretValueRequest_ValidInput_Passes() => + AssertValid( + new SetSecretValueRequestValidator(), + new SetSecretValueRequest("local", "val", true) + ); + + [Fact] + public void SetSecretValueRequest_ExpiresBeforeNotBefore_FailsOnExpiresAt() + { + var now = DateTimeOffset.UtcNow; + AssertInvalidFor( + new SetSecretValueRequestValidator(), + new SetSecretValueRequest( + "local", + "v", + true, + ExpiresAt: now.AddDays(-1), + NotBefore: now + ), + "ExpiresAt" + ); + } + + [Fact] + public void CreateConfigurationRequest_EmptyKey_Fails() => + AssertInvalidFor( + new CreateConfigurationRequestValidator(), + new CreateConfigurationRequest("local", "", "value"), + "Key" + ); + + [Fact] + public void CreateConfigurationRequest_EmptyEnvironmentSlug_Fails() => + AssertInvalidFor( + new CreateConfigurationRequestValidator(), + new CreateConfigurationRequest("", "key", "value"), + "EnvironmentSlug" + ); + + [Fact] + public void CreateConfigurationRequest_ValidInput_Passes() => + AssertValid( + new CreateConfigurationRequestValidator(), + new CreateConfigurationRequest("local", "Feature:EnableX", "true") + ); + + [Fact] + public void UpdateConfigurationRequest_ValidInput_Passes() => + AssertValid( + new UpdateConfigurationRequestValidator(), + new UpdateConfigurationRequest("new-value") + ); +}