Adding Flags API
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.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));
|
||||
}
|
||||
}
|
||||
@@ -1,75 +1,185 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using MicCheck.Api.Auth;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Auth;
|
||||
|
||||
[TestFixture]
|
||||
public class AuthEndpointsTests
|
||||
{
|
||||
private WebApplicationFactory<Program> _factory = null!;
|
||||
private Mock<ITokenService> _tokenService = null!;
|
||||
private MicCheckWebApplicationFactory _factory = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_tokenService = new Mock<ITokenService>();
|
||||
|
||||
_factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.AddScoped<ITokenService>(_ => _tokenService.Object);
|
||||
});
|
||||
});
|
||||
_factory = new MicCheckWebApplicationFactory();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _factory.Dispose();
|
||||
|
||||
[Test]
|
||||
public async Task WhenUsernameIsEmpty_ThenBadRequestIsReturned()
|
||||
public async Task WhenRegisteringWithValidDetails_ThenOkIsReturnedWithTokens()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/auth/token",
|
||||
new TokenRequest("", "password123"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenPasswordIsEmpty_ThenBadRequestIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/auth/token",
|
||||
new TokenRequest("testuser", ""));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenValidCredentialsAreProvided_ThenOkIsReturnedWithToken()
|
||||
{
|
||||
var expectedResponse = new TokenResponse("signed.jwt.token", DateTime.UtcNow.AddHours(1));
|
||||
_tokenService
|
||||
.Setup(s => s.GenerateToken("testuser"))
|
||||
.Returns(expectedResponse);
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/auth/token",
|
||||
new TokenRequest("testuser", "password123"));
|
||||
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<TokenResponse>();
|
||||
Assert.That(body?.Token, Is.EqualTo(expectedResponse.Token));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MicCheck.Api.Auth;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Auth;
|
||||
@@ -27,29 +29,43 @@ public class TokenServiceTests
|
||||
_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 WhenAUsernameIsProvided_ThenATokenIsReturned()
|
||||
public void WhenAUserIsProvided_ThenATokenIsReturned()
|
||||
{
|
||||
var result = _tokenService.GenerateToken("testuser");
|
||||
var result = _tokenService.GenerateToken(BuildUser());
|
||||
|
||||
Assert.That(result.Token, Is.Not.Null.And.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenATokenIsGenerated_ThenItContainsTheUsernameAsSubjectClaim()
|
||||
public void WhenATokenIsGenerated_ThenItContainsTheUserEmailClaim()
|
||||
{
|
||||
var result = _tokenService.GenerateToken("testuser");
|
||||
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.Subject, Is.EqualTo("testuser"));
|
||||
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("testuser");
|
||||
var result = _tokenService.GenerateToken(BuildUser());
|
||||
|
||||
Assert.That(result.ExpiresAt, Is.GreaterThan(DateTime.UtcNow));
|
||||
}
|
||||
@@ -57,11 +73,31 @@ public class TokenServiceTests
|
||||
[Test]
|
||||
public void WhenATokenIsGenerated_ThenItHasTheConfiguredIssuer()
|
||||
{
|
||||
var result = _tokenService.GenerateToken("testuser");
|
||||
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,0 +1,142 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.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/organisations/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/organisations/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/organisations/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/organisations/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/organisations/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/organisations/1/api-keys/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using MicCheck.Api.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.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,52 @@
|
||||
using NUnit.Framework;
|
||||
using MicCheck.Api.Data;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Data;
|
||||
|
||||
[TestFixture]
|
||||
public class DatabaseUrlParserTests
|
||||
{
|
||||
[Test]
|
||||
public void WhenAFullDatabaseUrlIsParsed_ThenAllComponentsAreExtracted()
|
||||
{
|
||||
var url = "postgresql://miccheck:password@localhost:5432/miccheck";
|
||||
|
||||
var result = DatabaseUrlParser.ToNpgsqlConnectionString(url);
|
||||
|
||||
Assert.That(result, Does.Contain("Host=localhost"));
|
||||
Assert.That(result, Does.Contain("Port=5432"));
|
||||
Assert.That(result, Does.Contain("Database=miccheck"));
|
||||
Assert.That(result, Does.Contain("Username=miccheck"));
|
||||
Assert.That(result, Does.Contain("Password=password"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenADatabaseUrlWithNoExplicitPortIsParsed_ThenPortDefaultsTo5432()
|
||||
{
|
||||
var url = "postgresql://user:pass@db.example.com/mydb";
|
||||
|
||||
var result = DatabaseUrlParser.ToNpgsqlConnectionString(url);
|
||||
|
||||
Assert.That(result, Does.Contain("Port=5432"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenADatabaseUrlWithACustomPortIsParsed_ThenThatPortIsUsed()
|
||||
{
|
||||
var url = "postgresql://user:pass@db.example.com:5433/mydb";
|
||||
|
||||
var result = DatabaseUrlParser.ToNpgsqlConnectionString(url);
|
||||
|
||||
Assert.That(result, Does.Contain("Port=5433"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenADatabaseUrlContainsSpecialCharactersInPassword_ThenTheyArePreserved()
|
||||
{
|
||||
var url = "postgresql://user:s3cr3t@host:5432/db";
|
||||
|
||||
var result = DatabaseUrlParser.ToNpgsqlConnectionString(url);
|
||||
|
||||
Assert.That(result, Does.Contain("Password=s3cr3t"));
|
||||
}
|
||||
}
|
||||
224
tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs
Normal file
224
tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs
Normal file
@@ -0,0 +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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
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 NUnit.Framework;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Features;
|
||||
|
||||
[TestFixture]
|
||||
public class FeatureEvaluationServiceTests
|
||||
{
|
||||
private MicCheckDbContext _db = null!;
|
||||
private FeatureEvaluationService _service = null!;
|
||||
private const int EnvironmentId = 1;
|
||||
private const int ProjectId = 1;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
|
||||
var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions()));
|
||||
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache);
|
||||
|
||||
SeedBaseData();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
|
||||
private void SeedBaseData()
|
||||
{
|
||||
_db.Environments.Add(new AppEnvironment
|
||||
{
|
||||
Id = EnvironmentId,
|
||||
Name = "Production",
|
||||
ApiKey = "env-key-test",
|
||||
ProjectId = ProjectId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
_db.SaveChanges();
|
||||
}
|
||||
|
||||
private MicCheck.Api.Features.Feature AddFeature(string name, bool defaultEnabled = false)
|
||||
{
|
||||
var feature = new MicCheck.Api.Features.Feature
|
||||
{
|
||||
Name = name,
|
||||
ProjectId = ProjectId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
DefaultEnabled = defaultEnabled
|
||||
};
|
||||
_db.Features.Add(feature);
|
||||
_db.SaveChanges();
|
||||
return feature;
|
||||
}
|
||||
|
||||
private FeatureState AddEnvironmentDefault(int featureId, bool enabled, string? value = null)
|
||||
{
|
||||
var fs = new FeatureState
|
||||
{
|
||||
FeatureId = featureId,
|
||||
EnvironmentId = EnvironmentId,
|
||||
Enabled = enabled,
|
||||
Value = value,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.FeatureStates.Add(fs);
|
||||
_db.SaveChanges();
|
||||
return fs;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenNoOverridesExist_ThenEnvironmentDefaultIsReturned()
|
||||
{
|
||||
var feature = AddFeature("dark_mode");
|
||||
AddEnvironmentDefault(feature.Id, enabled: true, value: null);
|
||||
|
||||
var results = await _service.EvaluateForEnvironmentAsync(EnvironmentId);
|
||||
|
||||
Assert.That(results, Has.Count.EqualTo(1));
|
||||
Assert.That(results[0].Feature.Name, Is.EqualTo("dark_mode"));
|
||||
Assert.That(results[0].Enabled, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenFeatureIsDisabled_ThenResultReflectsThat()
|
||||
{
|
||||
var feature = AddFeature("beta_feature");
|
||||
AddEnvironmentDefault(feature.Id, enabled: false);
|
||||
|
||||
var results = await _service.EvaluateForEnvironmentAsync(EnvironmentId);
|
||||
|
||||
Assert.That(results[0].Enabled, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenIdentityHasNoOverrides_ThenEnvironmentDefaultIsUsed()
|
||||
{
|
||||
var feature = AddFeature("dark_mode");
|
||||
AddEnvironmentDefault(feature.Id, enabled: true);
|
||||
|
||||
var results = await _service.EvaluateForIdentityAsync(EnvironmentId, "user-123");
|
||||
|
||||
Assert.That(results, Has.Count.EqualTo(1));
|
||||
Assert.That(results[0].Enabled, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenIdentityHasOverride_ThenIdentityOverrideTakesPriority()
|
||||
{
|
||||
var feature = AddFeature("dark_mode");
|
||||
AddEnvironmentDefault(feature.Id, enabled: false);
|
||||
|
||||
var identity = new Identity
|
||||
{
|
||||
Identifier = "user-vip",
|
||||
EnvironmentId = EnvironmentId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.Identities.Add(identity);
|
||||
_db.SaveChanges();
|
||||
|
||||
_db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
EnvironmentId = EnvironmentId,
|
||||
IdentityId = identity.Id,
|
||||
Enabled = true,
|
||||
Value = "identity-value",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
_db.SaveChanges();
|
||||
|
||||
var results = await _service.EvaluateForIdentityAsync(EnvironmentId, "user-vip");
|
||||
|
||||
Assert.That(results[0].Enabled, Is.True);
|
||||
Assert.That(results[0].Value, Is.EqualTo("identity-value"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenSegmentMatchesAndHasOverride_ThenSegmentOverrideIsUsed()
|
||||
{
|
||||
var feature = AddFeature("premium_feature");
|
||||
var envDefault = AddEnvironmentDefault(feature.Id, enabled: false);
|
||||
|
||||
var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Segments.Add(segment);
|
||||
_db.SaveChanges();
|
||||
|
||||
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
||||
rule.Conditions.Add(new SegmentCondition
|
||||
{
|
||||
RuleId = rule.Id,
|
||||
Property = "plan",
|
||||
Operator = SegmentConditionOperator.Equal,
|
||||
Value = "premium"
|
||||
});
|
||||
_db.SegmentRules.Add(rule);
|
||||
_db.SaveChanges();
|
||||
|
||||
var featureSegment = new FeatureSegment
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
SegmentId = segment.Id,
|
||||
EnvironmentId = EnvironmentId,
|
||||
Priority = 1
|
||||
};
|
||||
_db.FeatureSegments.Add(featureSegment);
|
||||
_db.SaveChanges();
|
||||
|
||||
_db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
EnvironmentId = EnvironmentId,
|
||||
FeatureSegmentId = featureSegment.Id,
|
||||
Enabled = true,
|
||||
Value = "segment-value",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
_db.SaveChanges();
|
||||
|
||||
var results = await _service.EvaluateForIdentityAsync(
|
||||
EnvironmentId, "user-123",
|
||||
[new TraitInput("plan", "premium")]);
|
||||
|
||||
Assert.That(results[0].Enabled, Is.True);
|
||||
Assert.That(results[0].Value, Is.EqualTo("segment-value"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenSegmentDoesNotMatch_ThenEnvironmentDefaultIsUsed()
|
||||
{
|
||||
var feature = AddFeature("premium_feature");
|
||||
AddEnvironmentDefault(feature.Id, enabled: false, value: "default-value");
|
||||
|
||||
var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Segments.Add(segment);
|
||||
_db.SaveChanges();
|
||||
|
||||
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
||||
rule.Conditions.Add(new SegmentCondition
|
||||
{
|
||||
RuleId = rule.Id,
|
||||
Property = "plan",
|
||||
Operator = SegmentConditionOperator.Equal,
|
||||
Value = "premium"
|
||||
});
|
||||
_db.SegmentRules.Add(rule);
|
||||
_db.SaveChanges();
|
||||
|
||||
var featureSegment = new FeatureSegment
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
SegmentId = segment.Id,
|
||||
EnvironmentId = EnvironmentId,
|
||||
Priority = 1
|
||||
};
|
||||
_db.FeatureSegments.Add(featureSegment);
|
||||
_db.SaveChanges();
|
||||
|
||||
_db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
EnvironmentId = EnvironmentId,
|
||||
FeatureSegmentId = featureSegment.Id,
|
||||
Enabled = true,
|
||||
Value = "segment-value",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
_db.SaveChanges();
|
||||
|
||||
var results = await _service.EvaluateForIdentityAsync(
|
||||
EnvironmentId, "user-123",
|
||||
[new TraitInput("plan", "free")]);
|
||||
|
||||
Assert.That(results[0].Enabled, Is.False);
|
||||
Assert.That(results[0].Value, Is.EqualTo("default-value"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenIdentityOverrideAndSegmentBothExist_ThenIdentityOverrideTakesPriority()
|
||||
{
|
||||
var feature = AddFeature("feature_x");
|
||||
AddEnvironmentDefault(feature.Id, enabled: false);
|
||||
|
||||
var segment = new Segment { Name = "All Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Segments.Add(segment);
|
||||
_db.SaveChanges();
|
||||
|
||||
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
||||
rule.Conditions.Add(new SegmentCondition
|
||||
{
|
||||
RuleId = rule.Id,
|
||||
Property = "country",
|
||||
Operator = SegmentConditionOperator.IsSet,
|
||||
Value = ""
|
||||
});
|
||||
_db.SegmentRules.Add(rule);
|
||||
_db.SaveChanges();
|
||||
|
||||
var featureSegment = new FeatureSegment
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
SegmentId = segment.Id,
|
||||
EnvironmentId = EnvironmentId,
|
||||
Priority = 1
|
||||
};
|
||||
_db.FeatureSegments.Add(featureSegment);
|
||||
_db.SaveChanges();
|
||||
|
||||
_db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
EnvironmentId = EnvironmentId,
|
||||
FeatureSegmentId = featureSegment.Id,
|
||||
Enabled = true,
|
||||
Value = "segment-value",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var identity = new Identity
|
||||
{
|
||||
Identifier = "user-special",
|
||||
EnvironmentId = EnvironmentId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.Identities.Add(identity);
|
||||
_db.SaveChanges();
|
||||
|
||||
identity.Traits.Add(new IdentityTrait
|
||||
{
|
||||
IdentityId = identity.Id,
|
||||
Key = "country",
|
||||
Value = "US"
|
||||
});
|
||||
|
||||
_db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
EnvironmentId = EnvironmentId,
|
||||
IdentityId = identity.Id,
|
||||
Enabled = false,
|
||||
Value = "identity-override",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
_db.SaveChanges();
|
||||
|
||||
var results = await _service.EvaluateForIdentityAsync(
|
||||
EnvironmentId, "user-special",
|
||||
[new TraitInput("country", "US")]);
|
||||
|
||||
Assert.That(results[0].Value, Is.EqualTo("identity-override"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Features;
|
||||
|
||||
[TestFixture]
|
||||
public class FlagsApiIntegrationTests
|
||||
{
|
||||
private MicCheckWebApplicationFactory _factory = null!;
|
||||
private string _envApiKey = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_factory = new MicCheckWebApplicationFactory();
|
||||
_envApiKey = SeedEnvironmentWithFlag();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _factory.Dispose();
|
||||
|
||||
private string SeedEnvironmentWithFlag()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
|
||||
var apiKey = $"test-env-key-{Guid.NewGuid():N}";
|
||||
|
||||
var project = new MicCheck.Api.Projects.Project
|
||||
{
|
||||
Name = "Test Project",
|
||||
OrganizationId = 1,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
db.SaveChanges();
|
||||
|
||||
var environment = new AppEnvironment
|
||||
{
|
||||
Name = "Test Env",
|
||||
ApiKey = apiKey,
|
||||
ProjectId = project.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Environments.Add(environment);
|
||||
db.SaveChanges();
|
||||
|
||||
var feature = new Feature
|
||||
{
|
||||
Name = "dark_mode",
|
||||
ProjectId = project.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Features.Add(feature);
|
||||
db.SaveChanges();
|
||||
|
||||
db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
EnvironmentId = environment.Id,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyIsValid_ThenFlagsAreReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
|
||||
|
||||
var response = await client.GetAsync("/api/v1/flags/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
var flags = await response.Content.ReadFromJsonAsync<List<FlagResponse>>();
|
||||
Assert.That(flags, Has.Count.EqualTo(1));
|
||||
Assert.That(flags![0].Feature.Name, Is.EqualTo("dark_mode"));
|
||||
Assert.That(flags[0].Enabled, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyIsAbsent_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/flags/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyIsInvalid_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Environment-Key", "not-a-real-key");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/flags/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenIdentifyingUserWithValidKey_ThenFlagsAndTraitsAreReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/identities/", new
|
||||
{
|
||||
identifier = "user-123",
|
||||
traits = new[] { new { trait_key = "plan", trait_value = "premium" } }
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenGettingEnvironmentDocument_ThenDocumentIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
|
||||
|
||||
var response = await client.GetAsync("/api/v1/environment-document/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="NUnit" Version="4.5.1" />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit;
|
||||
|
||||
public class MicCheckWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private static readonly IServiceProvider InMemoryEfServiceProvider =
|
||||
new ServiceCollection()
|
||||
.AddEntityFrameworkInMemoryDatabase()
|
||||
.BuildServiceProvider();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(MicCheckDbContext));
|
||||
if (contextDescriptor is not null)
|
||||
services.Remove(contextDescriptor);
|
||||
|
||||
var optionsDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<MicCheckDbContext>));
|
||||
if (optionsDescriptor is not null)
|
||||
services.Remove(optionsDescriptor);
|
||||
|
||||
services.AddDbContext<MicCheckDbContext>(options =>
|
||||
options
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.UseInternalServiceProvider(InMemoryEfServiceProvider));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Segments;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Segments;
|
||||
|
||||
[TestFixture]
|
||||
public class SegmentEvaluatorTests
|
||||
{
|
||||
private SegmentEvaluator _evaluator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => _evaluator = new SegmentEvaluator();
|
||||
|
||||
private static Segment BuildSegment(SegmentRuleType ruleType, params SegmentCondition[] conditions)
|
||||
{
|
||||
var segment = new Segment { Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
var rule = new SegmentRule { SegmentId = segment.Id, Type = ruleType };
|
||||
foreach (var condition in conditions)
|
||||
rule.Conditions.Add(condition);
|
||||
segment.Rules.Add(rule);
|
||||
return segment;
|
||||
}
|
||||
|
||||
private static SegmentCondition Condition(string property, SegmentConditionOperator op, string value) =>
|
||||
new() { Property = property, Operator = op, Value = value };
|
||||
|
||||
private static IdentityTrait Trait(string key, string value) =>
|
||||
new() { Key = key, Value = value, IdentityId = 1 };
|
||||
|
||||
[Test]
|
||||
public void WhenTraitEqualsConditionValue_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("plan", SegmentConditionOperator.Equal, "premium"));
|
||||
var traits = new[] { Trait("plan", "premium") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenTraitDoesNotEqualConditionValue_ThenSegmentDoesNotMatch()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("plan", SegmentConditionOperator.Equal, "premium"));
|
||||
var traits = new[] { Trait("plan", "free") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenNotEqualOperatorAndValuesAreDifferent_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("plan", SegmentConditionOperator.NotEqual, "premium"));
|
||||
var traits = new[] { Trait("plan", "free") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenGreaterThanConditionIsSatisfied_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("age", SegmentConditionOperator.GreaterThan, "18"));
|
||||
var traits = new[] { Trait("age", "25") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenGreaterThanConditionIsNotSatisfied_ThenSegmentDoesNotMatch()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("age", SegmentConditionOperator.GreaterThan, "18"));
|
||||
var traits = new[] { Trait("age", "16") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenGreaterThanOrEqualConditionIsExactMatch_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("age", SegmentConditionOperator.GreaterThanOrEqual, "18"));
|
||||
var traits = new[] { Trait("age", "18") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenLessThanConditionIsSatisfied_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("age", SegmentConditionOperator.LessThan, "18"));
|
||||
var traits = new[] { Trait("age", "16") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenLessThanOrEqualConditionIsExactMatch_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("age", SegmentConditionOperator.LessThanOrEqual, "18"));
|
||||
var traits = new[] { Trait("age", "18") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenContainsConditionIsSatisfied_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("email", SegmentConditionOperator.Contains, "@example.com"));
|
||||
var traits = new[] { Trait("email", "alice@example.com") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenNotContainsConditionIsSatisfied_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("email", SegmentConditionOperator.NotContains, "@test.com"));
|
||||
var traits = new[] { Trait("email", "alice@example.com") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenRegexConditionMatchesTraitValue_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("email", SegmentConditionOperator.Regex, @"^[^@]+@example\.com$"));
|
||||
var traits = new[] { Trait("email", "alice@example.com") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenRegexConditionDoesNotMatchTraitValue_ThenSegmentDoesNotMatch()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("email", SegmentConditionOperator.Regex, @"^[^@]+@example\.com$"));
|
||||
var traits = new[] { Trait("email", "alice@other.com") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenIsSetConditionAndTraitExists_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("plan", SegmentConditionOperator.IsSet, ""));
|
||||
var traits = new[] { Trait("plan", "premium") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenIsSetConditionAndTraitDoesNotExist_ThenSegmentDoesNotMatch()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("plan", SegmentConditionOperator.IsSet, ""));
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, [], "user1"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenIsNotSetConditionAndTraitDoesNotExist_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("plan", SegmentConditionOperator.IsNotSet, ""));
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, [], "user1"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenInConditionAndValueIsInList_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("country", SegmentConditionOperator.In, "US,UK,CA"));
|
||||
var traits = new[] { Trait("country", "UK") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenNotInConditionAndValueIsNotInList_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("country", SegmentConditionOperator.NotIn, "US,UK,CA"));
|
||||
var traits = new[] { Trait("country", "AU") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenIsTrueConditionAndValueIsTrue_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("beta", SegmentConditionOperator.IsTrue, ""));
|
||||
var traits = new[] { Trait("beta", "true") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenIsFalseConditionAndValueIsFalse_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("beta", SegmentConditionOperator.IsFalse, ""));
|
||||
var traits = new[] { Trait("beta", "false") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenModuloConditionIsSatisfied_ThenSegmentMatches()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("userId", SegmentConditionOperator.ModuloValueDivisorRemainder, "3|0"));
|
||||
var traits = new[] { Trait("userId", "9") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenModuloConditionIsNotSatisfied_ThenSegmentDoesNotMatch()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.All,
|
||||
Condition("userId", SegmentConditionOperator.ModuloValueDivisorRemainder, "3|0"));
|
||||
var traits = new[] { Trait("userId", "7") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenPercentageSplitIsDeterministic_ThenSameInputProducesSameResult()
|
||||
{
|
||||
var segment = new Segment { Id = 1, Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
var rule = new SegmentRule { SegmentId = 1, Type = SegmentRuleType.All };
|
||||
rule.Conditions.Add(Condition("", SegmentConditionOperator.PercentageSplit, "50"));
|
||||
segment.Rules.Add(rule);
|
||||
|
||||
var result1 = _evaluator.Evaluate(segment, [], "user-abc");
|
||||
var result2 = _evaluator.Evaluate(segment, [], "user-abc");
|
||||
|
||||
Assert.That(result1, Is.EqualTo(result2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenPercentageSplitIs100_ThenAllIdentitiesAreIncluded()
|
||||
{
|
||||
var segment = new Segment { Id = 42, Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
var rule = new SegmentRule { SegmentId = 42, Type = SegmentRuleType.All };
|
||||
rule.Conditions.Add(Condition("", SegmentConditionOperator.PercentageSplit, "100"));
|
||||
segment.Rules.Add(rule);
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, [], "any-user"), Is.True);
|
||||
Assert.That(_evaluator.Evaluate(segment, [], "another-user"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenPercentageSplitIs0_ThenNoIdentitiesAreIncluded()
|
||||
{
|
||||
var segment = new Segment { Id = 99, Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
var rule = new SegmentRule { SegmentId = 99, Type = SegmentRuleType.All };
|
||||
rule.Conditions.Add(Condition("", SegmentConditionOperator.PercentageSplit, "0"));
|
||||
segment.Rules.Add(rule);
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, [], "any-user"), Is.False);
|
||||
Assert.That(_evaluator.Evaluate(segment, [], "another-user"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenRuleTypeIsAny_ThenAtLeastOneConditionMustMatch()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.Any,
|
||||
Condition("plan", SegmentConditionOperator.Equal, "premium"),
|
||||
Condition("plan", SegmentConditionOperator.Equal, "enterprise"));
|
||||
var traits = new[] { Trait("plan", "enterprise") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenRuleTypeIsNone_ThenNoConditionMustMatch()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.None,
|
||||
Condition("plan", SegmentConditionOperator.Equal, "premium"),
|
||||
Condition("plan", SegmentConditionOperator.Equal, "enterprise"));
|
||||
var traits = new[] { Trait("plan", "free") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenRuleTypeIsNoneAndOneConditionMatches_ThenSegmentDoesNotMatch()
|
||||
{
|
||||
var segment = BuildSegment(SegmentRuleType.None,
|
||||
Condition("plan", SegmentConditionOperator.Equal, "premium"));
|
||||
var traits = new[] { Trait("plan", "premium") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, traits), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenAllTopLevelRulesMustPass_ThenSegmentOnlyMatchesWhenAllPass()
|
||||
{
|
||||
var segment = new Segment { Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
|
||||
var rule1 = new SegmentRule { Type = SegmentRuleType.All };
|
||||
rule1.Conditions.Add(Condition("plan", SegmentConditionOperator.Equal, "premium"));
|
||||
|
||||
var rule2 = new SegmentRule { Type = SegmentRuleType.All };
|
||||
rule2.Conditions.Add(Condition("country", SegmentConditionOperator.Equal, "US"));
|
||||
|
||||
segment.Rules.Add(rule1);
|
||||
segment.Rules.Add(rule2);
|
||||
|
||||
var matchingTraits = new[] { Trait("plan", "premium"), Trait("country", "US") };
|
||||
var nonMatchingTraits = new[] { Trait("plan", "premium"), Trait("country", "UK") };
|
||||
|
||||
Assert.That(_evaluator.Evaluate(segment, matchingTraits), Is.True);
|
||||
Assert.That(_evaluator.Evaluate(segment, nonMatchingTraits), Is.False);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user