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
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:
@@ -14,7 +14,7 @@ public sealed class AuthorityDataSource : DataSourceBase
|
||||
/// <summary>
|
||||
/// Default schema name for Authority tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "auth";
|
||||
public const string DefaultSchemaName = "authority";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Authority data source.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Backfill;
|
||||
|
||||
/// <summary>
|
||||
/// Performs one-way backfill from the secondary (legacy) store into the primary PostgreSQL store.
|
||||
/// </summary>
|
||||
public sealed class AuthorityBackfillService
|
||||
{
|
||||
private readonly ITokenRepository _primaryTokens;
|
||||
private readonly ISecondaryTokenRepository _secondaryTokens;
|
||||
private readonly IRefreshTokenRepository _primaryRefreshTokens;
|
||||
private readonly ISecondaryRefreshTokenRepository _secondaryRefreshTokens;
|
||||
private readonly IUserRepository _primaryUsers;
|
||||
private readonly ISecondaryUserRepository _secondaryUsers;
|
||||
private readonly ILogger<AuthorityBackfillService> _logger;
|
||||
|
||||
public AuthorityBackfillService(
|
||||
ITokenRepository primaryTokens,
|
||||
ISecondaryTokenRepository secondaryTokens,
|
||||
IRefreshTokenRepository primaryRefreshTokens,
|
||||
ISecondaryRefreshTokenRepository secondaryRefreshTokens,
|
||||
IUserRepository primaryUsers,
|
||||
ISecondaryUserRepository secondaryUsers,
|
||||
ILogger<AuthorityBackfillService> logger)
|
||||
{
|
||||
_primaryTokens = primaryTokens;
|
||||
_secondaryTokens = secondaryTokens;
|
||||
_primaryRefreshTokens = primaryRefreshTokens;
|
||||
_secondaryRefreshTokens = secondaryRefreshTokens;
|
||||
_primaryUsers = primaryUsers;
|
||||
_secondaryUsers = secondaryUsers;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<BackfillResult> BackfillAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var users = await _secondaryUsers.GetAllAsync(tenantId, null, int.MaxValue, 0, cancellationToken).ConfigureAwait(false);
|
||||
var tokensCopied = 0;
|
||||
var tokensSkipped = 0;
|
||||
var refreshCopied = 0;
|
||||
var refreshSkipped = 0;
|
||||
var primaryTokensSnapshot = new List<TokenEntity>();
|
||||
var secondaryTokensSnapshot = new List<TokenEntity>();
|
||||
var primaryRefreshSnapshot = new List<RefreshTokenEntity>();
|
||||
var secondaryRefreshSnapshot = new List<RefreshTokenEntity>();
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var primaryUser = await _primaryUsers.GetByIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (primaryUser is null)
|
||||
{
|
||||
await _primaryUsers.CreateAsync(user, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var secondaryTokens = await _secondaryTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
var primaryTokens = await _primaryTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
primaryTokensSnapshot.AddRange(primaryTokens);
|
||||
secondaryTokensSnapshot.AddRange(secondaryTokens);
|
||||
foreach (var token in secondaryTokens)
|
||||
{
|
||||
if (await _primaryTokens.GetByIdAsync(tenantId, token.Id, cancellationToken).ConfigureAwait(false) is null)
|
||||
{
|
||||
await _primaryTokens.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
|
||||
primaryTokensSnapshot.Add(token);
|
||||
tokensCopied++;
|
||||
}
|
||||
else
|
||||
{
|
||||
tokensSkipped++;
|
||||
}
|
||||
}
|
||||
|
||||
var secondaryRefreshTokens = await _secondaryRefreshTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
var primaryRefreshTokens = await _primaryRefreshTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
primaryRefreshSnapshot.AddRange(primaryRefreshTokens);
|
||||
secondaryRefreshSnapshot.AddRange(secondaryRefreshTokens);
|
||||
foreach (var refresh in secondaryRefreshTokens)
|
||||
{
|
||||
if (await _primaryRefreshTokens.GetByIdAsync(tenantId, refresh.Id, cancellationToken).ConfigureAwait(false) is null)
|
||||
{
|
||||
await _primaryRefreshTokens.CreateAsync(tenantId, refresh, cancellationToken).ConfigureAwait(false);
|
||||
primaryRefreshSnapshot.Add(refresh);
|
||||
refreshCopied++;
|
||||
}
|
||||
else
|
||||
{
|
||||
refreshSkipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var secondaryChecksum = ComputeChecksums(secondaryTokensSnapshot, secondaryRefreshSnapshot);
|
||||
var primaryChecksum = ComputeChecksums(primaryTokensSnapshot, primaryRefreshSnapshot);
|
||||
|
||||
return new BackfillResult(
|
||||
tenantId,
|
||||
users.Count,
|
||||
tokensCopied,
|
||||
tokensSkipped,
|
||||
refreshCopied,
|
||||
refreshSkipped,
|
||||
primaryChecksum,
|
||||
secondaryChecksum);
|
||||
}
|
||||
|
||||
private static BackfillChecksum ComputeChecksums(
|
||||
IReadOnlyCollection<TokenEntity> tokens,
|
||||
IReadOnlyCollection<RefreshTokenEntity> refreshTokens)
|
||||
{
|
||||
var tokenHash = ComputeHash(tokens.Select(t =>
|
||||
$"{t.Id}|{t.TenantId}|{t.UserId}|{t.TokenHash}|{t.TokenType}|{t.ExpiresAt.UtcDateTime:o}|{t.RevokedAt?.UtcDateTime:o}|{t.RevokedBy}|{string.Join(',', t.Scopes)}"));
|
||||
var refreshHash = ComputeHash(refreshTokens.Select(t =>
|
||||
$"{t.Id}|{t.TenantId}|{t.UserId}|{t.TokenHash}|{t.AccessTokenId}|{t.ClientId}|{t.ExpiresAt.UtcDateTime:o}|{t.RevokedAt?.UtcDateTime:o}|{t.RevokedBy}|{t.ReplacedBy}"));
|
||||
|
||||
return new BackfillChecksum(tokens.Count, refreshTokens.Count, tokenHash, refreshHash);
|
||||
}
|
||||
|
||||
private static string ComputeHash(IEnumerable<string> lines)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
foreach (var line in lines.OrderBy(l => l, StringComparer.Ordinal))
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(line);
|
||||
sha.TransformBlock(bytes, 0, bytes.Length, null, 0);
|
||||
}
|
||||
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
||||
return Convert.ToHexString(sha.Hash ?? Array.Empty<byte>());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BackfillChecksum(int TokenCount, int RefreshTokenCount, string TokenChecksum, string RefreshTokenChecksum);
|
||||
|
||||
public sealed record BackfillResult(
|
||||
string TenantId,
|
||||
int UsersProcessed,
|
||||
int TokensCopied,
|
||||
int TokensSkipped,
|
||||
int RefreshTokensCopied,
|
||||
int RefreshTokensSkipped,
|
||||
BackfillChecksum PrimaryChecksum,
|
||||
BackfillChecksum SecondaryChecksum)
|
||||
{
|
||||
public bool ChecksumsMatch =>
|
||||
PrimaryChecksum.TokenChecksum == SecondaryChecksum.TokenChecksum &&
|
||||
PrimaryChecksum.RefreshTokenChecksum == SecondaryChecksum.RefreshTokenChecksum &&
|
||||
PrimaryChecksum.TokenCount == SecondaryChecksum.TokenCount &&
|
||||
PrimaryChecksum.RefreshTokenCount == SecondaryChecksum.RefreshTokenCount;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Captures counters for dual-write operations to aid verification during cutover.
|
||||
/// </summary>
|
||||
public sealed class DualWriteMetrics : IDisposable
|
||||
{
|
||||
private readonly Meter _meter = new("StellaOps.Authority.Storage.Postgres.DualWrite", "1.0.0");
|
||||
private readonly Counter<long> _primaryWrites;
|
||||
private readonly Counter<long> _secondaryWrites;
|
||||
private readonly Counter<long> _secondaryWriteFailures;
|
||||
private readonly Counter<long> _fallbackReads;
|
||||
|
||||
public DualWriteMetrics()
|
||||
{
|
||||
_primaryWrites = _meter.CreateCounter<long>("authority.dualwrite.primary.writes");
|
||||
_secondaryWrites = _meter.CreateCounter<long>("authority.dualwrite.secondary.writes");
|
||||
_secondaryWriteFailures = _meter.CreateCounter<long>("authority.dualwrite.secondary.write.failures");
|
||||
_fallbackReads = _meter.CreateCounter<long>("authority.dualwrite.fallback.reads");
|
||||
}
|
||||
|
||||
public void RecordPrimaryWrite() => _primaryWrites.Add(1);
|
||||
public void RecordSecondaryWrite() => _secondaryWrites.Add(1);
|
||||
public void RecordSecondaryWriteFailure() => _secondaryWriteFailures.Add(1);
|
||||
public void RecordFallbackRead() => _fallbackReads.Add(1);
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling dual-write behaviour during Mongo → PostgreSQL cutover.
|
||||
/// </summary>
|
||||
public sealed class DualWriteOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether dual-write is enabled. When false, repositories run primary-only.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, write operations are attempted against both primary and secondary repositories.
|
||||
/// </summary>
|
||||
public bool WriteSecondary { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, reads will fall back to the secondary repository if the primary has no result.
|
||||
/// </summary>
|
||||
public bool FallbackToSecondary { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, any secondary write failure is logged but does not throw; primary success is preserved.
|
||||
/// </summary>
|
||||
public bool LogSecondaryFailuresOnly { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, secondary write/read failures propagate to callers.
|
||||
/// </summary>
|
||||
public bool FailFastOnSecondary { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tag describing which backend is primary (for metrics/logging only).
|
||||
/// </summary>
|
||||
[AllowNull]
|
||||
public string PrimaryBackend { get; set; } = "Postgres";
|
||||
|
||||
/// <summary>
|
||||
/// Optional tag describing which backend is secondary (for metrics/logging only).
|
||||
/// </summary>
|
||||
[AllowNull]
|
||||
public string? SecondaryBackend { get; set; } = "Mongo";
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Decorator that writes refresh tokens to both primary and secondary stores during cutover.
|
||||
/// </summary>
|
||||
public sealed class DualWriteRefreshTokenRepository : IRefreshTokenRepository
|
||||
{
|
||||
private readonly IRefreshTokenRepository _primary;
|
||||
private readonly ISecondaryRefreshTokenRepository _secondary;
|
||||
private readonly DualWriteOptions _options;
|
||||
private readonly DualWriteMetrics _metrics;
|
||||
private readonly ILogger<DualWriteRefreshTokenRepository> _logger;
|
||||
|
||||
public DualWriteRefreshTokenRepository(
|
||||
IRefreshTokenRepository primary,
|
||||
ISecondaryRefreshTokenRepository secondary,
|
||||
IOptions<DualWriteOptions> options,
|
||||
DualWriteMetrics metrics,
|
||||
ILogger<DualWriteRefreshTokenRepository> logger)
|
||||
{
|
||||
_primary = primary;
|
||||
_secondary = secondary;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByIdAsync(tenantId, id, cancellationToken).ConfigureAwait(false);
|
||||
if (primary is not null || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByIdAsync(tenantId, id, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary is not null)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback refresh token hit for tenant {TenantId} token {TokenId}", tenantId, id);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
|
||||
if (primary is not null || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByHashAsync(tokenHash, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary is not null)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback refresh token hash hit for {Hash}", tokenHash);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByUserIdAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
if (primary.Count > 0 || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByUserIdAsync(tenantId, userId, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary.Count > 0)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback refresh tokens for tenant {TenantId} user {UserId}", tenantId, userId);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = await _primary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(async () =>
|
||||
{
|
||||
await _secondary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
|
||||
}, tenantId, token.Id);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.RevokeAsync(tenantId, id, revokedBy, replacedBy, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.RevokeAsync(tenantId, id, revokedBy, replacedBy, cancellationToken), tenantId, id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken), tenantId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.DeleteExpiredAsync(cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.DeleteExpiredAsync(cancellationToken), tenantId: "system", id: Guid.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T> SafeSecondaryCall<T>(Func<Task<T>> call)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await call().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Dual-write secondary refresh read failed for backend {Backend}", _options.SecondaryBackend);
|
||||
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
return default!;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SafeSecondaryWrite(Func<Task> call, string tenantId, Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await call().ConfigureAwait(false);
|
||||
_metrics.RecordSecondaryWrite();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.RecordSecondaryWriteFailure();
|
||||
_logger.LogWarning(ex,
|
||||
"Dual-write secondary refresh write failed for tenant {TenantId}, id {Id}, primary={Primary}, secondary={Secondary}",
|
||||
tenantId,
|
||||
id,
|
||||
_options.PrimaryBackend,
|
||||
_options.SecondaryBackend);
|
||||
|
||||
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Decorator that writes to both primary (PostgreSQL) and secondary (legacy/Mongo) stores during cutover.
|
||||
/// </summary>
|
||||
public sealed class DualWriteTokenRepository : ITokenRepository
|
||||
{
|
||||
private readonly ITokenRepository _primary;
|
||||
private readonly ISecondaryTokenRepository _secondary;
|
||||
private readonly DualWriteOptions _options;
|
||||
private readonly DualWriteMetrics _metrics;
|
||||
private readonly ILogger<DualWriteTokenRepository> _logger;
|
||||
|
||||
public DualWriteTokenRepository(
|
||||
ITokenRepository primary,
|
||||
ISecondaryTokenRepository secondary,
|
||||
IOptions<DualWriteOptions> options,
|
||||
DualWriteMetrics metrics,
|
||||
ILogger<DualWriteTokenRepository> logger)
|
||||
{
|
||||
_primary = primary;
|
||||
_secondary = secondary;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByIdAsync(tenantId, id, cancellationToken).ConfigureAwait(false);
|
||||
if (primary is not null || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByIdAsync(tenantId, id, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary is not null)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback token hit for tenant {TenantId} token {TokenId}", tenantId, id);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
|
||||
if (primary is not null || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByHashAsync(tokenHash, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary is not null)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback token hash hit for {Hash}", tokenHash);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByUserIdAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
if (primary.Count > 0 || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByUserIdAsync(tenantId, userId, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary.Count > 0)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback tokens for tenant {TenantId} user {UserId}", tenantId, userId);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = await _primary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(async () =>
|
||||
{
|
||||
await _secondary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
|
||||
}, tenantId, token.Id);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.RevokeAsync(tenantId, id, revokedBy, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.RevokeAsync(tenantId, id, revokedBy, cancellationToken), tenantId, id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken), tenantId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.DeleteExpiredAsync(cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.DeleteExpiredAsync(cancellationToken), tenantId: "system", id: Guid.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T> SafeSecondaryCall<T>(Func<Task<T>> call)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await call().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Dual-write secondary read failed for backend {Backend}", _options.SecondaryBackend);
|
||||
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
return default!;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SafeSecondaryWrite(Func<Task> call, string tenantId, Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await call().ConfigureAwait(false);
|
||||
_metrics.RecordSecondaryWrite();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.RecordSecondaryWriteFailure();
|
||||
_logger.LogWarning(ex,
|
||||
"Dual-write secondary write failed for tenant {TenantId}, id {Id}, primary={Primary}, secondary={Secondary}",
|
||||
tenantId,
|
||||
id,
|
||||
_options.PrimaryBackend,
|
||||
_options.SecondaryBackend);
|
||||
|
||||
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for secondary (legacy/Mongo) token repository.
|
||||
/// </summary>
|
||||
public interface ISecondaryTokenRepository : ITokenRepository { }
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for secondary refresh token repository.
|
||||
/// </summary>
|
||||
public interface ISecondaryRefreshTokenRepository : IRefreshTokenRepository { }
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for secondary user repository.
|
||||
/// </summary>
|
||||
public interface ISecondaryUserRepository : IUserRepository { }
|
||||
|
||||
/// <summary>
|
||||
/// No-op secondary token repository used when dual-write is enabled without a configured secondary backend.
|
||||
/// </summary>
|
||||
internal sealed class NullSecondaryTokenRepository : ISecondaryTokenRepository
|
||||
{
|
||||
public Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<TokenEntity?>(null);
|
||||
|
||||
public Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<TokenEntity?>(null);
|
||||
|
||||
public Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<TokenEntity>>(Array.Empty<TokenEntity>());
|
||||
|
||||
public Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(token.Id == Guid.Empty ? Guid.NewGuid() : token.Id);
|
||||
|
||||
public Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task DeleteExpiredAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op secondary refresh token repository used when dual-write is enabled without a configured secondary backend.
|
||||
/// </summary>
|
||||
internal sealed class NullSecondaryRefreshTokenRepository : ISecondaryRefreshTokenRepository
|
||||
{
|
||||
public Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<RefreshTokenEntity?>(null);
|
||||
|
||||
public Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<RefreshTokenEntity?>(null);
|
||||
|
||||
public Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<RefreshTokenEntity>>(Array.Empty<RefreshTokenEntity>());
|
||||
|
||||
public Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(token.Id == Guid.Empty ? Guid.NewGuid() : token.Id);
|
||||
|
||||
public Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task DeleteExpiredAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op secondary user repository used when dual-write is enabled without a configured secondary backend.
|
||||
/// </summary>
|
||||
internal sealed class NullSecondaryUserRepository : ISecondaryUserRepository
|
||||
{
|
||||
public Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(user);
|
||||
|
||||
public Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<UserEntity?>(null);
|
||||
|
||||
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<UserEntity?>(null);
|
||||
|
||||
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<UserEntity?>(null);
|
||||
|
||||
public Task<IReadOnlyList<UserEntity>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<UserEntity>>(Array.Empty<UserEntity>());
|
||||
|
||||
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<bool> UpdatePasswordAsync(string tenantId, Guid userId, string passwordHash, string passwordSalt, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<int> RecordFailedLoginAsync(string tenantId, Guid userId, DateTimeOffset? lockUntil = null, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task RecordSuccessfulLoginAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ public sealed class TenantRepository : RepositoryBase<AuthorityDataSource>, ITen
|
||||
public async Task<TenantEntity> CreateAsync(TenantEntity tenant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO auth.tenants (id, slug, name, description, contact_email, enabled, settings, metadata, created_by)
|
||||
INSERT INTO authority.tenants (id, slug, name, description, contact_email, enabled, settings, metadata, created_by)
|
||||
VALUES (@id, @slug, @name, @description, @contact_email, @enabled, @settings::jsonb, @metadata::jsonb, @created_by)
|
||||
RETURNING id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
|
||||
""";
|
||||
@@ -53,7 +53,7 @@ public sealed class TenantRepository : RepositoryBase<AuthorityDataSource>, ITen
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
|
||||
FROM auth.tenants
|
||||
FROM authority.tenants
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed class TenantRepository : RepositoryBase<AuthorityDataSource>, ITen
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
|
||||
FROM auth.tenants
|
||||
FROM authority.tenants
|
||||
WHERE slug = @slug
|
||||
""";
|
||||
|
||||
@@ -91,7 +91,7 @@ public sealed class TenantRepository : RepositoryBase<AuthorityDataSource>, ITen
|
||||
{
|
||||
var sql = """
|
||||
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
|
||||
FROM auth.tenants
|
||||
FROM authority.tenants
|
||||
""";
|
||||
|
||||
if (enabled.HasValue)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Storage.Postgres.Backfill;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
@@ -24,19 +28,11 @@ public static class ServiceCollectionExtensions
|
||||
string sectionName = "Postgres:Authority")
|
||||
{
|
||||
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
|
||||
services.AddSingleton<AuthorityDataSource>();
|
||||
|
||||
// Register repositories
|
||||
services.AddScoped<ITenantRepository, TenantRepository>();
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<IRoleRepository, RoleRepository>();
|
||||
services.AddScoped<IPermissionRepository, PermissionRepository>();
|
||||
services.AddScoped<ITokenRepository, TokenRepository>();
|
||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||
services.AddScoped<IApiKeyRepository, ApiKeyRepository>();
|
||||
services.AddScoped<ISessionRepository, SessionRepository>();
|
||||
services.AddScoped<IAuditRepository, AuditRepository>();
|
||||
var dualWriteSection = configuration.GetSection($"{sectionName}:DualWrite");
|
||||
services.Configure<DualWriteOptions>(dualWriteSection);
|
||||
var dualWriteEnabled = dualWriteSection.GetValue<bool>("Enabled");
|
||||
|
||||
RegisterAuthorityServices(services, dualWriteEnabled);
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -51,19 +47,62 @@ public static class ServiceCollectionExtensions
|
||||
Action<PostgresOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
services.AddSingleton<AuthorityDataSource>();
|
||||
|
||||
// Register repositories
|
||||
services.AddScoped<ITenantRepository, TenantRepository>();
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<IRoleRepository, RoleRepository>();
|
||||
services.AddScoped<IPermissionRepository, PermissionRepository>();
|
||||
services.AddScoped<ITokenRepository, TokenRepository>();
|
||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||
services.AddScoped<IApiKeyRepository, ApiKeyRepository>();
|
||||
services.AddScoped<ISessionRepository, SessionRepository>();
|
||||
services.AddScoped<IAuditRepository, AuditRepository>();
|
||||
|
||||
RegisterAuthorityServices(services, dualWriteEnabled: false);
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterAuthorityServices(IServiceCollection services, bool dualWriteEnabled)
|
||||
{
|
||||
services.AddSingleton<AuthorityDataSource>();
|
||||
services.AddSingleton<DualWriteMetrics>();
|
||||
|
||||
// Primary repositories
|
||||
services.AddScoped<TenantRepository>();
|
||||
services.AddScoped<UserRepository>();
|
||||
services.AddScoped<RoleRepository>();
|
||||
services.AddScoped<PermissionRepository>();
|
||||
services.AddScoped<TokenRepository>();
|
||||
services.AddScoped<RefreshTokenRepository>();
|
||||
services.AddScoped<ApiKeyRepository>();
|
||||
services.AddScoped<SessionRepository>();
|
||||
services.AddScoped<AuditRepository>();
|
||||
|
||||
// Default interface bindings
|
||||
services.AddScoped<ITenantRepository>(sp => sp.GetRequiredService<TenantRepository>());
|
||||
services.AddScoped<IUserRepository>(sp => sp.GetRequiredService<UserRepository>());
|
||||
services.AddScoped<IRoleRepository>(sp => sp.GetRequiredService<RoleRepository>());
|
||||
services.AddScoped<IPermissionRepository>(sp => sp.GetRequiredService<PermissionRepository>());
|
||||
services.AddScoped<IApiKeyRepository>(sp => sp.GetRequiredService<ApiKeyRepository>());
|
||||
services.AddScoped<ISessionRepository>(sp => sp.GetRequiredService<SessionRepository>());
|
||||
services.AddScoped<IAuditRepository>(sp => sp.GetRequiredService<AuditRepository>());
|
||||
|
||||
if (dualWriteEnabled)
|
||||
{
|
||||
services.TryAddScoped<ISecondaryTokenRepository, NullSecondaryTokenRepository>();
|
||||
services.TryAddScoped<ISecondaryRefreshTokenRepository, NullSecondaryRefreshTokenRepository>();
|
||||
services.TryAddScoped<ISecondaryUserRepository, NullSecondaryUserRepository>();
|
||||
|
||||
services.AddScoped<ITokenRepository>(sp => new DualWriteTokenRepository(
|
||||
sp.GetRequiredService<TokenRepository>(),
|
||||
sp.GetRequiredService<ISecondaryTokenRepository>(),
|
||||
sp.GetRequiredService<IOptions<DualWriteOptions>>(),
|
||||
sp.GetRequiredService<DualWriteMetrics>(),
|
||||
sp.GetRequiredService<ILogger<DualWriteTokenRepository>>()));
|
||||
|
||||
services.AddScoped<IRefreshTokenRepository>(sp => new DualWriteRefreshTokenRepository(
|
||||
sp.GetRequiredService<RefreshTokenRepository>(),
|
||||
sp.GetRequiredService<ISecondaryRefreshTokenRepository>(),
|
||||
sp.GetRequiredService<IOptions<DualWriteOptions>>(),
|
||||
sp.GetRequiredService<DualWriteMetrics>(),
|
||||
sp.GetRequiredService<ILogger<DualWriteRefreshTokenRepository>>()));
|
||||
|
||||
// Backfill service available only when dual-write is enabled.
|
||||
services.AddScoped<AuthorityBackfillService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddScoped<ITokenRepository>(sp => sp.GetRequiredService<TokenRepository>());
|
||||
services.AddScoped<IRefreshTokenRepository>(sp => sp.GetRequiredService<RefreshTokenRepository>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "{}"
|
||||
};
|
||||
}
|
||||
@@ -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 = "{}"
|
||||
};
|
||||
}
|
||||
@@ -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;");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user