350 lines
13 KiB
C#
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.
|
|
}
|
|
}
|
|
}
|