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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user