Initial commit: scadaproj umbrella — sister-project index, auth component normalization (design + GAPS), and the built ZB.MOM.WW.Auth shared library (0.1.0, flattened in).
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
||||
|
||||
public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
|
||||
{
|
||||
private const string Pepper = "test-pepper-value";
|
||||
|
||||
private readonly string _dbPath =
|
||||
Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");
|
||||
|
||||
private AuthSqliteConnectionFactory _factory = null!;
|
||||
private SqliteAuthStoreMigrator _migrator = null!;
|
||||
private SqliteApiKeyAdminStore _admin = null!;
|
||||
private SqliteApiKeyStore _read = null!;
|
||||
private SqliteApiKeyAuditStore _audit = null!;
|
||||
private ApiKeyOptions _options = null!;
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_factory = new AuthSqliteConnectionFactory(_dbPath);
|
||||
_migrator = new SqliteAuthStoreMigrator(_factory);
|
||||
_admin = new SqliteApiKeyAdminStore(_factory);
|
||||
_read = new SqliteApiKeyStore(_factory);
|
||||
_audit = new SqliteApiKeyAuditStore(_factory);
|
||||
_options = new ApiKeyOptions { TokenPrefix = "mxgw", SqlitePath = _dbPath };
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private ApiKeyAdminCommands BuildCommands(string? pepper = Pepper) => new(
|
||||
_options,
|
||||
_admin,
|
||||
_audit,
|
||||
new FakePepperProvider(pepper),
|
||||
_migrator);
|
||||
|
||||
// --- init-db ---
|
||||
|
||||
[Fact]
|
||||
public async Task InitDb_CreatesTables_AndAppendsAudit()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
|
||||
await commands.InitDbAsync(remoteAddress: "10.0.0.1", CancellationToken.None);
|
||||
|
||||
// Tables exist: a create after init must succeed.
|
||||
Assert.True(await TableExistsAsync("api_keys"));
|
||||
Assert.True(await TableExistsAsync("api_key_audit"));
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
|
||||
Assert.Single(recent, e => e.EventType == "init-db");
|
||||
}
|
||||
|
||||
// --- create-key ---
|
||||
|
||||
[Fact]
|
||||
public async Task CreateKey_ReturnsAssembledToken_KeyFindable_AndAuditAppended()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
|
||||
CreateKeyResult result = await commands.CreateKeyAsync(
|
||||
"key-1",
|
||||
"Service A",
|
||||
new HashSet<string>(["read", "write"], StringComparer.Ordinal),
|
||||
constraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
|
||||
remoteAddress: "10.0.0.1",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("key-1", result.KeyId);
|
||||
Assert.StartsWith("mxgw_key-1_", result.Token);
|
||||
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(found);
|
||||
|
||||
// The returned token's secret matches what is stored (hash of parsed secret == stored hash).
|
||||
string secret = ParseSecret(result.Token);
|
||||
byte[] expected = ApiKeySecretHasher.Hash(secret, Pepper);
|
||||
Assert.True(found!.SecretHash.SequenceEqual(expected));
|
||||
|
||||
// Exactly one create-key audit row.
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
|
||||
Assert.Single(recent, e => e.EventType == "create-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateKey_PepperUnavailable_ReturnsNoTokenAndAppendsNoAudit()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands(pepper: null);
|
||||
await new ApiKeyAdminCommands(_options, _admin, _audit, new FakePepperProvider(Pepper), _migrator)
|
||||
.InitDbAsync(null, CancellationToken.None);
|
||||
|
||||
int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => commands.CreateKeyAsync(
|
||||
"key-x",
|
||||
"No Pepper",
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
constraintsJson: null,
|
||||
remoteAddress: null,
|
||||
CancellationToken.None));
|
||||
|
||||
// No key created, no audit appended.
|
||||
Assert.Null(await _read.FindByKeyIdAsync("key-x", CancellationToken.None));
|
||||
int auditCountAfter = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
|
||||
Assert.Equal(auditCountBefore, auditCountAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateKey_KeyIdContainsUnderscore_ThrowsArgumentException()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => commands.CreateKeyAsync(
|
||||
"a_b",
|
||||
"Service A",
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
constraintsJson: null,
|
||||
remoteAddress: null,
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
// --- list-keys ---
|
||||
|
||||
[Fact]
|
||||
public async Task ListKeys_ReturnsCreatedKey_WithoutSecretMaterial()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
await commands.CreateKeyAsync(
|
||||
"key-1",
|
||||
"Service A",
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
constraintsJson: null,
|
||||
remoteAddress: null,
|
||||
CancellationToken.None);
|
||||
|
||||
IReadOnlyList<ApiKeyListItem> keys = await commands.ListKeysAsync(CancellationToken.None);
|
||||
|
||||
ApiKeyListItem item = Assert.Single(keys, k => k.KeyId == "key-1");
|
||||
Assert.Equal("Service A", item.DisplayName);
|
||||
Assert.Contains("read", item.Scopes);
|
||||
Assert.Null(item.RevokedUtc);
|
||||
|
||||
// ApiKeyListItem has NO secret/hash member by construction (compile-time guarantee).
|
||||
Assert.DoesNotContain(
|
||||
typeof(ApiKeyListItem).GetProperties(),
|
||||
p => p.Name.Contains("Hash", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.Name.Contains("Secret", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// --- revoke-key ---
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeKey_DeactivatesKey_AndAppendsAudit()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
await commands.CreateKeyAsync(
|
||||
"key-1",
|
||||
"Service A",
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
KeyActionResult result = await commands.RevokeKeyAsync("key-1", "10.0.0.1", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
|
||||
Assert.Single(recent, e => e.EventType == "revoke-key");
|
||||
}
|
||||
|
||||
// --- rotate-key ---
|
||||
|
||||
[Fact]
|
||||
public async Task RotateKey_ReturnsNewToken_OldSecretFails_NewSecretWorks_AndAuditAppended()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
CreateKeyResult created = await commands.CreateKeyAsync(
|
||||
"key-1",
|
||||
"Service A",
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
string oldSecret = ParseSecret(created.Token);
|
||||
|
||||
CreateKeyResult rotated = await commands.RotateKeyAsync("key-1", "10.0.0.1", CancellationToken.None);
|
||||
|
||||
Assert.Equal("key-1", rotated.KeyId);
|
||||
Assert.NotEqual(created.Token, rotated.Token);
|
||||
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(found);
|
||||
|
||||
// Old secret no longer verifies; new one does.
|
||||
Assert.False(ApiKeySecretHasher.Verify(oldSecret, Pepper, found!.SecretHash));
|
||||
Assert.True(ApiKeySecretHasher.Verify(ParseSecret(rotated.Token), Pepper, found.SecretHash));
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
|
||||
Assert.Single(recent, e => e.EventType == "rotate-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateKey_UnknownKey_ReturnsFailureResult_AndAppendsAudit()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
|
||||
int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
|
||||
|
||||
CreateKeyResult result = await commands.RotateKeyAsync("missing", null, CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Token);
|
||||
|
||||
// Auditing failed/not-found attempts is INTENTIONAL (security trail): exactly one rotate-key row.
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
|
||||
int newAuditRows = recent.Count - auditCountBefore;
|
||||
Assert.Equal(1, newAuditRows);
|
||||
ApiKeyAuditEntry auditRow = recent.First(e => e.EventType == "rotate-key");
|
||||
Assert.Equal("not-found", auditRow.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateKey_PepperUnavailable_Throws_HashUnchanged_AndAppendsNoAudit()
|
||||
{
|
||||
// Arrange: create a key with a valid pepper.
|
||||
ApiKeyAdminCommands setupCommands = BuildCommands(pepper: Pepper);
|
||||
await setupCommands.InitDbAsync(null, CancellationToken.None);
|
||||
await setupCommands.CreateKeyAsync(
|
||||
"key-1",
|
||||
"Service A",
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
constraintsJson: null,
|
||||
remoteAddress: null,
|
||||
CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? before = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(before);
|
||||
byte[] hashBefore = before!.SecretHash;
|
||||
|
||||
int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
|
||||
|
||||
// Act: rotate with no pepper available.
|
||||
ApiKeyAdminCommands nopepper = BuildCommands(pepper: null);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
nopepper.RotateKeyAsync("key-1", null, CancellationToken.None));
|
||||
|
||||
// Assert: stored hash is unchanged.
|
||||
ApiKeyRecord? after = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(after);
|
||||
Assert.True(after!.SecretHash.SequenceEqual(hashBefore));
|
||||
|
||||
// Assert: no rotate-key audit row was appended (RequirePepper fires before any store/audit write).
|
||||
int auditCountAfter = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
|
||||
Assert.Equal(auditCountBefore, auditCountAfter);
|
||||
}
|
||||
|
||||
// --- delete-key ---
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteKey_OnlyWorksAfterRevoke_AndAppendsAudit()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
await commands.CreateKeyAsync(
|
||||
"key-1",
|
||||
"Service A",
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
// Delete before revoke fails.
|
||||
KeyActionResult beforeRevoke = await commands.DeleteKeyAsync("key-1", null, CancellationToken.None);
|
||||
Assert.False(beforeRevoke.Succeeded);
|
||||
Assert.NotNull(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
|
||||
|
||||
await commands.RevokeKeyAsync("key-1", null, CancellationToken.None);
|
||||
|
||||
KeyActionResult afterRevoke = await commands.DeleteKeyAsync("key-1", null, CancellationToken.None);
|
||||
Assert.True(afterRevoke.Succeeded);
|
||||
Assert.Null(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
|
||||
|
||||
// Two delete-key audit rows (one failed attempt, one success) — each verb audits exactly once per call.
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
|
||||
Assert.Equal(2, recent.Count(e => e.EventType == "delete-key"));
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static string ParseSecret(string? token)
|
||||
{
|
||||
// token = "<prefix>_<keyId>_<secret>"; secret may contain underscores.
|
||||
Assert.NotNull(token);
|
||||
string[] parts = token!.Split('_', 3);
|
||||
return parts[2];
|
||||
}
|
||||
|
||||
private async Task<bool> TableExistsAsync(string tableName)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await _factory.OpenConnectionAsync(CancellationToken.None);
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=$name;";
|
||||
command.Parameters.AddWithValue("$name", tableName);
|
||||
long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider
|
||||
{
|
||||
public string? GetPepper() => pepper;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
TryDelete(_dbPath);
|
||||
TryDelete(_dbPath + "-wal");
|
||||
TryDelete(_dbPath + "-shm");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort cleanup of the per-test temp database.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
||||
|
||||
public class ApiKeyParserTests
|
||||
{
|
||||
// --- basic happy path ---
|
||||
|
||||
[Fact]
|
||||
public void TryParse_SimpleToken_ReturnsParsedKey()
|
||||
{
|
||||
var result = ApiKeyParser.TryParse("mxgw_alice_SECRET", "mxgw");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("alice", result.KeyId);
|
||||
Assert.Equal("SECRET", result.Secret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_BearerPrefixCaseInsensitive_ReturnsParsedKey()
|
||||
{
|
||||
var result = ApiKeyParser.TryParse("Bearer mxgw_alice_SEC_RET", "mxgw");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("alice", result.KeyId);
|
||||
Assert.Equal("SEC_RET", result.Secret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_BearerLowercase_ReturnsParsedKey()
|
||||
{
|
||||
var result = ApiKeyParser.TryParse("bearer mxgw_alice_SECRET", "mxgw");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("alice", result.KeyId);
|
||||
Assert.Equal("SECRET", result.Secret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_SecretContainsUnderscores_SecretIsEverythingAfterFirstSplit()
|
||||
{
|
||||
var result = ApiKeyParser.TryParse("mxgw_k1_a_b_c", "mxgw");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("k1", result.KeyId);
|
||||
Assert.Equal("a_b_c", result.Secret);
|
||||
}
|
||||
|
||||
// --- custom prefix ---
|
||||
|
||||
[Fact]
|
||||
public void TryParse_CustomPrefix_Works()
|
||||
{
|
||||
var result = ApiKeyParser.TryParse("myapp_user42_s3cr3t", "myapp");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("user42", result.KeyId);
|
||||
Assert.Equal("s3cr3t", result.Secret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_CustomPrefix_WithBearer()
|
||||
{
|
||||
var result = ApiKeyParser.TryParse("Bearer myapp_user42_s3cr3t", "myapp");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("user42", result.KeyId);
|
||||
Assert.Equal("s3cr3t", result.Secret);
|
||||
}
|
||||
|
||||
// --- rejection cases ---
|
||||
|
||||
[Fact]
|
||||
public void TryParse_WrongPrefix_ReturnsNull()
|
||||
{
|
||||
var result = ApiKeyParser.TryParse("zzz_a_b", "mxgw");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_NoDelimiters_ReturnsNull()
|
||||
{
|
||||
var result = ApiKeyParser.TryParse("nodelims", "mxgw");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_NullInput_ReturnsNull()
|
||||
{
|
||||
var result = ApiKeyParser.TryParse(null, "mxgw");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_EmptyInput_ReturnsNull()
|
||||
{
|
||||
var result = ApiKeyParser.TryParse("", "mxgw");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_WhitespaceInput_ReturnsNull()
|
||||
{
|
||||
var result = ApiKeyParser.TryParse(" ", "mxgw");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_OnlyPrefix_NoKeyIdOrSecret_ReturnsNull()
|
||||
{
|
||||
// "mxgw_" — prefix present but no key id segment
|
||||
var result = ApiKeyParser.TryParse("mxgw_", "mxgw");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_PrefixAndKeyIdButNoSecret_ReturnsNull()
|
||||
{
|
||||
// "mxgw_alice" — no second underscore after key id
|
||||
var result = ApiKeyParser.TryParse("mxgw_alice", "mxgw");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_PrefixAndUnderscoreButEmptySecret_ReturnsNull()
|
||||
{
|
||||
// "mxgw_alice_" — secret is empty
|
||||
var result = ApiKeyParser.TryParse("mxgw_alice_", "mxgw");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// --- generator↔parser round-trip ---
|
||||
|
||||
[Fact]
|
||||
public void TryParse_RealGeneratedSecret_RoundTripsKeyIdAndFullSecret()
|
||||
{
|
||||
// ApiKeySecretGenerator produces URL-safe base64 which may contain '_'.
|
||||
// The parser must preserve the full secret even when it contains underscores.
|
||||
string secret = ApiKeySecretGenerator.NewSecret();
|
||||
string token = $"mxgw_someid_{secret}";
|
||||
|
||||
var result = ApiKeyParser.TryParse(token, "mxgw");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("someid", result!.KeyId);
|
||||
Assert.Equal(secret, result.Secret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
||||
|
||||
public class ApiKeySecretGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void NewSecret_ReturnsNonEmpty()
|
||||
{
|
||||
var secret = ApiKeySecretGenerator.NewSecret();
|
||||
|
||||
Assert.NotEmpty(secret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSecret_TwoCallsDiffer()
|
||||
{
|
||||
var first = ApiKeySecretGenerator.NewSecret();
|
||||
var second = ApiKeySecretGenerator.NewSecret();
|
||||
|
||||
Assert.NotEqual(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSecret_DecodesToThirtyTwoBytes()
|
||||
{
|
||||
var secret = ApiKeySecretGenerator.NewSecret();
|
||||
|
||||
// Restore URL-safe base64 to standard before decoding
|
||||
string standard = secret.Replace('-', '+').Replace('_', '/');
|
||||
// Add padding if needed
|
||||
int pad = standard.Length % 4;
|
||||
if (pad == 2) standard += "==";
|
||||
else if (pad == 3) standard += "=";
|
||||
|
||||
byte[] bytes = Convert.FromBase64String(standard);
|
||||
|
||||
Assert.Equal(32, bytes.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSecret_IsUrlSafe_NoPlus()
|
||||
{
|
||||
// Run many iterations to make collisions with '+' unlikely to be missed
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
Assert.DoesNotContain('+', ApiKeySecretGenerator.NewSecret());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSecret_IsUrlSafe_NoSlash()
|
||||
{
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
Assert.DoesNotContain('/', ApiKeySecretGenerator.NewSecret());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSecret_IsUrlSafe_NoPaddingEquals()
|
||||
{
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
Assert.DoesNotContain('=', ApiKeySecretGenerator.NewSecret());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
||||
|
||||
public class ApiKeySecretHasherTests
|
||||
{
|
||||
private const string Secret = "mysecret";
|
||||
private const string Pepper = "mypepper";
|
||||
|
||||
// --- Hash determinism ---
|
||||
|
||||
[Fact]
|
||||
public void Hash_SameInputs_ProducesIdenticalHashes()
|
||||
{
|
||||
byte[] first = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
byte[] second = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DifferentPepper_ProducesDifferentHash()
|
||||
{
|
||||
byte[] withPepper1 = ApiKeySecretHasher.Hash(Secret, "pepper1");
|
||||
byte[] withPepper2 = ApiKeySecretHasher.Hash(Secret, "pepper2");
|
||||
|
||||
Assert.NotEqual(withPepper1, withPepper2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DifferentSecret_ProducesDifferentHash()
|
||||
{
|
||||
byte[] hash1 = ApiKeySecretHasher.Hash("secret1", Pepper);
|
||||
byte[] hash2 = ApiKeySecretHasher.Hash("secret2", Pepper);
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsThirtyTwoBytes()
|
||||
{
|
||||
// HMAC-SHA256 output is 256 bits = 32 bytes
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
|
||||
Assert.Equal(32, hash.Length);
|
||||
}
|
||||
|
||||
// --- Verify happy path ---
|
||||
|
||||
[Fact]
|
||||
public void Verify_CorrectSecretAndPepper_ReturnsTrue()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
|
||||
Assert.True(ApiKeySecretHasher.Verify(Secret, Pepper, hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WrongSecret_ReturnsFalse()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
|
||||
Assert.False(ApiKeySecretHasher.Verify("wrongsecret", Pepper, hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WrongPepper_ReturnsFalse()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
|
||||
Assert.False(ApiKeySecretHasher.Verify(Secret, "wrongpepper", hash));
|
||||
}
|
||||
|
||||
// --- Constant-time: length mismatch must not throw ---
|
||||
|
||||
[Fact]
|
||||
public void Verify_HashOfDifferentLength_ReturnsFalseWithoutThrowing()
|
||||
{
|
||||
// A hash of a completely different length — FixedTimeEquals must handle it
|
||||
// without throwing and return false.
|
||||
byte[] shortHash = [1, 2, 3];
|
||||
|
||||
var exception = Record.Exception(() => ApiKeySecretHasher.Verify(Secret, Pepper, shortHash));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.False(ApiKeySecretHasher.Verify(Secret, Pepper, shortHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_EmptyHash_ReturnsFalseWithoutThrowing()
|
||||
{
|
||||
byte[] emptyHash = [];
|
||||
|
||||
var exception = Record.Exception(() => ApiKeySecretHasher.Verify(Secret, Pepper, emptyHash));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.False(ApiKeySecretHasher.Verify(Secret, Pepper, emptyHash));
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
||||
|
||||
public class ApiKeyServiceCollectionExtensionsTests
|
||||
{
|
||||
private const string ApiKeySection = "Auth:ApiKeys";
|
||||
|
||||
private const string PepperSecretName = "ApiKeyPepper";
|
||||
private const string PepperValue = "super-secret-pepper-value";
|
||||
|
||||
private static IConfiguration BuildConfiguration(string sqlitePath, bool runMigrationsOnStartup = false) =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{ApiKeySection}:TokenPrefix"] = "mxgw",
|
||||
[$"{ApiKeySection}:SqlitePath"] = sqlitePath,
|
||||
[$"{ApiKeySection}:PepperSecretName"] = PepperSecretName,
|
||||
[$"{ApiKeySection}:RunMigrationsOnStartup"] = runMigrationsOnStartup ? "true" : "false",
|
||||
|
||||
// The pepper itself lives at the top level under the configured secret name.
|
||||
[PepperSecretName] = PepperValue,
|
||||
})
|
||||
.Build();
|
||||
|
||||
private static string TempSqlitePath() =>
|
||||
Path.Combine(Path.GetTempPath(), $"zbauth-test-{Guid.NewGuid():N}.db");
|
||||
|
||||
[Fact]
|
||||
public void AddZbApiKeyAuth_ResolvesVerifier()
|
||||
{
|
||||
IConfiguration config = BuildConfiguration(TempSqlitePath());
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddZbApiKeyAuth(config, ApiKeySection);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
var verifier = provider.GetRequiredService<IApiKeyVerifier>();
|
||||
|
||||
Assert.NotNull(verifier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbApiKeyAuth_ResolvesAllStores()
|
||||
{
|
||||
IConfiguration config = BuildConfiguration(TempSqlitePath());
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddZbApiKeyAuth(config, ApiKeySection);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
Assert.NotNull(provider.GetRequiredService<IApiKeyStore>());
|
||||
Assert.NotNull(provider.GetRequiredService<IApiKeyAdminStore>());
|
||||
Assert.NotNull(provider.GetRequiredService<IApiKeyAuditStore>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbApiKeyAuth_BindsOptionsFromSection()
|
||||
{
|
||||
string sqlitePath = TempSqlitePath();
|
||||
IConfiguration config = BuildConfiguration(sqlitePath);
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddZbApiKeyAuth(config, ApiKeySection);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<ApiKeyOptions>>();
|
||||
|
||||
Assert.Equal("mxgw", options.Value.TokenPrefix);
|
||||
Assert.Equal(sqlitePath, options.Value.SqlitePath);
|
||||
Assert.Equal(PepperSecretName, options.Value.PepperSecretName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbApiKeyAuth_PepperProviderReturnsConfiguredPepper()
|
||||
{
|
||||
IConfiguration config = BuildConfiguration(TempSqlitePath());
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddZbApiKeyAuth(config, ApiKeySection);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
var pepperProvider = provider.GetRequiredService<IApiKeyPepperProvider>();
|
||||
|
||||
Assert.IsType<ConfigurationApiKeyPepperProvider>(pepperProvider);
|
||||
Assert.Equal(PepperValue, pepperProvider.GetPepper());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigurationApiKeyPepperProvider_ReturnsNull_WhenSecretNameUnset()
|
||||
{
|
||||
IConfiguration config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.Build();
|
||||
var options = Options.Create(new ApiKeyOptions { PepperSecretName = "" });
|
||||
|
||||
var provider = new ConfigurationApiKeyPepperProvider(config, options);
|
||||
|
||||
Assert.Null(provider.GetPepper());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigurationApiKeyPepperProvider_ReturnsNull_WhenValueAbsent()
|
||||
{
|
||||
IConfiguration config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.Build();
|
||||
var options = Options.Create(new ApiKeyOptions { PepperSecretName = "Missing" });
|
||||
|
||||
var provider = new ConfigurationApiKeyPepperProvider(config, options);
|
||||
|
||||
Assert.Null(provider.GetPepper());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddZbApiKeyAuth_MigrationHostedService_CreatesSchemaOnStartup()
|
||||
{
|
||||
string sqlitePath = TempSqlitePath();
|
||||
try
|
||||
{
|
||||
IConfiguration config = BuildConfiguration(sqlitePath, runMigrationsOnStartup: true);
|
||||
var services = new ServiceCollection();
|
||||
services.AddZbApiKeyAuth(config, ApiKeySection);
|
||||
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
// Find the ApiKeyMigrationHostedService among all registered IHostedService instances.
|
||||
var hostedServices = provider.GetServices<IHostedService>().ToList();
|
||||
IHostedService? migrationService = hostedServices
|
||||
.FirstOrDefault(s => s.GetType().Name == "ApiKeyMigrationHostedService");
|
||||
|
||||
Assert.NotNull(migrationService);
|
||||
|
||||
await migrationService!.StartAsync(CancellationToken.None);
|
||||
|
||||
// Verify the api_keys table was created by the migration.
|
||||
string connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = sqlitePath,
|
||||
Mode = SqliteOpenMode.ReadOnly,
|
||||
}.ToString();
|
||||
|
||||
await using var connection = new SqliteConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'api_keys';
|
||||
""";
|
||||
|
||||
long tableCount = (long)(await command.ExecuteScalarAsync() ?? 0L);
|
||||
|
||||
Assert.Equal(1L, tableCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(sqlitePath))
|
||||
File.Delete(sqlitePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddZbApiKeyAuth_MigrationHostedService_SkipsMigration_WhenRunMigrationsOnStartupFalse()
|
||||
{
|
||||
string sqlitePath = TempSqlitePath();
|
||||
try
|
||||
{
|
||||
IConfiguration config = BuildConfiguration(sqlitePath, runMigrationsOnStartup: false);
|
||||
var services = new ServiceCollection();
|
||||
services.AddZbApiKeyAuth(config, ApiKeySection);
|
||||
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
var hostedServices = provider.GetServices<IHostedService>().ToList();
|
||||
IHostedService? migrationService = hostedServices
|
||||
.FirstOrDefault(s => s.GetType().Name == "ApiKeyMigrationHostedService");
|
||||
|
||||
Assert.NotNull(migrationService);
|
||||
|
||||
// StartAsync should complete without creating the database file.
|
||||
await migrationService!.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(File.Exists(sqlitePath),
|
||||
"Migration should not run when RunMigrationsOnStartup is false.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(sqlitePath))
|
||||
File.Delete(sqlitePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
||||
|
||||
public class ApiKeyVerifierTests
|
||||
{
|
||||
private const string TokenPrefix = "mxgw";
|
||||
private const string Pepper = "test-pepper";
|
||||
private const string KeyId = "abc123";
|
||||
private const string Secret = "supersecretvalue";
|
||||
private const string DisplayName = "Test Key";
|
||||
private const string ConstraintsJson = """{"ipAllow":["10.0.0.0/8"]}""";
|
||||
|
||||
private static readonly IReadOnlySet<string> Scopes =
|
||||
new HashSet<string> { "read", "write" };
|
||||
|
||||
private static string Header(string keyId, string secret) =>
|
||||
$"{TokenPrefix}_{keyId}_{secret}";
|
||||
|
||||
private static ApiKeyRecord BuildRecord(
|
||||
byte[] secretHash,
|
||||
DateTimeOffset? revokedUtc = null) => new(
|
||||
KeyId: KeyId,
|
||||
KeyPrefix: TokenPrefix,
|
||||
SecretHash: secretHash,
|
||||
DisplayName: DisplayName,
|
||||
Scopes: Scopes,
|
||||
ConstraintsJson: ConstraintsJson,
|
||||
CreatedUtc: DateTimeOffset.UnixEpoch,
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: revokedUtc);
|
||||
|
||||
private static ApiKeyVerifier BuildVerifier(
|
||||
FakeApiKeyStore store,
|
||||
FakePepperProvider pepperProvider) =>
|
||||
new(new ApiKeyOptions { TokenPrefix = TokenPrefix }, store, pepperProvider);
|
||||
|
||||
// --- MissingOrMalformed ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("garbage")]
|
||||
[InlineData("wrongprefix_abc123_secret")]
|
||||
public async Task VerifyAsync_MissingOrMalformedHeader_ReturnsMissingOrMalformed(string? header)
|
||||
{
|
||||
var store = new FakeApiKeyStore();
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
ApiKeyVerification result = await verifier.VerifyAsync(header!, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyFailure.MissingOrMalformed, result.Failure);
|
||||
Assert.Null(result.Identity);
|
||||
Assert.False(store.MarkUsedCalled);
|
||||
}
|
||||
|
||||
// --- PepperUnavailable ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task VerifyAsync_PepperUnavailable_ReturnsPepperUnavailable(string? pepper)
|
||||
{
|
||||
var store = new FakeApiKeyStore();
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(pepper));
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyFailure.PepperUnavailable, result.Failure);
|
||||
Assert.Null(result.Identity);
|
||||
Assert.False(store.MarkUsedCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_PepperUnavailable_DoesNotQueryStore()
|
||||
{
|
||||
var store = new FakeApiKeyStore();
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(null));
|
||||
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.False(store.FindByKeyIdCalled);
|
||||
}
|
||||
|
||||
// --- KeyNotFound ---
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_KeyNotFound_ReturnsKeyNotFound()
|
||||
{
|
||||
var store = new FakeApiKeyStore { Record = null };
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyFailure.KeyNotFound, result.Failure);
|
||||
Assert.Null(result.Identity);
|
||||
Assert.False(store.MarkUsedCalled);
|
||||
}
|
||||
|
||||
// --- KeyRevoked ---
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RevokedKey_ReturnsKeyRevoked()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore
|
||||
{
|
||||
Record = BuildRecord(hash, revokedUtc: DateTimeOffset.UtcNow),
|
||||
};
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure);
|
||||
Assert.Null(result.Identity);
|
||||
Assert.False(store.MarkUsedCalled);
|
||||
}
|
||||
|
||||
// --- SecretMismatch ---
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WrongSecret_ReturnsSecretMismatch()
|
||||
{
|
||||
// Record's hash is built from a DIFFERENT secret with the test pepper.
|
||||
byte[] hash = ApiKeySecretHasher.Hash("a-different-secret", Pepper);
|
||||
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyFailure.SecretMismatch, result.Failure);
|
||||
Assert.Null(result.Identity);
|
||||
Assert.False(store.MarkUsedCalled);
|
||||
}
|
||||
|
||||
// --- Success ---
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_ReturnsSuccessWithIdentity()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Null(result.Failure);
|
||||
Assert.NotNull(result.Identity);
|
||||
Assert.Equal(KeyId, result.Identity!.KeyId);
|
||||
Assert.Equal(DisplayName, result.Identity.DisplayName);
|
||||
Assert.Equal(Scopes, result.Identity.Scopes);
|
||||
Assert.Equal(ConstraintsJson, result.Identity.Constraints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_MarksKeyUsed()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.True(store.MarkUsedCalled);
|
||||
Assert.Equal(KeyId, store.MarkUsedKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_UsesInjectedTimeProviderForMarkUsed()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
||||
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero));
|
||||
var verifier = new ApiKeyVerifier(
|
||||
new ApiKeyOptions { TokenPrefix = TokenPrefix },
|
||||
store,
|
||||
new FakePepperProvider(Pepper),
|
||||
fakeTime);
|
||||
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.Equal(fakeTime.Now, store.MarkUsedWhenUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_DoesNotLeakSecretInIdentity()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
string identityText = result.Identity!.ToString();
|
||||
Assert.DoesNotContain(Secret, identityText, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Pepper, identityText, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Convert.ToBase64String(hash), identityText, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// --- Cancellation ---
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_AlreadyCancelled_Throws()
|
||||
{
|
||||
var store = new FakeApiKeyStore();
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => verifier.VerifyAsync(Header(KeyId, Secret), cts.Token));
|
||||
|
||||
Assert.False(store.MarkUsedCalled);
|
||||
}
|
||||
|
||||
// --- Bearer scheme acceptance (sanity) ---
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_BearerPrefixedValidKey_Succeeds()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync($"Bearer {Header(KeyId, Secret)}", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
// --- Fakes ---
|
||||
|
||||
private sealed class FakeApiKeyStore : IApiKeyStore
|
||||
{
|
||||
public ApiKeyRecord? Record { get; set; }
|
||||
public bool FindByKeyIdCalled { get; private set; }
|
||||
public bool MarkUsedCalled { get; private set; }
|
||||
public string? MarkUsedKeyId { get; private set; }
|
||||
public DateTimeOffset? MarkUsedWhenUtc { get; private set; }
|
||||
|
||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
FindByKeyIdCalled = true;
|
||||
return Task.FromResult(Record);
|
||||
}
|
||||
|
||||
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct) =>
|
||||
throw new NotSupportedException("Verifier must use FindByKeyIdAsync to discriminate revoked keys.");
|
||||
|
||||
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||
{
|
||||
MarkUsedCalled = true;
|
||||
MarkUsedKeyId = keyId;
|
||||
MarkUsedWhenUtc = whenUtc;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider
|
||||
{
|
||||
public string? GetPepper() => pepper;
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; } = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
||||
|
||||
public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _dbPath =
|
||||
Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");
|
||||
|
||||
private AuthSqliteConnectionFactory _factory = null!;
|
||||
private SqliteApiKeyAdminStore _admin = null!;
|
||||
private SqliteApiKeyStore _read = null!;
|
||||
private SqliteApiKeyAuditStore _audit = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_factory = new AuthSqliteConnectionFactory(_dbPath);
|
||||
await new SqliteAuthStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
|
||||
_admin = new SqliteApiKeyAdminStore(_factory);
|
||||
_read = new SqliteApiKeyStore(_factory);
|
||||
_audit = new SqliteApiKeyAuditStore(_factory);
|
||||
}
|
||||
|
||||
// --- Create ---
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ThenFindByKeyId_ReturnsRecord()
|
||||
{
|
||||
ApiKeyRecord record = SampleRecord("key-1");
|
||||
|
||||
await _admin.CreateAsync(record, CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(record.SecretHash, found!.SecretHash);
|
||||
Assert.True(record.Scopes.SetEquals(found.Scopes));
|
||||
Assert.Equal(record.ConstraintsJson, found.ConstraintsJson);
|
||||
Assert.Null(found.LastUsedUtc);
|
||||
Assert.Null(found.RevokedUtc);
|
||||
}
|
||||
|
||||
// --- Revoke ---
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_ActiveKey_SetsRevokedAndFindActiveReturnsNull()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);
|
||||
|
||||
bool result = await _admin.RevokeAsync("key-1", when, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.Equal(when, found!.RevokedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
bool result = await _admin.RevokeAsync("missing", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_AlreadyRevoked_ReturnsFalse()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
bool result = await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- Rotate ---
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_ChangesHashAndReactivates()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
await _read.MarkUsedAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
byte[] newHash = [9, 9, 9, 9];
|
||||
|
||||
bool result = await _admin.RotateAsync("key-1", newHash, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.Equal(newHash, found!.SecretHash);
|
||||
Assert.Null(found.RevokedUtc);
|
||||
Assert.Null(found.LastUsedUtc);
|
||||
// Reactivated: now visible as active.
|
||||
Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
bool result = await _admin.RotateAsync("missing", [1], CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_ActiveKey_ReturnsFalseAndKeyStillPresent()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
|
||||
bool result = await _admin.DeleteAsync("key-1", CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.NotNull(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_RevokedKey_ReturnsTrueAndKeyGone()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
bool result = await _admin.DeleteAsync("key-1", CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Null(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
bool result = await _admin.DeleteAsync("missing", CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- keyId guard tests ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Revoke_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
// ArgumentNullException (null) and ArgumentException (empty/whitespace) are both acceptable;
|
||||
// ThrowIfNullOrWhiteSpace throws ArgumentNullException for null, ArgumentException for whitespace.
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.RevokeAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Rotate_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.RotateAsync(keyId!, [1, 2, 3], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Delete_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.DeleteAsync(keyId!, CancellationToken.None));
|
||||
}
|
||||
|
||||
// --- Audit ---
|
||||
|
||||
[Fact]
|
||||
public async Task Audit_AppendThenListRecent_ReturnsEntry()
|
||||
{
|
||||
var entry = new ApiKeyAuditEntry(
|
||||
KeyId: "key-1",
|
||||
EventType: "created",
|
||||
RemoteAddress: "10.0.0.1",
|
||||
CreatedUtc: new DateTimeOffset(2026, 5, 31, 10, 0, 0, TimeSpan.Zero),
|
||||
Details: "by admin");
|
||||
|
||||
await _audit.AppendAsync(entry, CancellationToken.None);
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
|
||||
Assert.Single(recent);
|
||||
Assert.Equal("key-1", recent[0].KeyId);
|
||||
Assert.Equal("created", recent[0].EventType);
|
||||
Assert.Equal("10.0.0.1", recent[0].RemoteAddress);
|
||||
Assert.Equal("by admin", recent[0].Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Audit_ListRecent_ReturnsNewestFirst()
|
||||
{
|
||||
await _audit.AppendAsync(
|
||||
new ApiKeyAuditEntry("k", "first", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
||||
await _audit.AppendAsync(
|
||||
new ApiKeyAuditEntry("k", "second", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
||||
await _audit.AppendAsync(
|
||||
new ApiKeyAuditEntry("k", "third", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
|
||||
|
||||
Assert.Equal(["third", "second", "first"], recent.Select(e => e.EventType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Audit_ListRecent_RespectsLimit()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _audit.AppendAsync(
|
||||
new ApiKeyAuditEntry("k", $"e{i}", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
||||
}
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(2, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, recent.Count);
|
||||
Assert.Equal(["e4", "e3"], recent.Select(e => e.EventType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Audit_NullableFields_RoundTripAsNull()
|
||||
{
|
||||
await _audit.AppendAsync(
|
||||
new ApiKeyAuditEntry(null, "anon", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
|
||||
Assert.Single(recent);
|
||||
Assert.Null(recent[0].KeyId);
|
||||
Assert.Null(recent[0].RemoteAddress);
|
||||
Assert.Null(recent[0].Details);
|
||||
}
|
||||
|
||||
private static ApiKeyRecord SampleRecord(string keyId) => new(
|
||||
KeyId: keyId,
|
||||
KeyPrefix: "mxgw_ab12",
|
||||
SecretHash: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
DisplayName: "Test Key " + keyId,
|
||||
Scopes: new HashSet<string>(["read", "write"], StringComparer.Ordinal),
|
||||
ConstraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
|
||||
CreatedUtc: new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero),
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null);
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
TryDelete(_dbPath);
|
||||
TryDelete(_dbPath + "-wal");
|
||||
TryDelete(_dbPath + "-shm");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort cleanup of the per-test temp database.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
||||
|
||||
public sealed class SqliteApiKeyStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _dbPath =
|
||||
Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");
|
||||
|
||||
private AuthSqliteConnectionFactory _factory = null!;
|
||||
private SqliteApiKeyStore _store = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_factory = new AuthSqliteConnectionFactory(_dbPath);
|
||||
await new SqliteAuthStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
|
||||
_store = new SqliteApiKeyStore(_factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindByKeyId_AfterInsert_ReturnsEqualRecord()
|
||||
{
|
||||
ApiKeyRecord record = SampleRecord("key-1");
|
||||
await InsertAsync(record);
|
||||
|
||||
ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(found);
|
||||
AssertRecordEqual(record, found!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindByKeyId_ReturnsRevokedRecord()
|
||||
{
|
||||
ApiKeyRecord record = SampleRecord("key-revoked") with
|
||||
{
|
||||
RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
|
||||
};
|
||||
await InsertAsync(record);
|
||||
|
||||
ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-revoked", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.NotNull(found!.RevokedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindActiveByKeyId_RevokedKey_ReturnsNull()
|
||||
{
|
||||
ApiKeyRecord record = SampleRecord("key-revoked") with
|
||||
{
|
||||
RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
|
||||
};
|
||||
await InsertAsync(record);
|
||||
|
||||
ApiKeyRecord? found = await _store.FindActiveByKeyIdAsync("key-revoked", CancellationToken.None);
|
||||
|
||||
Assert.Null(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindActiveByKeyId_ActiveKey_ReturnsRecord()
|
||||
{
|
||||
await InsertAsync(SampleRecord("key-active"));
|
||||
|
||||
ApiKeyRecord? found = await _store.FindActiveByKeyIdAsync("key-active", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindByKeyId_UnknownKey_ReturnsNull()
|
||||
{
|
||||
ApiKeyRecord? found = await _store.FindByKeyIdAsync("missing", CancellationToken.None);
|
||||
|
||||
Assert.Null(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkUsed_ActiveKey_UpdatesLastUsed()
|
||||
{
|
||||
await InsertAsync(SampleRecord("key-active"));
|
||||
var when = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
await _store.MarkUsedAsync("key-active", when, CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-active", CancellationToken.None);
|
||||
Assert.Equal(when, found!.LastUsedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkUsed_RevokedKey_DoesNotUpdateLastUsed()
|
||||
{
|
||||
ApiKeyRecord record = SampleRecord("key-revoked") with
|
||||
{
|
||||
RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
|
||||
};
|
||||
await InsertAsync(record);
|
||||
var when = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
await _store.MarkUsedAsync("key-revoked", when, CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-revoked", CancellationToken.None);
|
||||
Assert.Null(found!.LastUsedUtc);
|
||||
}
|
||||
|
||||
// --- keyId guard tests ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task FindByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
// ArgumentNullException (null) and ArgumentException (empty/whitespace) are both acceptable;
|
||||
// ThrowIfNullOrWhiteSpace throws ArgumentNullException for null, ArgumentException for whitespace.
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _store.FindByKeyIdAsync(keyId!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task FindActiveByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _store.FindActiveByKeyIdAsync(keyId!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task MarkUsed_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _store.MarkUsedAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeSerializer_RoundTripsAndSortsOrdinally()
|
||||
{
|
||||
var unsorted = new HashSet<string>(["zeta", "alpha", "mike"], StringComparer.Ordinal);
|
||||
var differentOrder = new HashSet<string>(["mike", "zeta", "alpha"], StringComparer.Ordinal);
|
||||
|
||||
string a = ScopeSerializer.Serialize(unsorted);
|
||||
string b = ScopeSerializer.Serialize(differentOrder);
|
||||
|
||||
// Equal sets must produce identical column text regardless of insertion order.
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal("""["alpha","mike","zeta"]""", a);
|
||||
|
||||
IReadOnlySet<string> roundTripped = ScopeSerializer.Deserialize(a);
|
||||
Assert.True(roundTripped.SetEquals(unsorted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeSerializer_DeserializeNullOrEmpty_ReturnsEmptySet()
|
||||
{
|
||||
Assert.Empty(ScopeSerializer.Deserialize(null));
|
||||
Assert.Empty(ScopeSerializer.Deserialize(""));
|
||||
}
|
||||
|
||||
private static ApiKeyRecord SampleRecord(string keyId) => new(
|
||||
KeyId: keyId,
|
||||
KeyPrefix: "mxgw_ab12",
|
||||
SecretHash: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
DisplayName: "Test Key " + keyId,
|
||||
Scopes: new HashSet<string>(["read", "write"], StringComparer.Ordinal),
|
||||
ConstraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
|
||||
CreatedUtc: new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero),
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null);
|
||||
|
||||
private static void AssertRecordEqual(ApiKeyRecord expected, ApiKeyRecord actual)
|
||||
{
|
||||
Assert.Equal(expected.KeyId, actual.KeyId);
|
||||
Assert.Equal(expected.KeyPrefix, actual.KeyPrefix);
|
||||
Assert.Equal(expected.SecretHash, actual.SecretHash);
|
||||
Assert.Equal(expected.DisplayName, actual.DisplayName);
|
||||
Assert.True(expected.Scopes.SetEquals(actual.Scopes));
|
||||
Assert.Equal(expected.ConstraintsJson, actual.ConstraintsJson);
|
||||
Assert.Equal(expected.CreatedUtc, actual.CreatedUtc);
|
||||
Assert.Equal(expected.LastUsedUtc, actual.LastUsedUtc);
|
||||
Assert.Equal(expected.RevokedUtc, actual.RevokedUtc);
|
||||
}
|
||||
|
||||
private async Task InsertAsync(ApiKeyRecord record)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await _factory.OpenConnectionAsync(CancellationToken.None);
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO api_keys (
|
||||
key_id, key_prefix, secret_hash, display_name, scopes,
|
||||
constraints, created_utc, last_used_utc, revoked_utc)
|
||||
VALUES (
|
||||
$key_id, $key_prefix, $secret_hash, $display_name, $scopes,
|
||||
$constraints, $created_utc, $last_used_utc, $revoked_utc);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", record.KeyId);
|
||||
command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix);
|
||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = record.SecretHash;
|
||||
command.Parameters.AddWithValue("$display_name", record.DisplayName);
|
||||
command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(record.Scopes));
|
||||
command.Parameters.AddWithValue("$constraints", (object?)record.ConstraintsJson ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$created_utc", record.CreatedUtc.ToString("O"));
|
||||
command.Parameters.AddWithValue("$last_used_utc", (object?)record.LastUsedUtc?.ToString("O") ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$revoked_utc", (object?)record.RevokedUtc?.ToString("O") ?? DBNull.Value);
|
||||
await command.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
TryDelete(_dbPath);
|
||||
TryDelete(_dbPath + "-wal");
|
||||
TryDelete(_dbPath + "-shm");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort cleanup of the per-test temp database.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
||||
|
||||
public sealed class SqliteMigratorTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath =
|
||||
Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");
|
||||
|
||||
private AuthSqliteConnectionFactory Factory => new(_dbPath);
|
||||
|
||||
[Fact]
|
||||
public async Task MigrateAsync_CreatesAllThreeTables()
|
||||
{
|
||||
var migrator = new SqliteAuthStoreMigrator(Factory);
|
||||
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
|
||||
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeyAuditTable));
|
||||
Assert.True(await TableExistsAsync(SqliteAuthSchema.SchemaVersionTable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrateAsync_RunTwice_IsIdempotentAndRecordsCurrentVersion()
|
||||
{
|
||||
var migrator = new SqliteAuthStoreMigrator(Factory);
|
||||
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadVersionAsync());
|
||||
Assert.Equal(1, await CountSchemaVersionRowsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrateAsync_FutureSchemaVersion_Throws()
|
||||
{
|
||||
var migrator = new SqliteAuthStoreMigrator(Factory);
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
|
||||
await SetVersionAsync(99);
|
||||
|
||||
await Assert.ThrowsAsync<AuthStoreMigrationException>(
|
||||
() => migrator.MigrateAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
private async Task<bool> TableExistsAsync(string tableName)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await Factory.OpenConnectionAsync(CancellationToken.None);
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $name;";
|
||||
command.Parameters.AddWithValue("$name", tableName);
|
||||
long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
|
||||
return count == 1;
|
||||
}
|
||||
|
||||
private async Task<int> ReadVersionAsync()
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await Factory.OpenConnectionAsync(CancellationToken.None);
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT version FROM schema_version WHERE id = 1;";
|
||||
object? value = await command.ExecuteScalarAsync(CancellationToken.None);
|
||||
return Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private async Task<int> CountSchemaVersionRowsAsync()
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await Factory.OpenConnectionAsync(CancellationToken.None);
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT COUNT(*) FROM schema_version;";
|
||||
long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
|
||||
return (int)count;
|
||||
}
|
||||
|
||||
private async Task SetVersionAsync(int version)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await Factory.OpenConnectionAsync(CancellationToken.None);
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = "UPDATE schema_version SET version = $version WHERE id = 1;";
|
||||
command.Parameters.AddWithValue("$version", version);
|
||||
await command.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
TryDelete(_dbPath);
|
||||
TryDelete(_dbPath + "-wal");
|
||||
TryDelete(_dbPath + "-shm");
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort cleanup of the per-test temp database.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<!-- Back the relocated AddZbApiKeyAuth DI / migration / pepper-provider tests.
|
||||
(Microsoft.Data.Sqlite flows in transitively via the ApiKeys project reference.) -->
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.ApiKeys\ZB.MOM.WW.Auth.ApiKeys.csproj" />
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.AspNetCore.Tests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
private const string LdapSection = "Auth:Ldap";
|
||||
|
||||
private const string LdapServer = "ldap.example.com";
|
||||
|
||||
private static IConfiguration BuildConfiguration() =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{LdapSection}:Server"] = LdapServer,
|
||||
[$"{LdapSection}:SearchBase"] = "dc=example,dc=com",
|
||||
[$"{LdapSection}:ServiceAccountDn"] = "cn=svc,dc=example,dc=com",
|
||||
[$"{LdapSection}:Transport"] = nameof(LdapTransport.Ldaps),
|
||||
})
|
||||
.Build();
|
||||
|
||||
[Fact]
|
||||
public void AddZbLdapAuth_ResolvesLdapAuthService()
|
||||
{
|
||||
IConfiguration config = BuildConfiguration();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddZbLdapAuth(config, LdapSection);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
var service = provider.GetRequiredService<ILdapAuthService>();
|
||||
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbLdapAuth_ILdapAuthService_IsSingleton()
|
||||
{
|
||||
IConfiguration config = BuildConfiguration();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddZbLdapAuth(config, LdapSection);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
var first = provider.GetRequiredService<ILdapAuthService>();
|
||||
var second = provider.GetRequiredService<ILdapAuthService>();
|
||||
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbLdapAuth_BindsOptionsFromSection()
|
||||
{
|
||||
IConfiguration config = BuildConfiguration();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddZbLdapAuth(config, LdapSection);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<LdapOptions>>();
|
||||
|
||||
Assert.Equal(LdapServer, options.Value.Server);
|
||||
Assert.Equal("dc=example,dc=com", options.Value.SearchBase);
|
||||
Assert.Equal(LdapTransport.Ldaps, options.Value.Transport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbLdapAuth_RegistersOptionsValidator()
|
||||
{
|
||||
IConfiguration config = BuildConfiguration();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddZbLdapAuth(config, LdapSection);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
var validators = provider.GetServices<IValidateOptions<LdapOptions>>().ToList();
|
||||
|
||||
Assert.Contains(validators, v => v is LdapOptionsValidator);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Resolves CookieAuthenticationOptions, IServiceCollection, IConfiguration, and the
|
||||
in-memory configuration provider used by the DI/cookie tests. -->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.AspNetCore\ZB.MOM.WW.Auth.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
|
||||
<!-- Referenced so the DI tests can assert the concrete LdapOptionsValidator registration. -->
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Ldap\ZB.MOM.WW.Auth.Ldap.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Security.Claims;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.AspNetCore.Tests;
|
||||
|
||||
public class ZbClaimTypesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Name_AliasesClaimTypesName()
|
||||
{
|
||||
Assert.Equal(ClaimTypes.Name, ZbClaimTypes.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Role_AliasesClaimTypesRole()
|
||||
{
|
||||
Assert.Equal(ClaimTypes.Role, ZbClaimTypes.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_HasExpectedLiteralValue()
|
||||
{
|
||||
Assert.Equal("zb:displayname", ZbClaimTypes.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Username_HasExpectedLiteralValue()
|
||||
{
|
||||
Assert.Equal("zb:username", ZbClaimTypes.Username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeId_HasExpectedLiteralValue()
|
||||
{
|
||||
Assert.Equal("zb:scopeid", ZbClaimTypes.ScopeId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.AspNetCore.Tests;
|
||||
|
||||
public class ZbCookieDefaultsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Apply_SetsHardenedCookieFlags()
|
||||
{
|
||||
var options = new CookieAuthenticationOptions();
|
||||
|
||||
ZbCookieDefaults.Apply(options);
|
||||
|
||||
Assert.True(options.Cookie.HttpOnly);
|
||||
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
|
||||
Assert.True(options.SlidingExpiration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_UsesSuppliedIdleTimeout()
|
||||
{
|
||||
var options = new CookieAuthenticationOptions();
|
||||
var idle = TimeSpan.FromMinutes(12);
|
||||
|
||||
ZbCookieDefaults.Apply(options, idleTimeout: idle);
|
||||
|
||||
Assert.Equal(idle, options.ExpireTimeSpan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_DefaultsToDefaultIdleTimeout_WhenNotSupplied()
|
||||
{
|
||||
var options = new CookieAuthenticationOptions();
|
||||
|
||||
ZbCookieDefaults.Apply(options);
|
||||
|
||||
Assert.Equal(ZbCookieDefaults.DefaultIdleTimeout, options.ExpireTimeSpan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_RequireHttpsTrue_SetsSecurePolicyAlways()
|
||||
{
|
||||
var options = new CookieAuthenticationOptions();
|
||||
|
||||
ZbCookieDefaults.Apply(options, requireHttps: true);
|
||||
|
||||
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_RequireHttpsFalse_SetsSecurePolicySameAsRequest()
|
||||
{
|
||||
var options = new CookieAuthenticationOptions();
|
||||
|
||||
ZbCookieDefaults.Apply(options, requireHttps: false);
|
||||
|
||||
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_DefaultsRequireHttpsToAlways()
|
||||
{
|
||||
var options = new CookieAuthenticationOptions();
|
||||
|
||||
ZbCookieDefaults.Apply(options);
|
||||
|
||||
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_NullOptions_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => ZbCookieDefaults.Apply(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for <see cref="ILdapConnection"/>. Script results and error
|
||||
/// conditions with the builder methods; inspect recorded calls via properties.
|
||||
/// Consumed by Task 5 (LdapAuthService) unit tests.
|
||||
/// </summary>
|
||||
internal sealed class FakeLdapConnection : ILdapConnection
|
||||
{
|
||||
// ---- scripted state -----
|
||||
|
||||
private readonly List<LdapSearchEntry> _scriptedEntries = new();
|
||||
private readonly HashSet<string> _throwBindDns = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _throwOnConnect;
|
||||
private bool _throwOnServiceBind;
|
||||
private bool _throwOnUserBind;
|
||||
|
||||
// ---- observation -----
|
||||
|
||||
public (string Host, int Port, LdapTransport Transport, bool AllowInsecure, int TimeoutMs)? ConnectArgs { get; private set; }
|
||||
public List<string> BoundDns { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Count of <see cref="Bind"/> attempts (including ones that throw). The first attempt is
|
||||
/// the service-account bind; the second is the user bind. Used to distinguish the two.
|
||||
/// </summary>
|
||||
public int BindAttempts { get; private set; }
|
||||
|
||||
// ---- builder methods -----
|
||||
|
||||
/// <summary>
|
||||
/// Scripts a user entry that will be returned by the next <see cref="Search"/> call.
|
||||
/// Builds a minimal attribute bag with <c>memberOf</c> and optional <c>displayName</c>.
|
||||
/// </summary>
|
||||
public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null)
|
||||
{
|
||||
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["memberOf"] = memberOf.ToList()
|
||||
};
|
||||
if (displayName is not null)
|
||||
attrs["displayName"] = new[] { displayName };
|
||||
|
||||
_scriptedEntries.Add(new LdapSearchEntry(dn, attrs));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the fake to throw <see cref="Novell.Directory.Ldap.LdapException"/> when
|
||||
/// <see cref="Bind"/> is called for <paramref name="dn"/> (simulates bad credentials).
|
||||
/// </summary>
|
||||
public FakeLdapConnection ThrowOnBind(string dn)
|
||||
{
|
||||
_throwBindDns.Add(dn);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> on the SECOND bind — the user
|
||||
/// re-bind in the bind-then-search-then-bind flow — to simulate bad user credentials. The
|
||||
/// first (service-account) bind still succeeds. Bind order, not DN, decides which one throws.
|
||||
/// </summary>
|
||||
public FakeLdapConnection ThrowOnUserBind()
|
||||
{
|
||||
_throwOnUserBind = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> on the FIRST bind — the
|
||||
/// service-account bind — to simulate a service-account misconfiguration. Distinct from
|
||||
/// <see cref="ThrowOnUserBind"/>; this fails before the directory search ever runs.
|
||||
/// </summary>
|
||||
public FakeLdapConnection ThrowOnServiceBind()
|
||||
{
|
||||
_throwOnServiceBind = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> to
|
||||
/// simulate an unreachable directory (infrastructure failure).
|
||||
/// </summary>
|
||||
public FakeLdapConnection ThrowOnConnect()
|
||||
{
|
||||
_throwOnConnect = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scripts a search that returns ZERO entries (no <see cref="WithUserEntry"/> call also
|
||||
/// yields zero, but this states the intent explicitly). Simulates user-not-found.
|
||||
/// </summary>
|
||||
public FakeLdapConnection WithNoMatch() => this;
|
||||
|
||||
/// <summary>
|
||||
/// Scripts a search that returns TWO entries for the username, simulating an ambiguous /
|
||||
/// non-unique match. Group/display-name content is irrelevant; only the count matters.
|
||||
/// </summary>
|
||||
public FakeLdapConnection WithDuplicateMatch()
|
||||
{
|
||||
WithUserEntry("cn=dup1,dc=x", new[] { "cn=g,dc=x" });
|
||||
WithUserEntry("cn=dup2,dc=x", new[] { "cn=g,dc=x" });
|
||||
return this;
|
||||
}
|
||||
|
||||
// ---- ILdapConnection -----
|
||||
|
||||
public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
|
||||
{
|
||||
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
|
||||
if (_throwOnConnect)
|
||||
throw new Novell.Directory.Ldap.LdapException(
|
||||
"Directory unreachable", Novell.Directory.Ldap.LdapException.ConnectError, host);
|
||||
}
|
||||
|
||||
public void Bind(string dn, string password)
|
||||
{
|
||||
BindAttempts++;
|
||||
var isServiceBind = BindAttempts == 1;
|
||||
|
||||
if ((_throwOnServiceBind && isServiceBind)
|
||||
|| (_throwOnUserBind && !isServiceBind)
|
||||
|| _throwBindDns.Contains(dn))
|
||||
{
|
||||
throw new Novell.Directory.Ldap.LdapException(
|
||||
"Invalid credentials", Novell.Directory.Ldap.LdapException.InvalidCredentials, dn);
|
||||
}
|
||||
|
||||
BoundDns.Add(dn);
|
||||
}
|
||||
|
||||
public IReadOnlyList<LdapSearchEntry> Search(
|
||||
string searchBase,
|
||||
string filter,
|
||||
IReadOnlyList<string> attributes)
|
||||
=> _scriptedEntries.AsReadOnly();
|
||||
|
||||
public void Dispose() { /* nothing to clean up */ }
|
||||
}
|
||||
|
||||
/// <summary>Factory that always returns the same pre-configured fake instance.</summary>
|
||||
internal sealed class FakeLdapConnectionFactory : ILdapConnectionFactory
|
||||
{
|
||||
/// <summary>Wraps a caller-supplied fake so a test can script it before handing it to the service.</summary>
|
||||
public FakeLdapConnectionFactory(FakeLdapConnection fake) => Fake = fake;
|
||||
|
||||
/// <summary>Convenience overload that creates a bare, unscripted fake.</summary>
|
||||
public FakeLdapConnectionFactory() : this(new FakeLdapConnection()) { }
|
||||
|
||||
public FakeLdapConnection Fake { get; }
|
||||
public ILdapConnection Create() => Fake;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smoke test: verifies the fake compiles and scripted searches work correctly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public class FakeLdapConnectionSmokeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScriptedSearch_ReturnsEntry()
|
||||
{
|
||||
var fake = new FakeLdapConnection();
|
||||
fake.WithUserEntry(
|
||||
dn: "cn=alice,dc=example,dc=com",
|
||||
memberOf: new[] { "cn=admins,dc=example,dc=com" },
|
||||
displayName: "Alice Smith");
|
||||
|
||||
fake.Connect("ldap.example.com", 636, LdapTransport.Ldaps, false, 5000);
|
||||
|
||||
var results = fake.Search("dc=example,dc=com", "(cn=alice)", new[] { "memberOf", "displayName" });
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Equal("cn=alice,dc=example,dc=com", results[0].Dn);
|
||||
Assert.Equal("Alice Smith", results[0].Attributes["displayName"][0]);
|
||||
Assert.Equal("cn=admins,dc=example,dc=com", results[0].Attributes["memberOf"][0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_RecordsArgs()
|
||||
{
|
||||
var fake = new FakeLdapConnection();
|
||||
fake.Connect("ldap.example.com", 389, LdapTransport.StartTls, false, 10_000);
|
||||
|
||||
Assert.NotNull(fake.ConnectArgs);
|
||||
Assert.Equal("ldap.example.com", fake.ConnectArgs!.Value.Host);
|
||||
Assert.Equal(LdapTransport.StartTls, fake.ConnectArgs.Value.Transport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowOnUserBind_ThrowsOnSecondBindOnly()
|
||||
{
|
||||
var fake = new FakeLdapConnection().ThrowOnUserBind();
|
||||
fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0);
|
||||
|
||||
// First bind = service account: succeeds.
|
||||
fake.Bind("cn=svc,dc=example,dc=com", "secret");
|
||||
// Second bind = user: throws (bad user credentials).
|
||||
Assert.Throws<Novell.Directory.Ldap.LdapException>(
|
||||
() => fake.Bind("cn=bob,dc=example,dc=com", "wrong"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowOnServiceBind_ThrowsOnFirstBind()
|
||||
{
|
||||
var fake = new FakeLdapConnection().ThrowOnServiceBind();
|
||||
fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0);
|
||||
|
||||
Assert.Throws<Novell.Directory.Ldap.LdapException>(
|
||||
() => fake.Bind("cn=svc,dc=example,dc=com", "secret"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowOnConnect_ThrowsLdapException()
|
||||
{
|
||||
var fake = new FakeLdapConnection().ThrowOnConnect();
|
||||
|
||||
Assert.Throws<Novell.Directory.Ldap.LdapException>(
|
||||
() => fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_RecordsDn_WhenNotThrowing()
|
||||
{
|
||||
var fake = new FakeLdapConnection();
|
||||
fake.Connect("ldap.example.com", 636, LdapTransport.Ldaps, false, 5000);
|
||||
fake.Bind("cn=svc,dc=example,dc=com", "secret");
|
||||
|
||||
Assert.Contains("cn=svc,dc=example,dc=com", fake.BoundDns);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// GLAuth integration test — opt-in only.
|
||||
//
|
||||
// Prerequisites
|
||||
// -------------
|
||||
// 1. A running GLAuth instance (plaintext LDAP, no TLS).
|
||||
// A ready-made Docker Compose stack lives in the sibling repo:
|
||||
// ~/Desktop/ScadaBridge/infra/glauth
|
||||
// Start it with: docker compose up -d
|
||||
// Default listen address: localhost:3893
|
||||
//
|
||||
// 2. Set the following environment variables before running:
|
||||
// ZB_LDAP_IT=1 (required — gates the test)
|
||||
// ZB_LDAP_SERVER=localhost (optional, default localhost)
|
||||
// ZB_LDAP_PORT=3893 (optional, default 3893)
|
||||
// ZB_LDAP_BASE=dc=lmxopcua,dc=local (optional)
|
||||
// ZB_LDAP_SVC_DN=cn=svc,dc=lmxopcua,dc=local (service-account DN)
|
||||
// ZB_LDAP_SVC_PW=svcpass (service-account password)
|
||||
// ZB_LDAP_USER=alice (test user login)
|
||||
// ZB_LDAP_PW=alicepass (test user password)
|
||||
// ZB_LDAP_USERATTR=cn (optional, default cn)
|
||||
//
|
||||
// Run command:
|
||||
// ZB_LDAP_IT=1 ZB_LDAP_SVC_DN=... ZB_LDAP_SVC_PW=... \
|
||||
// ZB_LDAP_USER=... ZB_LDAP_PW=... \
|
||||
// dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests \
|
||||
// --filter "FullyQualifiedName~GLAuthIntegrationTests"
|
||||
//
|
||||
// Without ZB_LDAP_IT=1 the test is SKIPPED — it does not affect the normal CI run.
|
||||
|
||||
using System.Net.Sockets;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests.Integration;
|
||||
|
||||
public sealed class GLAuthIntegrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs a real bind-then-search-then-bind against a live GLAuth instance.
|
||||
/// Verifies that authentication succeeds and that at least one LDAP group is returned.
|
||||
/// Skipped unless <c>ZB_LDAP_IT=1</c> is set; skipped again if the server is unreachable.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Authenticate_AgainstRealGLAuth_Succeeds()
|
||||
{
|
||||
// ------------------------------------------------------------------ opt-in gate
|
||||
Skip.IfNot(
|
||||
Environment.GetEnvironmentVariable("ZB_LDAP_IT") == "1",
|
||||
"Set ZB_LDAP_IT=1 and a reachable GLAuth to run.");
|
||||
|
||||
// ------------------------------------------------------------------ read config
|
||||
var server = Environment.GetEnvironmentVariable("ZB_LDAP_SERVER") ?? "localhost";
|
||||
var port = int.TryParse(Environment.GetEnvironmentVariable("ZB_LDAP_PORT"), out var p) ? p : 3893;
|
||||
var baseDn = Environment.GetEnvironmentVariable("ZB_LDAP_BASE") ?? "dc=lmxopcua,dc=local";
|
||||
var svcDn = Environment.GetEnvironmentVariable("ZB_LDAP_SVC_DN") ?? "";
|
||||
var svcPw = Environment.GetEnvironmentVariable("ZB_LDAP_SVC_PW") ?? "";
|
||||
var user = Environment.GetEnvironmentVariable("ZB_LDAP_USER") ?? "";
|
||||
var pw = Environment.GetEnvironmentVariable("ZB_LDAP_PW") ?? "";
|
||||
var userAttr = Environment.GetEnvironmentVariable("ZB_LDAP_USERATTR") ?? "cn";
|
||||
|
||||
// ------------------------------------------------------------------ reachability probe
|
||||
try
|
||||
{
|
||||
using var tcp = new TcpClient();
|
||||
// 3-second connect timeout to keep the test suite snappy when the server is absent
|
||||
var connectTask = tcp.ConnectAsync(server, port);
|
||||
if (!connectTask.Wait(TimeSpan.FromSeconds(3)))
|
||||
Skip.If(true, $"GLAuth not reachable at {server}:{port} (connect timed out).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Skip.If(true, $"GLAuth not reachable at {server}:{port}: {ex.Message}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ build options
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = server,
|
||||
Port = port,
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = true,
|
||||
SearchBase = baseDn,
|
||||
ServiceAccountDn = svcDn,
|
||||
ServiceAccountPassword = svcPw,
|
||||
UserNameAttribute = userAttr,
|
||||
// GLAuth returns memberOf by default; keep the library default
|
||||
GroupAttribute = "memberOf",
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ exercise the real service
|
||||
// Uses the public single-argument constructor, which wires up NovellLdapConnectionFactory
|
||||
// internally — no test seam involved.
|
||||
var svc = new LdapAuthService(options);
|
||||
var result = await svc.AuthenticateAsync(user, pw, default);
|
||||
|
||||
// ------------------------------------------------------------------ assertions
|
||||
Assert.True(result.Succeeded,
|
||||
$"Authentication failed: {result.Failure} (server={server}:{port}, user={user})");
|
||||
Assert.NotEmpty(result.Groups);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 6 failure-mode tests. These pin the fail-closed contract: every error path returns a
|
||||
/// structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>, the method never throws, and
|
||||
/// a successful result always carries at least one group.
|
||||
/// </summary>
|
||||
public class LdapAuthServiceFailureTests
|
||||
{
|
||||
// Mirrors the happy-path test defaults (insecure plaintext dev transport, service account
|
||||
// set, DisplayNameAttribute aligned with the fake's "displayName" key).
|
||||
private static LdapOptions Opts() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "x",
|
||||
Port = 3893,
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = true,
|
||||
SearchBase = "dc=x",
|
||||
ServiceAccountDn = "cn=svc,dc=x",
|
||||
ServiceAccountPassword = "svcpw",
|
||||
UserNameAttribute = "cn",
|
||||
DisplayNameAttribute = "displayName",
|
||||
GroupAttribute = "memberOf",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task BadCredentials_WhenUserBindThrows()
|
||||
{
|
||||
var fake = new FakeLdapConnection()
|
||||
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
|
||||
.ThrowOnUserBind();
|
||||
|
||||
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
||||
.AuthenticateAsync("alice", "bad", default);
|
||||
|
||||
Assert.False(r.Succeeded);
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserNotFound_WhenZeroMatches()
|
||||
{
|
||||
var fake = new FakeLdapConnection().WithNoMatch();
|
||||
|
||||
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
||||
.AuthenticateAsync("ghost", "pw", default);
|
||||
|
||||
Assert.False(r.Succeeded);
|
||||
Assert.Equal(LdapAuthFailure.UserNotFound, r.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AmbiguousUser_WhenMultipleMatches()
|
||||
{
|
||||
var fake = new FakeLdapConnection().WithDuplicateMatch();
|
||||
|
||||
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
||||
.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
Assert.False(r.Succeeded);
|
||||
Assert.Equal(LdapAuthFailure.AmbiguousUser, r.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AmbiguousUser_DoesNotAttemptUserBind()
|
||||
{
|
||||
var fake = new FakeLdapConnection().WithDuplicateMatch();
|
||||
|
||||
await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
||||
.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
// Only the service-account bind should have happened; never bind an ambiguous DN.
|
||||
Assert.Equal(new[] { "cn=svc,dc=x" }, fake.BoundDns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupLookupFailed_WhenUserHasNoGroups()
|
||||
{
|
||||
var fake = new FakeLdapConnection()
|
||||
.WithUserEntry("cn=alice,dc=x", memberOf: Array.Empty<string>());
|
||||
|
||||
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
||||
.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
Assert.False(r.Succeeded);
|
||||
Assert.Equal(LdapAuthFailure.GroupLookupFailed, r.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceAccountBindFailed_Distinctly_WhenServiceBindThrows()
|
||||
{
|
||||
var fake = new FakeLdapConnection()
|
||||
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
|
||||
.ThrowOnServiceBind();
|
||||
|
||||
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
||||
.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
Assert.False(r.Succeeded);
|
||||
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure);
|
||||
// Distinct from BadCredentials: a service-account problem is a system misconfiguration,
|
||||
// not the end user's fault.
|
||||
Assert.NotEqual(LdapAuthFailure.BadCredentials, r.Failure);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task BadCredentials_WhenUsernameNullOrWhitespace_NoConnectionAttempted(string? username)
|
||||
{
|
||||
// I4: an empty/whitespace/null username is rejected up front as BadCredentials,
|
||||
// before any connection or bind is attempted (and a null can't NRE into the catch-all).
|
||||
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" });
|
||||
|
||||
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
||||
.AuthenticateAsync(username!, "pw", default);
|
||||
|
||||
Assert.False(r.Succeeded);
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
|
||||
Assert.Null(fake.ConnectArgs); // never connected
|
||||
Assert.Empty(fake.BoundDns); // never bound
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throws_WhenCancellationRequested()
|
||||
{
|
||||
// I3: a pre-cancelled token is observed at entry, before any work.
|
||||
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" });
|
||||
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => svc.AuthenticateAsync("alice", "pw", new CancellationToken(canceled: true)));
|
||||
|
||||
Assert.Null(fake.ConnectArgs); // never connected
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NeverThrows_OnConnectFailure()
|
||||
{
|
||||
var fake = new FakeLdapConnection()
|
||||
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
|
||||
.ThrowOnConnect();
|
||||
|
||||
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
||||
.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
Assert.False(r.Succeeded);
|
||||
// Directory unreachable is a system-side failure -> bucketed under ServiceAccountBindFailed.
|
||||
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
||||
|
||||
public class LdapAuthServiceTests
|
||||
{
|
||||
// Sensible test defaults: insecure plaintext transport (dev/test), a service
|
||||
// account set, and DisplayNameAttribute aligned with the fake's "displayName"
|
||||
// key so display-name extraction is genuinely exercised.
|
||||
private static LdapOptions Opts() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "x",
|
||||
Port = 3893,
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = true,
|
||||
SearchBase = "dc=x",
|
||||
ServiceAccountDn = "cn=svc,dc=x",
|
||||
ServiceAccountPassword = "svcpw",
|
||||
UserNameAttribute = "cn",
|
||||
DisplayNameAttribute = "displayName",
|
||||
GroupAttribute = "memberOf",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Succeeds_AndReturnsStrippedGroups_OnValidCredentials()
|
||||
{
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=alice,dc=x",
|
||||
memberOf: new[] { "cn=Engineers,ou=g,dc=x", "cn=Viewers,ou=g,dc=x" },
|
||||
displayName: "Alice");
|
||||
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
||||
|
||||
var r = await svc.AuthenticateAsync(" alice ", "pw", default);
|
||||
|
||||
Assert.True(r.Succeeded);
|
||||
Assert.Equal("alice", r.Username); // trimmed
|
||||
Assert.Equal("Alice", r.DisplayName); // from DisplayNameAttribute
|
||||
Assert.Equal(new[] { "Engineers", "Viewers" }, r.Groups); // CN= stripped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BindsServiceAccountThenUser_OnValidCredentials()
|
||||
{
|
||||
// Non-empty memberOf: fail-closed requires at least one group for success, and this
|
||||
// test asserts bind ORDER, so the user must successfully resolve and bind.
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" }, displayName: "Alice");
|
||||
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
||||
|
||||
await svc.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
// Service account first, user DN second (bind-then-search-then-bind).
|
||||
Assert.Equal(new[] { "cn=svc,dc=x", "cn=alice,dc=x" }, fake.BoundDns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FallsBackToUsername_WhenNoDisplayName()
|
||||
{
|
||||
// Non-empty memberOf so fail-closed lets success through; this test only asserts the
|
||||
// display-name fallback (no displayName attribute -> username).
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=bob,dc=x", memberOf: new[] { "cn=Viewers,ou=g,dc=x" });
|
||||
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
||||
|
||||
var r = await svc.AuthenticateAsync("bob", "pw", default);
|
||||
|
||||
Assert.True(r.Succeeded);
|
||||
Assert.Equal("bob", r.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_Disabled_WhenNotEnabled()
|
||||
{
|
||||
var svc = new LdapAuthService(
|
||||
Opts() with { Enabled = false },
|
||||
new FakeLdapConnectionFactory(new FakeLdapConnection()));
|
||||
|
||||
Assert.Equal(LdapAuthFailure.Disabled, (await svc.AuthenticateAsync("a", "b", default)).Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreservesEscapedCommaInGroupName_OnRfc4514Dn()
|
||||
{
|
||||
// C1: a group CN that legitimately contains a comma (escaped per RFC 4514)
|
||||
// must be returned intact, not truncated at the escaped comma.
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=alice,dc=x",
|
||||
memberOf: new[] { @"cn=Eng\,ineers,ou=g,dc=x", @"cn=A\2cB,dc=x" },
|
||||
displayName: "Alice");
|
||||
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
||||
|
||||
var r = await svc.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
Assert.True(r.Succeeded);
|
||||
Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
public class LdapEscapingTests {
|
||||
[Theory]
|
||||
[InlineData("a*b", @"a\2ab")]
|
||||
[InlineData("a(b)", @"a\28b\29")]
|
||||
[InlineData(@"a\b", @"a\5cb")]
|
||||
public void Filter_EscapesMetacharacters(string raw, string expected)
|
||||
=> Assert.Equal(expected, LdapEscaping.Filter(raw));
|
||||
|
||||
[Fact]
|
||||
public void Filter_EscapesNul()
|
||||
=> Assert.Equal(@"a\00b", LdapEscaping.Filter("a\0b"));
|
||||
|
||||
[Fact]
|
||||
public void Dn_EscapesSpecialChars()
|
||||
=> Assert.Equal(@"\#cn\,test", LdapEscaping.Dn("#cn,test"));
|
||||
|
||||
// M2: each RFC 4514 special char is backslash-escaped, plus leading/trailing space.
|
||||
[Theory]
|
||||
[InlineData("a,b", @"a\,b")]
|
||||
[InlineData("a+b", @"a\+b")]
|
||||
[InlineData("a\"b", "a\\\"b")]
|
||||
[InlineData(@"a\b", @"a\\b")]
|
||||
[InlineData("a<b", @"a\<b")]
|
||||
[InlineData("a>b", @"a\>b")]
|
||||
[InlineData("a;b", @"a\;b")]
|
||||
[InlineData(" ab", @"\ ab")]
|
||||
[InlineData("ab ", @"ab\ ")]
|
||||
public void Dn_EscapesEachSpecialChar(string raw, string expected)
|
||||
=> Assert.Equal(expected, LdapEscaping.Dn(raw));
|
||||
|
||||
// C1: RFC 4514 escape-aware first-RDN-value extraction.
|
||||
[Theory]
|
||||
[InlineData("cn=Engineers,ou=g,dc=x", "Engineers")] // simple case still works
|
||||
[InlineData(@"cn=Eng\,ineers,ou=g,dc=x", "Eng,ineers")] // single-char escaped comma
|
||||
[InlineData(@"cn=A\2cB,dc=x", "A,B")] // hex-escaped comma \2c
|
||||
[InlineData(@"cn=A\5cB,dc=x", @"A\B")] // hex-escaped backslash \5c
|
||||
public void FirstRdnValue_IsEscapeAware(string dn, string expected)
|
||||
=> Assert.Equal(expected, LdapEscaping.FirstRdnValue(dn));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
||||
|
||||
public class LdapOptionsValidatorTests
|
||||
{
|
||||
private static LdapOptions Opts() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "x",
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = true,
|
||||
SearchBase = "dc=x",
|
||||
ServiceAccountDn = "cn=svc,dc=x",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validator_Fails_PlainTransport_WhenNotAllowInsecure() =>
|
||||
Assert.True(new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with { Transport = LdapTransport.None, AllowInsecure = false })
|
||||
.Failed);
|
||||
|
||||
[Fact]
|
||||
public void Validator_Fails_WhenServerEmpty() =>
|
||||
Assert.True(new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with { Server = " " })
|
||||
.Failed);
|
||||
|
||||
[Fact]
|
||||
public void Validator_Fails_WhenSearchBaseEmpty() =>
|
||||
Assert.True(new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with { SearchBase = "" })
|
||||
.Failed);
|
||||
|
||||
[Fact]
|
||||
public void Validator_FailureMessage_NamesOffendingField()
|
||||
{
|
||||
var result = new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with { Server = "" });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(nameof(LdapOptions.Server), result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_Fails_WhenServiceAccountDnEmpty()
|
||||
{
|
||||
// I5: an empty ServiceAccountDn risks an anonymous bind, so it must be rejected
|
||||
// and the failure message must name the offending key.
|
||||
var result = new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with { ServiceAccountDn = " " });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(nameof(LdapOptions.ServiceAccountDn), result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_Succeeds_OnValidSecureConfig() =>
|
||||
Assert.False(new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with
|
||||
{
|
||||
Transport = LdapTransport.Ldaps,
|
||||
AllowInsecure = false,
|
||||
Server = "s",
|
||||
SearchBase = "dc=x",
|
||||
})
|
||||
.Failed);
|
||||
|
||||
[Fact]
|
||||
public void Validator_Succeeds_OnInsecureWhenAllowed() =>
|
||||
Assert.False(new LdapOptionsValidator()
|
||||
.Validate(null, Opts())
|
||||
.Failed);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Xunit.SkippableFact" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Ldap\ZB.MOM.WW.Auth.Ldap.csproj" />
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user