feat(auth): typed ApiKeyPepperUnavailableException for missing API-key pepper; bump to 0.4.2 (scadaproj#23)

Derives from InvalidOperationException so existing catches keep working.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 12:30:04 -05:00
parent 8bb2679e43
commit 243c3fa619
5 changed files with 88 additions and 4 deletions
@@ -163,7 +163,7 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
await Assert.ThrowsAsync<InvalidOperationException>(() => commands.CreateKeyAsync(
await Assert.ThrowsAsync<ApiKeyPepperUnavailableException>(() => commands.CreateKeyAsync(
"key-x",
"No Pepper",
new HashSet<string>(StringComparer.Ordinal),
@@ -297,6 +297,52 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
Assert.Equal("not-found", auditRow.Details);
}
[Fact]
public async Task PepperUnavailable_IsStillCatchableAsInvalidOperationException_WithOriginalMessage()
{
// Back-compat: consumers that caught InvalidOperationException before the typed exception
// existed must keep working, for both create and rotate.
ApiKeyAdminCommands setup = BuildCommands(pepper: Pepper);
await setup.InitDbAsync(null, CancellationToken.None);
await setup.CreateKeyAsync("key-bc", "BC", new HashSet<string>(StringComparer.Ordinal), null, null, CancellationToken.None);
ApiKeyAdminCommands nopepper = BuildCommands(pepper: null);
InvalidOperationException create = await Assert.ThrowsAnyAsync<InvalidOperationException>(() =>
nopepper.CreateKeyAsync("key-bc2", "BC2", new HashSet<string>(StringComparer.Ordinal), null, null, CancellationToken.None));
InvalidOperationException rotate = await Assert.ThrowsAnyAsync<InvalidOperationException>(() =>
nopepper.RotateKeyAsync("key-bc", null, CancellationToken.None));
Assert.IsType<ApiKeyPepperUnavailableException>(create);
Assert.IsType<ApiKeyPepperUnavailableException>(rotate);
Assert.Equal("pepper unavailable", create.Message);
Assert.Equal("pepper unavailable", rotate.Message);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task CreateKey_NullEmptyOrWhitespacePepper_ThrowsTypedException(string? pepper)
{
ApiKeyAdminCommands commands = BuildCommands(pepper);
await new ApiKeyAdminCommands(_options, _admin, _audit, new FakePepperProvider(Pepper), _migrator)
.InitDbAsync(null, CancellationToken.None);
await Assert.ThrowsAsync<ApiKeyPepperUnavailableException>(() => commands.CreateKeyAsync(
"key-w", "W", new HashSet<string>(StringComparer.Ordinal), null, null, CancellationToken.None));
}
[Fact]
public void PepperUnavailableException_ConstructorOverloads_AreInvalidOperationExceptions()
{
var inner = new Exception("inner");
Assert.Equal("pepper unavailable", new ApiKeyPepperUnavailableException().Message);
Assert.Equal("custom", new ApiKeyPepperUnavailableException("custom").Message);
var withInner = new ApiKeyPepperUnavailableException("m", inner);
Assert.Same(inner, withInner.InnerException);
Assert.IsAssignableFrom<InvalidOperationException>(withInner);
}
[Fact]
public async Task RotateKey_PepperUnavailable_Throws_HashUnchanged_AndAppendsNoAudit()
{
@@ -319,7 +365,7 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
// Act: rotate with no pepper available.
ApiKeyAdminCommands nopepper = BuildCommands(pepper: null);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
await Assert.ThrowsAsync<ApiKeyPepperUnavailableException>(() =>
nopepper.RotateKeyAsync("key-1", null, CancellationToken.None));
// Assert: stored hash is unchanged.