version-0.1 (#1)
Squash and merge of version-0.1 Co-authored-by: James Wampler <james@wamp.dev> Co-committed-by: James Wampler <james@wamp.dev>
This commit was merged in pull request #1.
This commit is contained in:
78
tests/api/MicCheck.Api.Tests.Integration/Common/FlagApiHttpClient.cs
Executable file
78
tests/api/MicCheck.Api.Tests.Integration/Common/FlagApiHttpClient.cs
Executable file
@@ -0,0 +1,78 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
|
||||
namespace MicCheck.Api.Tests.Integration.Common;
|
||||
|
||||
public sealed class FlagApiHttpClient : IDisposable
|
||||
{
|
||||
private static readonly string HttpFilesDirectory = Path.Combine(AppContext.BaseDirectory, "HttpFiles");
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly Dictionary<string, IReadOnlyDictionary<string, HttpFileRequest>> requestsByFile = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public FlagApiHttpClient(IntegrationTestSettings settings)
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
if (settings.AcceptAnyServerCertificate)
|
||||
handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
|
||||
|
||||
httpClient = new HttpClient(handler);
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendAsync(
|
||||
string httpFile,
|
||||
string requestName,
|
||||
IReadOnlyDictionary<string, string> variables,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var requests = LoadRequests(httpFile);
|
||||
if (!requests.TryGetValue(requestName, out var request))
|
||||
throw new InvalidOperationException(
|
||||
$"Request '{requestName}' was not found in '{httpFile}'. Known requests: {string.Join(", ", requests.Keys)}.");
|
||||
|
||||
var resolved = request.WithVariables(variables);
|
||||
var httpRequest = new HttpRequestMessage(new HttpMethod(resolved.Method), resolved.Url);
|
||||
|
||||
string? contentType = null;
|
||||
foreach (var header in resolved.Headers)
|
||||
{
|
||||
if (string.Equals(header.Name, "Content-Type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentType = header.Value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!httpRequest.Headers.TryAddWithoutValidation(header.Name, header.Value))
|
||||
throw new InvalidOperationException(
|
||||
$"Could not add header '{header.Name}' on request '{requestName}'.");
|
||||
}
|
||||
|
||||
if (resolved.Body is not null)
|
||||
{
|
||||
var content = new StringContent(resolved.Body, Encoding.UTF8);
|
||||
if (contentType is not null)
|
||||
content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
|
||||
httpRequest.Content = content;
|
||||
}
|
||||
|
||||
return await httpClient.SendAsync(httpRequest, ct);
|
||||
}
|
||||
|
||||
public void Dispose() => httpClient.Dispose();
|
||||
|
||||
private IReadOnlyDictionary<string, HttpFileRequest> LoadRequests(string httpFile)
|
||||
{
|
||||
if (requestsByFile.TryGetValue(httpFile, out var cached))
|
||||
return cached;
|
||||
|
||||
var path = Path.Combine(HttpFilesDirectory, httpFile);
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException(
|
||||
$"HTTP file '{httpFile}' was not found at '{path}'. Check the file is in HttpFiles/ and set to CopyToOutputDirectory.",
|
||||
path);
|
||||
|
||||
var parsed = HttpFileParser.Parse(File.ReadAllText(path));
|
||||
requestsByFile[httpFile] = parsed;
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
131
tests/api/MicCheck.Api.Tests.Integration/Common/HttpFile.cs
Executable file
131
tests/api/MicCheck.Api.Tests.Integration/Common/HttpFile.cs
Executable file
@@ -0,0 +1,131 @@
|
||||
using System.Text;
|
||||
|
||||
namespace MicCheck.Api.Tests.Integration.Common;
|
||||
|
||||
public sealed record HttpFileHeader(string Name, string Value);
|
||||
|
||||
public sealed record HttpFileRequest(string Name, string Method, string Url, IReadOnlyList<HttpFileHeader> Headers, string? Body)
|
||||
{
|
||||
public HttpFileRequest WithVariables(IReadOnlyDictionary<string, string> variables)
|
||||
{
|
||||
string Substitute(string input)
|
||||
{
|
||||
var result = input;
|
||||
foreach (var (key, value) in variables)
|
||||
result = result.Replace("{{" + key + "}}", value, StringComparison.Ordinal);
|
||||
return result;
|
||||
}
|
||||
|
||||
return new HttpFileRequest(
|
||||
Name,
|
||||
Method,
|
||||
Substitute(Url),
|
||||
Headers.Select(h => new HttpFileHeader(h.Name, Substitute(h.Value))).ToList(),
|
||||
Body is null ? null : Substitute(Body));
|
||||
}
|
||||
}
|
||||
|
||||
public static class HttpFileParser
|
||||
{
|
||||
public static IReadOnlyDictionary<string, HttpFileRequest> Parse(string content)
|
||||
{
|
||||
var requests = new Dictionary<string, HttpFileRequest>(StringComparer.OrdinalIgnoreCase);
|
||||
var blocks = SplitBlocks(content);
|
||||
|
||||
foreach (var block in blocks)
|
||||
{
|
||||
var parsed = ParseBlock(block);
|
||||
if (parsed is not null)
|
||||
requests[parsed.Name] = parsed;
|
||||
}
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
private static List<string> SplitBlocks(string content)
|
||||
{
|
||||
var blocks = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
|
||||
foreach (var rawLine in content.Split('\n'))
|
||||
{
|
||||
var line = rawLine.TrimEnd('\r');
|
||||
if (line.StartsWith("###", StringComparison.Ordinal))
|
||||
{
|
||||
if (current.Length > 0)
|
||||
{
|
||||
blocks.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
current.AppendLine(line);
|
||||
}
|
||||
else
|
||||
{
|
||||
current.AppendLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
blocks.Add(current.ToString());
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static HttpFileRequest? ParseBlock(string block)
|
||||
{
|
||||
var lines = block.Split('\n').Select(l => l.TrimEnd('\r')).ToList();
|
||||
if (lines.Count == 0)
|
||||
return null;
|
||||
|
||||
var nameLine = lines[0];
|
||||
if (!nameLine.StartsWith("###", StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
var name = nameLine.TrimStart('#').Trim();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return null;
|
||||
|
||||
var i = 1;
|
||||
while (i < lines.Count && string.IsNullOrWhiteSpace(lines[i]))
|
||||
i++;
|
||||
|
||||
if (i >= lines.Count)
|
||||
return null;
|
||||
|
||||
var requestLineParts = lines[i].Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (requestLineParts.Length < 2)
|
||||
throw new InvalidOperationException(
|
||||
$"Request '{name}' is missing a method and URL line.");
|
||||
|
||||
var method = requestLineParts[0];
|
||||
var url = requestLineParts[1];
|
||||
i++;
|
||||
|
||||
var headers = new List<HttpFileHeader>();
|
||||
while (i < lines.Count && !string.IsNullOrWhiteSpace(lines[i]))
|
||||
{
|
||||
var colon = lines[i].IndexOf(':');
|
||||
if (colon <= 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Request '{name}' has a malformed header line: '{lines[i]}'.");
|
||||
|
||||
headers.Add(new HttpFileHeader(
|
||||
lines[i][..colon].Trim(),
|
||||
lines[i][(colon + 1)..].Trim()));
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < lines.Count && string.IsNullOrWhiteSpace(lines[i]))
|
||||
i++;
|
||||
|
||||
string? body = null;
|
||||
if (i < lines.Count)
|
||||
{
|
||||
var bodyText = string.Join("\n", lines.Skip(i)).Trim();
|
||||
if (bodyText.Length > 0)
|
||||
body = bodyText;
|
||||
}
|
||||
|
||||
return new HttpFileRequest(name, method, url, headers, body);
|
||||
}
|
||||
}
|
||||
33
tests/api/MicCheck.Api.Tests.Integration/Common/IntegrationTestSettings.cs
Executable file
33
tests/api/MicCheck.Api.Tests.Integration/Common/IntegrationTestSettings.cs
Executable file
@@ -0,0 +1,33 @@
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Integration.Common;
|
||||
|
||||
public sealed record IntegrationTestSettings(string BaseUrl, string DbConnectionString, bool AcceptAnyServerCertificate)
|
||||
{
|
||||
public static IntegrationTestSettings Load()
|
||||
{
|
||||
var baseUrl = Resolve("BaseUrl", "MICCHECK_API_BASE_URL")
|
||||
?? throw new InvalidOperationException(
|
||||
"BaseUrl is not set. Provide it via .runsettings (TestRunParameters.BaseUrl) or the MICCHECK_API_BASE_URL environment variable.");
|
||||
|
||||
var connectionString = Resolve("DbConnectionString", "MICCHECK_DB_CONNECTION_STRING")
|
||||
?? throw new InvalidOperationException(
|
||||
"DbConnectionString is not set. Provide it via .runsettings (TestRunParameters.DbConnectionString) or the MICCHECK_DB_CONNECTION_STRING environment variable.");
|
||||
|
||||
var acceptAnyCert = bool.TryParse(
|
||||
Resolve("AcceptAnyServerCertificate", "MICCHECK_ACCEPT_ANY_SERVER_CERTIFICATE"),
|
||||
out var parsed) && parsed;
|
||||
|
||||
return new IntegrationTestSettings(baseUrl.TrimEnd('/'), connectionString, acceptAnyCert);
|
||||
}
|
||||
|
||||
private static string? Resolve(string runSettingsParameter, string environmentVariable)
|
||||
{
|
||||
var fromEnv = System.Environment.GetEnvironmentVariable(environmentVariable);
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||
return fromEnv;
|
||||
|
||||
var fromRunSettings = TestContext.Parameters.Get(runSettingsParameter);
|
||||
return string.IsNullOrWhiteSpace(fromRunSettings) ? null : fromRunSettings;
|
||||
}
|
||||
}
|
||||
68
tests/api/MicCheck.Api.Tests.Integration/Common/TestDatabase.cs
Executable file
68
tests/api/MicCheck.Api.Tests.Integration/Common/TestDatabase.cs
Executable file
@@ -0,0 +1,68 @@
|
||||
using System.Data;
|
||||
using System.Text;
|
||||
using Npgsql;
|
||||
|
||||
namespace MicCheck.Api.Tests.Integration.Common;
|
||||
|
||||
public sealed class TestDatabase(string connectionString)
|
||||
{
|
||||
public async Task<T> ExecuteScalarAsync<T>(string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
await using var connection = await OpenAsync();
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
if (result is null || result is DBNull)
|
||||
throw new InvalidOperationException($"Scalar query returned NULL. SQL: {sql}");
|
||||
return (T)Convert.ChangeType(result, typeof(T), System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
await using var connection = await OpenAsync();
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
public async Task<string> SnapshotRowsAsync(string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
await using var connection = await OpenAsync();
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
|
||||
var output = new StringBuilder();
|
||||
output.AppendLine($"SQL: {sql}");
|
||||
|
||||
if (!reader.HasRows)
|
||||
{
|
||||
output.AppendLine("(no rows)");
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
var columnNames = Enumerable.Range(0, reader.FieldCount).Select(reader.GetName).ToList();
|
||||
output.AppendLine(string.Join(" | ", columnNames));
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
var values = Enumerable.Range(0, reader.FieldCount)
|
||||
.Select(i => reader.IsDBNull(i) ? "NULL" : reader.GetValue(i).ToString() ?? "");
|
||||
output.AppendLine(string.Join(" | ", values));
|
||||
}
|
||||
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
private async Task<NpgsqlConnection> OpenAsync()
|
||||
{
|
||||
var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static NpgsqlCommand CreateCommand(NpgsqlConnection connection, string sql, (string Name, object Value)[] parameters)
|
||||
{
|
||||
var command = new NpgsqlCommand(sql, connection);
|
||||
foreach (var (name, value) in parameters)
|
||||
command.Parameters.AddWithValue(name, value ?? DBNull.Value);
|
||||
return command;
|
||||
}
|
||||
}
|
||||
81
tests/api/MicCheck.Api.Tests.Integration/Flags/FlagEnabledTests.cs
Executable file
81
tests/api/MicCheck.Api.Tests.Integration/Flags/FlagEnabledTests.cs
Executable file
@@ -0,0 +1,81 @@
|
||||
using System.Text.Json;
|
||||
using MicCheck.Api.Tests.Integration.Common;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Integration.Flags;
|
||||
|
||||
[TestFixture]
|
||||
public class FlagEnabledTests
|
||||
{
|
||||
private IntegrationTestSettings settings = null!;
|
||||
private TestDatabase db = null!;
|
||||
private FlagApiHttpClient client = null!;
|
||||
private FlagSeed? seed;
|
||||
|
||||
[OneTimeSetUp]
|
||||
public void OneTimeSetUp()
|
||||
{
|
||||
settings = IntegrationTestSettings.Load();
|
||||
db = new TestDatabase(settings.DbConnectionString);
|
||||
client = new FlagApiHttpClient(settings);
|
||||
}
|
||||
|
||||
[OneTimeTearDown]
|
||||
public void OneTimeTearDown() => client.Dispose();
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
=> seed = await FlagSeed.InsertEnabledFlagAsync(db, testRunTag: "FlagEnabled", enabled: true, value: null);
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown()
|
||||
{
|
||||
if (seed is not null)
|
||||
{
|
||||
await seed.CleanupAsync(db);
|
||||
seed = null;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAFeatureIsEnabledInEnvironment_ThenTheFlagApiReportsItEnabled()
|
||||
{
|
||||
var current = seed!;
|
||||
|
||||
using var response = await client.SendAsync(
|
||||
httpFile: "Flags.http",
|
||||
requestName: "GetFlags",
|
||||
variables: new Dictionary<string, string>
|
||||
{
|
||||
["baseUrl"] = settings.BaseUrl,
|
||||
["environmentKey"] = current.EnvironmentApiKey
|
||||
});
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(
|
||||
response.IsSuccessStatusCode,
|
||||
Is.True,
|
||||
$"GET /api/v1/flags returned {(int)response.StatusCode} {response.ReasonPhrase}. Body: {body}. DB state:\n{await current.DescribeAsync(db)}");
|
||||
|
||||
var flags = JsonSerializer.Deserialize<List<FlagResponseDto>>(body, JsonOptions)
|
||||
?? throw new InvalidOperationException("Response body was not a JSON array.");
|
||||
|
||||
var match = flags.SingleOrDefault(f => f.Feature.Name == current.FeatureName);
|
||||
|
||||
Assert.That(
|
||||
match,
|
||||
Is.Not.Null,
|
||||
$"Feature '{current.FeatureName}' was not present in the flags response. Returned features: [{string.Join(", ", flags.Select(f => f.Feature.Name))}]. DB state:\n{await current.DescribeAsync(db)}");
|
||||
|
||||
Assert.That(
|
||||
match!.Enabled,
|
||||
Is.True,
|
||||
$"Feature '{current.FeatureName}' was reported as disabled, but the seeded FeatureState has Enabled=true. DB state:\n{await current.DescribeAsync(db)}");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private sealed record FlagResponseDto(int Id, FeatureSummaryDto Feature, bool Enabled, string? FeatureStateValue);
|
||||
private sealed record FeatureSummaryDto(int Id, string Name);
|
||||
}
|
||||
70
tests/api/MicCheck.Api.Tests.Integration/Flags/FlagSeed.cs
Executable file
70
tests/api/MicCheck.Api.Tests.Integration/Flags/FlagSeed.cs
Executable file
@@ -0,0 +1,70 @@
|
||||
using MicCheck.Api.Tests.Integration.Common;
|
||||
|
||||
namespace MicCheck.Api.Tests.Integration.Flags;
|
||||
|
||||
public sealed record FlagSeed(int OrganizationId, int ProjectId, int EnvironmentId, int FeatureId, int FeatureStateId, string EnvironmentApiKey, string FeatureName)
|
||||
{
|
||||
public static async Task<FlagSeed> InsertEnabledFlagAsync(TestDatabase db, string testRunTag, bool enabled, string? value)
|
||||
{
|
||||
var unique = $"{testRunTag}-{Guid.NewGuid():N}"[..40];
|
||||
var environmentApiKey = $"envkey_{Guid.NewGuid():N}";
|
||||
var featureName = $"feature_{unique}";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var organizationId = await db.ExecuteScalarAsync<int>(
|
||||
"INSERT INTO \"Organizations\" (\"Name\", \"CreatedAt\") VALUES (@name, @createdAt) RETURNING \"Id\"",
|
||||
("name", $"org_{unique}"),
|
||||
("createdAt", now));
|
||||
|
||||
var projectId = await db.ExecuteScalarAsync<int>(
|
||||
"INSERT INTO \"Projects\" (\"Name\", \"OrganizationId\", \"CreatedAt\", \"HideDisabledFlags\") VALUES (@name, @orgId, @createdAt, false) RETURNING \"Id\"",
|
||||
("name", $"project_{unique}"),
|
||||
("orgId", organizationId),
|
||||
("createdAt", now));
|
||||
|
||||
var environmentId = await db.ExecuteScalarAsync<int>(
|
||||
"INSERT INTO \"Environments\" (\"Name\", \"ApiKey\", \"ProjectId\", \"CreatedAt\") VALUES (@name, @apiKey, @projectId, @createdAt) RETURNING \"Id\"",
|
||||
("name", $"env_{unique}"),
|
||||
("apiKey", environmentApiKey),
|
||||
("projectId", projectId),
|
||||
("createdAt", now));
|
||||
|
||||
var featureId = await db.ExecuteScalarAsync<int>(
|
||||
"INSERT INTO \"Features\" (\"Name\", \"Type\", \"DefaultEnabled\", \"ProjectId\", \"CreatedAt\") VALUES (@name, 0, @defaultEnabled, @projectId, @createdAt) RETURNING \"Id\"",
|
||||
("name", featureName),
|
||||
("defaultEnabled", enabled),
|
||||
("projectId", projectId),
|
||||
("createdAt", now));
|
||||
|
||||
var featureStateId = await db.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
INSERT INTO "FeatureStates" ("FeatureId", "EnvironmentId", "Enabled", "Value", "CreatedAt", "UpdatedAt", "Version")
|
||||
VALUES (@featureId, @environmentId, @enabled, @value, @createdAt, @updatedAt, 1)
|
||||
RETURNING "Id"
|
||||
""",
|
||||
("featureId", featureId),
|
||||
("environmentId", environmentId),
|
||||
("enabled", enabled),
|
||||
("value", (object?)value ?? DBNull.Value),
|
||||
("createdAt", now),
|
||||
("updatedAt", now));
|
||||
|
||||
return new FlagSeed(organizationId, projectId, environmentId, featureId, featureStateId, environmentApiKey, featureName);
|
||||
}
|
||||
|
||||
public async Task CleanupAsync(TestDatabase db)
|
||||
=> await db.ExecuteAsync(
|
||||
"DELETE FROM \"Organizations\" WHERE \"Id\" = @orgId",
|
||||
("orgId", OrganizationId));
|
||||
|
||||
public async Task<string> DescribeAsync(TestDatabase db)
|
||||
=> await db.SnapshotRowsAsync(
|
||||
"""
|
||||
SELECT f."Id" AS "FeatureId", f."Name", f."DefaultEnabled",
|
||||
fs."Id" AS "FeatureStateId", fs."Enabled", fs."Value", fs."EnvironmentId"
|
||||
FROM "Features" f
|
||||
JOIN "FeatureStates" fs ON fs."FeatureId" = f."Id"
|
||||
WHERE f."Id" = @featureId
|
||||
""",
|
||||
("featureId", FeatureId));
|
||||
}
|
||||
4
tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Flags.http
Executable file
4
tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Flags.http
Executable file
@@ -0,0 +1,4 @@
|
||||
### GetFlags
|
||||
GET {{baseUrl}}/api/v1/flags
|
||||
X-Environment-Key: {{environmentKey}}
|
||||
Accept: application/json
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.1" />
|
||||
<PackageReference Include="NUnit" Version="4.5.1" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="HttpFiles\**\*.http">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="local.runsettings">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
8
tests/api/MicCheck.Api.Tests.Integration/local.runsettings
Executable file
8
tests/api/MicCheck.Api.Tests.Integration/local.runsettings
Executable file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RunSettings>
|
||||
<TestRunParameters>
|
||||
<Parameter name="BaseUrl" value="http://localhost:5000" />
|
||||
<Parameter name="DbConnectionString" value="Host=localhost;Port=5432;Database=miccheck;Username=miccheck;Password=password" />
|
||||
<Parameter name="AcceptAnyServerCertificate" value="true" />
|
||||
</TestRunParameters>
|
||||
</RunSettings>
|
||||
38
tests/api/MicCheck.Api.Tests.Integration/readme.md
Executable file
38
tests/api/MicCheck.Api.Tests.Integration/readme.md
Executable file
@@ -0,0 +1,38 @@
|
||||
# MicCheck.Api.Tests.Integration
|
||||
|
||||
Black-box integration tests. Targets a running MicCheck.Api instance by URL and seeds/cleans data directly in the Postgres database.
|
||||
|
||||
## Running against an environment
|
||||
|
||||
Two settings are required: `BaseUrl` and `DbConnectionString`.
|
||||
|
||||
### Via .runsettings (recommended for local + CI)
|
||||
|
||||
```
|
||||
dotnet test --settings local.runsettings
|
||||
```
|
||||
|
||||
Copy `local.runsettings` to e.g. `staging.runsettings` and override the values for other environments.
|
||||
|
||||
### Via command-line
|
||||
|
||||
```
|
||||
dotnet test -- TestRunParameters.Parameter\(name=\"BaseUrl\",value=\"https://staging.example.com\"\) ^
|
||||
TestRunParameters.Parameter\(name=\"DbConnectionString\",value=\"Host=...\"\)
|
||||
```
|
||||
|
||||
### Via environment variables (overrides .runsettings)
|
||||
|
||||
```
|
||||
MICCHECK_API_BASE_URL=...
|
||||
MICCHECK_DB_CONNECTION_STRING=...
|
||||
MICCHECK_ACCEPT_ANY_SERVER_CERTIFICATE=true|false
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
- Tests seed their own data **directly in the database**, never through the API.
|
||||
- Per-test data is seeded in `[SetUp]`. Reusable cross-test data goes in `[OneTimeSetUp]`.
|
||||
- Cleanup runs in `[TearDown]` / `[OneTimeTearDown]`, also directly in the database.
|
||||
- HTTP requests are defined in `HttpFiles/*.http` and executed via `FlagApiHttpClient`.
|
||||
- Assertion failure messages include a fresh snapshot of the relevant rows in the database.
|
||||
536
tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs
Normal file → Executable file
536
tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs
Normal file → Executable file
@@ -1,268 +1,268 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Projects;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Admin;
|
||||
|
||||
[TestFixture]
|
||||
public class AdminApiIntegrationTests
|
||||
{
|
||||
private MicCheckWebApplicationFactory _factory = null!;
|
||||
private string _rawApiKey = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_factory = new MicCheckWebApplicationFactory();
|
||||
_rawApiKey = SeedOrganizationWithApiKey();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _factory.Dispose();
|
||||
|
||||
private string SeedOrganizationWithApiKey()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
|
||||
var org = new Organization { Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow };
|
||||
db.Organizations.Add(org);
|
||||
db.SaveChanges();
|
||||
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
||||
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
|
||||
|
||||
db.ApiKeys.Add(new ApiKey
|
||||
{
|
||||
Key = hashedKey,
|
||||
Prefix = prefix,
|
||||
Name = "Test Key",
|
||||
OrganizationId = org.Id,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
return rawKey;
|
||||
}
|
||||
|
||||
private HttpClient CreateAuthenticatedClient()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {_rawApiKey}");
|
||||
return client;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingAProject_ThenProjectIsReturned()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/projects", new
|
||||
{
|
||||
name = "My Project",
|
||||
organizationId = org.Id
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingAFeature_ThenFeatureIsReturned()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = "My Project",
|
||||
OrganizationId = org.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
db.SaveChanges();
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
|
||||
{
|
||||
name = "dark_mode",
|
||||
type = "Standard",
|
||||
initialValue = (string?)null,
|
||||
description = (string?)null
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingAFeature_ThenFeatureStateIsAutoCreatedForEnvironments()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = "My Project",
|
||||
OrganizationId = org.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
db.SaveChanges();
|
||||
|
||||
db.Environments.Add(new AppEnvironment
|
||||
{
|
||||
Name = "Production",
|
||||
ApiKey = "env-key-prod",
|
||||
ProjectId = project.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
|
||||
{
|
||||
name = "flag_x",
|
||||
type = "Standard",
|
||||
initialValue = (string?)null,
|
||||
description = (string?)null
|
||||
});
|
||||
|
||||
using var verifyScope = _factory.Services.CreateScope();
|
||||
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var feature = verifyDb.Features.First(f => f.Name == "flag_x");
|
||||
var states = verifyDb.FeatureStates.Where(fs => fs.FeatureId == feature.Id).ToList();
|
||||
|
||||
Assert.That(states, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsAutoCreatedForExistingFeatures()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = "My Project",
|
||||
OrganizationId = org.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
db.Features.Add(new Feature
|
||||
{
|
||||
Name = "existing_flag",
|
||||
ProjectId = project.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/environments", new
|
||||
{
|
||||
name = "Staging",
|
||||
projectId = project.Id
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
|
||||
using var verifyScope = _factory.Services.CreateScope();
|
||||
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var feature = verifyDb.Features.First(f => f.Name == "existing_flag");
|
||||
var env = verifyDb.Environments.First(e => e.ProjectId == project.Id);
|
||||
var states = verifyDb.FeatureStates.Where(fs => fs.EnvironmentId == env.Id && fs.FeatureId == feature.Id).ToList();
|
||||
|
||||
Assert.That(states, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenRequestIsMissingApiKey_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/projects?organizationId=1");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingAFeatureWithInvalidName_ThenUnprocessableEntityIsReturned()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = "My Project",
|
||||
OrganizationId = org.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
db.SaveChanges();
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
|
||||
{
|
||||
name = "invalid name with spaces!",
|
||||
type = "Standard"
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingASegment_ThenSegmentIsReturned()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = "My Project",
|
||||
OrganizationId = org.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
db.SaveChanges();
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/segments", new
|
||||
{
|
||||
name = "Premium Users",
|
||||
rules = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "All",
|
||||
conditions = new[]
|
||||
{
|
||||
new { property = "plan", @operator = "Equal", value = "premium" }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
}
|
||||
}
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Projects;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Admin;
|
||||
|
||||
[TestFixture]
|
||||
public class AdminApiIntegrationTests
|
||||
{
|
||||
private MicCheckWebApplicationFactory _factory = null!;
|
||||
private string _rawApiKey = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_factory = new MicCheckWebApplicationFactory();
|
||||
_rawApiKey = SeedOrganizationWithApiKey();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _factory.Dispose();
|
||||
|
||||
private string SeedOrganizationWithApiKey()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
|
||||
var org = new Organization { Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow };
|
||||
db.Organizations.Add(org);
|
||||
db.SaveChanges();
|
||||
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
||||
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
|
||||
|
||||
db.ApiKeys.Add(new ApiKey
|
||||
{
|
||||
Key = hashedKey,
|
||||
Prefix = prefix,
|
||||
Name = "Test Key",
|
||||
OrganizationId = org.Id,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
return rawKey;
|
||||
}
|
||||
|
||||
private HttpClient CreateAuthenticatedClient()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {_rawApiKey}");
|
||||
return client;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingAProject_ThenProjectIsReturned()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/projects", new
|
||||
{
|
||||
name = "My Project",
|
||||
organizationId = org.Id
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingAFeature_ThenFeatureIsReturned()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = "My Project",
|
||||
OrganizationId = org.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
db.SaveChanges();
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new
|
||||
{
|
||||
name = "dark_mode",
|
||||
type = "Standard",
|
||||
initialValue = (string?)null,
|
||||
description = (string?)null
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingAFeature_ThenFeatureStateIsAutoCreatedForEnvironments()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = "My Project",
|
||||
OrganizationId = org.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
db.SaveChanges();
|
||||
|
||||
db.Environments.Add(new AppEnvironment
|
||||
{
|
||||
Name = "Production",
|
||||
ApiKey = "env-key-prod",
|
||||
ProjectId = project.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new
|
||||
{
|
||||
name = "flag_x",
|
||||
type = "Standard",
|
||||
initialValue = (string?)null,
|
||||
description = (string?)null
|
||||
});
|
||||
|
||||
using var verifyScope = _factory.Services.CreateScope();
|
||||
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var feature = verifyDb.Features.First(f => f.Name == "flag_x");
|
||||
var states = verifyDb.FeatureStates.Where(fs => fs.FeatureId == feature.Id).ToList();
|
||||
|
||||
Assert.That(states, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsAutoCreatedForExistingFeatures()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = "My Project",
|
||||
OrganizationId = org.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
db.Features.Add(new Feature
|
||||
{
|
||||
Name = "existing_flag",
|
||||
ProjectId = project.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/environments", new
|
||||
{
|
||||
name = "Staging",
|
||||
projectId = project.Id
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
|
||||
using var verifyScope = _factory.Services.CreateScope();
|
||||
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var feature = verifyDb.Features.First(f => f.Name == "existing_flag");
|
||||
var env = verifyDb.Environments.First(e => e.ProjectId == project.Id);
|
||||
var states = verifyDb.FeatureStates.Where(fs => fs.EnvironmentId == env.Id && fs.FeatureId == feature.Id).ToList();
|
||||
|
||||
Assert.That(states, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenRequestIsMissingApiKey_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/projects?organizationId=1");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingAFeatureWithInvalidName_ThenUnprocessableEntityIsReturned()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = "My Project",
|
||||
OrganizationId = org.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
db.SaveChanges();
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new
|
||||
{
|
||||
name = "invalid name with spaces!",
|
||||
type = "Standard"
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenCreatingASegment_ThenSegmentIsReturned()
|
||||
{
|
||||
var client = CreateAuthenticatedClient();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var org = db.Organizations.First();
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = "My Project",
|
||||
OrganizationId = org.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
db.SaveChanges();
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/segments", new
|
||||
{
|
||||
name = "Premium Users",
|
||||
rules = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "All",
|
||||
conditions = new[]
|
||||
{
|
||||
new { property = "plan", @operator = "Equal", value = "premium" }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
}
|
||||
}
|
||||
|
||||
2
tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyHasherTests.cs
Normal file → Executable file
2
tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyHasherTests.cs
Normal file → Executable file
@@ -1,4 +1,4 @@
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.ApiKeys;
|
||||
|
||||
2
tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyTests.cs
Normal file → Executable file
2
tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyTests.cs
Normal file → Executable file
@@ -1,5 +1,5 @@
|
||||
using NUnit.Framework;
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.ApiKeys;
|
||||
|
||||
|
||||
0
tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs
Normal file → Executable file
14
tests/api/MicCheck.Api.Tests.Unit/Authentication/ApiKeyAuthenticationHandlerTests.cs
Normal file → Executable file
14
tests/api/MicCheck.Api.Tests.Unit/Authentication/ApiKeyAuthenticationHandlerTests.cs
Normal file → Executable file
@@ -1,6 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
@@ -46,7 +46,7 @@ public class ApiKeyAuthenticationHandlerTests
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public class ApiKeyAuthenticationHandlerTests
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", "not-a-jwt");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
@@ -69,7 +69,7 @@ public class ApiKeyAuthenticationHandlerTests
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
@@ -95,7 +95,7 @@ public class ApiKeyAuthenticationHandlerTests
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
@@ -122,7 +122,7 @@ public class ApiKeyAuthenticationHandlerTests
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
@@ -135,7 +135,7 @@ public class ApiKeyAuthenticationHandlerTests
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
2
tests/api/MicCheck.Api.Tests.Unit/Authorization/AuthEndpointsTests.cs
Normal file → Executable file
2
tests/api/MicCheck.Api.Tests.Unit/Authorization/AuthEndpointsTests.cs
Normal file → Executable file
@@ -1,6 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Authorization;
|
||||
|
||||
2
tests/api/MicCheck.Api.Tests.Unit/Authorization/ProjectPermissionRequirementHandlerTests.cs
Normal file → Executable file
2
tests/api/MicCheck.Api.Tests.Unit/Authorization/ProjectPermissionRequirementHandlerTests.cs
Normal file → Executable file
@@ -1,6 +1,6 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
2
tests/api/MicCheck.Api.Tests.Unit/Authorization/TokenServiceTests.cs
Normal file → Executable file
2
tests/api/MicCheck.Api.Tests.Unit/Authorization/TokenServiceTests.cs
Normal file → Executable file
@@ -1,5 +1,5 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Common.Security.ApiKeys;
|
||||
|
||||
[TestFixture]
|
||||
public class ApiKeyHasherTests
|
||||
{
|
||||
[Test]
|
||||
public void WhenKeyIsHashed_ThenHashIsDeterministic()
|
||||
{
|
||||
var key = "test-api-key";
|
||||
|
||||
var hash1 = ApiKeyHasher.Hash(key);
|
||||
var hash2 = ApiKeyHasher.Hash(key);
|
||||
|
||||
Assert.That(hash1, Is.EqualTo(hash2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenKeyIsHashed_ThenHashIsLowercaseHex()
|
||||
{
|
||||
var hash = ApiKeyHasher.Hash("any-key");
|
||||
|
||||
Assert.That(hash, Does.Match("^[0-9a-f]{64}$"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenDifferentKeysAreHashed_ThenHashesDiffer()
|
||||
{
|
||||
var hash1 = ApiKeyHasher.Hash("key-one");
|
||||
var hash2 = ApiKeyHasher.Hash("key-two");
|
||||
|
||||
Assert.That(hash1, Is.Not.EqualTo(hash2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenGenerateKeyIsCalled_ThenKeyIsNotEmpty()
|
||||
{
|
||||
var key = ApiKeyHasher.GenerateKey();
|
||||
|
||||
Assert.That(key, Is.Not.Null.And.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenGenerateKeyIsCalled_ThenEachKeyIsUnique()
|
||||
{
|
||||
var key1 = ApiKeyHasher.GenerateKey();
|
||||
var key2 = ApiKeyHasher.GenerateKey();
|
||||
|
||||
Assert.That(key1, Is.Not.EqualTo(key2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenGenerateKeyIsCalled_ThenKeyIsBase64UrlSafe()
|
||||
{
|
||||
var key = ApiKeyHasher.GenerateKey();
|
||||
|
||||
Assert.That(key, Does.Not.Contain("+"));
|
||||
Assert.That(key, Does.Not.Contain("/"));
|
||||
Assert.That(key, Does.Not.Contain("="));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenGenerateKeyIsHashed_ThenHashCanBeUsedToVerify()
|
||||
{
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
var hash = ApiKeyHasher.Hash(rawKey);
|
||||
|
||||
Assert.That(ApiKeyHasher.Hash(rawKey), Is.EqualTo(hash));
|
||||
}
|
||||
}
|
||||
53
tests/api/MicCheck.Api.Tests.Unit/Common/Security/ApiKeys/ApiKeyTests.cs
Executable file
53
tests/api/MicCheck.Api.Tests.Unit/Common/Security/ApiKeys/ApiKeyTests.cs
Executable file
@@ -0,0 +1,53 @@
|
||||
using NUnit.Framework;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Common.Security.ApiKeys;
|
||||
|
||||
[TestFixture]
|
||||
public class ApiKeyTests
|
||||
{
|
||||
[Test]
|
||||
public void WhenAnApiKeyIsCreated_ThenRequiredFieldsAreSet()
|
||||
{
|
||||
var apiKey = new ApiKey
|
||||
{
|
||||
Key = "hashed-value",
|
||||
Prefix = "abc12345",
|
||||
Name = "CI Pipeline Key",
|
||||
OrganizationId = 1
|
||||
};
|
||||
|
||||
Assert.That(apiKey.Key, Is.EqualTo("hashed-value"));
|
||||
Assert.That(apiKey.Prefix, Is.EqualTo("hashed-value".Substring(0, 8)).Or.EqualTo("abc12345"));
|
||||
Assert.That(apiKey.Name, Is.EqualTo("CI Pipeline Key"));
|
||||
Assert.That(apiKey.OrganizationId, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenAnApiKeyIsCreated_ThenIsActiveDefaultsToFalse()
|
||||
{
|
||||
var apiKey = new ApiKey
|
||||
{
|
||||
Key = "hashed-value",
|
||||
Prefix = "abc12345",
|
||||
Name = "Test Key",
|
||||
OrganizationId = 1
|
||||
};
|
||||
|
||||
Assert.That(apiKey.IsActive, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenAnApiKeyIsCreated_ThenExpiresAtDefaultsToNull()
|
||||
{
|
||||
var apiKey = new ApiKey
|
||||
{
|
||||
Key = "hashed-value",
|
||||
Prefix = "abc12345",
|
||||
Name = "Test Key",
|
||||
OrganizationId = 1
|
||||
};
|
||||
|
||||
Assert.That(apiKey.ExpiresAt, Is.Null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Common.Security.Authentication;
|
||||
|
||||
[TestFixture]
|
||||
public class ApiKeyAuthenticationHandlerTests
|
||||
{
|
||||
private MicCheckWebApplicationFactory _factory = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_factory = new MicCheckWebApplicationFactory();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _factory.Dispose();
|
||||
|
||||
private async Task<string> SeedActiveApiKeyAsync(int organizationId = 1)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
db.ApiKeys.Add(new ApiKey
|
||||
{
|
||||
Key = ApiKeyHasher.Hash(rawKey),
|
||||
Prefix = rawKey[..8],
|
||||
Name = "Test Key",
|
||||
OrganizationId = organizationId,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return rawKey;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAuthorizationHeaderIsAbsent_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAuthorizationHeaderIsNotApiKeyScheme_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", "not-a-jwt");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenApiKeyIsInvalid_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenApiKeyIsInactive_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
db.ApiKeys.Add(new ApiKey
|
||||
{
|
||||
Key = ApiKeyHasher.Hash(rawKey),
|
||||
Prefix = rawKey[..8],
|
||||
Name = "Inactive Key",
|
||||
OrganizationId = 1,
|
||||
IsActive = false,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenApiKeyIsExpired_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
db.ApiKeys.Add(new ApiKey
|
||||
{
|
||||
Key = ApiKeyHasher.Hash(rawKey),
|
||||
Prefix = rawKey[..8],
|
||||
Name = "Expired Key",
|
||||
OrganizationId = 1,
|
||||
IsActive = true,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-10)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenApiKeyIsValid_ThenOkIsReturned()
|
||||
{
|
||||
var rawKey = await SeedActiveApiKeyAsync(organizationId: 1);
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization;
|
||||
|
||||
[TestFixture]
|
||||
public class AuthEndpointsTests
|
||||
{
|
||||
private MicCheckWebApplicationFactory _factory = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_factory = new MicCheckWebApplicationFactory();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _factory.Dispose();
|
||||
|
||||
[Test]
|
||||
public async Task WhenRegisteringWithValidDetails_ThenOkIsReturnedWithTokens()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"alice@example.com", "SecurePass1!", "Alice", "Smith", "Acme Corp"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(body?.RefreshToken, Is.Not.Null.And.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenRegisteringWithDuplicateEmail_ThenConflictIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"alice@example.com", "SecurePass1!", "Alice", "Smith", "Acme Corp"));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"alice@example.com", "AnotherPass1!", "Alice", "Jones", "Other Corp"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoginWithValidCredentials_ThenOkIsReturnedWithTokens()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"bob@example.com", "SecurePass1!", "Bob", "Jones", "BobCo"));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("bob@example.com", "SecurePass1!"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(body?.RefreshToken, Is.Not.Null.And.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoginWithWrongPassword_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"carol@example.com", "CorrectPass1!", "Carol", "White", "CarolCo"));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("carol@example.com", "WrongPassword"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoginWithUnknownEmail_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("nobody@example.com", "SomePass1!"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoginWithEmptyEmail_ThenBadRequestIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("", "SomePass1!"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenRefreshingWithValidToken_ThenOkIsReturnedWithNewTokens()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"dave@example.com", "SecurePass1!", "Dave", "Brown", "DaveCo"));
|
||||
var registerBody = await registerResponse.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||
new RefreshRequest(registerBody!.RefreshToken));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(body?.RefreshToken, Is.Not.EqualTo(registerBody.RefreshToken));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenRefreshingWithInvalidToken_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||
new RefreshRequest("not-a-valid-refresh-token"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenRefreshingWithRevokedToken_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"eve@example.com", "SecurePass1!", "Eve", "Davis", "EveCo"));
|
||||
var registerBody = await registerResponse.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
|
||||
await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||
new RefreshRequest(registerBody!.RefreshToken));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||
new RefreshRequest(registerBody.RefreshToken));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoggingOut_ThenNoContentIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"frank@example.com", "SecurePass1!", "Frank", "Green", "FrankCo"));
|
||||
var registerBody = await registerResponse.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/logout",
|
||||
new LogoutRequest(registerBody!.RefreshToken));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoggingOutAndRefreshing_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"grace@example.com", "SecurePass1!", "Grace", "Black", "GraceCo"));
|
||||
var registerBody = await registerResponse.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
|
||||
await client.PostAsJsonAsync("/api/v1/auth/logout",
|
||||
new LogoutRequest(registerBody!.RefreshToken));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||
new RefreshRequest(registerBody.RefreshToken));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization;
|
||||
|
||||
[TestFixture]
|
||||
public class ProjectPermissionRequirementHandlerTests
|
||||
{
|
||||
private MicCheckDbContext _db = null!;
|
||||
private Mock<IHttpContextAccessor> _httpContextAccessor = null!;
|
||||
private ProjectPermissionRequirementHandler _handler = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
|
||||
_httpContextAccessor = new Mock<IHttpContextAccessor>();
|
||||
_handler = new ProjectPermissionRequirementHandler(_db, _httpContextAccessor.Object);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
|
||||
private static AuthorizationHandlerContext CreateContext(
|
||||
ClaimsPrincipal user,
|
||||
ProjectPermission permission = ProjectPermission.ViewProject)
|
||||
{
|
||||
var requirement = new ProjectPermissionRequirement(permission);
|
||||
return new AuthorizationHandlerContext([requirement], user, null);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreateUserWithClaims(params Claim[] claims)
|
||||
{
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer"));
|
||||
}
|
||||
|
||||
private void SetupRouteProjectId(int projectId)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.RouteValues["projectId"] = projectId.ToString();
|
||||
_httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserIsOrganizationAdmin_ThenRequirementSucceeds()
|
||||
{
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "1"),
|
||||
new Claim("OrganizationRole", "Admin"));
|
||||
|
||||
var context = CreateContext(user);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserHasRequiredPermission_ThenRequirementSucceeds()
|
||||
{
|
||||
_db.UserProjectPermissions.Add(new UserProjectPermission
|
||||
{
|
||||
UserId = 1,
|
||||
ProjectId = 10,
|
||||
Permissions = [ProjectPermission.ViewProject]
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
SetupRouteProjectId(10);
|
||||
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "1"));
|
||||
|
||||
var context = CreateContext(user, ProjectPermission.ViewProject);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserIsProjectAdmin_ThenAllPermissionsAreGranted()
|
||||
{
|
||||
_db.UserProjectPermissions.Add(new UserProjectPermission
|
||||
{
|
||||
UserId = 2,
|
||||
ProjectId = 20,
|
||||
IsAdmin = true,
|
||||
Permissions = []
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
SetupRouteProjectId(20);
|
||||
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "2"));
|
||||
|
||||
var context = CreateContext(user, ProjectPermission.DeleteFeature);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserDoesNotHaveRequiredPermission_ThenRequirementFails()
|
||||
{
|
||||
_db.UserProjectPermissions.Add(new UserProjectPermission
|
||||
{
|
||||
UserId = 3,
|
||||
ProjectId = 30,
|
||||
Permissions = [ProjectPermission.ViewProject]
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
SetupRouteProjectId(30);
|
||||
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "3"));
|
||||
|
||||
var context = CreateContext(user, ProjectPermission.DeleteFeature);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserHasNoProjectPermissionRecord_ThenRequirementFails()
|
||||
{
|
||||
SetupRouteProjectId(99);
|
||||
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "5"));
|
||||
|
||||
var context = CreateContext(user);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserIdClaimIsMissing_ThenRequirementFails()
|
||||
{
|
||||
var user = CreateUserWithClaims();
|
||||
|
||||
var context = CreateContext(user);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenProjectIdIsNotInRoute_ThenRequirementFails()
|
||||
{
|
||||
_httpContextAccessor.Setup(a => a.HttpContext).Returns(new DefaultHttpContext());
|
||||
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "1"));
|
||||
|
||||
var context = CreateContext(user);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.False);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization;
|
||||
|
||||
[TestFixture]
|
||||
public class TokenServiceTests
|
||||
{
|
||||
private IConfiguration _configuration = null!;
|
||||
private TokenService _tokenService = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:SecretKey"] = "test-secret-key-that-is-long-enough-32c",
|
||||
["Jwt:Issuer"] = "MicCheck",
|
||||
["Jwt:Audience"] = "MicCheck",
|
||||
["Jwt:ExpiryMinutes"] = "60",
|
||||
})
|
||||
.Build();
|
||||
|
||||
_tokenService = new TokenService(_configuration);
|
||||
}
|
||||
|
||||
private static User BuildUser(int id = 1, string email = "alice@example.com") =>
|
||||
new()
|
||||
{
|
||||
Email = email,
|
||||
FirstName = "Alice",
|
||||
LastName = "Smith",
|
||||
PasswordHash = "hashed",
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void WhenAUserIsProvided_ThenATokenIsReturned()
|
||||
{
|
||||
var result = _tokenService.GenerateToken(BuildUser());
|
||||
|
||||
Assert.That(result.Token, Is.Not.Null.And.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenATokenIsGenerated_ThenItContainsTheUserEmailClaim()
|
||||
{
|
||||
var user = BuildUser(email: "bob@example.com");
|
||||
|
||||
var result = _tokenService.GenerateToken(user);
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwt = handler.ReadJwtToken(result.Token);
|
||||
|
||||
Assert.That(jwt.Claims.Any(c => c.Type == JwtRegisteredClaimNames.Email && c.Value == "bob@example.com"),
|
||||
Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenATokenIsGenerated_ThenItHasAFutureExpiry()
|
||||
{
|
||||
var result = _tokenService.GenerateToken(BuildUser());
|
||||
|
||||
Assert.That(result.ExpiresAt, Is.GreaterThan(DateTime.UtcNow));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenATokenIsGenerated_ThenItHasTheConfiguredIssuer()
|
||||
{
|
||||
var result = _tokenService.GenerateToken(BuildUser());
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwt = handler.ReadJwtToken(result.Token);
|
||||
|
||||
Assert.That(jwt.Issuer, Is.EqualTo("MicCheck"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenUserBelongsToOrganizations_ThenTokenContainsOrganizationClaims()
|
||||
{
|
||||
var user = BuildUser();
|
||||
user.Organizations.Add(new OrganizationUser
|
||||
{
|
||||
OrganizationId = 42,
|
||||
UserId = user.Id,
|
||||
Role = OrganizationRole.Admin
|
||||
});
|
||||
|
||||
var result = _tokenService.GenerateToken(user);
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwt = handler.ReadJwtToken(result.Token);
|
||||
|
||||
Assert.That(jwt.Claims.Any(c => c.Type == "OrganizationId" && c.Value == "42"), Is.True);
|
||||
Assert.That(jwt.Claims.Any(c => c.Type == "OrganizationRole" && c.Value == "Admin"), Is.True);
|
||||
}
|
||||
}
|
||||
0
tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseUrlParserTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseUrlParserTests.cs
Normal file → Executable file
448
tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs
Normal file → Executable file
448
tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs
Normal file → Executable file
@@ -1,224 +1,224 @@
|
||||
using NUnit.Framework;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Projects;
|
||||
using MicCheck.Api.Segments;
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Webhooks;
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Data;
|
||||
|
||||
[TestFixture]
|
||||
public class MicCheckDbContextTests
|
||||
{
|
||||
private MicCheckDbContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnOrganizationIsSaved_ThenItCanBeRetrievedById()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Organizations.Add(organization);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var retrieved = await _db.Organizations.FindAsync(organization.Id);
|
||||
|
||||
Assert.That(retrieved, Is.Not.Null);
|
||||
Assert.That(retrieved!.Name, Is.EqualTo("Acme"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAProjectIsSaved_ThenItCanBeRetrievedByOrganization()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Organizations.Add(organization);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var project = new Project { Name = "My Project", OrganizationId = organization.Id, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Projects.Add(project);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var projects = await _db.Projects.Where(p => p.OrganizationId == organization.Id).ToListAsync();
|
||||
|
||||
Assert.That(projects, Has.Count.EqualTo(1));
|
||||
Assert.That(projects[0].Name, Is.EqualTo("My Project"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnEnvironmentIsSaved_ThenItCanBeRetrievedByApiKey()
|
||||
{
|
||||
var project = new Project { Name = "Test", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Projects.Add(project);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var env = new AppEnvironment { Name = "Production", ApiKey = "env-key-abc", ProjectId = project.Id, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Environments.Add(env);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var retrieved = await _db.Environments.FirstOrDefaultAsync(e => e.ApiKey == "env-key-abc");
|
||||
|
||||
Assert.That(retrieved, Is.Not.Null);
|
||||
Assert.That(retrieved!.Name, Is.EqualTo("Production"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAFeatureIsSaved_ThenItCanBeRetrievedByProject()
|
||||
{
|
||||
var feature = new Feature { Name = "dark_mode", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Features.Add(feature);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var features = await _db.Features.Where(f => f.ProjectId == 1).ToListAsync();
|
||||
|
||||
Assert.That(features, Has.Count.EqualTo(1));
|
||||
Assert.That(features[0].Name, Is.EqualTo("dark_mode"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAFeatureStateIsSaved_ThenItCanBeQueriedByEnvironment()
|
||||
{
|
||||
var state = new FeatureState
|
||||
{
|
||||
FeatureId = 1,
|
||||
EnvironmentId = 1,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.FeatureStates.Add(state);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var states = await _db.FeatureStates.Where(fs => fs.EnvironmentId == 1).ToListAsync();
|
||||
|
||||
Assert.That(states, Has.Count.EqualTo(1));
|
||||
Assert.That(states[0].Enabled, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenASegmentIsSaved_ThenItsRulesCanBeLoaded()
|
||||
{
|
||||
var segment = new Segment { Name = "Power Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Segments.Add(segment);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
||||
_db.SegmentRules.Add(rule);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var loaded = await _db.Segments
|
||||
.Include(s => s.Rules)
|
||||
.FirstAsync(s => s.Id == segment.Id);
|
||||
|
||||
Assert.That(loaded.Rules, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnIdentityIsSaved_ThenItsTraitsCanBeLoaded()
|
||||
{
|
||||
var identity = new Identity { Identifier = "user-123", EnvironmentId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Identities.Add(identity);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_db.IdentityTraits.Add(new IdentityTrait { IdentityId = identity.Id, Key = "plan", Value = "premium" });
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var loaded = await _db.Identities
|
||||
.Include(i => i.Traits)
|
||||
.FirstAsync(i => i.Id == identity.Id);
|
||||
|
||||
Assert.That(loaded.Traits, Has.Count.EqualTo(1));
|
||||
Assert.That(loaded.Traits.First().Key, Is.EqualTo("plan"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnAuditLogIsSaved_ThenItCanBeFilteredByOrganization()
|
||||
{
|
||||
_db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
ResourceType = "Feature",
|
||||
ResourceId = "1",
|
||||
Action = "Created",
|
||||
OrganizationId = 42,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var logs = await _db.AuditLogs.Where(a => a.OrganizationId == 42).ToListAsync();
|
||||
|
||||
Assert.That(logs, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAWebhookIsSaved_ThenItCanBeFilteredByEnvironment()
|
||||
{
|
||||
_db.Webhooks.Add(new Webhook
|
||||
{
|
||||
Url = "https://example.com/hook",
|
||||
Scope = WebhookScope.Environment,
|
||||
EnvironmentId = 5,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var webhooks = await _db.Webhooks.Where(w => w.EnvironmentId == 5).ToListAsync();
|
||||
|
||||
Assert.That(webhooks, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnApiKeyIsSaved_ThenItCanBeRetrievedByHashedKey()
|
||||
{
|
||||
_db.ApiKeys.Add(new ApiKey
|
||||
{
|
||||
Key = "hashed-value-xyz",
|
||||
Prefix = "hashed-va",
|
||||
Name = "CI Key",
|
||||
OrganizationId = 1,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var key = await _db.ApiKeys.FirstOrDefaultAsync(k => k.Key == "hashed-value-xyz");
|
||||
|
||||
Assert.That(key, Is.Not.Null);
|
||||
Assert.That(key!.IsActive, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAUserIsSaved_ThenItCanBeRetrievedByEmail()
|
||||
{
|
||||
_db.Users.Add(new User
|
||||
{
|
||||
Email = "alice@example.com",
|
||||
PasswordHash = "hashed",
|
||||
FirstName = "Alice",
|
||||
LastName = "Smith",
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == "alice@example.com");
|
||||
|
||||
Assert.That(user, Is.Not.Null);
|
||||
Assert.That(user!.FirstName, Is.EqualTo("Alice"));
|
||||
}
|
||||
}
|
||||
using NUnit.Framework;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Projects;
|
||||
using MicCheck.Api.Segments;
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Webhooks;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Data;
|
||||
|
||||
[TestFixture]
|
||||
public class MicCheckDbContextTests
|
||||
{
|
||||
private MicCheckDbContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnOrganizationIsSaved_ThenItCanBeRetrievedById()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Organizations.Add(organization);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var retrieved = await _db.Organizations.FindAsync(organization.Id);
|
||||
|
||||
Assert.That(retrieved, Is.Not.Null);
|
||||
Assert.That(retrieved!.Name, Is.EqualTo("Acme"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAProjectIsSaved_ThenItCanBeRetrievedByOrganization()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Organizations.Add(organization);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var project = new Project { Name = "My Project", OrganizationId = organization.Id, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Projects.Add(project);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var projects = await _db.Projects.Where(p => p.OrganizationId == organization.Id).ToListAsync();
|
||||
|
||||
Assert.That(projects, Has.Count.EqualTo(1));
|
||||
Assert.That(projects[0].Name, Is.EqualTo("My Project"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnEnvironmentIsSaved_ThenItCanBeRetrievedByApiKey()
|
||||
{
|
||||
var project = new Project { Name = "Test", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Projects.Add(project);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var env = new AppEnvironment { Name = "Production", ApiKey = "env-key-abc", ProjectId = project.Id, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Environments.Add(env);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var retrieved = await _db.Environments.FirstOrDefaultAsync(e => e.ApiKey == "env-key-abc");
|
||||
|
||||
Assert.That(retrieved, Is.Not.Null);
|
||||
Assert.That(retrieved!.Name, Is.EqualTo("Production"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAFeatureIsSaved_ThenItCanBeRetrievedByProject()
|
||||
{
|
||||
var feature = new Feature { Name = "dark_mode", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Features.Add(feature);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var features = await _db.Features.Where(f => f.ProjectId == 1).ToListAsync();
|
||||
|
||||
Assert.That(features, Has.Count.EqualTo(1));
|
||||
Assert.That(features[0].Name, Is.EqualTo("dark_mode"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAFeatureStateIsSaved_ThenItCanBeQueriedByEnvironment()
|
||||
{
|
||||
var state = new FeatureState
|
||||
{
|
||||
FeatureId = 1,
|
||||
EnvironmentId = 1,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.FeatureStates.Add(state);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var states = await _db.FeatureStates.Where(fs => fs.EnvironmentId == 1).ToListAsync();
|
||||
|
||||
Assert.That(states, Has.Count.EqualTo(1));
|
||||
Assert.That(states[0].Enabled, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenASegmentIsSaved_ThenItsRulesCanBeLoaded()
|
||||
{
|
||||
var segment = new Segment { Name = "Power Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Segments.Add(segment);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
||||
_db.SegmentRules.Add(rule);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var loaded = await _db.Segments
|
||||
.Include(s => s.Rules)
|
||||
.FirstAsync(s => s.Id == segment.Id);
|
||||
|
||||
Assert.That(loaded.Rules, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnIdentityIsSaved_ThenItsTraitsCanBeLoaded()
|
||||
{
|
||||
var identity = new Identity { Identifier = "user-123", EnvironmentId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Identities.Add(identity);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_db.IdentityTraits.Add(new IdentityTrait { IdentityId = identity.Id, Key = "plan", Value = "premium" });
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var loaded = await _db.Identities
|
||||
.Include(i => i.Traits)
|
||||
.FirstAsync(i => i.Id == identity.Id);
|
||||
|
||||
Assert.That(loaded.Traits, Has.Count.EqualTo(1));
|
||||
Assert.That(loaded.Traits.First().Key, Is.EqualTo("plan"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnAuditLogIsSaved_ThenItCanBeFilteredByOrganization()
|
||||
{
|
||||
_db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
ResourceType = "Feature",
|
||||
ResourceId = "1",
|
||||
Action = "Created",
|
||||
OrganizationId = 42,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var logs = await _db.AuditLogs.Where(a => a.OrganizationId == 42).ToListAsync();
|
||||
|
||||
Assert.That(logs, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAWebhookIsSaved_ThenItCanBeFilteredByEnvironment()
|
||||
{
|
||||
_db.Webhooks.Add(new Webhook
|
||||
{
|
||||
Url = "https://example.com/hook",
|
||||
Scope = WebhookScope.Environment,
|
||||
EnvironmentId = 5,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var webhooks = await _db.Webhooks.Where(w => w.EnvironmentId == 5).ToListAsync();
|
||||
|
||||
Assert.That(webhooks, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnApiKeyIsSaved_ThenItCanBeRetrievedByHashedKey()
|
||||
{
|
||||
_db.ApiKeys.Add(new ApiKey
|
||||
{
|
||||
Key = "hashed-value-xyz",
|
||||
Prefix = "hashed-va",
|
||||
Name = "CI Key",
|
||||
OrganizationId = 1,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var key = await _db.ApiKeys.FirstOrDefaultAsync(k => k.Key == "hashed-value-xyz");
|
||||
|
||||
Assert.That(key, Is.Not.Null);
|
||||
Assert.That(key!.IsActive, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAUserIsSaved_ThenItCanBeRetrievedByEmail()
|
||||
{
|
||||
_db.Users.Add(new User
|
||||
{
|
||||
Email = "alice@example.com",
|
||||
PasswordHash = "hashed",
|
||||
FirstName = "Alice",
|
||||
LastName = "Smith",
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == "alice@example.com");
|
||||
|
||||
Assert.That(user, Is.Not.Null);
|
||||
Assert.That(user!.FirstName, Is.EqualTo("Alice"));
|
||||
}
|
||||
}
|
||||
|
||||
0
tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentTests.cs
Normal file → Executable file
15
tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs
Normal file → Executable file
15
tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs
Normal file → Executable file
@@ -1,9 +1,11 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Segments;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
@@ -14,6 +16,7 @@ public class FeatureEvaluationServiceTests
|
||||
{
|
||||
private MicCheckDbContext _db = null!;
|
||||
private FeatureEvaluationService _service = null!;
|
||||
private FeatureUsageMetrics _usageMetrics = null!;
|
||||
private const int EnvironmentId = 1;
|
||||
private const int ProjectId = 1;
|
||||
|
||||
@@ -25,14 +28,22 @@ public class FeatureEvaluationServiceTests
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
|
||||
var meterFactory = new Mock<IMeterFactory>();
|
||||
meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test"));
|
||||
_usageMetrics = new FeatureUsageMetrics(meterFactory.Object);
|
||||
|
||||
var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions()));
|
||||
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache);
|
||||
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache, _usageMetrics);
|
||||
|
||||
SeedBaseData();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
public void TearDown()
|
||||
{
|
||||
_db.Dispose();
|
||||
_usageMetrics.Dispose();
|
||||
}
|
||||
|
||||
private void SeedBaseData()
|
||||
{
|
||||
|
||||
59
tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs
Normal file → Executable file
59
tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs
Normal file → Executable file
@@ -137,4 +137,63 @@ public class FeatureServiceTests
|
||||
|
||||
Assert.That(features, Has.Count.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAssigningATag_ThenItAppearsOnTheFeature()
|
||||
{
|
||||
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
||||
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
|
||||
_db.Tags.Add(tag);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var updated = await _service.AssignTagAsync(feature.Id, tag.Id);
|
||||
|
||||
Assert.That(updated.Tags.Select(t => t.Id), Does.Contain(tag.Id));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAssigningATagAlreadyOnTheFeature_ThenItIsNotDuplicated()
|
||||
{
|
||||
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
||||
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
|
||||
_db.Tags.Add(tag);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _service.AssignTagAsync(feature.Id, tag.Id);
|
||||
var updated = await _service.AssignTagAsync(feature.Id, tag.Id);
|
||||
|
||||
Assert.That(updated.Tags.Count(t => t.Id == tag.Id), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAssigningATagFromAnotherProject_ThenDomainExceptionIsThrown()
|
||||
{
|
||||
_db.Projects.Add(new MicCheck.Api.Projects.Project
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Other Project",
|
||||
OrganizationId = OrganizationId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
||||
var foreignTag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = 2 };
|
||||
_db.Tags.Add(foreignTag);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
Assert.ThrowsAsync<DomainException>(() => _service.AssignTagAsync(feature.Id, foreignTag.Id));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenRemovingAnAssignedTag_ThenItNoLongerAppearsOnTheFeature()
|
||||
{
|
||||
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
||||
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
|
||||
_db.Tags.Add(tag);
|
||||
await _db.SaveChangesAsync();
|
||||
await _service.AssignTagAsync(feature.Id, tag.Id);
|
||||
|
||||
var updated = await _service.RemoveTagAsync(feature.Id, tag.Id);
|
||||
|
||||
Assert.That(updated.Tags.Select(t => t.Id), Does.Not.Contain(tag.Id));
|
||||
}
|
||||
}
|
||||
|
||||
0
tests/api/MicCheck.Api.Tests.Unit/Features/FeatureTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Features/FeatureTests.cs
Normal file → Executable file
@@ -0,0 +1,80 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using MicCheck.Api.Features;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Features;
|
||||
|
||||
[TestFixture]
|
||||
public class FeatureUsageMetricsTests
|
||||
{
|
||||
private FeatureUsageMetrics _metrics = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var meterFactory = new Mock<IMeterFactory>();
|
||||
meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test"));
|
||||
_metrics = new FeatureUsageMetrics(meterFactory.Object);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _metrics.Dispose();
|
||||
|
||||
[Test]
|
||||
public void WhenRecordingEvaluations_ThenAccumulatorIncrementsByFeatureAndEnvironment()
|
||||
{
|
||||
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
|
||||
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
|
||||
_metrics.RecordEvaluation(environmentId: 1, featureId: 20, featureName: "beta");
|
||||
_metrics.RecordEvaluation(environmentId: 2, featureId: 10, featureName: "dark_mode");
|
||||
|
||||
var drained = _metrics.DrainAccumulated();
|
||||
|
||||
Assert.That(drained, Has.Count.EqualTo(3));
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
Assert.That(drained[new FeatureUsageBucketKey(1, 10, "dark_mode", today)], Is.EqualTo(2));
|
||||
Assert.That(drained[new FeatureUsageBucketKey(1, 20, "beta", today)], Is.EqualTo(1));
|
||||
Assert.That(drained[new FeatureUsageBucketKey(2, 10, "dark_mode", today)], Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenDrainCalled_ThenAccumulatorIsResetToEmpty()
|
||||
{
|
||||
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
|
||||
|
||||
_metrics.DrainAccumulated();
|
||||
var secondDrain = _metrics.DrainAccumulated();
|
||||
|
||||
Assert.That(secondDrain, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenNothingRecorded_ThenDrainReturnsEmptyDictionary()
|
||||
{
|
||||
var drained = _metrics.DrainAccumulated();
|
||||
|
||||
Assert.That(drained, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenRecordingConcurrently_ThenCountsAreAccurateAndNoDataLost()
|
||||
{
|
||||
const int threads = 8;
|
||||
const int recordsPerThread = 100;
|
||||
|
||||
var tasks = Enumerable.Range(0, threads).Select(_ =>
|
||||
Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < recordsPerThread; i++)
|
||||
_metrics.RecordEvaluation(environmentId: 1, featureId: 1, featureName: "concurrent_flag");
|
||||
})).ToArray();
|
||||
|
||||
Task.WaitAll(tasks);
|
||||
|
||||
var drained = _metrics.DrainAccumulated();
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
Assert.That(drained[new FeatureUsageBucketKey(1, 1, "concurrent_flag", today)], Is.EqualTo(threads * recordsPerThread));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Features;
|
||||
|
||||
[TestFixture]
|
||||
public class FeatureUsageQueryServiceTests
|
||||
{
|
||||
private MicCheckDbContext _db = null!;
|
||||
private FeatureUsageQueryService _service = null!;
|
||||
private const int EnvironmentId = 1;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
_service = new FeatureUsageQueryService(_db);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
|
||||
private void SeedUsage(int environmentId, int featureId, string featureName, DateOnly date, long count)
|
||||
{
|
||||
_db.FeatureUsageDaily.Add(new FeatureUsageDaily
|
||||
{
|
||||
EnvironmentId = environmentId,
|
||||
FeatureId = featureId,
|
||||
FeatureName = featureName,
|
||||
UsageDate = date,
|
||||
Count = count,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
_db.SaveChanges();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenNoUsageData_ThenEmptyCollectionsAreReturned()
|
||||
{
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 14);
|
||||
|
||||
Assert.That(result.TopFeaturesLastDay, Is.Empty);
|
||||
Assert.That(result.DailyUsage, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenMultipleFeaturesEvaluatedToday_ThenTopFeaturesLastDayOrdersByCountDescending()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "feature_a", today, count: 50);
|
||||
SeedUsage(EnvironmentId, featureId: 2, "feature_b", today, count: 200);
|
||||
SeedUsage(EnvironmentId, featureId: 3, "feature_c", today, count: 10);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
|
||||
|
||||
Assert.That(result.TopFeaturesLastDay, Has.Count.EqualTo(3));
|
||||
Assert.That(result.TopFeaturesLastDay[0].FeatureName, Is.EqualTo("feature_b"));
|
||||
Assert.That(result.TopFeaturesLastDay[0].Count, Is.EqualTo(200));
|
||||
Assert.That(result.TopFeaturesLastDay[1].FeatureName, Is.EqualTo("feature_a"));
|
||||
Assert.That(result.TopFeaturesLastDay[2].FeatureName, Is.EqualTo("feature_c"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenMoreThanTenFeatures_ThenTopFeaturesLastDayLimitsToTen()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
for (var i = 1; i <= 15; i++)
|
||||
SeedUsage(EnvironmentId, featureId: i, $"feature_{i}", today, count: i * 10L);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
|
||||
|
||||
Assert.That(result.TopFeaturesLastDay, Has.Count.EqualTo(10));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUsageSpansMultipleDays_ThenDailyUsageGroupsByDateAscending()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-2), count: 30);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-1), count: 50);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "flag", today, count: 20);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
|
||||
|
||||
Assert.That(result.DailyUsage, Has.Count.EqualTo(3));
|
||||
Assert.That(result.DailyUsage[0].Date, Is.EqualTo(today.AddDays(-2)));
|
||||
Assert.That(result.DailyUsage[0].TotalCount, Is.EqualTo(30));
|
||||
Assert.That(result.DailyUsage[1].TotalCount, Is.EqualTo(50));
|
||||
Assert.That(result.DailyUsage[2].TotalCount, Is.EqualTo(20));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenHoveringADay_ThenDailyUsageIncludesPerFeatureBreakdownOrderedByCount()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "feature_a", today, count: 10);
|
||||
SeedUsage(EnvironmentId, featureId: 2, "feature_b", today, count: 40);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
|
||||
|
||||
var day = result.DailyUsage.Single();
|
||||
Assert.That(day.TotalCount, Is.EqualTo(50));
|
||||
Assert.That(day.Features, Has.Count.EqualTo(2));
|
||||
Assert.That(day.Features[0].FeatureName, Is.EqualTo("feature_b"));
|
||||
Assert.That(day.Features[0].Count, Is.EqualTo(40));
|
||||
Assert.That(day.Features[1].FeatureName, Is.EqualTo("feature_a"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUsageExistsOutsideDaysWindow_ThenOldDataIsExcluded()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-30), count: 999);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-3), count: 5);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
|
||||
|
||||
Assert.That(result.DailyUsage, Has.Count.EqualTo(1));
|
||||
Assert.That(result.DailyUsage[0].TotalCount, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenOtherEnvironmentHasUsage_ThenItIsNotIncludedInResults()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
SeedUsage(environmentId: 99, featureId: 1, "flag", today, count: 100);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
|
||||
|
||||
Assert.That(result.TopFeaturesLastDay, Is.Empty);
|
||||
Assert.That(result.DailyUsage, Is.Empty);
|
||||
}
|
||||
}
|
||||
2
tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs
Normal file → Executable file
2
tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs
Normal file → Executable file
@@ -115,7 +115,7 @@ public class FlagsApiIntegrationTests
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/identities/", new
|
||||
var response = await client.PostAsJsonAsync("/api/v1/identity/", new
|
||||
{
|
||||
identifier = "user-123",
|
||||
traits = new[] { new { trait_key = "plan", trait_value = "premium" } }
|
||||
|
||||
0
tests/api/MicCheck.Api.Tests.Unit/Identities/IdentityTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Identities/IdentityTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/MicCheckWebApplicationFactory.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/MicCheckWebApplicationFactory.cs
Normal file → Executable file
140
tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationTests.cs
Normal file → Executable file
140
tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationTests.cs
Normal file → Executable file
@@ -1,70 +1,70 @@
|
||||
using NUnit.Framework;
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Projects;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Organizations;
|
||||
|
||||
[TestFixture]
|
||||
public class OrganizationTests
|
||||
{
|
||||
[Test]
|
||||
public void WhenAnOrganizationIsCreated_ThenCollectionsAreInitializedEmpty()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme" };
|
||||
|
||||
Assert.That(organization.Projects, Is.Empty);
|
||||
Assert.That(organization.Members, Is.Empty);
|
||||
Assert.That(organization.ApiKeys, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenAnOrganizationIsCreated_ThenNameIsSet()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme" };
|
||||
|
||||
Assert.That(organization.Name, Is.EqualTo("Acme"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenAProjectIsAddedToAnOrganization_ThenItAppearsInTheProjectsCollection()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme" };
|
||||
var project = new Project { Name = "My Project", OrganizationId = organization.Id };
|
||||
|
||||
organization.Projects.Add(project);
|
||||
|
||||
Assert.That(organization.Projects, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenAnApiKeyIsAddedToAnOrganization_ThenItAppearsInTheApiKeysCollection()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme" };
|
||||
var apiKey = new ApiKey { Key = "hashed-key", Prefix = "abc12345", Name = "CI Key", OrganizationId = organization.Id };
|
||||
|
||||
organization.ApiKeys.Add(apiKey);
|
||||
|
||||
Assert.That(organization.ApiKeys, Has.Count.EqualTo(1));
|
||||
}
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
public class OrganizationUserTests
|
||||
{
|
||||
[Test]
|
||||
public void WhenAnOrganizationUserIsCreated_ThenRoleDefaultsToUser()
|
||||
{
|
||||
var member = new OrganizationUser { OrganizationId = 1, UserId = 1 };
|
||||
|
||||
Assert.That(member.Role, Is.EqualTo(OrganizationRole.User));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenAnOrganizationUserRoleIsSetToAdmin_ThenRoleIsAdmin()
|
||||
{
|
||||
var member = new OrganizationUser { OrganizationId = 1, UserId = 1, Role = OrganizationRole.Admin };
|
||||
|
||||
Assert.That(member.Role, Is.EqualTo(OrganizationRole.Admin));
|
||||
}
|
||||
}
|
||||
using NUnit.Framework;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Projects;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Organizations;
|
||||
|
||||
[TestFixture]
|
||||
public class OrganizationTests
|
||||
{
|
||||
[Test]
|
||||
public void WhenAnOrganizationIsCreated_ThenCollectionsAreInitializedEmpty()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme" };
|
||||
|
||||
Assert.That(organization.Projects, Is.Empty);
|
||||
Assert.That(organization.Members, Is.Empty);
|
||||
Assert.That(organization.ApiKeys, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenAnOrganizationIsCreated_ThenNameIsSet()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme" };
|
||||
|
||||
Assert.That(organization.Name, Is.EqualTo("Acme"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenAProjectIsAddedToAnOrganization_ThenItAppearsInTheProjectsCollection()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme" };
|
||||
var project = new Project { Name = "My Project", OrganizationId = organization.Id };
|
||||
|
||||
organization.Projects.Add(project);
|
||||
|
||||
Assert.That(organization.Projects, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenAnApiKeyIsAddedToAnOrganization_ThenItAppearsInTheApiKeysCollection()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme" };
|
||||
var apiKey = new ApiKey { Key = "hashed-key", Prefix = "abc12345", Name = "CI Key", OrganizationId = organization.Id };
|
||||
|
||||
organization.ApiKeys.Add(apiKey);
|
||||
|
||||
Assert.That(organization.ApiKeys, Has.Count.EqualTo(1));
|
||||
}
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
public class OrganizationUserTests
|
||||
{
|
||||
[Test]
|
||||
public void WhenAnOrganizationUserIsCreated_ThenRoleDefaultsToUser()
|
||||
{
|
||||
var member = new OrganizationUser { OrganizationId = 1, UserId = 1 };
|
||||
|
||||
Assert.That(member.Role, Is.EqualTo(OrganizationRole.User));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenAnOrganizationUserRoleIsSetToAdmin_ThenRoleIsAdmin()
|
||||
{
|
||||
var member = new OrganizationUser { OrganizationId = 1, UserId = 1, Role = OrganizationRole.Admin };
|
||||
|
||||
Assert.That(member.Role, Is.EqualTo(OrganizationRole.Admin));
|
||||
}
|
||||
}
|
||||
|
||||
0
tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentEvaluatorTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentEvaluatorTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Users/UserTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Users/UserTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookRetryTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookRetryTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookTests.cs
Normal file → Executable file
0
tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookTests.cs
Normal file → Executable file
Reference in New Issue
Block a user