up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-03 00:10:19 +02:00
parent ea1d58a89b
commit 37cba83708
158 changed files with 147438 additions and 867 deletions

View File

@@ -24,13 +24,17 @@ public sealed class ApiKeyRepositoryTests : IAsyncLifetime
_repository = new ApiKeyRepository(dataSource, NullLogger<ApiKeyRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
await SeedTenantAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGetByPrefix_RoundTripsApiKey()
{
// Arrange
var keyPrefix = "sk_live_" + Guid.NewGuid().ToString("N")[..8];
var apiKey = new ApiKeyEntity
{
@@ -45,11 +49,10 @@ public sealed class ApiKeyRepositoryTests : IAsyncLifetime
ExpiresAt = DateTimeOffset.UtcNow.AddYears(1)
};
// Act
await SeedUsersAsync(apiKey.UserId!.Value);
await _repository.CreateAsync(_tenantId, apiKey);
var fetched = await _repository.GetByPrefixAsync(keyPrefix);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(apiKey.Id);
fetched.Name.Should().Be("CI/CD Key");
@@ -59,14 +62,12 @@ public sealed class ApiKeyRepositoryTests : IAsyncLifetime
[Fact]
public async Task GetById_ReturnsApiKey()
{
// Arrange
var apiKey = CreateApiKey(Guid.NewGuid(), "Test Key");
await SeedUsersAsync(apiKey.UserId!.Value);
await _repository.CreateAsync(_tenantId, apiKey);
// Act
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Name.Should().Be("Test Key");
}
@@ -74,81 +75,57 @@ public sealed class ApiKeyRepositoryTests : IAsyncLifetime
[Fact]
public async Task GetByUserId_ReturnsUserApiKeys()
{
// Arrange
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);
// Act
var keys = await _repository.GetByUserIdAsync(_tenantId, userId);
// Assert
keys.Should().HaveCount(2);
}
[Fact]
public async Task List_ReturnsAllKeysForTenant()
{
// Arrange
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);
// Act
var keys = await _repository.ListAsync(_tenantId);
// Assert
keys.Should().HaveCount(2);
}
[Fact]
public async Task Revoke_UpdatesStatusAndRevokedFields()
{
// Arrange
var apiKey = CreateApiKey(Guid.NewGuid(), "ToRevoke");
await SeedUsersAsync(apiKey.UserId!.Value);
await _repository.CreateAsync(_tenantId, apiKey);
// Act
await _repository.RevokeAsync(_tenantId, apiKey.Id, "security@test.com");
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
// Assert
fetched!.Status.Should().Be(ApiKeyStatus.Revoked);
fetched.RevokedAt.Should().NotBeNull();
fetched.RevokedBy.Should().Be("security@test.com");
}
[Fact]
public async Task UpdateLastUsed_SetsLastUsedAt()
{
// Arrange
var apiKey = CreateApiKey(Guid.NewGuid(), "Usage Test");
await _repository.CreateAsync(_tenantId, apiKey);
// Act
await _repository.UpdateLastUsedAsync(_tenantId, apiKey.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
// Assert
fetched!.LastUsedAt.Should().NotBeNull();
fetched.LastUsedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Delete_RemovesApiKey()
{
// Arrange
var apiKey = CreateApiKey(Guid.NewGuid(), "ToDelete");
var apiKey = CreateApiKey(Guid.NewGuid(), "DeleteKey");
await SeedUsersAsync(apiKey.UserId!.Value);
await _repository.CreateAsync(_tenantId, apiKey);
// Act
await _repository.DeleteAsync(_tenantId, apiKey.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
// Assert
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
fetched.Should().BeNull();
}
@@ -158,10 +135,24 @@ public sealed class ApiKeyRepositoryTests : IAsyncLifetime
TenantId = _tenantId,
UserId = userId,
Name = name,
KeyHash = $"sha256_{Guid.NewGuid():N}",
KeyPrefix = $"sk_test_{Guid.NewGuid():N}"[..16],
KeyHash = "sha256_key_" + Guid.NewGuid().ToString("N"),
KeyPrefix = "sk_" + Guid.NewGuid().ToString("N")[..8],
Scopes = ["read"],
Status = ApiKeyStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddYears(1)
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);
}
}

View File

@@ -0,0 +1,90 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Authority.Storage.Postgres.Backfill;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Authority.Storage.Postgres.Tests.TestDoubles;
namespace StellaOps.Authority.Storage.Postgres.Tests;
public sealed class BackfillVerificationTests
{
[Fact]
public async Task Backfill_copies_tokens_and_refresh_tokens_and_checksums_match()
{
var tenantId = "tenant-a";
var primaryTokens = new InMemoryTokenRepository();
var secondaryTokens = new InMemoryTokenRepository();
var primaryRefresh = new InMemoryRefreshTokenRepository();
var secondaryRefresh = new InMemoryRefreshTokenRepository();
var primaryUsers = new InMemoryUserRepository();
var secondaryUsers = new InMemoryUserRepository();
var user = BuildUser(tenantId);
await secondaryUsers.CreateAsync(user);
var token = BuildToken(tenantId, user.Id);
var refresh = BuildRefreshToken(tenantId, user.Id, token.Id);
await secondaryTokens.CreateAsync(tenantId, token);
await secondaryRefresh.CreateAsync(tenantId, refresh);
var backfill = new AuthorityBackfillService(
primaryTokens,
secondaryTokens,
primaryRefresh,
secondaryRefresh,
primaryUsers,
secondaryUsers,
NullLogger<AuthorityBackfillService>.Instance);
var result = await backfill.BackfillAsync(tenantId);
result.TokensCopied.Should().Be(1);
result.RefreshTokensCopied.Should().Be(1);
result.ChecksumsMatch.Should().BeTrue();
primaryTokens.Snapshot().Should().ContainSingle(t => t.Id == token.Id);
primaryRefresh.Snapshot().Should().ContainSingle(t => t.Id == refresh.Id);
}
private static UserEntity BuildUser(string tenantId) => new()
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Username = "user1",
Email = "user1@example.com",
Enabled = true,
EmailVerified = true,
MfaEnabled = false,
FailedLoginAttempts = 0,
Settings = "{}",
Metadata = "{}",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
private static TokenEntity BuildToken(string tenantId, Guid userId) => new()
{
Id = Guid.NewGuid(),
TenantId = tenantId,
UserId = userId,
TokenHash = "hash-primary",
TokenType = TokenType.Access,
Scopes = new[] { "scope-a" },
ClientId = "client",
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Metadata = "{}"
};
private static RefreshTokenEntity BuildRefreshToken(string tenantId, Guid userId, Guid accessTokenId) => new()
{
Id = Guid.NewGuid(),
TenantId = tenantId,
UserId = userId,
TokenHash = "r-hash",
AccessTokenId = accessTokenId,
ClientId = "client",
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(1),
Metadata = "{}"
};
}

View File

@@ -0,0 +1,107 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.Postgres;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Authority.Storage.Postgres.Tests.TestDoubles;
namespace StellaOps.Authority.Storage.Postgres.Tests;
public sealed class DualWriteRepositoryTests
{
private static DualWriteOptions DefaultOptions() => new()
{
Enabled = true,
WriteSecondary = true,
FallbackToSecondary = true,
LogSecondaryFailuresOnly = true
};
[Fact]
public async Task Create_writes_to_primary_and_secondary()
{
var primary = new InMemoryTokenRepository();
var secondary = new InMemoryTokenRepository();
var sut = new DualWriteTokenRepository(primary, secondary, Options.Create(DefaultOptions()), new DualWriteMetrics(), NullLogger<DualWriteTokenRepository>.Instance);
var token = BuildToken();
var id = await sut.CreateAsync("tenant-a", token);
id.Should().NotBe(Guid.Empty);
primary.Snapshot().Should().ContainSingle(t => t.Id == id);
secondary.Snapshot().Should().ContainSingle(t => t.Id == id);
}
[Fact]
public async Task Read_falls_back_to_secondary_when_primary_missing()
{
var primary = new InMemoryTokenRepository();
var secondary = new InMemoryTokenRepository();
var token = BuildToken();
await secondary.CreateAsync(token.TenantId, token);
var sut = new DualWriteTokenRepository(primary, secondary, Options.Create(DefaultOptions()), new DualWriteMetrics(), NullLogger<DualWriteTokenRepository>.Instance);
var fetched = await sut.GetByIdAsync(token.TenantId, token.Id);
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(token.Id);
}
[Fact]
public async Task Secondary_failure_does_not_block_primary_when_failfast_disabled()
{
var primary = new InMemoryTokenRepository();
var secondary = new InMemoryTokenRepository { FailWrites = true };
var options = DefaultOptions();
options.FailFastOnSecondary = false;
options.LogSecondaryFailuresOnly = true;
var sut = new DualWriteTokenRepository(primary, secondary, Options.Create(options), new DualWriteMetrics(), NullLogger<DualWriteTokenRepository>.Instance);
var token = BuildToken();
await sut.Invoking(s => s.CreateAsync(token.TenantId, token)).Should().NotThrowAsync();
primary.Snapshot().Should().ContainSingle(t => t.Id == token.Id);
}
[Fact]
public async Task Refresh_tokens_dual_write_honours_secondary()
{
var primary = new InMemoryRefreshTokenRepository();
var secondary = new InMemoryRefreshTokenRepository();
var options = DefaultOptions();
var sut = new DualWriteRefreshTokenRepository(primary, secondary, Options.Create(options), new DualWriteMetrics(), NullLogger<DualWriteRefreshTokenRepository>.Instance);
var token = BuildRefreshToken();
var id = await sut.CreateAsync(token.TenantId, token);
primary.Snapshot().Should().ContainSingle(t => t.Id == id);
secondary.Snapshot().Should().ContainSingle(t => t.Id == id);
}
private static TokenEntity BuildToken() => new()
{
Id = Guid.NewGuid(),
TenantId = "tenant-a",
UserId = Guid.NewGuid(),
TokenHash = "hash-123",
TokenType = TokenType.Access,
Scopes = new[] { "scope1", "scope2" },
ClientId = "client",
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Metadata = "{}"
};
private static RefreshTokenEntity BuildRefreshToken() => new()
{
Id = Guid.NewGuid(),
TenantId = "tenant-a",
UserId = Guid.NewGuid(),
TokenHash = "r-hash-1",
AccessTokenId = Guid.NewGuid(),
ClientId = "client",
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(1),
Metadata = "{}"
};
}

View File

@@ -24,13 +24,17 @@ public sealed class PermissionRepositoryTests : IAsyncLifetime
_repository = new PermissionRepository(dataSource, NullLogger<PermissionRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
await SeedTenantAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGet_RoundTripsPermission()
{
// Arrange
var permission = new PermissionEntity
{
Id = Guid.NewGuid(),
@@ -41,11 +45,9 @@ public sealed class PermissionRepositoryTests : IAsyncLifetime
Description = "Read user data"
};
// Act
await _repository.CreateAsync(_tenantId, permission);
var fetched = await _repository.GetByIdAsync(_tenantId, permission.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Name.Should().Be("users:read");
fetched.Resource.Should().Be("users");
@@ -55,79 +57,66 @@ public sealed class PermissionRepositoryTests : IAsyncLifetime
[Fact]
public async Task GetByName_ReturnsCorrectPermission()
{
// Arrange
var permission = new PermissionEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "roles:write",
Resource = "roles",
Action = "write"
};
var permission = BuildPermission("tokens:revoke", "tokens", "revoke", "Revoke tokens");
await _repository.CreateAsync(_tenantId, permission);
// Act
var fetched = await _repository.GetByNameAsync(_tenantId, "roles:write");
var fetched = await _repository.GetByNameAsync(_tenantId, "tokens:revoke");
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(permission.Id);
}
[Fact]
public async Task List_ReturnsAllPermissionsForTenant()
{
// Arrange
var perm1 = new PermissionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "p1", Resource = "r1", Action = "a1" };
var perm2 = new PermissionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "p2", Resource = "r2", Action = "a2" };
await _repository.CreateAsync(_tenantId, perm1);
await _repository.CreateAsync(_tenantId, perm2);
// Act
var permissions = await _repository.ListAsync(_tenantId);
// Assert
permissions.Should().HaveCount(2);
fetched!.Action.Should().Be("revoke");
}
[Fact]
public async Task GetByResource_ReturnsResourcePermissions()
{
// Arrange
var perm1 = new PermissionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "scans:read", Resource = "scans", Action = "read" };
var perm2 = new PermissionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "scans:write", Resource = "scans", Action = "write" };
var perm3 = new PermissionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "users:read", Resource = "users", Action = "read" };
await _repository.CreateAsync(_tenantId, perm1);
await _repository.CreateAsync(_tenantId, perm2);
await _repository.CreateAsync(_tenantId, perm3);
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);
// Act
var permissions = await _repository.GetByResourceAsync(_tenantId, "scans");
var perms = await _repository.GetByResourceAsync(_tenantId, "users");
// Assert
permissions.Should().HaveCount(2);
permissions.Should().AllSatisfy(p => p.Resource.Should().Be("scans"));
perms.Should().HaveCount(2);
}
[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);
}
[Fact]
public async Task Delete_RemovesPermission()
{
// Arrange
var permission = new PermissionEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "temp:delete",
Resource = "temp",
Action = "delete"
};
var permission = BuildPermission("tokens:revoke", "tokens", "revoke", "Revoke tokens");
await _repository.CreateAsync(_tenantId, permission);
// Act
await _repository.DeleteAsync(_tenantId, permission.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, permission.Id);
// Assert
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;");
}

View File

@@ -25,122 +25,110 @@ public sealed class RefreshTokenRepositoryTests : IAsyncLifetime
_repository = new RefreshTokenRepository(dataSource, NullLogger<RefreshTokenRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
await SeedTenantAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGetByHash_RoundTripsRefreshToken()
{
// Arrange
var token = new RefreshTokenEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = Guid.NewGuid(),
TokenHash = "refresh_hash_" + Guid.NewGuid().ToString("N"),
AccessTokenId = Guid.NewGuid(),
ClientId = "web-app",
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
var refresh = BuildToken(Guid.NewGuid());
await SeedUsersAsync(refresh.UserId);
await SeedAccessTokensAsync((refresh.AccessTokenId!.Value, refresh.UserId));
await _repository.CreateAsync(_tenantId, refresh);
// Act
await _repository.CreateAsync(_tenantId, token);
var fetched = await _repository.GetByHashAsync(token.TokenHash);
var fetched = await _repository.GetByHashAsync(refresh.TokenHash);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(token.Id);
fetched.ClientId.Should().Be("web-app");
fetched!.Id.Should().Be(refresh.Id);
}
[Fact]
public async Task GetById_ReturnsToken()
{
// Arrange
var token = CreateRefreshToken(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, token);
var refresh = BuildToken(Guid.NewGuid());
await SeedUsersAsync(refresh.UserId);
await SeedAccessTokensAsync((refresh.AccessTokenId!.Value, refresh.UserId));
await _repository.CreateAsync(_tenantId, refresh);
// Act
var fetched = await _repository.GetByIdAsync(_tenantId, token.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, refresh.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(token.Id);
fetched!.UserId.Should().Be(refresh.UserId);
}
[Fact]
public async Task GetByUserId_ReturnsUserTokens()
{
// Arrange
var userId = Guid.NewGuid();
var token1 = CreateRefreshToken(userId);
var token2 = CreateRefreshToken(userId);
await _repository.CreateAsync(_tenantId, token1);
await _repository.CreateAsync(_tenantId, token2);
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);
// Act
var tokens = await _repository.GetByUserIdAsync(_tenantId, userId);
// Assert
tokens.Should().HaveCount(2);
}
[Fact]
public async Task Revoke_SetsRevokedFields()
{
// Arrange
var token = CreateRefreshToken(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, token);
var refresh = BuildToken(Guid.NewGuid());
await SeedUsersAsync(refresh.UserId);
await SeedAccessTokensAsync((refresh.AccessTokenId!.Value, refresh.UserId));
await _repository.CreateAsync(_tenantId, refresh);
// Act
await _repository.RevokeAsync(_tenantId, token.Id, "admin@test.com", null);
var fetched = await _repository.GetByHashAsync(token.TokenHash);
await _repository.RevokeAsync(_tenantId, refresh.Id, "tester", Guid.Empty);
var fetched = await _repository.GetByIdAsync(_tenantId, refresh.Id);
// Assert
fetched!.RevokedAt.Should().NotBeNull();
fetched.RevokedBy.Should().Be("admin@test.com");
}
[Fact]
public async Task Revoke_WithReplacedBy_SetsReplacedByField()
{
// Arrange
var token = CreateRefreshToken(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, token);
var newTokenId = Guid.NewGuid();
// Act
await _repository.RevokeAsync(_tenantId, token.Id, "rotation", newTokenId);
var fetched = await _repository.GetByHashAsync(token.TokenHash);
// Assert
fetched!.RevokedAt.Should().NotBeNull();
fetched.ReplacedBy.Should().Be(newTokenId);
fetched.RevokedBy.Should().Be("tester");
}
[Fact]
public async Task RevokeByUserId_RevokesAllUserTokens()
{
// Arrange
var userId = Guid.NewGuid();
var token1 = CreateRefreshToken(userId);
var token2 = CreateRefreshToken(userId);
await _repository.CreateAsync(_tenantId, token1);
await _repository.CreateAsync(_tenantId, token2);
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);
// Act
await _repository.RevokeByUserIdAsync(_tenantId, userId, "security_action");
var tokens = await _repository.GetByUserIdAsync(_tenantId, userId);
await _repository.RevokeByUserIdAsync(_tenantId, userId, "bulk-revoke");
// Assert
tokens.Should().AllSatisfy(t => t.RevokedAt.Should().NotBeNull());
var revoked1 = await _repository.GetByIdAsync(_tenantId, t1.Id);
var revoked2 = await _repository.GetByIdAsync(_tenantId, t2.Id);
revoked1!.RevokedAt.Should().NotBeNull();
revoked2!.RevokedAt.Should().NotBeNull();
}
[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);
}
[Fact]
public async Task GetByUserId_IsDeterministic_WhenIssuedAtTies()
{
// Arrange: fixed IDs with same IssuedAt to assert stable ordering
var userId = Guid.NewGuid();
var issuedAt = new DateTimeOffset(2025, 11, 30, 12, 0, 0, TimeSpan.Zero);
@@ -151,42 +139,30 @@ public sealed class RefreshTokenRepositoryTests : IAsyncLifetime
Id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
TenantId = _tenantId,
UserId = userId,
TokenHash = "rhash1-" + Guid.NewGuid().ToString("N"),
AccessTokenId = Guid.Parse("10000000-0000-0000-0000-000000000000"),
ClientId = "web-app",
TokenHash = "hash-a",
AccessTokenId = Guid.NewGuid(),
IssuedAt = issuedAt,
ExpiresAt = issuedAt.AddDays(30)
ExpiresAt = issuedAt.AddHours(1)
},
new RefreshTokenEntity
{
Id = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
TenantId = _tenantId,
UserId = userId,
TokenHash = "rhash2-" + Guid.NewGuid().ToString("N"),
AccessTokenId = Guid.Parse("20000000-0000-0000-0000-000000000000"),
ClientId = "web-app",
TokenHash = "hash-b",
AccessTokenId = Guid.NewGuid(),
IssuedAt = issuedAt,
ExpiresAt = issuedAt.AddDays(30)
},
new RefreshTokenEntity
{
Id = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
TenantId = _tenantId,
UserId = userId,
TokenHash = "rhash3-" + Guid.NewGuid().ToString("N"),
AccessTokenId = Guid.Parse("30000000-0000-0000-0000-000000000000"),
ClientId = "web-app",
IssuedAt = issuedAt,
ExpiresAt = issuedAt.AddDays(30)
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);
}
// Act
var first = await _repository.GetByUserIdAsync(_tenantId, userId);
var second = await _repository.GetByUserIdAsync(_tenantId, userId);
@@ -196,18 +172,40 @@ public sealed class RefreshTokenRepositoryTests : IAsyncLifetime
.Select(t => t.Id)
.ToArray();
// Assert
first.Select(t => t.Id).Should().ContainInOrder(expectedOrder);
second.Should().BeEquivalentTo(first, o => o.WithStrictOrdering());
}
private RefreshTokenEntity CreateRefreshToken(Guid userId) => new()
private RefreshTokenEntity BuildToken(Guid userId) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
TokenHash = $"refresh_{Guid.NewGuid():N}",
TokenHash = "refresh_" + Guid.NewGuid().ToString("N"),
AccessTokenId = Guid.NewGuid(),
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
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);
}
}

View File

@@ -24,117 +24,99 @@ public sealed class RoleRepositoryTests : IAsyncLifetime
_repository = new RoleRepository(dataSource, NullLogger<RoleRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
await SeedTenantAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGet_RoundTripsRole()
{
// Arrange
var role = new RoleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "admin",
DisplayName = "Administrator",
Description = "Full system access",
IsSystem = true,
Metadata = "{\"level\": 1}"
};
// Act
var role = BuildRole("Admin");
await _repository.CreateAsync(_tenantId, role);
var fetched = await _repository.GetByIdAsync(_tenantId, role.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(role.Id);
fetched.Name.Should().Be("admin");
fetched.DisplayName.Should().Be("Administrator");
fetched.IsSystem.Should().BeTrue();
fetched!.Name.Should().Be("Admin");
}
[Fact]
public async Task GetByName_ReturnsCorrectRole()
{
// Arrange
var role = new RoleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "viewer",
DisplayName = "Viewer",
Description = "Read-only access"
};
var role = BuildRole("Reader");
await _repository.CreateAsync(_tenantId, role);
// Act
var fetched = await _repository.GetByNameAsync(_tenantId, "viewer");
var fetched = await _repository.GetByNameAsync(_tenantId, "Reader");
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(role.Id);
fetched!.Description.Should().Be("Reader role");
}
[Fact]
public async Task List_ReturnsAllRolesForTenant()
{
// Arrange
var role1 = new RoleEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "role1" };
var role2 = new RoleEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "role2" };
await _repository.CreateAsync(_tenantId, role1);
await _repository.CreateAsync(_tenantId, role2);
await _repository.CreateAsync(_tenantId, BuildRole("Reader"));
await _repository.CreateAsync(_tenantId, BuildRole("Writer"));
// Act
var roles = await _repository.ListAsync(_tenantId);
// Assert
roles.Should().HaveCount(2);
roles.Select(r => r.Name).Should().Contain(["role1", "role2"]);
}
[Fact]
public async Task Update_ModifiesRole()
{
// Arrange
var role = new RoleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "editor",
DisplayName = "Editor"
};
var role = BuildRole("Updater");
await _repository.CreateAsync(_tenantId, role);
// Act
var updated = new RoleEntity
{
Id = role.Id,
TenantId = _tenantId,
Name = "editor",
DisplayName = "Content Editor",
Description = "Updated description"
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);
// Assert
fetched!.DisplayName.Should().Be("Content Editor");
fetched.Description.Should().Be("Updated description");
await _repository.UpdateAsync(_tenantId, updated);
var fetched = await _repository.GetByIdAsync(_tenantId, role.Id);
fetched!.Description.Should().Be("Updated description");
}
[Fact]
public async Task Delete_RemovesRole()
{
// Arrange
var role = new RoleEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "temp" };
var role = BuildRole("Deleter");
await _repository.CreateAsync(_tenantId, role);
// Act
await _repository.DeleteAsync(_tenantId, role.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, role.Id);
// Assert
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;");
}

View File

@@ -24,156 +24,81 @@ public sealed class SessionRepositoryTests : IAsyncLifetime
_repository = new SessionRepository(dataSource, NullLogger<SessionRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
await SeedTenantAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGet_RoundTripsSession()
{
// Arrange
var session = new SessionEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
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.AddDays(7)
};
// Act
var session = BuildSession();
await SeedUsersAsync(session.UserId);
await _repository.CreateAsync(_tenantId, session);
var fetched = await _repository.GetByIdAsync(_tenantId, session.Id);
// Assert
var fetched = await _repository.GetByTokenHashAsync(session.SessionTokenHash);
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(session.Id);
fetched.IpAddress.Should().Be("192.168.1.1");
fetched.UserAgent.Should().Be("Mozilla/5.0");
}
[Fact]
public async Task GetByTokenHash_ReturnsSession()
{
// Arrange
var tokenHash = "lookup_hash_" + Guid.NewGuid().ToString("N");
var session = new SessionEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = Guid.NewGuid(),
SessionTokenHash = tokenHash,
StartedAt = DateTimeOffset.UtcNow,
LastActivityAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
};
var session = BuildSession();
await SeedUsersAsync(session.UserId);
await _repository.CreateAsync(_tenantId, session);
// Act
var fetched = await _repository.GetByTokenHashAsync(tokenHash);
var fetched = await _repository.GetByTokenHashAsync(session.SessionTokenHash);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(session.Id);
}
[Fact]
public async Task GetByUserId_WithActiveOnly_ReturnsOnlyActiveSessions()
{
// Arrange
var userId = Guid.NewGuid();
var activeSession = CreateSession(userId);
var endedSession = new SessionEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
SessionTokenHash = "ended_" + Guid.NewGuid().ToString("N"),
StartedAt = DateTimeOffset.UtcNow.AddHours(-2),
LastActivityAt = DateTimeOffset.UtcNow.AddHours(-1),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7),
EndedAt = DateTimeOffset.UtcNow,
EndReason = "logout"
};
await _repository.CreateAsync(_tenantId, activeSession);
await _repository.CreateAsync(_tenantId, endedSession);
// Act
var activeSessions = await _repository.GetByUserIdAsync(_tenantId, userId, activeOnly: true);
var allSessions = await _repository.GetByUserIdAsync(_tenantId, userId, activeOnly: false);
// Assert
activeSessions.Should().HaveCount(1);
allSessions.Should().HaveCount(2);
}
[Fact]
public async Task UpdateLastActivity_UpdatesTimestamp()
{
// Arrange
var session = CreateSession(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, session);
// Act
await Task.Delay(100); // Ensure time difference
await _repository.UpdateLastActivityAsync(_tenantId, session.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, session.Id);
// Assert
fetched!.LastActivityAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task End_SetsEndFieldsCorrectly()
{
// Arrange
var session = CreateSession(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, session);
// Act
await _repository.EndAsync(_tenantId, session.Id, "session_timeout");
var fetched = await _repository.GetByIdAsync(_tenantId, session.Id);
// Assert
fetched!.EndedAt.Should().NotBeNull();
fetched.EndReason.Should().Be("session_timeout");
fetched!.UserId.Should().Be(session.UserId);
}
[Fact]
public async Task EndByUserId_EndsAllUserSessions()
{
// Arrange
var userId = Guid.NewGuid();
var session1 = CreateSession(userId);
var session2 = CreateSession(userId);
await _repository.CreateAsync(_tenantId, session1);
await _repository.CreateAsync(_tenantId, session2);
var s1 = BuildSession(userId);
var s2 = BuildSession(userId);
await SeedUsersAsync(userId);
await _repository.CreateAsync(_tenantId, s1);
await _repository.CreateAsync(_tenantId, s2);
// Act
await _repository.EndByUserIdAsync(_tenantId, userId, "forced_logout");
var sessions = await _repository.GetByUserIdAsync(_tenantId, userId, activeOnly: false);
await _repository.EndByUserIdAsync(_tenantId, userId, "test-end");
// Assert
sessions.Should().HaveCount(2);
sessions.Should().AllSatisfy(s =>
{
s.EndedAt.Should().NotBeNull();
s.EndReason.Should().Be("forced_logout");
});
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 CreateSession(Guid userId) => new()
private SessionEntity BuildSession(Guid? userId = null) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
SessionTokenHash = $"session_{Guid.NewGuid():N}",
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.AddDays(7)
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);
}
}

View File

@@ -13,6 +13,8 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">

View File

@@ -0,0 +1,281 @@
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using System.Collections.Concurrent;
namespace StellaOps.Authority.Storage.Postgres.Tests.TestDoubles;
internal sealed class InMemoryTokenRepository : ITokenRepository, ISecondaryTokenRepository
{
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, ISecondaryRefreshTokenRepository
{
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, ISecondaryUserRepository
{
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
};
}

View File

@@ -25,7 +25,11 @@ public sealed class TokenRepositoryTests : IAsyncLifetime
_repository = new TokenRepository(dataSource, NullLogger<TokenRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
await SeedTenantAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
@@ -46,6 +50,7 @@ public sealed class TokenRepositoryTests : IAsyncLifetime
};
// Act
await SeedUsersAsync(token.UserId!.Value);
await _repository.CreateAsync(_tenantId, token);
var fetched = await _repository.GetByHashAsync(token.TokenHash);
@@ -61,6 +66,7 @@ public sealed class TokenRepositoryTests : IAsyncLifetime
{
// Arrange
var token = CreateToken(Guid.NewGuid());
await SeedUsersAsync(token.UserId!.Value);
await _repository.CreateAsync(_tenantId, token);
// Act
@@ -78,6 +84,7 @@ public sealed class TokenRepositoryTests : IAsyncLifetime
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);
@@ -93,11 +100,12 @@ public sealed class TokenRepositoryTests : IAsyncLifetime
{
// 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.GetByHashAsync(token.TokenHash);
var fetched = await _repository.GetByIdAsync(_tenantId, token.Id);
// Assert
fetched!.RevokedAt.Should().NotBeNull();
@@ -111,15 +119,18 @@ public sealed class TokenRepositoryTests : IAsyncLifetime
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 tokens = await _repository.GetByUserIdAsync(_tenantId, userId);
var revoked1 = await _repository.GetByIdAsync(_tenantId, token1.Id);
var revoked2 = await _repository.GetByIdAsync(_tenantId, token2.Id);
// Assert
tokens.Should().AllSatisfy(t => t.RevokedAt.Should().NotBeNull());
revoked1!.RevokedAt.Should().NotBeNull();
revoked2!.RevokedAt.Should().NotBeNull();
}
[Fact]
@@ -162,11 +173,12 @@ public sealed class TokenRepositoryTests : IAsyncLifetime
TokenType = TokenType.Access,
Scopes = ["a"],
IssuedAt = issuedAt,
ExpiresAt = issuedAt.AddHours(1)
}
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);
@@ -198,4 +210,17 @@ public sealed class TokenRepositoryTests : IAsyncLifetime
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);
}
}