Tests fixes, audit progress, UI completions

This commit is contained in:
StellaOps Bot
2025-12-30 09:03:22 +02:00
parent 7a5210e2aa
commit 82e55c206a
318 changed files with 7232 additions and 1256 deletions

View File

@@ -0,0 +1,21 @@
# Authority Standard Plugin Tests AGENTS
## Purpose & Scope
- Working directory: `src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/`.
- Roles: QA automation, backend engineer.
- Focus: Standard plugin test coverage, credential flows, and determinism.
## Required Reading (treat as read before DOING)
- `docs/modules/authority/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md`
- Relevant sprint files.
## Working Agreements
- Keep tests deterministic (fixed time/IDs); avoid external network calls.
- Prefer exercising production code paths over test-only simulations.
- Update `docs/implplan/SPRINT_*.md` and local `TASKS.md` when starting or completing work.
## Testing
- Use xUnit + FluentAssertions + TestKit helpers.
- Cover credential flows, lockouts, bootstrap behavior, and client provisioning.

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
@@ -16,16 +17,16 @@ using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Persistence.Documents;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Audit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardPluginRegistrarTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
{
var client = new InMemoryClient();
@@ -58,10 +59,11 @@ public class StandardPluginRegistrarTests
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
using var provider = services.BuildServiceProvider();
var hostedServices = provider.GetServices<IHostedService>();
@@ -88,7 +90,7 @@ public class StandardPluginRegistrarTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Register_LogsWarning_WhenPasswordPolicyWeaker()
{
var client = new InMemoryClient();
@@ -116,12 +118,13 @@ public class StandardPluginRegistrarTests
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
var loggerProvider = new CapturingLoggerProvider();
services.AddLogging(builder => builder.AddProvider(loggerProvider));
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
@@ -134,7 +137,7 @@ public class StandardPluginRegistrarTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Register_ForcesPasswordCapability_WhenManifestMissing()
{
var client = new InMemoryClient();
@@ -152,10 +155,11 @@ public class StandardPluginRegistrarTests
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
@@ -167,7 +171,7 @@ public class StandardPluginRegistrarTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Register_Throws_WhenBootstrapConfigurationIncomplete()
{
var client = new InMemoryClient();
@@ -191,10 +195,11 @@ public class StandardPluginRegistrarTests
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
@@ -202,7 +207,7 @@ public class StandardPluginRegistrarTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Register_NormalizesTokenSigningKeyDirectory()
{
var client = new InMemoryClient();
@@ -232,11 +237,12 @@ public class StandardPluginRegistrarTests
configPath);
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
services.AddSingleton(TimeProvider.System);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
using var provider = services.BuildServiceProvider();
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
@@ -398,6 +404,7 @@ internal static class StandardPluginRegistrarTestHelpers
{
public static ServiceCollection CreateServiceCollection(
IDatabase database,
IConfiguration? configuration = null,
IAuthEventSink? authEventSink = null,
IAuthorityCredentialAuditContextAccessor? auditContextAccessor = null)
{
@@ -405,10 +412,12 @@ internal static class StandardPluginRegistrarTestHelpers
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(configuration ?? new ConfigurationBuilder().Build());
services.AddSingleton(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton<IAuthorityLoginAttemptStore>(new InMemoryLoginAttemptStore());
services.AddSingleton<IUserRepository>(new InMemoryUserRepository());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityCredentialAuditContextAccessor>(
auditContextAccessor ?? new TestAuthorityCredentialAuditContextAccessor());

View File

@@ -2,12 +2,11 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
@@ -15,20 +14,18 @@ using StellaOps.Cryptography;
using StellaOps.Cryptography.Audit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardUserCredentialStoreTests : IAsyncLifetime
{
private readonly IDatabase database;
private readonly InMemoryUserRepository userRepository;
private readonly StandardPluginOptions options;
private readonly StandardUserCredentialStore store;
private readonly TestAuditLogger auditLogger;
private readonly Mock<IUserRepository> userRepositoryMock;
public StandardUserCredentialStoreTests()
{
var client = new InMemoryClient();
database = client.GetDatabase("authority-tests");
options = new StandardPluginOptions
{
PasswordPolicy = new PasswordPolicyOptions
@@ -55,11 +52,11 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
};
var cryptoProvider = new DefaultCryptoProvider();
auditLogger = new TestAuditLogger();
userRepositoryMock = new Mock<IUserRepository>();
userRepository = new InMemoryUserRepository();
store = new StandardUserCredentialStore(
"standard",
"test-tenant",
userRepositoryMock.Object,
userRepository,
options,
new CryptoPasswordHasher(options, cryptoProvider),
auditLogger,
@@ -67,7 +64,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task VerifyPasswordAsync_ReturnsSuccess_ForValidCredentials()
{
auditLogger.Reset();
@@ -95,7 +92,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task VerifyPasswordAsync_EnforcesLockout_AfterRepeatedFailures()
{
auditLogger.Reset();
@@ -144,7 +141,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task VerifyPasswordAsync_RehashesLegacyHashesToArgon2()
{
auditLogger.Reset();
@@ -156,19 +153,24 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Iterations = 160_000
});
var document = new StandardUserDocument
await userRepository.CreateAsync(new UserEntity
{
Id = Guid.NewGuid(),
TenantId = "test-tenant",
Username = "legacy",
NormalizedUsername = "legacy",
Email = "legacy@local",
DisplayName = "Legacy",
PasswordHash = legacyHash,
Roles = new List<string>(),
Attributes = new Dictionary<string, string?>(),
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1)
};
await database.GetCollection<StandardUserDocument>("authority_users_standard")
.InsertOneAsync(document);
PasswordSalt = "",
Enabled = true,
Metadata = JsonSerializer.Serialize(new Dictionary<string, object?>
{
["subjectId"] = "legacy",
["roles"] = new List<string>(),
["attributes"] = new Dictionary<string, string?>(),
["requirePasswordReset"] = false
})
});
var result = await store.VerifyPasswordAsync("legacy", "Legacy1!", CancellationToken.None);
@@ -180,16 +182,14 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.True(auditEntry.Success);
Assert.Equal("legacy", auditEntry.Username);
var results = await database.GetCollection<StandardUserDocument>("authority_users_standard")
.FindAsync(u => u.NormalizedUsername == "legacy");
var updated = results.FirstOrDefault();
var updated = await userRepository.GetByUsernameAsync("test-tenant", "legacy", CancellationToken.None);
Assert.NotNull(updated);
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task VerifyPasswordAsync_RecordsAudit_ForUnknownUser()
{
auditLogger.Reset();

View File

@@ -0,0 +1,10 @@
# Authority Standard Plugin Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0097-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Standard.Tests. |
| AUDIT-0097-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Standard.Tests. |
| AUDIT-0097-A | TODO | Pending approval for changes. |

View File

@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
namespace StellaOps.Authority.Plugin.Standard.Tests;
internal sealed class InMemoryUserRepository : IUserRepository
{
private readonly Dictionary<Guid, UserEntity> users = new();
private readonly Dictionary<string, Guid> byUsername = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Guid> byEmail = new(StringComparer.OrdinalIgnoreCase);
public Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var created = new UserEntity
{
Id = user.Id,
TenantId = user.TenantId,
Username = user.Username,
Email = user.Email,
DisplayName = user.DisplayName,
PasswordHash = user.PasswordHash,
PasswordSalt = user.PasswordSalt,
Enabled = user.Enabled,
EmailVerified = user.EmailVerified,
MfaEnabled = user.MfaEnabled,
MfaSecret = user.MfaSecret,
MfaBackupCodes = user.MfaBackupCodes,
FailedLoginAttempts = user.FailedLoginAttempts,
LockedUntil = user.LockedUntil,
LastLoginAt = user.LastLoginAt,
PasswordChangedAt = user.PasswordChangedAt,
Settings = string.IsNullOrWhiteSpace(user.Settings) ? "{}" : user.Settings,
Metadata = string.IsNullOrWhiteSpace(user.Metadata) ? "{}" : user.Metadata,
CreatedAt = user.CreatedAt == default ? now : user.CreatedAt,
UpdatedAt = user.UpdatedAt == default ? now : user.UpdatedAt,
CreatedBy = user.CreatedBy
};
users[created.Id] = created;
byUsername[GetUsernameKey(created.TenantId, created.Username)] = created.Id;
byEmail[GetEmailKey(created.TenantId, created.Email)] = created.Id;
return Task.FromResult(created);
}
public Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
if (users.TryGetValue(id, out var user) && string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.FromResult<UserEntity?>(user);
}
return Task.FromResult<UserEntity?>(null);
}
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default)
{
var key = GetUsernameKey(tenantId, username);
if (byUsername.TryGetValue(key, out var id) && users.TryGetValue(id, out var user))
{
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);
if (byEmail.TryGetValue(key, out var id) && users.TryGetValue(id, out var user))
{
return Task.FromResult<UserEntity?>(user);
}
return Task.FromResult<UserEntity?>(null);
}
public Task<IReadOnlyList<UserEntity>> GetAllAsync(
string tenantId,
bool? enabled = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var results = users.Values
.Where(u => string.Equals(u.TenantId, tenantId, StringComparison.Ordinal))
.Where(u => enabled is null || u.Enabled == enabled.Value)
.OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase)
.Skip(offset)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<UserEntity>>(results);
}
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
{
if (!users.TryGetValue(user.Id, out var existing))
{
return Task.FromResult(false);
}
var now = DateTimeOffset.UtcNow;
var updated = new UserEntity
{
Id = user.Id,
TenantId = user.TenantId,
Username = user.Username,
Email = user.Email,
DisplayName = user.DisplayName,
PasswordHash = user.PasswordHash,
PasswordSalt = user.PasswordSalt,
Enabled = user.Enabled,
EmailVerified = user.EmailVerified,
MfaEnabled = user.MfaEnabled,
MfaSecret = user.MfaSecret,
MfaBackupCodes = user.MfaBackupCodes,
FailedLoginAttempts = user.FailedLoginAttempts,
LockedUntil = user.LockedUntil,
LastLoginAt = user.LastLoginAt,
PasswordChangedAt = user.PasswordChangedAt,
Settings = string.IsNullOrWhiteSpace(user.Settings) ? existing.Settings : user.Settings,
Metadata = string.IsNullOrWhiteSpace(user.Metadata) ? existing.Metadata : user.Metadata,
CreatedAt = existing.CreatedAt,
UpdatedAt = now,
CreatedBy = user.CreatedBy ?? existing.CreatedBy
};
users[updated.Id] = updated;
byUsername[GetUsernameKey(updated.TenantId, updated.Username)] = updated.Id;
byEmail[GetEmailKey(updated.TenantId, updated.Email)] = updated.Id;
return Task.FromResult(true);
}
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
if (users.TryGetValue(id, out var user) && string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
{
users.Remove(id);
byUsername.Remove(GetUsernameKey(user.TenantId, user.Username));
byEmail.Remove(GetEmailKey(user.TenantId, user.Email));
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public Task<bool> UpdatePasswordAsync(
string tenantId,
Guid userId,
string passwordHash,
string passwordSalt,
CancellationToken cancellationToken = default)
{
if (!users.TryGetValue(userId, out var existing) || !string.Equals(existing.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.FromResult(false);
}
var now = DateTimeOffset.UtcNow;
var updated = new UserEntity
{
Id = existing.Id,
TenantId = existing.TenantId,
Username = existing.Username,
Email = existing.Email,
DisplayName = existing.DisplayName,
PasswordHash = passwordHash,
PasswordSalt = passwordSalt,
Enabled = existing.Enabled,
EmailVerified = existing.EmailVerified,
MfaEnabled = existing.MfaEnabled,
MfaSecret = existing.MfaSecret,
MfaBackupCodes = existing.MfaBackupCodes,
FailedLoginAttempts = existing.FailedLoginAttempts,
LockedUntil = existing.LockedUntil,
LastLoginAt = existing.LastLoginAt,
PasswordChangedAt = now,
Settings = existing.Settings,
Metadata = existing.Metadata,
CreatedAt = existing.CreatedAt,
UpdatedAt = now,
CreatedBy = existing.CreatedBy
};
users[updated.Id] = updated;
return Task.FromResult(true);
}
public Task<int> RecordFailedLoginAsync(
string tenantId,
Guid userId,
DateTimeOffset? lockUntil = null,
CancellationToken cancellationToken = default)
{
if (!users.TryGetValue(userId, out var existing) || !string.Equals(existing.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.FromResult(0);
}
var now = DateTimeOffset.UtcNow;
var attempts = existing.FailedLoginAttempts + 1;
var updated = new UserEntity
{
Id = existing.Id,
TenantId = existing.TenantId,
Username = existing.Username,
Email = existing.Email,
DisplayName = existing.DisplayName,
PasswordHash = existing.PasswordHash,
PasswordSalt = existing.PasswordSalt,
Enabled = existing.Enabled,
EmailVerified = existing.EmailVerified,
MfaEnabled = existing.MfaEnabled,
MfaSecret = existing.MfaSecret,
MfaBackupCodes = existing.MfaBackupCodes,
FailedLoginAttempts = attempts,
LockedUntil = lockUntil,
LastLoginAt = existing.LastLoginAt,
PasswordChangedAt = existing.PasswordChangedAt,
Settings = existing.Settings,
Metadata = existing.Metadata,
CreatedAt = existing.CreatedAt,
UpdatedAt = now,
CreatedBy = existing.CreatedBy
};
users[updated.Id] = updated;
return Task.FromResult(attempts);
}
public Task RecordSuccessfulLoginAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
if (!users.TryGetValue(userId, out var existing) || !string.Equals(existing.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.CompletedTask;
}
var now = DateTimeOffset.UtcNow;
var updated = new UserEntity
{
Id = existing.Id,
TenantId = existing.TenantId,
Username = existing.Username,
Email = existing.Email,
DisplayName = existing.DisplayName,
PasswordHash = existing.PasswordHash,
PasswordSalt = existing.PasswordSalt,
Enabled = existing.Enabled,
EmailVerified = existing.EmailVerified,
MfaEnabled = existing.MfaEnabled,
MfaSecret = existing.MfaSecret,
MfaBackupCodes = existing.MfaBackupCodes,
FailedLoginAttempts = 0,
LockedUntil = null,
LastLoginAt = now,
PasswordChangedAt = existing.PasswordChangedAt,
Settings = existing.Settings,
Metadata = existing.Metadata,
CreatedAt = existing.CreatedAt,
UpdatedAt = now,
CreatedBy = existing.CreatedBy
};
users[updated.Id] = updated;
return Task.CompletedTask;
}
private static string GetUsernameKey(string tenantId, string username)
=> $"{tenantId}::{username}".ToLowerInvariant();
private static string GetEmailKey(string tenantId, string email)
=> $"{tenantId}::{email}".ToLowerInvariant();
}