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")
+ );
+}