save progress
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
public class StandardClaimsEnricherTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnrichAsync_AddsRolesAndAttributes()
|
||||
{
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"standard",
|
||||
"standard",
|
||||
true,
|
||||
"StellaOps.Authority.Plugin.Standard",
|
||||
"standard.dll",
|
||||
new[] { AuthorityPluginCapabilities.Password },
|
||||
new Dictionary<string, string?>(),
|
||||
"standard.yaml");
|
||||
var context = new AuthorityClaimsEnrichmentContext(
|
||||
new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()),
|
||||
new AuthorityUserDescriptor(
|
||||
"subject-1",
|
||||
"alice",
|
||||
"Alice",
|
||||
false,
|
||||
new[] { "admin", "ops" },
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["region"] = "eu",
|
||||
["team"] = "platform"
|
||||
}),
|
||||
client: null);
|
||||
|
||||
var identity = new ClaimsIdentity();
|
||||
var enricher = new StandardClaimsEnricher();
|
||||
|
||||
await enricher.EnrichAsync(identity, context, CancellationToken.None);
|
||||
|
||||
Assert.Contains(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == "admin");
|
||||
Assert.Contains(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == "ops");
|
||||
Assert.Contains(identity.Claims, claim => claim.Type == "region" && claim.Value == "eu");
|
||||
Assert.Contains(identity.Claims, claim => claim.Type == "team" && claim.Value == "platform");
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,40 @@ public class StandardClientProvisioningStoreTests
|
||||
Assert.Equal("primary", binding.Label);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RemovesClientAndWritesRevocation()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T12:30:00Z"));
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, clock);
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "delete-me",
|
||||
confidential: false,
|
||||
displayName: "Delete Me",
|
||||
clientSecret: null,
|
||||
allowedGrantTypes: new[] { "client_credentials" },
|
||||
allowedScopes: new[] { "scopeA" });
|
||||
|
||||
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
|
||||
|
||||
var result = await provisioning.DeleteAsync("delete-me", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.False(store.Documents.ContainsKey("delete-me"));
|
||||
|
||||
var revocation = Assert.Single(revocations.Upserts);
|
||||
Assert.Equal("client", revocation.Category);
|
||||
Assert.Equal("delete-me", revocation.RevocationId);
|
||||
Assert.Equal("delete-me", revocation.ClientId);
|
||||
Assert.Equal("operator_request", revocation.Reason);
|
||||
Assert.Equal(clock.GetUtcNow(), revocation.RevokedAt);
|
||||
Assert.Equal(clock.GetUtcNow(), revocation.EffectiveAt);
|
||||
Assert.Equal("standard", revocation.Metadata["plugin"]);
|
||||
}
|
||||
|
||||
private sealed class TrackingClientStore : IAuthorityClientStore
|
||||
{
|
||||
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -186,4 +220,13 @@ public class StandardClientProvisioningStoreTests
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset fixedNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => fixedNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
public class StandardIdentityProviderPluginTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ReturnsHealthy()
|
||||
{
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"standard",
|
||||
"standard",
|
||||
true,
|
||||
"StellaOps.Authority.Plugin.Standard",
|
||||
"standard.dll",
|
||||
new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning },
|
||||
new Dictionary<string, string?>(),
|
||||
"standard.yaml");
|
||||
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
|
||||
|
||||
var userRepository = new InMemoryUserRepository();
|
||||
var options = new StandardPluginOptions();
|
||||
var cryptoProvider = new DefaultCryptoProvider();
|
||||
var auditLogger = new TestAuditLogger();
|
||||
var store = new StandardUserCredentialStore(
|
||||
"standard",
|
||||
"tenant-1",
|
||||
userRepository,
|
||||
options,
|
||||
new CryptoPasswordHasher(options, cryptoProvider),
|
||||
auditLogger,
|
||||
TimeProvider.System,
|
||||
new FixedStandardIdGenerator(),
|
||||
NullLogger<StandardUserCredentialStore>.Instance);
|
||||
|
||||
var clientStore = new InMemoryClientStore();
|
||||
var revocationStore = new InMemoryRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", clientStore, revocationStore, TimeProvider.System);
|
||||
|
||||
var plugin = new StandardIdentityProviderPlugin(
|
||||
context,
|
||||
store,
|
||||
provisioning,
|
||||
new StandardClaimsEnricher(),
|
||||
NullLogger<StandardIdentityProviderPlugin>.Instance);
|
||||
|
||||
var health = await plugin.CheckHealthAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(AuthorityPluginHealthStatus.Healthy, health.Status);
|
||||
}
|
||||
|
||||
private sealed class FixedStandardIdGenerator : IStandardIdGenerator
|
||||
{
|
||||
public Guid NewUserId() => Guid.Parse("00000000-0000-0000-0000-000000000201");
|
||||
|
||||
public string NewSubjectId() => "subject-201";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard;
|
||||
using StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
public class StandardPluginBootstrapperTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenBootstrapFails()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions<StandardPluginOptions>("standard")
|
||||
.Configure(options =>
|
||||
{
|
||||
options.BootstrapUser = new BootstrapUserOptions
|
||||
{
|
||||
Username = "bootstrap",
|
||||
Password = "Password1!",
|
||||
RequirePasswordReset = false
|
||||
};
|
||||
});
|
||||
|
||||
services.AddSingleton<IUserRepository>(new ThrowingUserRepository());
|
||||
services.AddSingleton<IStandardCredentialAuditLogger, NullAuditLogger>();
|
||||
services.AddSingleton<TimeProvider>(new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T13:00:00Z")));
|
||||
services.AddSingleton<IStandardIdGenerator>(new FixedStandardIdGenerator());
|
||||
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var options = optionsMonitor.Get("standard");
|
||||
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
|
||||
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
|
||||
return new StandardUserCredentialStore(
|
||||
"standard",
|
||||
"tenant-1",
|
||||
sp.GetRequiredService<IUserRepository>(),
|
||||
options,
|
||||
new CryptoPasswordHasher(options, cryptoProvider),
|
||||
auditLogger,
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
sp.GetRequiredService<IStandardIdGenerator>(),
|
||||
NullLogger<StandardUserCredentialStore>.Instance);
|
||||
});
|
||||
|
||||
services.AddSingleton<StandardPluginBootstrapper>(sp =>
|
||||
new StandardPluginBootstrapper("standard", sp.GetRequiredService<IServiceScopeFactory>(), NullLogger<StandardPluginBootstrapper>.Instance));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<StandardPluginBootstrapper>();
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => bootstrapper.StartAsync(CancellationToken.None));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
private sealed class ThrowingUserRepository : IUserRepository
|
||||
{
|
||||
public Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<IReadOnlyList<UserEntity>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<bool> UpdatePasswordAsync(string tenantId, Guid userId, string passwordHash, string passwordSalt, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<int> RecordFailedLoginAsync(string tenantId, Guid userId, DateTimeOffset? lockUntil = null, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task RecordSuccessfulLoginAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
}
|
||||
|
||||
private sealed class NullAuditLogger : IStandardCredentialAuditLogger
|
||||
{
|
||||
public ValueTask RecordAsync(
|
||||
string pluginName,
|
||||
string normalizedUsername,
|
||||
string? subjectId,
|
||||
bool success,
|
||||
AuthorityCredentialFailureCode? failureCode,
|
||||
string? reason,
|
||||
IReadOnlyList<AuthEventProperty>? properties,
|
||||
CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset fixedNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => fixedNow;
|
||||
}
|
||||
|
||||
private sealed class FixedStandardIdGenerator : IStandardIdGenerator
|
||||
{
|
||||
public Guid NewUserId() => Guid.Parse("00000000-0000-0000-0000-000000000301");
|
||||
|
||||
public string NewSubjectId() => "subject-301";
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,40 @@ public class StandardPluginOptionsTests
|
||||
Assert.Equal(Path.GetFullPath(absolute), options.TokenSigning.KeyDirectory);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Normalize_TrimsTenantAndBootstrapValues()
|
||||
{
|
||||
var options = new StandardPluginOptions
|
||||
{
|
||||
TenantId = " Tenant-A ",
|
||||
BootstrapUser = new BootstrapUserOptions
|
||||
{
|
||||
Username = " admin ",
|
||||
Password = " "
|
||||
}
|
||||
};
|
||||
|
||||
options.Normalize(Path.Combine(Path.GetTempPath(), "config", "standard.yaml"));
|
||||
|
||||
Assert.Equal("tenant-a", options.TenantId);
|
||||
Assert.Equal("admin", options.BootstrapUser?.Username);
|
||||
Assert.Null(options.BootstrapUser?.Password);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenTokenSigningConfigured()
|
||||
{
|
||||
var options = new StandardPluginOptions
|
||||
{
|
||||
TokenSigning = { KeyDirectory = "/tmp/keys" }
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
|
||||
Assert.Contains("token signing", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenPasswordHashingMemoryInvalid()
|
||||
|
||||
@@ -208,7 +208,7 @@ public class StandardPluginRegistrarTests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Register_NormalizesTokenSigningKeyDirectory()
|
||||
public void Register_Throws_WhenTokenSigningKeyDirectoryConfigured()
|
||||
{
|
||||
var client = new InMemoryClient();
|
||||
var database = client.GetDatabase("registrar-token-signing");
|
||||
@@ -238,7 +238,6 @@ public class StandardPluginRegistrarTests
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
@@ -246,10 +245,7 @@ public class StandardPluginRegistrarTests
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var options = optionsMonitor.Get("standard");
|
||||
|
||||
var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
|
||||
Assert.Equal(expected, options.TokenSigning.KeyDirectory);
|
||||
Assert.Throws<InvalidOperationException>(() => optionsMonitor.Get("standard"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -23,6 +23,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
private readonly StandardPluginOptions options;
|
||||
private readonly StandardUserCredentialStore store;
|
||||
private readonly TestAuditLogger auditLogger;
|
||||
private readonly FakeTimeProvider clock;
|
||||
private readonly SequenceStandardIdGenerator idGenerator;
|
||||
|
||||
public StandardUserCredentialStoreTests()
|
||||
{
|
||||
@@ -53,6 +55,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
var cryptoProvider = new DefaultCryptoProvider();
|
||||
auditLogger = new TestAuditLogger();
|
||||
userRepository = new InMemoryUserRepository();
|
||||
clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T12:00:00Z"));
|
||||
idGenerator = new SequenceStandardIdGenerator();
|
||||
store = new StandardUserCredentialStore(
|
||||
"standard",
|
||||
"test-tenant",
|
||||
@@ -60,6 +64,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
options,
|
||||
new CryptoPasswordHasher(options, cryptoProvider),
|
||||
auditLogger,
|
||||
clock,
|
||||
idGenerator,
|
||||
NullLogger<StandardUserCredentialStore>.Instance);
|
||||
}
|
||||
|
||||
@@ -155,7 +161,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
|
||||
await userRepository.CreateAsync(new UserEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = Guid.Parse("00000000-0000-0000-0000-000000000101"),
|
||||
TenantId = "test-tenant",
|
||||
Username = "legacy",
|
||||
Email = "legacy@local",
|
||||
@@ -188,6 +194,87 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpsertUserAsync_PreservesRolesAndAttributesOnUpdate()
|
||||
{
|
||||
var registration = new AuthorityUserRegistration(
|
||||
"chris",
|
||||
"Password1!",
|
||||
"Chris",
|
||||
null,
|
||||
false,
|
||||
new[] { "viewer" },
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["region"] = "eu"
|
||||
});
|
||||
|
||||
var created = await store.UpsertUserAsync(registration, CancellationToken.None);
|
||||
Assert.True(created.Succeeded);
|
||||
|
||||
var update = new AuthorityUserRegistration(
|
||||
"chris",
|
||||
password: null,
|
||||
displayName: "Chris Updated",
|
||||
email: null,
|
||||
requirePasswordReset: true,
|
||||
roles: new[] { "editor", "admin" },
|
||||
attributes: new Dictionary<string, string?>
|
||||
{
|
||||
["region"] = "us",
|
||||
["team"] = "platform"
|
||||
});
|
||||
|
||||
var updated = await store.UpsertUserAsync(update, CancellationToken.None);
|
||||
Assert.True(updated.Succeeded);
|
||||
Assert.Contains("editor", updated.Value.Roles);
|
||||
Assert.Contains("admin", updated.Value.Roles);
|
||||
Assert.Equal("us", updated.Value.Attributes["region"]);
|
||||
Assert.Equal("platform", updated.Value.Attributes["team"]);
|
||||
Assert.True(updated.Value.RequiresPasswordReset);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FindBySubjectAsync_ReturnsUserWhenSubjectMatches()
|
||||
{
|
||||
var registration = new AuthorityUserRegistration(
|
||||
"dana",
|
||||
"Password1!",
|
||||
"Dana",
|
||||
null,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
var created = await store.UpsertUserAsync(registration, CancellationToken.None);
|
||||
Assert.True(created.Succeeded);
|
||||
|
||||
var found = await store.FindBySubjectAsync(created.Value.SubjectId, CancellationToken.None);
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal("dana", found!.Username);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpsertUserAsync_RejectsWeakPasswords()
|
||||
{
|
||||
var registration = new AuthorityUserRegistration(
|
||||
"erin",
|
||||
"short",
|
||||
"Erin",
|
||||
null,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
var result = await store.UpsertUserAsync(registration, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal("password_policy_violation", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyPasswordAsync_RecordsAudit_ForUnknownUser()
|
||||
@@ -249,6 +336,34 @@ internal sealed class TestAuditLogger : IStandardCredentialAuditLogger
|
||||
IReadOnlyList<AuthEventProperty> Properties);
|
||||
}
|
||||
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset fixedNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => fixedNow;
|
||||
}
|
||||
|
||||
internal sealed class SequenceStandardIdGenerator : IStandardIdGenerator
|
||||
{
|
||||
private int userCounter;
|
||||
private int subjectCounter;
|
||||
|
||||
public Guid NewUserId()
|
||||
{
|
||||
userCounter++;
|
||||
var suffix = userCounter.ToString("D12", CultureInfo.InvariantCulture);
|
||||
return Guid.Parse($"00000000-0000-0000-0000-{suffix}");
|
||||
}
|
||||
|
||||
public string NewSubjectId()
|
||||
{
|
||||
subjectCounter++;
|
||||
return $"subject-{subjectCounter}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
@@ -70,6 +71,24 @@ internal sealed class InMemoryUserRepository : IUserRepository
|
||||
return Task.FromResult<UserEntity?>(null);
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var user in users.Values)
|
||||
{
|
||||
if (!string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryGetSubjectId(user.Metadata, out var stored) && string.Equals(stored, subjectId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult<UserEntity?>(user);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<UserEntity?>(null);
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetEmailKey(tenantId, email);
|
||||
@@ -278,4 +297,34 @@ internal sealed class InMemoryUserRepository : IUserRepository
|
||||
|
||||
private static string GetEmailKey(string tenantId, string email)
|
||||
=> $"{tenantId}::{email}".ToLowerInvariant();
|
||||
|
||||
private static bool TryGetSubjectId(string? metadataJson, out string? subjectId)
|
||||
{
|
||||
subjectId = null;
|
||||
if (string.IsNullOrWhiteSpace(metadataJson))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(metadataJson);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (document.RootElement.TryGetProperty("subjectId", out var subjectElement)
|
||||
&& subjectElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
subjectId = subjectElement.GetString();
|
||||
return !string.IsNullOrWhiteSpace(subjectId);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user