Files
scadaproj/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs
T

350 lines
13 KiB
C#

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.
}
}
}