1.7 ApiKey value type
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,7 +63,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.
|
|||||||
- `enum { FloydSteinberg, Atkinson, Stucki, Jarvis }`.
|
- `enum { FloydSteinberg, Atkinson, Stucki, Jarvis }`.
|
||||||
- JSON converter reading kebab-case (`floyd-steinberg`, etc.).
|
- JSON converter reading kebab-case (`floyd-steinberg`, etc.).
|
||||||
|
|
||||||
### [ ] 1.7 `ApiKey`
|
### [x] 1.7 `ApiKey`
|
||||||
- `record struct` wrapping a string.
|
- `record struct` wrapping a string.
|
||||||
- `bool Matches(string candidate)` using `CryptographicOperations.FixedTimeEquals` over UTF-8 bytes.
|
- `bool Matches(string candidate)` using `CryptographicOperations.FixedTimeEquals` over UTF-8 bytes.
|
||||||
|
|
||||||
|
|||||||
33
src/FrameProcessor/Domain/ApiKey.cs
Normal file
33
src/FrameProcessor/Domain/ApiKey.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace FrameProcessor.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps the shared API key. <see cref="Matches"/> uses a constant-time comparison
|
||||||
|
/// over UTF-8 bytes to avoid leaking key length or content via timing side channels.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct ApiKey
|
||||||
|
{
|
||||||
|
private readonly string? _value;
|
||||||
|
|
||||||
|
public ApiKey(string value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
_value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Value => _value ?? string.Empty;
|
||||||
|
|
||||||
|
public bool Matches(string? candidate)
|
||||||
|
{
|
||||||
|
if (candidate is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedBytes = Encoding.UTF8.GetBytes(Value);
|
||||||
|
var candidateBytes = Encoding.UTF8.GetBytes(candidate);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(expectedBytes, candidateBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
tests/FrameProcessor.Tests/ApiKeyTests.cs
Normal file
78
tests/FrameProcessor.Tests/ApiKeyTests.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using FrameProcessor.Domain;
|
||||||
|
|
||||||
|
namespace FrameProcessor.Tests;
|
||||||
|
|
||||||
|
public class ApiKeyTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Matches_ReturnsTrue_ForIdenticalKey()
|
||||||
|
{
|
||||||
|
var key = new ApiKey("s3cret-value");
|
||||||
|
Assert.True(key.Matches("s3cret-value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Matches_ReturnsFalse_ForDifferentKey()
|
||||||
|
{
|
||||||
|
var key = new ApiKey("s3cret-value");
|
||||||
|
Assert.False(key.Matches("wrong"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Matches_ReturnsFalse_ForKeyOfDifferentLength()
|
||||||
|
{
|
||||||
|
var key = new ApiKey("s3cret");
|
||||||
|
Assert.False(key.Matches("s3cret-value"));
|
||||||
|
Assert.False(key.Matches("s3cre"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Matches_ReturnsFalse_ForNull()
|
||||||
|
{
|
||||||
|
var key = new ApiKey("s3cret");
|
||||||
|
Assert.False(key.Matches(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Matches_ReturnsFalse_ForEmptyCandidate_WhenKeyIsNotEmpty()
|
||||||
|
{
|
||||||
|
var key = new ApiKey("s3cret");
|
||||||
|
Assert.False(key.Matches(string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Matches_IsCaseSensitive()
|
||||||
|
{
|
||||||
|
var key = new ApiKey("Secret");
|
||||||
|
Assert.False(key.Matches("secret"));
|
||||||
|
Assert.False(key.Matches("SECRET"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Matches_HandlesMultiByteUtf8()
|
||||||
|
{
|
||||||
|
var key = new ApiKey("nyckel-äöå");
|
||||||
|
Assert.True(key.Matches("nyckel-äöå"));
|
||||||
|
Assert.False(key.Matches("nyckel-aoa"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_ThrowsOnNull()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => new ApiKey(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Value_ReturnsConfiguredString()
|
||||||
|
{
|
||||||
|
var key = new ApiKey("abc");
|
||||||
|
Assert.Equal("abc", key.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultStruct_Value_IsEmpty()
|
||||||
|
{
|
||||||
|
var key = default(ApiKey);
|
||||||
|
Assert.Equal(string.Empty, key.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user