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:
dohertj2
2026-06-01 03:59:23 -04:00
commit 37e23cf9f2
73 changed files with 6836 additions and 0 deletions
@@ -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));
}
}
@@ -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>