Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ApiKeyConcurrencyTests.cs
|
||||
// Sprint: SPRINT_5100_0007_0004_storage_harness
|
||||
// Task: STOR-HARNESS-010
|
||||
// Description: Model S1 concurrency tests for Authority API key storage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Concurrency tests for API key storage operations.
|
||||
/// Implements Model S1 (Storage/Postgres) test requirements:
|
||||
/// - Parallel writes to same key → correct conflict behavior
|
||||
/// - Parallel reads during write → consistent state
|
||||
/// - No deadlocks under load
|
||||
/// </summary>
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Trait("Category", TestCategories.StorageConcurrency)]
|
||||
public sealed class ApiKeyConcurrencyTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
private ApiKeyRepository _repository = null!;
|
||||
private NpgsqlDataSource _npgsqlDataSource = null!;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
private readonly Guid _userId = Guid.NewGuid();
|
||||
|
||||
public ApiKeyConcurrencyTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var options = _fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = _fixture.SchemaName;
|
||||
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
|
||||
_repository = new ApiKeyRepository(dataSource, NullLogger<ApiKeyRepository>.Instance);
|
||||
_npgsqlDataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
|
||||
|
||||
await SeedTenantAsync();
|
||||
await SeedUserAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _npgsqlDataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ParallelCreates_DifferentIds_All_Succeed()
|
||||
{
|
||||
// Arrange
|
||||
const int parallelCount = 20;
|
||||
var keys = Enumerable.Range(0, parallelCount)
|
||||
.Select(i => CreateApiKeyEntity(Guid.NewGuid(), $"Parallel-{i}"))
|
||||
.ToList();
|
||||
|
||||
// Act - Create all keys in parallel
|
||||
var tasks = keys.Select(k => _repository.CreateAsync(_tenantId, k));
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - All keys should be created
|
||||
var allKeys = await _repository.ListAsync(_tenantId);
|
||||
allKeys.Should().HaveCount(parallelCount);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ConcurrentReads_SameKey_All_Succeed()
|
||||
{
|
||||
// Arrange
|
||||
var key = CreateApiKeyEntity(Guid.NewGuid(), "Concurrent Read Test");
|
||||
await _repository.CreateAsync(_tenantId, key);
|
||||
|
||||
// Act - 50 concurrent reads
|
||||
var readTasks = Enumerable.Range(0, 50)
|
||||
.Select(_ => _repository.GetByIdAsync(_tenantId, key.Id))
|
||||
.ToList();
|
||||
|
||||
var results = await Task.WhenAll(readTasks);
|
||||
|
||||
// Assert - All reads should succeed and return same data
|
||||
results.Should().AllSatisfy(r => r.Should().NotBeNull());
|
||||
results.Select(r => r!.Id).Distinct().Should().HaveCount(1,
|
||||
"all concurrent reads should return same key");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ParallelReadsDuringWrite_ReturnsConsistentState()
|
||||
{
|
||||
// Arrange
|
||||
var key = CreateApiKeyEntity(Guid.NewGuid(), "Read During Write");
|
||||
await _repository.CreateAsync(_tenantId, key);
|
||||
|
||||
// Act - Parallel reads while updating
|
||||
var readTasks = Enumerable.Range(0, 20)
|
||||
.Select(_ => _repository.GetByIdAsync(_tenantId, key.Id))
|
||||
.ToList();
|
||||
|
||||
var writeTask = _repository.UpdateLastUsedAsync(_tenantId, key.Id);
|
||||
|
||||
await Task.WhenAll(readTasks.Cast<Task>().Append(writeTask));
|
||||
|
||||
var readResults = await Task.WhenAll(readTasks);
|
||||
|
||||
// Assert - All reads should return valid state
|
||||
readResults.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Should().NotBeNull();
|
||||
r!.Id.Should().Be(key.Id);
|
||||
r.Status.Should().Be(ApiKeyStatus.Active);
|
||||
});
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ConcurrentUpdateLastUsed_SameKey_NoConflict()
|
||||
{
|
||||
// Arrange
|
||||
var key = CreateApiKeyEntity(Guid.NewGuid(), "Concurrent Update");
|
||||
await _repository.CreateAsync(_tenantId, key);
|
||||
|
||||
// Act - Multiple concurrent updates
|
||||
var updateTasks = Enumerable.Range(0, 10)
|
||||
.Select(_ => _repository.UpdateLastUsedAsync(_tenantId, key.Id))
|
||||
.ToList();
|
||||
|
||||
var action = () => Task.WhenAll(updateTasks);
|
||||
|
||||
// Assert - Should not throw
|
||||
await action.Should().NotThrowAsync();
|
||||
|
||||
var result = await _repository.GetByIdAsync(_tenantId, key.Id);
|
||||
result.Should().NotBeNull();
|
||||
result!.LastUsedAt.Should().NotBeNull("at least one update should have succeeded");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ParallelListOperations_NoDeadlock()
|
||||
{
|
||||
// Arrange - Create some keys first
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _repository.CreateAsync(_tenantId, CreateApiKeyEntity(Guid.NewGuid(), $"List-{i}"));
|
||||
}
|
||||
|
||||
// Act - Parallel list operations
|
||||
var listTasks = Enumerable.Range(0, 30)
|
||||
.Select(_ => _repository.ListAsync(_tenantId))
|
||||
.ToList();
|
||||
|
||||
var completedInTime = Task.WaitAll([.. listTasks], TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert
|
||||
completedInTime.Should().BeTrue("parallel list operations should not deadlock");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MixedOperations_NoDeadlock()
|
||||
{
|
||||
// Arrange
|
||||
var existingKeys = new List<Guid>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var key = CreateApiKeyEntity(Guid.NewGuid(), $"Mixed-{i}");
|
||||
await _repository.CreateAsync(_tenantId, key);
|
||||
existingKeys.Add(key.Id);
|
||||
}
|
||||
|
||||
// Act - Mixed operations in parallel
|
||||
var tasks = new List<Task>();
|
||||
|
||||
// Reads
|
||||
tasks.AddRange(existingKeys.Select(id => _repository.GetByIdAsync(_tenantId, id)));
|
||||
|
||||
// Lists
|
||||
tasks.AddRange(Enumerable.Range(0, 5).Select(_ => _repository.ListAsync(_tenantId)));
|
||||
|
||||
// Updates
|
||||
tasks.AddRange(existingKeys.Select(id => _repository.UpdateLastUsedAsync(_tenantId, id)));
|
||||
|
||||
// Creates
|
||||
tasks.AddRange(Enumerable.Range(0, 5).Select(i =>
|
||||
_repository.CreateAsync(_tenantId, CreateApiKeyEntity(Guid.NewGuid(), $"NewKey-{i}"))));
|
||||
|
||||
var completedInTime = Task.WaitAll([.. tasks], TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert
|
||||
completedInTime.Should().BeTrue("mixed operations should not deadlock");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RapidSuccessiveWrites_AllSucceed()
|
||||
{
|
||||
// Arrange
|
||||
const int iterations = 50;
|
||||
|
||||
// Act - Rapid successive creates
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
await _repository.CreateAsync(_tenantId, CreateApiKeyEntity(Guid.NewGuid(), $"Rapid-{i}"));
|
||||
}
|
||||
|
||||
// Assert
|
||||
var allKeys = await _repository.ListAsync(_tenantId);
|
||||
allKeys.Should().HaveCount(iterations);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ConcurrentDeleteAndRead_ReturnsConsistentState()
|
||||
{
|
||||
// Arrange
|
||||
var key = CreateApiKeyEntity(Guid.NewGuid(), "Delete Race");
|
||||
await _repository.CreateAsync(_tenantId, key);
|
||||
|
||||
// Act - Delete and read in parallel
|
||||
var deleteTask = _repository.DeleteAsync(_tenantId, key.Id);
|
||||
var readTasks = Enumerable.Range(0, 10)
|
||||
.Select(_ => _repository.GetByIdAsync(_tenantId, key.Id))
|
||||
.ToList();
|
||||
|
||||
await Task.WhenAll(readTasks.Cast<Task>().Append(deleteTask));
|
||||
|
||||
var readResults = await Task.WhenAll(readTasks);
|
||||
|
||||
// Assert - Reads should either return the key or null (after delete)
|
||||
// No partial/corrupted data should be returned
|
||||
foreach (var result in readResults)
|
||||
{
|
||||
if (result != null)
|
||||
{
|
||||
result.Id.Should().Be(key.Id);
|
||||
result.Status.Should().BeOneOf(ApiKeyStatus.Active, ApiKeyStatus.Revoked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ApiKeyEntity CreateApiKeyEntity(Guid id, string name) => new()
|
||||
{
|
||||
Id = id,
|
||||
TenantId = _tenantId,
|
||||
UserId = _userId,
|
||||
Name = name,
|
||||
KeyHash = "sha256_" + Guid.NewGuid().ToString("N"),
|
||||
KeyPrefix = "sk_" + Guid.NewGuid().ToString("N")[..8],
|
||||
Scopes = ["read"],
|
||||
Status = ApiKeyStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMonths(6)
|
||||
};
|
||||
|
||||
private Task SeedTenantAsync() =>
|
||||
_fixture.ExecuteSqlAsync(
|
||||
$"INSERT INTO authority.tenants (tenant_id, name, status, settings, metadata) " +
|
||||
$"VALUES ('{_tenantId}', 'Tenant {_tenantId}', 'active', '{{}}', '{{}}') " +
|
||||
"ON CONFLICT (tenant_id) DO NOTHING;");
|
||||
|
||||
private Task SeedUserAsync() =>
|
||||
_fixture.ExecuteSqlAsync(
|
||||
$"INSERT INTO authority.users (id, tenant_id, username, status) " +
|
||||
$"VALUES ('{_userId}', '{_tenantId}', 'user-{_userId:N}', 'active') " +
|
||||
"ON CONFLICT (id) DO NOTHING;");
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ApiKeyIdempotencyTests.cs
|
||||
// Sprint: SPRINT_5100_0007_0004_storage_harness
|
||||
// Task: STOR-HARNESS-010
|
||||
// Description: Model S1 idempotency tests for Authority API key storage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotency tests for API key storage operations.
|
||||
/// Implements Model S1 (Storage/Postgres) test requirements:
|
||||
/// - Insert same entity twice → no duplicates
|
||||
/// - Upsert creates when not exists
|
||||
/// - Upsert updates when exists
|
||||
/// </summary>
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Trait("Category", TestCategories.StorageIdempotency)]
|
||||
public sealed class ApiKeyIdempotencyTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
private ApiKeyRepository _repository = null!;
|
||||
private NpgsqlDataSource _npgsqlDataSource = null!;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
private readonly Guid _userId = Guid.NewGuid();
|
||||
|
||||
public ApiKeyIdempotencyTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var options = _fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = _fixture.SchemaName;
|
||||
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
|
||||
_repository = new ApiKeyRepository(dataSource, NullLogger<ApiKeyRepository>.Instance);
|
||||
_npgsqlDataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
|
||||
|
||||
await SeedTenantAsync();
|
||||
await SeedUserAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _npgsqlDataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_SameId_Twice_Should_Not_Duplicate()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = Guid.NewGuid();
|
||||
var key1 = CreateApiKeyEntity(keyId, "First Key");
|
||||
var key2 = CreateApiKeyEntity(keyId, "Second Key");
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(_tenantId, key1);
|
||||
|
||||
// Second creation with same ID should throw or be ignored
|
||||
var createSecond = async () => await _repository.CreateAsync(_tenantId, key2);
|
||||
|
||||
// Assert - Either throws or upserts, but should not create duplicate
|
||||
try
|
||||
{
|
||||
await createSecond();
|
||||
// If no exception, verify only one record exists
|
||||
var all = await _repository.ListAsync(_tenantId);
|
||||
all.Count(k => k.Id == keyId).Should().Be(1,
|
||||
"duplicate ID should not create multiple records");
|
||||
}
|
||||
catch (PostgresException)
|
||||
{
|
||||
// Expected if DB enforces uniqueness
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_DifferentIds_SamePrefix_Should_Not_Duplicate()
|
||||
{
|
||||
// Arrange
|
||||
var prefix = "sk_unique_" + Guid.NewGuid().ToString("N")[..6];
|
||||
var key1 = CreateApiKeyEntityWithPrefix(Guid.NewGuid(), "Key One", prefix);
|
||||
var key2 = CreateApiKeyEntityWithPrefix(Guid.NewGuid(), "Key Two", prefix);
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(_tenantId, key1);
|
||||
|
||||
var createSecond = async () => await _repository.CreateAsync(_tenantId, key2);
|
||||
|
||||
// Assert - Should fail due to unique constraint on key_prefix
|
||||
try
|
||||
{
|
||||
await createSecond();
|
||||
var result = await _repository.GetByPrefixAsync(prefix);
|
||||
result.Should().NotBeNull("at least one key should exist");
|
||||
}
|
||||
catch (PostgresException)
|
||||
{
|
||||
// Expected if DB enforces uniqueness on prefix
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateLastUsedAsync_Twice_Should_Be_Idempotent()
|
||||
{
|
||||
// Arrange
|
||||
var key = CreateApiKeyEntity(Guid.NewGuid(), "Update Test");
|
||||
await _repository.CreateAsync(_tenantId, key);
|
||||
|
||||
// Act - Update last used twice
|
||||
await _repository.UpdateLastUsedAsync(_tenantId, key.Id);
|
||||
var after1 = await _repository.GetByIdAsync(_tenantId, key.Id);
|
||||
|
||||
await Task.Delay(50); // Small delay to ensure different timestamp potential
|
||||
|
||||
await _repository.UpdateLastUsedAsync(_tenantId, key.Id);
|
||||
var after2 = await _repository.GetByIdAsync(_tenantId, key.Id);
|
||||
|
||||
// Assert - Should have exactly one key, second update should succeed
|
||||
after1.Should().NotBeNull();
|
||||
after2.Should().NotBeNull();
|
||||
after2!.Id.Should().Be(key.Id);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RevokeAsync_Twice_Should_Be_Idempotent()
|
||||
{
|
||||
// Arrange
|
||||
var key = CreateApiKeyEntity(Guid.NewGuid(), "Revoke Test");
|
||||
await _repository.CreateAsync(_tenantId, key);
|
||||
|
||||
// Act - Revoke twice
|
||||
await _repository.RevokeAsync(_tenantId, key.Id, "admin@test.com");
|
||||
var after1 = await _repository.GetByIdAsync(_tenantId, key.Id);
|
||||
|
||||
await _repository.RevokeAsync(_tenantId, key.Id, "admin2@test.com");
|
||||
var after2 = await _repository.GetByIdAsync(_tenantId, key.Id);
|
||||
|
||||
// Assert - Key should be revoked, second revoke should not fail
|
||||
after1.Should().NotBeNull();
|
||||
after1!.Status.Should().Be(ApiKeyStatus.Revoked);
|
||||
|
||||
after2.Should().NotBeNull();
|
||||
after2!.Status.Should().Be(ApiKeyStatus.Revoked);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_Twice_Should_Be_Idempotent()
|
||||
{
|
||||
// Arrange
|
||||
var key = CreateApiKeyEntity(Guid.NewGuid(), "Delete Test");
|
||||
await _repository.CreateAsync(_tenantId, key);
|
||||
|
||||
// Act - Delete twice
|
||||
await _repository.DeleteAsync(_tenantId, key.Id);
|
||||
var afterFirst = await _repository.GetByIdAsync(_tenantId, key.Id);
|
||||
|
||||
// Second delete should not throw
|
||||
var deleteSecond = async () => await _repository.DeleteAsync(_tenantId, key.Id);
|
||||
await deleteSecond.Should().NotThrowAsync();
|
||||
|
||||
var afterSecond = await _repository.GetByIdAsync(_tenantId, key.Id);
|
||||
|
||||
// Assert
|
||||
afterFirst.Should().BeNull("first delete should remove key");
|
||||
afterSecond.Should().BeNull("second delete should also succeed");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_Multiple_Keys_For_Same_User_Allowed()
|
||||
{
|
||||
// Arrange - Create 5 keys for same user
|
||||
var keys = Enumerable.Range(0, 5)
|
||||
.Select(i => CreateApiKeyEntity(Guid.NewGuid(), $"MultiKey-{i}"))
|
||||
.ToList();
|
||||
|
||||
// Act
|
||||
foreach (var key in keys)
|
||||
{
|
||||
await _repository.CreateAsync(_tenantId, key);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var userKeys = await _repository.GetByUserIdAsync(_tenantId, _userId);
|
||||
userKeys.Should().HaveCount(5, "user can have multiple API keys");
|
||||
}
|
||||
|
||||
private ApiKeyEntity CreateApiKeyEntity(Guid id, string name) => new()
|
||||
{
|
||||
Id = id,
|
||||
TenantId = _tenantId,
|
||||
UserId = _userId,
|
||||
Name = name,
|
||||
KeyHash = "sha256_" + Guid.NewGuid().ToString("N"),
|
||||
KeyPrefix = "sk_" + Guid.NewGuid().ToString("N")[..8],
|
||||
Scopes = ["read"],
|
||||
Status = ApiKeyStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMonths(6)
|
||||
};
|
||||
|
||||
private ApiKeyEntity CreateApiKeyEntityWithPrefix(Guid id, string name, string keyPrefix) => new()
|
||||
{
|
||||
Id = id,
|
||||
TenantId = _tenantId,
|
||||
UserId = _userId,
|
||||
Name = name,
|
||||
KeyHash = "sha256_" + Guid.NewGuid().ToString("N"),
|
||||
KeyPrefix = keyPrefix,
|
||||
Scopes = ["read"],
|
||||
Status = ApiKeyStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMonths(6)
|
||||
};
|
||||
|
||||
private Task SeedTenantAsync() =>
|
||||
_fixture.ExecuteSqlAsync(
|
||||
$"INSERT INTO authority.tenants (tenant_id, name, status, settings, metadata) " +
|
||||
$"VALUES ('{_tenantId}', 'Tenant {_tenantId}', 'active', '{{}}', '{{}}') " +
|
||||
"ON CONFLICT (tenant_id) DO NOTHING;");
|
||||
|
||||
private Task SeedUserAsync() =>
|
||||
_fixture.ExecuteSqlAsync(
|
||||
$"INSERT INTO authority.users (id, tenant_id, username, status) " +
|
||||
$"VALUES ('{_userId}', '{_tenantId}', 'user-{_userId:N}', 'active') " +
|
||||
"ON CONFLICT (id) DO NOTHING;");
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
public sealed class ApiKeyRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
private readonly ApiKeyRepository _repository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public ApiKeyRepositoryTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
|
||||
_repository = new ApiKeyRepository(dataSource, NullLogger<ApiKeyRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGetByPrefix_RoundTripsApiKey()
|
||||
{
|
||||
var keyPrefix = "sk_live_" + Guid.NewGuid().ToString("N")[..8];
|
||||
var apiKey = new ApiKeyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "CI/CD Key",
|
||||
KeyHash = "sha256_key_" + Guid.NewGuid().ToString("N"),
|
||||
KeyPrefix = keyPrefix,
|
||||
Scopes = ["scan:read", "scan:write"],
|
||||
Status = ApiKeyStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddYears(1)
|
||||
};
|
||||
|
||||
await SeedUsersAsync(apiKey.UserId!.Value);
|
||||
await _repository.CreateAsync(_tenantId, apiKey);
|
||||
var fetched = await _repository.GetByPrefixAsync(keyPrefix);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Id.Should().Be(apiKey.Id);
|
||||
fetched.Name.Should().Be("CI/CD Key");
|
||||
fetched.Scopes.Should().BeEquivalentTo(["scan:read", "scan:write"]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetById_ReturnsApiKey()
|
||||
{
|
||||
var apiKey = CreateApiKey(Guid.NewGuid(), "Test Key");
|
||||
await SeedUsersAsync(apiKey.UserId!.Value);
|
||||
await _repository.CreateAsync(_tenantId, apiKey);
|
||||
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Name.Should().Be("Test Key");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByUserId_ReturnsUserApiKeys()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var key1 = CreateApiKey(userId, "Key 1");
|
||||
var key2 = CreateApiKey(userId, "Key 2");
|
||||
await SeedUsersAsync(userId);
|
||||
await _repository.CreateAsync(_tenantId, key1);
|
||||
await _repository.CreateAsync(_tenantId, key2);
|
||||
|
||||
var keys = await _repository.GetByUserIdAsync(_tenantId, userId);
|
||||
|
||||
keys.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_ReturnsAllKeysForTenant()
|
||||
{
|
||||
var key1 = CreateApiKey(Guid.NewGuid(), "Key A");
|
||||
var key2 = CreateApiKey(Guid.NewGuid(), "Key B");
|
||||
await SeedUsersAsync(key1.UserId!.Value, key2.UserId!.Value);
|
||||
await _repository.CreateAsync(_tenantId, key1);
|
||||
await _repository.CreateAsync(_tenantId, key2);
|
||||
|
||||
var keys = await _repository.ListAsync(_tenantId);
|
||||
|
||||
keys.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Revoke_UpdatesStatusAndRevokedFields()
|
||||
{
|
||||
var apiKey = CreateApiKey(Guid.NewGuid(), "ToRevoke");
|
||||
await SeedUsersAsync(apiKey.UserId!.Value);
|
||||
await _repository.CreateAsync(_tenantId, apiKey);
|
||||
|
||||
await _repository.RevokeAsync(_tenantId, apiKey.Id, "security@test.com");
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
|
||||
|
||||
fetched!.Status.Should().Be(ApiKeyStatus.Revoked);
|
||||
fetched.RevokedAt.Should().NotBeNull();
|
||||
fetched.RevokedBy.Should().Be("security@test.com");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Delete_RemovesApiKey()
|
||||
{
|
||||
var apiKey = CreateApiKey(Guid.NewGuid(), "DeleteKey");
|
||||
await SeedUsersAsync(apiKey.UserId!.Value);
|
||||
await _repository.CreateAsync(_tenantId, apiKey);
|
||||
|
||||
await _repository.DeleteAsync(_tenantId, apiKey.Id);
|
||||
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
private ApiKeyEntity CreateApiKey(Guid userId, string name) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
UserId = userId,
|
||||
Name = name,
|
||||
KeyHash = "sha256_key_" + Guid.NewGuid().ToString("N"),
|
||||
KeyPrefix = "sk_" + Guid.NewGuid().ToString("N")[..8],
|
||||
Scopes = ["read"],
|
||||
Status = ApiKeyStatus.Active,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMonths(6)
|
||||
};
|
||||
|
||||
private Task SeedTenantAsync() =>
|
||||
_fixture.ExecuteSqlAsync(
|
||||
$"INSERT INTO authority.tenants (tenant_id, name, status, settings, metadata) " +
|
||||
$"VALUES ('{_tenantId}', 'Tenant {_tenantId}', 'active', '{{}}', '{{}}') " +
|
||||
"ON CONFLICT (tenant_id) DO NOTHING;");
|
||||
|
||||
private Task SeedUsersAsync(params Guid[] userIds)
|
||||
{
|
||||
var statements = string.Join("\n", userIds.Distinct().Select(id =>
|
||||
$"INSERT INTO authority.users (id, tenant_id, username, status) VALUES ('{id}', '{_tenantId}', 'user-{id:N}', 'active') ON CONFLICT (id) DO NOTHING;"));
|
||||
return _fixture.ExecuteSqlAsync(statements);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
public sealed class AuditRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
private readonly AuditRepository _repository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public AuditRepositoryTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
|
||||
_repository = new AuditRepository(dataSource, NullLogger<AuditRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_ReturnsGeneratedId()
|
||||
{
|
||||
// Arrange
|
||||
var audit = new AuditEntity
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Action = "user.login",
|
||||
ResourceType = "user",
|
||||
ResourceId = Guid.NewGuid().ToString(),
|
||||
IpAddress = "192.168.1.1",
|
||||
UserAgent = "Mozilla/5.0",
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
};
|
||||
|
||||
// Act
|
||||
var id = await _repository.CreateAsync(_tenantId, audit);
|
||||
|
||||
// Assert
|
||||
id.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_ReturnsAuditEntriesOrderedByCreatedAtDesc()
|
||||
{
|
||||
// Arrange
|
||||
var audit1 = CreateAudit("action1");
|
||||
var audit2 = CreateAudit("action2");
|
||||
await _repository.CreateAsync(_tenantId, audit1);
|
||||
await Task.Delay(10); // Ensure different timestamps
|
||||
await _repository.CreateAsync(_tenantId, audit2);
|
||||
|
||||
// Act
|
||||
var audits = await _repository.ListAsync(_tenantId, limit: 10);
|
||||
|
||||
// Assert
|
||||
audits.Should().HaveCount(2);
|
||||
audits[0].Action.Should().Be("action2"); // Most recent first
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByUserId_ReturnsUserAudits()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var audit = new AuditEntity
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
UserId = userId,
|
||||
Action = "user.action",
|
||||
ResourceType = "test"
|
||||
};
|
||||
await _repository.CreateAsync(_tenantId, audit);
|
||||
|
||||
// Act
|
||||
var audits = await _repository.GetByUserIdAsync(_tenantId, userId);
|
||||
|
||||
// Assert
|
||||
audits.Should().HaveCount(1);
|
||||
audits[0].UserId.Should().Be(userId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByResource_ReturnsResourceAudits()
|
||||
{
|
||||
// Arrange
|
||||
var resourceId = Guid.NewGuid().ToString();
|
||||
var audit = new AuditEntity
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
Action = "resource.update",
|
||||
ResourceType = "role",
|
||||
ResourceId = resourceId
|
||||
};
|
||||
await _repository.CreateAsync(_tenantId, audit);
|
||||
|
||||
// Act
|
||||
var audits = await _repository.GetByResourceAsync(_tenantId, "role", resourceId);
|
||||
|
||||
// Assert
|
||||
audits.Should().HaveCount(1);
|
||||
audits[0].ResourceId.Should().Be(resourceId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByCorrelationId_ReturnsCorrelatedAudits()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var audit1 = new AuditEntity
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
Action = "step1",
|
||||
ResourceType = "test",
|
||||
CorrelationId = correlationId
|
||||
};
|
||||
var audit2 = new AuditEntity
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
Action = "step2",
|
||||
ResourceType = "test",
|
||||
CorrelationId = correlationId
|
||||
};
|
||||
await _repository.CreateAsync(_tenantId, audit1);
|
||||
await _repository.CreateAsync(_tenantId, audit2);
|
||||
|
||||
// Act
|
||||
var audits = await _repository.GetByCorrelationIdAsync(_tenantId, correlationId);
|
||||
|
||||
// Assert
|
||||
audits.Should().HaveCount(2);
|
||||
audits.Should().AllSatisfy(a => a.CorrelationId.Should().Be(correlationId));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByAction_ReturnsMatchingAudits()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.CreateAsync(_tenantId, CreateAudit("user.login"));
|
||||
await _repository.CreateAsync(_tenantId, CreateAudit("user.logout"));
|
||||
await _repository.CreateAsync(_tenantId, CreateAudit("user.login"));
|
||||
|
||||
// Act
|
||||
var audits = await _repository.GetByActionAsync(_tenantId, "user.login");
|
||||
|
||||
// Assert
|
||||
audits.Should().HaveCount(2);
|
||||
audits.Should().AllSatisfy(a => a.Action.Should().Be("user.login"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_StoresJsonbValues()
|
||||
{
|
||||
// Arrange
|
||||
var audit = new AuditEntity
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
Action = "config.update",
|
||||
ResourceType = "config",
|
||||
OldValue = "{\"setting\": \"old\"}",
|
||||
NewValue = "{\"setting\": \"new\"}"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(_tenantId, audit);
|
||||
var audits = await _repository.GetByActionAsync(_tenantId, "config.update");
|
||||
|
||||
// Assert
|
||||
audits.Should().HaveCount(1);
|
||||
audits[0].OldValue.Should().Contain("old");
|
||||
audits[0].NewValue.Should().Contain("new");
|
||||
}
|
||||
|
||||
private AuditEntity CreateAudit(string action) => new()
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Action = action,
|
||||
ResourceType = "test",
|
||||
ResourceId = Guid.NewGuid().ToString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using FluentAssertions;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that verify Authority module migrations run successfully.
|
||||
/// </summary>
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
public sealed class AuthorityMigrationTests
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
|
||||
public AuthorityMigrationTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MigrationsApplied_SchemaHasTables()
|
||||
{
|
||||
// Arrange
|
||||
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
// Act - Query for tables in schema
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = @schema
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name;
|
||||
""",
|
||||
connection);
|
||||
cmd.Parameters.AddWithValue("schema", _fixture.SchemaName);
|
||||
|
||||
var tables = new List<string>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
// Assert - Should have core Authority tables
|
||||
tables.Should().Contain("schema_migrations");
|
||||
// Add more specific table assertions based on Authority migrations
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MigrationsApplied_SchemaVersionRecorded()
|
||||
{
|
||||
// Arrange
|
||||
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
// Act - Check schema_migrations table
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT COUNT(*) FROM {_fixture.SchemaName}.schema_migrations;",
|
||||
connection);
|
||||
|
||||
var count = await cmd.ExecuteScalarAsync();
|
||||
|
||||
// Assert - At least one migration should be recorded
|
||||
count.Should().NotBeNull();
|
||||
((long)count!).Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AuthorityPostgresFixture.cs
|
||||
// Sprint: SPRINT_5100_0007_0004_storage_harness
|
||||
// Task: STOR-HARNESS-010
|
||||
// Description: Authority PostgreSQL test fixture with TestKit integration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Reflection;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.TestKit.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
// Type aliases to disambiguate TestKit and Infrastructure fixtures
|
||||
using TestKitPostgresFixture = StellaOps.TestKit.Fixtures.PostgresFixture;
|
||||
using TestKitPostgresIsolationMode = StellaOps.TestKit.Fixtures.PostgresIsolationMode;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the Authority module.
|
||||
/// Runs migrations from embedded resources and provides test isolation.
|
||||
/// </summary>
|
||||
public sealed class AuthorityPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<AuthorityPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(AuthorityDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Authority";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Authority PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(AuthorityPostgresCollection.Name)]
|
||||
public sealed class AuthorityPostgresCollection : ICollectionFixture<AuthorityPostgresFixture>
|
||||
{
|
||||
public const string Name = "AuthorityPostgres";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TestKit-based PostgreSQL fixture for Authority storage tests.
|
||||
/// Provides TestKit features like isolation modes, session management,
|
||||
/// and integration with deterministic time/random utilities.
|
||||
/// </summary>
|
||||
public sealed class AuthorityTestKitPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private TestKitPostgresFixture _fixture = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying TestKit PostgresFixture.
|
||||
/// </summary>
|
||||
public TestKitPostgresFixture Fixture => _fixture;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for the PostgreSQL container.
|
||||
/// </summary>
|
||||
public string ConnectionString => _fixture.ConnectionString;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the isolation mode for tests.
|
||||
/// </summary>
|
||||
public TestKitPostgresIsolationMode IsolationMode
|
||||
{
|
||||
get => _fixture.IsolationMode;
|
||||
set => _fixture.IsolationMode = value;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_fixture = new TestKitPostgresFixture
|
||||
{
|
||||
IsolationMode = TestKitPostgresIsolationMode.Truncation
|
||||
};
|
||||
await _fixture.InitializeAsync();
|
||||
|
||||
// Apply Authority migrations
|
||||
var migrationAssembly = typeof(AuthorityDataSource).Assembly;
|
||||
await _fixture.ApplyMigrationsFromAssemblyAsync(migrationAssembly, "authority", "Migrations");
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _fixture.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates all tables for test isolation.
|
||||
/// </summary>
|
||||
public Task TruncateAllTablesAsync() => _fixture.TruncateAllTablesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an isolated test session for a test.
|
||||
/// </summary>
|
||||
public Task<PostgresTestSession> CreateSessionAsync(string? testName = null)
|
||||
=> _fixture.CreateSessionAsync(testName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Authority TestKit PostgreSQL tests.
|
||||
/// </summary>
|
||||
[CollectionDefinition(AuthorityTestKitPostgresCollection.Name)]
|
||||
public sealed class AuthorityTestKitPostgresCollection : ICollectionFixture<AuthorityTestKitPostgresFixture>
|
||||
{
|
||||
public const string Name = "AuthorityTestKitPostgres";
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
public sealed class OfflineKitAuditRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
private readonly OfflineKitAuditRepository _repository;
|
||||
|
||||
public OfflineKitAuditRepositoryTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
|
||||
_repository = new OfflineKitAuditRepository(dataSource, NullLogger<OfflineKitAuditRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Insert_ThenList_ReturnsRecord()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("N");
|
||||
var entity = new OfflineKitAuditEntity
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
EventType = "IMPORT_VALIDATED",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Actor = "system",
|
||||
Details = """{"kitFilename":"bundle-2025-12-14.tar.zst"}""",
|
||||
Result = "success"
|
||||
};
|
||||
|
||||
await _repository.InsertAsync(entity);
|
||||
var listed = await _repository.ListAsync(tenantId, limit: 10);
|
||||
|
||||
listed.Should().ContainSingle();
|
||||
listed[0].EventId.Should().Be(entity.EventId);
|
||||
listed[0].EventType.Should().Be(entity.EventType);
|
||||
listed[0].Actor.Should().Be(entity.Actor);
|
||||
listed[0].Result.Should().Be(entity.Result);
|
||||
listed[0].Details.Should().Contain("kitFilename");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_WithFilters_ReturnsMatchingRows()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("N");
|
||||
|
||||
await _repository.InsertAsync(new OfflineKitAuditEntity
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
EventType = "IMPORT_FAILED_DSSE",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2),
|
||||
Actor = "system",
|
||||
Details = """{"reasonCode":"DSSE_VERIFY_FAIL"}""",
|
||||
Result = "failed"
|
||||
});
|
||||
|
||||
await _repository.InsertAsync(new OfflineKitAuditEntity
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
EventType = "IMPORT_VALIDATED",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
Actor = "system",
|
||||
Details = """{"status":"ok"}""",
|
||||
Result = "success"
|
||||
});
|
||||
|
||||
var failed = await _repository.ListAsync(tenantId, result: "failed", limit: 10);
|
||||
failed.Should().ContainSingle();
|
||||
failed[0].Result.Should().Be("failed");
|
||||
|
||||
var validated = await _repository.ListAsync(tenantId, eventType: "IMPORT_VALIDATED", limit: 10);
|
||||
validated.Should().ContainSingle();
|
||||
validated[0].EventType.Should().Be("IMPORT_VALIDATED");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_IsTenantIsolated()
|
||||
{
|
||||
var tenantA = Guid.NewGuid().ToString("N");
|
||||
var tenantB = Guid.NewGuid().ToString("N");
|
||||
|
||||
await _repository.InsertAsync(new OfflineKitAuditEntity
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
TenantId = tenantA,
|
||||
EventType = "IMPORT_VALIDATED",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
Actor = "system",
|
||||
Details = """{"status":"ok"}""",
|
||||
Result = "success"
|
||||
});
|
||||
|
||||
await _repository.InsertAsync(new OfflineKitAuditEntity
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
TenantId = tenantB,
|
||||
EventType = "IMPORT_VALIDATED",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Actor = "system",
|
||||
Details = """{"status":"ok"}""",
|
||||
Result = "success"
|
||||
});
|
||||
|
||||
var tenantAResults = await _repository.ListAsync(tenantA, limit: 10);
|
||||
tenantAResults.Should().ContainSingle();
|
||||
tenantAResults[0].TenantId.Should().Be(tenantA);
|
||||
|
||||
var tenantBResults = await _repository.ListAsync(tenantB, limit: 10);
|
||||
tenantBResults.Should().ContainSingle();
|
||||
tenantBResults[0].TenantId.Should().Be(tenantB);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
public sealed class PermissionRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
private readonly PermissionRepository _repository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public PermissionRepositoryTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
|
||||
_repository = new PermissionRepository(dataSource, NullLogger<PermissionRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGet_RoundTripsPermission()
|
||||
{
|
||||
var permission = new PermissionEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "users:read",
|
||||
Resource = "users",
|
||||
Action = "read",
|
||||
Description = "Read user data"
|
||||
};
|
||||
|
||||
await _repository.CreateAsync(_tenantId, permission);
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, permission.Id);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Name.Should().Be("users:read");
|
||||
fetched.Resource.Should().Be("users");
|
||||
fetched.Action.Should().Be("read");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByName_ReturnsCorrectPermission()
|
||||
{
|
||||
var permission = BuildPermission("tokens:revoke", "tokens", "revoke", "Revoke tokens");
|
||||
await _repository.CreateAsync(_tenantId, permission);
|
||||
|
||||
var fetched = await _repository.GetByNameAsync(_tenantId, "tokens:revoke");
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Action.Should().Be("revoke");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByResource_ReturnsResourcePermissions()
|
||||
{
|
||||
var p1 = BuildPermission("users:read", "users", "read", "Read");
|
||||
var p2 = BuildPermission("users:write", "users", "write", "Write");
|
||||
await _repository.CreateAsync(_tenantId, p1);
|
||||
await _repository.CreateAsync(_tenantId, p2);
|
||||
|
||||
var perms = await _repository.GetByResourceAsync(_tenantId, "users");
|
||||
|
||||
perms.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_ReturnsAllPermissionsForTenant()
|
||||
{
|
||||
var p1 = BuildPermission("orch:read", "orch", "read", "Read orch");
|
||||
var p2 = BuildPermission("orch:write", "orch", "write", "Write orch");
|
||||
await _repository.CreateAsync(_tenantId, p1);
|
||||
await _repository.CreateAsync(_tenantId, p2);
|
||||
|
||||
var perms = await _repository.ListAsync(_tenantId);
|
||||
|
||||
perms.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Delete_RemovesPermission()
|
||||
{
|
||||
var permission = BuildPermission("tokens:revoke", "tokens", "revoke", "Revoke tokens");
|
||||
await _repository.CreateAsync(_tenantId, permission);
|
||||
|
||||
await _repository.DeleteAsync(_tenantId, permission.Id);
|
||||
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, permission.Id);
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
private PermissionEntity BuildPermission(string name, string resource, string action, string description) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = name,
|
||||
Resource = resource,
|
||||
Action = action,
|
||||
Description = description
|
||||
};
|
||||
|
||||
private Task SeedTenantAsync() =>
|
||||
_fixture.ExecuteSqlAsync(
|
||||
$"INSERT INTO authority.tenants (tenant_id, name, status, settings, metadata) " +
|
||||
$"VALUES ('{_tenantId}', 'Tenant {_tenantId}', 'active', '{{}}', '{{}}') " +
|
||||
"ON CONFLICT (tenant_id) DO NOTHING;");
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
public sealed class RefreshTokenRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
private readonly RefreshTokenRepository _repository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public RefreshTokenRepositoryTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
|
||||
_repository = new RefreshTokenRepository(dataSource, NullLogger<RefreshTokenRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGetByHash_RoundTripsRefreshToken()
|
||||
{
|
||||
var refresh = BuildToken(Guid.NewGuid());
|
||||
await SeedUsersAsync(refresh.UserId);
|
||||
await SeedAccessTokensAsync((refresh.AccessTokenId!.Value, refresh.UserId));
|
||||
await _repository.CreateAsync(_tenantId, refresh);
|
||||
|
||||
var fetched = await _repository.GetByHashAsync(refresh.TokenHash);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Id.Should().Be(refresh.Id);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetById_ReturnsToken()
|
||||
{
|
||||
var refresh = BuildToken(Guid.NewGuid());
|
||||
await SeedUsersAsync(refresh.UserId);
|
||||
await SeedAccessTokensAsync((refresh.AccessTokenId!.Value, refresh.UserId));
|
||||
await _repository.CreateAsync(_tenantId, refresh);
|
||||
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, refresh.Id);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.UserId.Should().Be(refresh.UserId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByUserId_ReturnsUserTokens()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var t1 = BuildToken(userId);
|
||||
var t2 = BuildToken(userId);
|
||||
await SeedUsersAsync(userId);
|
||||
await SeedAccessTokensAsync((t1.AccessTokenId!.Value, userId), (t2.AccessTokenId!.Value, userId));
|
||||
await _repository.CreateAsync(_tenantId, t1);
|
||||
await _repository.CreateAsync(_tenantId, t2);
|
||||
|
||||
var tokens = await _repository.GetByUserIdAsync(_tenantId, userId);
|
||||
|
||||
tokens.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Revoke_SetsRevokedFields()
|
||||
{
|
||||
var refresh = BuildToken(Guid.NewGuid());
|
||||
await SeedUsersAsync(refresh.UserId);
|
||||
await SeedAccessTokensAsync((refresh.AccessTokenId!.Value, refresh.UserId));
|
||||
await _repository.CreateAsync(_tenantId, refresh);
|
||||
|
||||
await _repository.RevokeAsync(_tenantId, refresh.Id, "tester", Guid.Empty);
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, refresh.Id);
|
||||
|
||||
fetched!.RevokedAt.Should().NotBeNull();
|
||||
fetched.RevokedBy.Should().Be("tester");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RevokeByUserId_RevokesAllUserTokens()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var t1 = BuildToken(userId);
|
||||
var t2 = BuildToken(userId);
|
||||
await SeedUsersAsync(userId);
|
||||
await SeedAccessTokensAsync((t1.AccessTokenId!.Value, userId), (t2.AccessTokenId!.Value, userId));
|
||||
await _repository.CreateAsync(_tenantId, t1);
|
||||
await _repository.CreateAsync(_tenantId, t2);
|
||||
|
||||
await _repository.RevokeByUserIdAsync(_tenantId, userId, "bulk-revoke");
|
||||
|
||||
var revoked1 = await _repository.GetByIdAsync(_tenantId, t1.Id);
|
||||
var revoked2 = await _repository.GetByIdAsync(_tenantId, t2.Id);
|
||||
revoked1!.RevokedAt.Should().NotBeNull();
|
||||
revoked2!.RevokedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Revoke_WithReplacedBy_SetsReplacedByField()
|
||||
{
|
||||
var refresh = BuildToken(Guid.NewGuid());
|
||||
await SeedUsersAsync(refresh.UserId);
|
||||
await SeedAccessTokensAsync((refresh.AccessTokenId!.Value, refresh.UserId));
|
||||
await _repository.CreateAsync(_tenantId, refresh);
|
||||
var newTokenId = Guid.NewGuid();
|
||||
|
||||
await _repository.RevokeAsync(_tenantId, refresh.Id, "rotate", newTokenId);
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, refresh.Id);
|
||||
|
||||
fetched!.ReplacedBy.Should().Be(newTokenId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByUserId_IsDeterministic_WhenIssuedAtTies()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var issuedAt = new DateTimeOffset(2025, 11, 30, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var tokens = new[]
|
||||
{
|
||||
new RefreshTokenEntity
|
||||
{
|
||||
Id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
TenantId = _tenantId,
|
||||
UserId = userId,
|
||||
TokenHash = "hash-a",
|
||||
AccessTokenId = Guid.NewGuid(),
|
||||
IssuedAt = issuedAt,
|
||||
ExpiresAt = issuedAt.AddHours(1)
|
||||
},
|
||||
new RefreshTokenEntity
|
||||
{
|
||||
Id = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
TenantId = _tenantId,
|
||||
UserId = userId,
|
||||
TokenHash = "hash-b",
|
||||
AccessTokenId = Guid.NewGuid(),
|
||||
IssuedAt = issuedAt,
|
||||
ExpiresAt = issuedAt.AddHours(1)
|
||||
}
|
||||
};
|
||||
|
||||
await SeedUsersAsync(userId);
|
||||
await SeedAccessTokensAsync((tokens[0].AccessTokenId!.Value, userId), (tokens[1].AccessTokenId!.Value, userId));
|
||||
foreach (var token in tokens.Reverse())
|
||||
{
|
||||
await _repository.CreateAsync(_tenantId, token);
|
||||
}
|
||||
|
||||
var first = await _repository.GetByUserIdAsync(_tenantId, userId);
|
||||
var second = await _repository.GetByUserIdAsync(_tenantId, userId);
|
||||
|
||||
var expectedOrder = tokens
|
||||
.OrderByDescending(t => t.IssuedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(t => t.Id)
|
||||
.ToArray();
|
||||
|
||||
first.Select(t => t.Id).Should().ContainInOrder(expectedOrder);
|
||||
second.Should().BeEquivalentTo(first, o => o.WithStrictOrdering());
|
||||
}
|
||||
|
||||
private RefreshTokenEntity BuildToken(Guid userId) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
UserId = userId,
|
||||
TokenHash = "refresh_" + Guid.NewGuid().ToString("N"),
|
||||
AccessTokenId = Guid.NewGuid(),
|
||||
IssuedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(2)
|
||||
};
|
||||
|
||||
private Task SeedTenantAsync() =>
|
||||
_fixture.ExecuteSqlAsync(
|
||||
$"INSERT INTO authority.tenants (tenant_id, name, status, settings, metadata) " +
|
||||
$"VALUES ('{_tenantId}', 'Tenant {_tenantId}', 'active', '{{}}', '{{}}') " +
|
||||
"ON CONFLICT (tenant_id) DO NOTHING;");
|
||||
|
||||
private Task SeedUsersAsync(params Guid[] userIds)
|
||||
{
|
||||
var statements = string.Join("\n", userIds.Distinct().Select(id =>
|
||||
$"INSERT INTO authority.users (id, tenant_id, username, status) VALUES ('{id}', '{_tenantId}', 'user-{id:N}', 'active') ON CONFLICT (id) DO NOTHING;"));
|
||||
return _fixture.ExecuteSqlAsync(statements);
|
||||
}
|
||||
|
||||
private Task SeedAccessTokensAsync(params (Guid TokenId, Guid UserId)[] tokens)
|
||||
{
|
||||
var statements = string.Join("\n", tokens.Distinct().Select(t =>
|
||||
$"INSERT INTO authority.tokens (id, tenant_id, user_id, token_hash, token_type, scopes, expires_at, metadata) " +
|
||||
$"VALUES ('{t.TokenId}', '{_tenantId}', '{t.UserId}', 'seed-hash-{t.TokenId:N}', 'access', '{{}}', NOW() + INTERVAL '1 day', '{{}}') " +
|
||||
"ON CONFLICT (id) DO NOTHING;"));
|
||||
return _fixture.ExecuteSqlAsync(statements);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RoleBasedAccessTests.cs
|
||||
// Sprint: SPRINT_5100_0009_0005_authority_tests
|
||||
// Task: AUTHORITY-5100-005
|
||||
// Description: Model L0 role-based access control tests for Authority module
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Role-based access control (RBAC) tests for Authority module.
|
||||
/// Implements Model L0 (Core Logic) test requirements:
|
||||
/// - User with role gets correct permissions
|
||||
/// - User without role cannot access (deny-by-default)
|
||||
/// - Expired role assignments are not honored
|
||||
/// - Multiple roles accumulate permissions
|
||||
/// </summary>
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
public sealed class RoleBasedAccessTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
private RoleRepository _roleRepository = null!;
|
||||
private PermissionRepository _permissionRepository = null!;
|
||||
private UserRepository _userRepository = null!;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public RoleBasedAccessTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var options = _fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = _fixture.SchemaName;
|
||||
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
|
||||
|
||||
_roleRepository = new RoleRepository(dataSource, NullLogger<RoleRepository>.Instance);
|
||||
_permissionRepository = new PermissionRepository(dataSource, NullLogger<PermissionRepository>.Instance);
|
||||
_userRepository = new UserRepository(dataSource, NullLogger<UserRepository>.Instance);
|
||||
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
#region User-Role Assignment Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UserWithRole_GetsRolePermissions()
|
||||
{
|
||||
// Arrange
|
||||
var user = await CreateUserAsync("rbac-user-1");
|
||||
var role = await CreateRoleAsync("Admin");
|
||||
var permission1 = await CreatePermissionAsync("scanner", "scan");
|
||||
var permission2 = await CreatePermissionAsync("scanner", "view");
|
||||
|
||||
// Assign permissions to role
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role.Id, permission1.Id);
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role.Id, permission2.Id);
|
||||
|
||||
// Assign role to user
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user.Id, role.Id, "admin@test.com", null);
|
||||
|
||||
// Act
|
||||
var userPermissions = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user.Id);
|
||||
|
||||
// Assert
|
||||
userPermissions.Should().HaveCount(2);
|
||||
userPermissions.Should().Contain(p => p.Resource == "scanner" && p.Action == "scan");
|
||||
userPermissions.Should().Contain(p => p.Resource == "scanner" && p.Action == "view");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UserWithoutRole_HasNoPermissions_DenyByDefault()
|
||||
{
|
||||
// Arrange
|
||||
var user = await CreateUserAsync("rbac-user-no-role");
|
||||
var role = await CreateRoleAsync("Admin");
|
||||
var permission = await CreatePermissionAsync("scanner", "scan");
|
||||
|
||||
// Assign permission to role but NOT role to user
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role.Id, permission.Id);
|
||||
|
||||
// Act
|
||||
var userPermissions = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user.Id);
|
||||
var userRoles = await _roleRepository.GetUserRolesAsync(_tenantId, user.Id);
|
||||
|
||||
// Assert - Deny by default: no roles = no permissions
|
||||
userRoles.Should().BeEmpty();
|
||||
userPermissions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UserWithExpiredRole_HasNoPermissions()
|
||||
{
|
||||
// Arrange
|
||||
var user = await CreateUserAsync("rbac-user-expired");
|
||||
var role = await CreateRoleAsync("TempAdmin");
|
||||
var permission = await CreatePermissionAsync("scanner", "admin");
|
||||
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role.Id, permission.Id);
|
||||
|
||||
// Assign role with expiry in the past
|
||||
var expiredAt = DateTimeOffset.UtcNow.AddHours(-1);
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user.Id, role.Id, "admin@test.com", expiredAt);
|
||||
|
||||
// Act
|
||||
var userPermissions = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user.Id);
|
||||
var userRoles = await _roleRepository.GetUserRolesAsync(_tenantId, user.Id);
|
||||
|
||||
// Assert - Expired role should not grant permissions
|
||||
userRoles.Should().BeEmpty("expired role should not be returned");
|
||||
userPermissions.Should().BeEmpty("expired role should not grant permissions");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UserWithFutureExpiryRole_HasPermissions()
|
||||
{
|
||||
// Arrange
|
||||
var user = await CreateUserAsync("rbac-user-future");
|
||||
var role = await CreateRoleAsync("LimitedAdmin");
|
||||
var permission = await CreatePermissionAsync("policy", "read");
|
||||
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role.Id, permission.Id);
|
||||
|
||||
// Assign role with expiry in the future
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddDays(30);
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user.Id, role.Id, "admin@test.com", expiresAt);
|
||||
|
||||
// Act
|
||||
var userPermissions = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user.Id);
|
||||
var userRoles = await _roleRepository.GetUserRolesAsync(_tenantId, user.Id);
|
||||
|
||||
// Assert - Non-expired role should grant permissions
|
||||
userRoles.Should().HaveCount(1);
|
||||
userPermissions.Should().HaveCount(1);
|
||||
userPermissions.Should().Contain(p => p.Resource == "policy" && p.Action == "read");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UserWithNoExpiryRole_HasPermissions()
|
||||
{
|
||||
// Arrange
|
||||
var user = await CreateUserAsync("rbac-user-no-expiry");
|
||||
var role = await CreateRoleAsync("PermanentAdmin");
|
||||
var permission = await CreatePermissionAsync("authority", "manage");
|
||||
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role.Id, permission.Id);
|
||||
|
||||
// Assign role without expiry (null = permanent)
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user.Id, role.Id, "admin@test.com", null);
|
||||
|
||||
// Act
|
||||
var userPermissions = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user.Id);
|
||||
var userRoles = await _roleRepository.GetUserRolesAsync(_tenantId, user.Id);
|
||||
|
||||
// Assert - Permanent role should grant permissions
|
||||
userRoles.Should().HaveCount(1);
|
||||
userPermissions.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multiple Roles Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UserWithMultipleRoles_AccumulatesPermissions()
|
||||
{
|
||||
// Arrange
|
||||
var user = await CreateUserAsync("rbac-user-multi");
|
||||
var readerRole = await CreateRoleAsync("Reader");
|
||||
var writerRole = await CreateRoleAsync("Writer");
|
||||
|
||||
var readPermission = await CreatePermissionAsync("scanner", "read");
|
||||
var writePermission = await CreatePermissionAsync("scanner", "write");
|
||||
var deletePermission = await CreatePermissionAsync("scanner", "delete");
|
||||
|
||||
// Reader gets read
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, readerRole.Id, readPermission.Id);
|
||||
|
||||
// Writer gets write and delete
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, writerRole.Id, writePermission.Id);
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, writerRole.Id, deletePermission.Id);
|
||||
|
||||
// User has both roles
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user.Id, readerRole.Id, "admin@test.com", null);
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user.Id, writerRole.Id, "admin@test.com", null);
|
||||
|
||||
// Act
|
||||
var userPermissions = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user.Id);
|
||||
var userRoles = await _roleRepository.GetUserRolesAsync(_tenantId, user.Id);
|
||||
|
||||
// Assert - Permissions from both roles should be combined
|
||||
userRoles.Should().HaveCount(2);
|
||||
userPermissions.Should().HaveCount(3);
|
||||
userPermissions.Should().Contain(p => p.Action == "read");
|
||||
userPermissions.Should().Contain(p => p.Action == "write");
|
||||
userPermissions.Should().Contain(p => p.Action == "delete");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UserWithOverlappingRolePermissions_GetsDistinctPermissions()
|
||||
{
|
||||
// Arrange
|
||||
var user = await CreateUserAsync("rbac-user-overlap");
|
||||
var role1 = await CreateRoleAsync("Role1");
|
||||
var role2 = await CreateRoleAsync("Role2");
|
||||
|
||||
var sharedPermission = await CreatePermissionAsync("concelier", "view");
|
||||
var uniquePermission = await CreatePermissionAsync("concelier", "edit");
|
||||
|
||||
// Both roles have the shared permission
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role1.Id, sharedPermission.Id);
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role2.Id, sharedPermission.Id);
|
||||
|
||||
// Only role2 has unique permission
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role2.Id, uniquePermission.Id);
|
||||
|
||||
// User has both roles
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user.Id, role1.Id, "admin@test.com", null);
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user.Id, role2.Id, "admin@test.com", null);
|
||||
|
||||
// Act
|
||||
var userPermissions = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user.Id);
|
||||
|
||||
// Assert - Should get distinct permissions (no duplicates)
|
||||
userPermissions.Should().HaveCount(2);
|
||||
userPermissions.Select(p => p.Id).Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UserWithOneExpiredRole_StillHasOtherRolePermissions()
|
||||
{
|
||||
// Arrange
|
||||
var user = await CreateUserAsync("rbac-user-partial-expired");
|
||||
var permanentRole = await CreateRoleAsync("Permanent");
|
||||
var tempRole = await CreateRoleAsync("Temporary");
|
||||
|
||||
var permPerm = await CreatePermissionAsync("system", "basic");
|
||||
var tempPerm = await CreatePermissionAsync("system", "admin");
|
||||
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, permanentRole.Id, permPerm.Id);
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, tempRole.Id, tempPerm.Id);
|
||||
|
||||
// Permanent role (no expiry)
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user.Id, permanentRole.Id, "admin@test.com", null);
|
||||
|
||||
// Temporary role (expired)
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user.Id, tempRole.Id, "admin@test.com",
|
||||
DateTimeOffset.UtcNow.AddHours(-1));
|
||||
|
||||
// Act
|
||||
var userPermissions = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user.Id);
|
||||
var userRoles = await _roleRepository.GetUserRolesAsync(_tenantId, user.Id);
|
||||
|
||||
// Assert - Only permanent role and its permissions
|
||||
userRoles.Should().HaveCount(1);
|
||||
userRoles.Should().Contain(r => r.Name == "Permanent");
|
||||
|
||||
userPermissions.Should().HaveCount(1);
|
||||
userPermissions.Should().Contain(p => p.Action == "basic");
|
||||
userPermissions.Should().NotContain(p => p.Action == "admin");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Role Removal Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RemovingRole_RemovesPermissions()
|
||||
{
|
||||
// Arrange
|
||||
var user = await CreateUserAsync("rbac-user-remove");
|
||||
var role = await CreateRoleAsync("Removable");
|
||||
var permission = await CreatePermissionAsync("resource", "action");
|
||||
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role.Id, permission.Id);
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user.Id, role.Id, "admin@test.com", null);
|
||||
|
||||
// Verify permissions before removal
|
||||
var beforeRemoval = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user.Id);
|
||||
beforeRemoval.Should().HaveCount(1);
|
||||
|
||||
// Act - Remove role from user
|
||||
await _roleRepository.RemoveFromUserAsync(_tenantId, user.Id, role.Id);
|
||||
|
||||
// Assert
|
||||
var afterRemoval = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user.Id);
|
||||
var userRoles = await _roleRepository.GetUserRolesAsync(_tenantId, user.Id);
|
||||
|
||||
userRoles.Should().BeEmpty();
|
||||
afterRemoval.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RemovingPermissionFromRole_AffectsAllUsersWithRole()
|
||||
{
|
||||
// Arrange
|
||||
var user1 = await CreateUserAsync("rbac-user-a");
|
||||
var user2 = await CreateUserAsync("rbac-user-b");
|
||||
var role = await CreateRoleAsync("SharedRole");
|
||||
var permission = await CreatePermissionAsync("shared", "access");
|
||||
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role.Id, permission.Id);
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user1.Id, role.Id, "admin@test.com", null);
|
||||
await _roleRepository.AssignToUserAsync(_tenantId, user2.Id, role.Id, "admin@test.com", null);
|
||||
|
||||
// Verify both users have permission
|
||||
var user1Before = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user1.Id);
|
||||
var user2Before = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user2.Id);
|
||||
user1Before.Should().HaveCount(1);
|
||||
user2Before.Should().HaveCount(1);
|
||||
|
||||
// Act - Remove permission from role
|
||||
await _permissionRepository.RemoveFromRoleAsync(_tenantId, role.Id, permission.Id);
|
||||
|
||||
// Assert - Both users lose the permission
|
||||
var user1After = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user1.Id);
|
||||
var user2After = await _permissionRepository.GetUserPermissionsAsync(_tenantId, user2.Id);
|
||||
|
||||
user1After.Should().BeEmpty();
|
||||
user2After.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Role Permission Enforcement Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetRolePermissions_ReturnsOnlyAssignedPermissions()
|
||||
{
|
||||
// Arrange
|
||||
var role = await CreateRoleAsync("LimitedRole");
|
||||
var assignedPerm = await CreatePermissionAsync("allowed", "yes");
|
||||
var unassignedPerm = await CreatePermissionAsync("notallowed", "no");
|
||||
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, role.Id, assignedPerm.Id);
|
||||
// Note: unassignedPerm is NOT assigned to role
|
||||
|
||||
// Act
|
||||
var rolePermissions = await _permissionRepository.GetRolePermissionsAsync(_tenantId, role.Id);
|
||||
|
||||
// Assert
|
||||
rolePermissions.Should().HaveCount(1);
|
||||
rolePermissions.Should().Contain(p => p.Resource == "allowed");
|
||||
rolePermissions.Should().NotContain(p => p.Resource == "notallowed");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SystemRole_CanHaveSpecialPermissions()
|
||||
{
|
||||
// Arrange
|
||||
var systemRole = new RoleEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "SystemAdmin",
|
||||
IsSystem = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _roleRepository.CreateAsync(_tenantId, systemRole);
|
||||
|
||||
var superPermission = await CreatePermissionAsync("*", "*"); // Wildcard permission
|
||||
|
||||
await _permissionRepository.AssignToRoleAsync(_tenantId, systemRole.Id, superPermission.Id);
|
||||
|
||||
// Act
|
||||
var rolePermissions = await _permissionRepository.GetRolePermissionsAsync(_tenantId, systemRole.Id);
|
||||
|
||||
// Assert
|
||||
rolePermissions.Should().HaveCount(1);
|
||||
rolePermissions.Should().Contain(p => p.Resource == "*" && p.Action == "*");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task<UserEntity> CreateUserAsync(string username)
|
||||
{
|
||||
var user = new UserEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Username = username,
|
||||
Email = $"{username}@test.com",
|
||||
Enabled = true,
|
||||
Settings = "{}",
|
||||
Metadata = "{}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _userRepository.CreateAsync(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<RoleEntity> CreateRoleAsync(string name)
|
||||
{
|
||||
var role = new RoleEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = name,
|
||||
Description = $"{name} role",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _roleRepository.CreateAsync(_tenantId, role);
|
||||
return role;
|
||||
}
|
||||
|
||||
private async Task<PermissionEntity> CreatePermissionAsync(string resource, string action)
|
||||
{
|
||||
var permission = new PermissionEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = $"{resource}:{action}",
|
||||
Resource = resource,
|
||||
Action = action,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _permissionRepository.CreateAsync(_tenantId, permission);
|
||||
return permission;
|
||||
}
|
||||
|
||||
private Task SeedTenantAsync() =>
|
||||
_fixture.ExecuteSqlAsync(
|
||||
$"INSERT INTO authority.tenants (tenant_id, name, status, settings, metadata) " +
|
||||
$"VALUES ('{_tenantId}', 'Tenant {_tenantId}', 'active', '{{}}', '{{}}') " +
|
||||
"ON CONFLICT (tenant_id) DO NOTHING;");
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
public sealed class RoleRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
private readonly RoleRepository _repository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public RoleRepositoryTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
|
||||
_repository = new RoleRepository(dataSource, NullLogger<RoleRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGet_RoundTripsRole()
|
||||
{
|
||||
var role = BuildRole("Admin");
|
||||
await _repository.CreateAsync(_tenantId, role);
|
||||
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, role.Id);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Name.Should().Be("Admin");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByName_ReturnsCorrectRole()
|
||||
{
|
||||
var role = BuildRole("Reader");
|
||||
await _repository.CreateAsync(_tenantId, role);
|
||||
|
||||
var fetched = await _repository.GetByNameAsync(_tenantId, "Reader");
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Description.Should().Be("Reader role");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_ReturnsAllRolesForTenant()
|
||||
{
|
||||
await _repository.CreateAsync(_tenantId, BuildRole("Reader"));
|
||||
await _repository.CreateAsync(_tenantId, BuildRole("Writer"));
|
||||
|
||||
var roles = await _repository.ListAsync(_tenantId);
|
||||
|
||||
roles.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Update_ModifiesRole()
|
||||
{
|
||||
var role = BuildRole("Updater");
|
||||
await _repository.CreateAsync(_tenantId, role);
|
||||
|
||||
var updated = new RoleEntity
|
||||
{
|
||||
Id = role.Id,
|
||||
TenantId = role.TenantId,
|
||||
Name = role.Name,
|
||||
Description = "Updated description",
|
||||
DisplayName = role.DisplayName,
|
||||
IsSystem = role.IsSystem,
|
||||
Metadata = role.Metadata,
|
||||
CreatedAt = role.CreatedAt,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _repository.UpdateAsync(_tenantId, updated);
|
||||
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, role.Id);
|
||||
fetched!.Description.Should().Be("Updated description");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Delete_RemovesRole()
|
||||
{
|
||||
var role = BuildRole("Deleter");
|
||||
await _repository.CreateAsync(_tenantId, role);
|
||||
|
||||
await _repository.DeleteAsync(_tenantId, role.Id);
|
||||
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, role.Id);
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
private RoleEntity BuildRole(string name) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = name,
|
||||
Description = $"{name} role",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private Task SeedTenantAsync() =>
|
||||
_fixture.ExecuteSqlAsync(
|
||||
$"INSERT INTO authority.tenants (tenant_id, name, status, settings, metadata) " +
|
||||
$"VALUES ('{_tenantId}', 'Tenant {_tenantId}', 'active', '{{}}', '{{}}') " +
|
||||
"ON CONFLICT (tenant_id) DO NOTHING;");
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
public sealed class SessionRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
private readonly SessionRepository _repository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public SessionRepositoryTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
|
||||
_repository = new SessionRepository(dataSource, NullLogger<SessionRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGet_RoundTripsSession()
|
||||
{
|
||||
var session = BuildSession();
|
||||
await SeedUsersAsync(session.UserId);
|
||||
await _repository.CreateAsync(_tenantId, session);
|
||||
|
||||
var fetched = await _repository.GetByTokenHashAsync(session.SessionTokenHash);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Id.Should().Be(session.Id);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByTokenHash_ReturnsSession()
|
||||
{
|
||||
var session = BuildSession();
|
||||
await SeedUsersAsync(session.UserId);
|
||||
await _repository.CreateAsync(_tenantId, session);
|
||||
|
||||
var fetched = await _repository.GetByTokenHashAsync(session.SessionTokenHash);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.UserId.Should().Be(session.UserId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EndByUserId_EndsAllUserSessions()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var s1 = BuildSession(userId);
|
||||
var s2 = BuildSession(userId);
|
||||
await SeedUsersAsync(userId);
|
||||
await _repository.CreateAsync(_tenantId, s1);
|
||||
await _repository.CreateAsync(_tenantId, s2);
|
||||
|
||||
await _repository.EndByUserIdAsync(_tenantId, userId, "test-end");
|
||||
|
||||
var s1Fetched = await _repository.GetByIdAsync(_tenantId, s1.Id);
|
||||
var s2Fetched = await _repository.GetByIdAsync(_tenantId, s2.Id);
|
||||
s1Fetched!.EndedAt.Should().NotBeNull();
|
||||
s2Fetched!.EndedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
private SessionEntity BuildSession(Guid? userId = null) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
UserId = userId ?? Guid.NewGuid(),
|
||||
SessionTokenHash = "session_hash_" + Guid.NewGuid().ToString("N"),
|
||||
IpAddress = "192.168.1.1",
|
||||
UserAgent = "Mozilla/5.0",
|
||||
StartedAt = DateTimeOffset.UtcNow,
|
||||
LastActivityAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(6)
|
||||
};
|
||||
|
||||
private Task SeedTenantAsync() =>
|
||||
_fixture.ExecuteSqlAsync(
|
||||
$"INSERT INTO authority.tenants (tenant_id, name, status, settings, metadata) " +
|
||||
$"VALUES ('{_tenantId}', 'Tenant {_tenantId}', 'active', '{{}}', '{{}}') " +
|
||||
"ON CONFLICT (tenant_id) DO NOTHING;");
|
||||
|
||||
private Task SeedUsersAsync(params Guid[] userIds)
|
||||
{
|
||||
var statements = string.Join("\n", userIds.Distinct().Select(id =>
|
||||
$"INSERT INTO authority.users (id, tenant_id, username, status) VALUES ('{id}', '{_tenantId}', 'user-{id:N}', 'active') ON CONFLICT (id) DO NOTHING;"));
|
||||
return _fixture.ExecuteSqlAsync(statements);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Authority.Persistence.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Authority.Persistence\StellaOps.Authority.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,281 @@
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Tests.TestDoubles;
|
||||
|
||||
internal sealed class InMemoryTokenRepository : ITokenRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, TokenEntity> _tokens = new();
|
||||
public bool FailWrites { get; set; }
|
||||
|
||||
public Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_tokens.TryGetValue(id, out var token) && token.TenantId == tenantId ? token : null);
|
||||
|
||||
public Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_tokens.Values.FirstOrDefault(t => t.TokenHash == tokenHash));
|
||||
|
||||
public Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var list = _tokens.Values
|
||||
.Where(t => t.TenantId == tenantId && t.UserId == userId)
|
||||
.OrderByDescending(t => t.IssuedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<TokenEntity>>(list);
|
||||
}
|
||||
|
||||
public Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (FailWrites) throw new InvalidOperationException("Simulated secondary failure");
|
||||
var id = token.Id == Guid.Empty ? Guid.NewGuid() : token.Id;
|
||||
_tokens[id] = AuthorityCloneHelpers.CloneToken(token, id, tenantId);
|
||||
return Task.FromResult(id);
|
||||
}
|
||||
|
||||
public Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (FailWrites) throw new InvalidOperationException("Simulated secondary failure");
|
||||
if (_tokens.TryGetValue(id, out var token) && token.TenantId == tenantId)
|
||||
{
|
||||
_tokens[id] = AuthorityCloneHelpers.CloneToken(token, token.Id, token.TenantId, revokedAt: DateTimeOffset.UtcNow, revokedBy: revokedBy);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (FailWrites) throw new InvalidOperationException("Simulated secondary failure");
|
||||
foreach (var kvp in _tokens.Where(kvp => kvp.Value.TenantId == tenantId && kvp.Value.UserId == userId))
|
||||
{
|
||||
_tokens[kvp.Key] = AuthorityCloneHelpers.CloneToken(kvp.Value, kvp.Value.Id, kvp.Value.TenantId, revokedAt: DateTimeOffset.UtcNow, revokedBy: revokedBy);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (FailWrites) throw new InvalidOperationException("Simulated secondary failure");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var kvp in _tokens.Where(kvp => kvp.Value.ExpiresAt < now).ToList())
|
||||
{
|
||||
_tokens.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<TokenEntity> Snapshot() => _tokens.Values.ToList();
|
||||
}
|
||||
|
||||
internal sealed class InMemoryRefreshTokenRepository : IRefreshTokenRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, RefreshTokenEntity> _tokens = new();
|
||||
public bool FailWrites { get; set; }
|
||||
|
||||
public Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_tokens.TryGetValue(id, out var token) && token.TenantId == tenantId ? token : null);
|
||||
|
||||
public Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_tokens.Values.FirstOrDefault(t => t.TokenHash == tokenHash));
|
||||
|
||||
public Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var list = _tokens.Values
|
||||
.Where(t => t.TenantId == tenantId && t.UserId == userId)
|
||||
.OrderByDescending(t => t.IssuedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<RefreshTokenEntity>>(list);
|
||||
}
|
||||
|
||||
public Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (FailWrites) throw new InvalidOperationException("Simulated secondary failure");
|
||||
var id = token.Id == Guid.Empty ? Guid.NewGuid() : token.Id;
|
||||
_tokens[id] = AuthorityCloneHelpers.CloneRefresh(token, id, tenantId);
|
||||
return Task.FromResult(id);
|
||||
}
|
||||
|
||||
public Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (FailWrites) throw new InvalidOperationException("Simulated secondary failure");
|
||||
if (_tokens.TryGetValue(id, out var token) && token.TenantId == tenantId)
|
||||
{
|
||||
_tokens[id] = AuthorityCloneHelpers.CloneRefresh(token, token.Id, token.TenantId, revokedAt: DateTimeOffset.UtcNow, revokedBy: revokedBy, replacedBy: replacedBy);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (FailWrites) throw new InvalidOperationException("Simulated secondary failure");
|
||||
foreach (var kvp in _tokens.Where(kvp => kvp.Value.TenantId == tenantId && kvp.Value.UserId == userId))
|
||||
{
|
||||
_tokens[kvp.Key] = AuthorityCloneHelpers.CloneRefresh(kvp.Value, kvp.Value.Id, kvp.Value.TenantId, revokedAt: DateTimeOffset.UtcNow, revokedBy: revokedBy);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (FailWrites) throw new InvalidOperationException("Simulated secondary failure");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var kvp in _tokens.Where(kvp => kvp.Value.ExpiresAt < now).ToList())
|
||||
{
|
||||
_tokens.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<RefreshTokenEntity> Snapshot() => _tokens.Values.ToList();
|
||||
}
|
||||
|
||||
internal sealed class InMemoryUserRepository : IUserRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, UserEntity> _users = new();
|
||||
|
||||
public Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_users[user.Id] = user;
|
||||
return Task.FromResult(user);
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_users.TryGetValue(id, out var user) && user.TenantId == tenantId ? user : null);
|
||||
|
||||
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_users.Values.FirstOrDefault(u => u.TenantId == tenantId && u.Username == username));
|
||||
|
||||
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_users.Values.FirstOrDefault(u => u.TenantId == tenantId && u.Email == email));
|
||||
|
||||
public Task<IReadOnlyList<UserEntity>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filtered = _users.Values
|
||||
.Where(u => u.TenantId == tenantId && (!enabled.HasValue || u.Enabled == enabled.Value))
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<UserEntity>>(filtered);
|
||||
}
|
||||
|
||||
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_users[user.Id] = user;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_users.TryRemove(id, out _));
|
||||
|
||||
public Task<bool> UpdatePasswordAsync(string tenantId, Guid userId, string passwordHash, string passwordSalt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_users.TryGetValue(userId, out var user) && user.TenantId == tenantId)
|
||||
{
|
||||
_users[userId] = AuthorityCloneHelpers.CloneUser(user, passwordHash: passwordHash, passwordSalt: passwordSalt);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<int> RecordFailedLoginAsync(string tenantId, Guid userId, DateTimeOffset? lockUntil = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_users.TryGetValue(userId, out var user) && user.TenantId == tenantId)
|
||||
{
|
||||
_users[userId] = AuthorityCloneHelpers.CloneUser(user, failedAttempts: user.FailedLoginAttempts + 1, lockedUntil: lockUntil);
|
||||
return Task.FromResult(user.FailedLoginAttempts + 1);
|
||||
}
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task RecordSuccessfulLoginAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_users.TryGetValue(userId, out var user) && user.TenantId == tenantId)
|
||||
{
|
||||
_users[userId] = AuthorityCloneHelpers.CloneUser(user, failedAttempts: 0, lastLogin: DateTimeOffset.UtcNow, lockedUntil: null);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<UserEntity> Snapshot() => _users.Values.ToList();
|
||||
}
|
||||
|
||||
internal static class AuthorityCloneHelpers
|
||||
{
|
||||
public static TokenEntity CloneToken(
|
||||
TokenEntity source,
|
||||
Guid id,
|
||||
string tenantId,
|
||||
DateTimeOffset? revokedAt = null,
|
||||
string? revokedBy = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
UserId = source.UserId,
|
||||
TokenHash = source.TokenHash,
|
||||
TokenType = source.TokenType,
|
||||
Scopes = source.Scopes,
|
||||
ClientId = source.ClientId,
|
||||
IssuedAt = source.IssuedAt,
|
||||
ExpiresAt = source.ExpiresAt,
|
||||
RevokedAt = revokedAt ?? source.RevokedAt,
|
||||
RevokedBy = revokedBy ?? source.RevokedBy,
|
||||
Metadata = source.Metadata
|
||||
};
|
||||
|
||||
public static RefreshTokenEntity CloneRefresh(
|
||||
RefreshTokenEntity source,
|
||||
Guid id,
|
||||
string tenantId,
|
||||
DateTimeOffset? revokedAt = null,
|
||||
string? revokedBy = null,
|
||||
Guid? replacedBy = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
UserId = source.UserId,
|
||||
TokenHash = source.TokenHash,
|
||||
AccessTokenId = source.AccessTokenId,
|
||||
ClientId = source.ClientId,
|
||||
IssuedAt = source.IssuedAt,
|
||||
ExpiresAt = source.ExpiresAt,
|
||||
RevokedAt = revokedAt ?? source.RevokedAt,
|
||||
RevokedBy = revokedBy ?? source.RevokedBy,
|
||||
ReplacedBy = replacedBy ?? source.ReplacedBy,
|
||||
Metadata = source.Metadata
|
||||
};
|
||||
|
||||
public static UserEntity CloneUser(
|
||||
UserEntity source,
|
||||
string? passwordHash = null,
|
||||
string? passwordSalt = null,
|
||||
int? failedAttempts = null,
|
||||
DateTimeOffset? lockedUntil = null,
|
||||
DateTimeOffset? lastLogin = null) =>
|
||||
new()
|
||||
{
|
||||
Id = source.Id,
|
||||
TenantId = source.TenantId,
|
||||
Username = source.Username,
|
||||
Email = source.Email,
|
||||
DisplayName = source.DisplayName,
|
||||
PasswordHash = passwordHash ?? source.PasswordHash,
|
||||
PasswordSalt = passwordSalt ?? source.PasswordSalt,
|
||||
Enabled = source.Enabled,
|
||||
EmailVerified = source.EmailVerified,
|
||||
MfaEnabled = source.MfaEnabled,
|
||||
MfaSecret = source.MfaSecret,
|
||||
MfaBackupCodes = source.MfaBackupCodes,
|
||||
FailedLoginAttempts = failedAttempts ?? source.FailedLoginAttempts,
|
||||
LockedUntil = lockedUntil ?? source.LockedUntil,
|
||||
LastLoginAt = lastLogin ?? source.LastLoginAt,
|
||||
PasswordChangedAt = source.PasswordChangedAt,
|
||||
Settings = source.Settings,
|
||||
Metadata = source.Metadata,
|
||||
CreatedAt = source.CreatedAt,
|
||||
UpdatedAt = source.UpdatedAt,
|
||||
CreatedBy = source.CreatedBy
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
public sealed class TokenRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
private readonly TokenRepository _repository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public TokenRepositoryTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
|
||||
_repository = new TokenRepository(dataSource, NullLogger<TokenRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGetByHash_RoundTripsToken()
|
||||
{
|
||||
// Arrange
|
||||
var token = new TokenEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
UserId = Guid.NewGuid(),
|
||||
TokenHash = "sha256_hash_" + Guid.NewGuid().ToString("N"),
|
||||
TokenType = TokenType.Access,
|
||||
Scopes = ["read", "write"],
|
||||
ClientId = "web-app",
|
||||
IssuedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
|
||||
// Act
|
||||
await SeedUsersAsync(token.UserId!.Value);
|
||||
await _repository.CreateAsync(_tenantId, token);
|
||||
var fetched = await _repository.GetByHashAsync(token.TokenHash);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Id.Should().Be(token.Id);
|
||||
fetched.TokenType.Should().Be(TokenType.Access);
|
||||
fetched.Scopes.Should().BeEquivalentTo(["read", "write"]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetById_ReturnsToken()
|
||||
{
|
||||
// Arrange
|
||||
var token = CreateToken(Guid.NewGuid());
|
||||
await SeedUsersAsync(token.UserId!.Value);
|
||||
await _repository.CreateAsync(_tenantId, token);
|
||||
|
||||
// Act
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, token.Id);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Id.Should().Be(token.Id);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByUserId_ReturnsUserTokens()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var token1 = CreateToken(userId);
|
||||
var token2 = CreateToken(userId);
|
||||
await SeedUsersAsync(userId);
|
||||
await _repository.CreateAsync(_tenantId, token1);
|
||||
await _repository.CreateAsync(_tenantId, token2);
|
||||
|
||||
// Act
|
||||
var tokens = await _repository.GetByUserIdAsync(_tenantId, userId);
|
||||
|
||||
// Assert
|
||||
tokens.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Revoke_SetsRevokedFields()
|
||||
{
|
||||
// Arrange
|
||||
var token = CreateToken(Guid.NewGuid());
|
||||
await SeedUsersAsync(token.UserId!.Value);
|
||||
await _repository.CreateAsync(_tenantId, token);
|
||||
|
||||
// Act
|
||||
await _repository.RevokeAsync(_tenantId, token.Id, "admin@test.com");
|
||||
var fetched = await _repository.GetByIdAsync(_tenantId, token.Id);
|
||||
|
||||
// Assert
|
||||
fetched!.RevokedAt.Should().NotBeNull();
|
||||
fetched.RevokedBy.Should().Be("admin@test.com");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RevokeByUserId_RevokesAllUserTokens()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var token1 = CreateToken(userId);
|
||||
var token2 = CreateToken(userId);
|
||||
await SeedUsersAsync(userId);
|
||||
await _repository.CreateAsync(_tenantId, token1);
|
||||
await _repository.CreateAsync(_tenantId, token2);
|
||||
|
||||
// Act
|
||||
await _repository.RevokeByUserIdAsync(_tenantId, userId, "security_action");
|
||||
var revoked1 = await _repository.GetByIdAsync(_tenantId, token1.Id);
|
||||
var revoked2 = await _repository.GetByIdAsync(_tenantId, token2.Id);
|
||||
|
||||
// Assert
|
||||
revoked1!.RevokedAt.Should().NotBeNull();
|
||||
revoked2!.RevokedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByUserId_IsDeterministic_WhenIssuedAtTies()
|
||||
{
|
||||
// Arrange: same IssuedAt, fixed IDs to validate ordering
|
||||
var userId = Guid.NewGuid();
|
||||
var issuedAt = new DateTimeOffset(2025, 11, 30, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var tokens = new[]
|
||||
{
|
||||
new TokenEntity
|
||||
{
|
||||
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
TenantId = _tenantId,
|
||||
UserId = userId,
|
||||
TokenHash = "hash1-" + Guid.NewGuid().ToString("N"),
|
||||
TokenType = TokenType.Access,
|
||||
Scopes = ["a"],
|
||||
IssuedAt = issuedAt,
|
||||
ExpiresAt = issuedAt.AddHours(1)
|
||||
},
|
||||
new TokenEntity
|
||||
{
|
||||
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
TenantId = _tenantId,
|
||||
UserId = userId,
|
||||
TokenHash = "hash2-" + Guid.NewGuid().ToString("N"),
|
||||
TokenType = TokenType.Access,
|
||||
Scopes = ["a"],
|
||||
IssuedAt = issuedAt,
|
||||
ExpiresAt = issuedAt.AddHours(1)
|
||||
},
|
||||
new TokenEntity
|
||||
{
|
||||
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
|
||||
TenantId = _tenantId,
|
||||
UserId = userId,
|
||||
TokenHash = "hash3-" + Guid.NewGuid().ToString("N"),
|
||||
TokenType = TokenType.Access,
|
||||
Scopes = ["a"],
|
||||
IssuedAt = issuedAt,
|
||||
ExpiresAt = issuedAt.AddHours(1)
|
||||
}
|
||||
};
|
||||
|
||||
// Insert out of order to ensure repository enforces deterministic ordering
|
||||
await SeedUsersAsync(userId);
|
||||
foreach (var token in tokens.Reverse())
|
||||
{
|
||||
await _repository.CreateAsync(_tenantId, token);
|
||||
}
|
||||
|
||||
// Act
|
||||
var first = await _repository.GetByUserIdAsync(_tenantId, userId);
|
||||
var second = await _repository.GetByUserIdAsync(_tenantId, userId);
|
||||
|
||||
var expectedOrder = tokens
|
||||
.OrderByDescending(t => t.IssuedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(t => t.Id)
|
||||
.ToArray();
|
||||
|
||||
// Assert
|
||||
first.Select(t => t.Id).Should().ContainInOrder(expectedOrder);
|
||||
second.Should().BeEquivalentTo(first, o => o.WithStrictOrdering());
|
||||
}
|
||||
|
||||
private TokenEntity CreateToken(Guid userId) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
UserId = userId,
|
||||
TokenHash = $"sha256_{Guid.NewGuid():N}",
|
||||
TokenType = TokenType.Access,
|
||||
Scopes = ["read"],
|
||||
IssuedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
|
||||
private Task SeedTenantAsync() =>
|
||||
_fixture.ExecuteSqlAsync(
|
||||
$"INSERT INTO authority.tenants (tenant_id, name, status, settings, metadata) " +
|
||||
$"VALUES ('{_tenantId}', 'Tenant {_tenantId}', 'active', '{{}}', '{{}}') " +
|
||||
"ON CONFLICT (tenant_id) DO NOTHING;");
|
||||
|
||||
private Task SeedUsersAsync(params Guid[] userIds)
|
||||
{
|
||||
var statements = string.Join("\n", userIds.Distinct().Select(id =>
|
||||
$"INSERT INTO authority.users (id, tenant_id, username, status) VALUES ('{id}', '{_tenantId}', 'user-{id:N}', 'active') ON CONFLICT (id) DO NOTHING;"));
|
||||
return _fixture.ExecuteSqlAsync(statements);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user