b33f0f93c9
- Over-length key records (key_id > 64, prefix > 16, display name > 256) are refused with ArgumentException before connecting; an over-64 key-id lookup or mutation answers not-found without querying (SqlClient silently truncated the sized parameter, so a different id sharing the first 64 characters matched). - Every key_id predicate adds an exact VARBINARY comparison, so lookups are case- and trailing-space-exact like SQLite; the PK seek is unchanged and there is no schema change. - SqliteApiKeyStoreImporter reports case-only key-id collisions and over-length rows in new init lists (KeyIdCollisions, KeysRejected) instead of counting them as skips or importing them truncated. - Audit text truncation kept deliberately and documented. Offline refused-port tests and live SQL Server 2022 tests fail pre-fix; SqlServer suite 101/101 live. Auth findings now 0 open; CLAUDE.md records the #46/#50 work; code-reviews/README.md regenerated. (scadaproj#46) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
78 lines
3.6 KiB
C#
78 lines
3.6 KiB
C#
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
|
|
|
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Auth-008 (scadaproj#46), the offline half: a value longer than its column is rejected or treated
|
|
/// as absent BEFORE any connection is opened. Every <c>SqlParameter</c> carries an explicit size, and
|
|
/// SqlClient truncates an over-length value to that size silently — so a 70-character key id was
|
|
/// stored as its first 64 characters, and a different 70-character id sharing that prefix then
|
|
/// "found" it. These tests point at a refused loopback port: pre-fix they reach the network and throw
|
|
/// <c>SqlException</c>; post-fix nothing is sent.
|
|
/// </summary>
|
|
public sealed class SqlServerColumnLimitTests
|
|
{
|
|
private static readonly AuthSqlServerConnectionFactory Unreachable = new(new SqlServerApiKeyOptions
|
|
{
|
|
ConnectionString = "Server=tcp:127.0.0.1,1;Connect Timeout=3;ConnectRetryCount=0;TrustServerCertificate=true",
|
|
});
|
|
|
|
private static readonly string LongKeyId = new('k', 65);
|
|
|
|
private static ApiKeyRecord Record(string keyId = "key-1", string prefix = "sbk", string displayName = "Key") => new(
|
|
KeyId: keyId,
|
|
KeyPrefix: prefix,
|
|
SecretHash: [1, 2, 3],
|
|
DisplayName: displayName,
|
|
Scopes: new HashSet<string>(StringComparer.Ordinal),
|
|
ConstraintsJson: null,
|
|
CreatedUtc: DateTimeOffset.UnixEpoch,
|
|
LastUsedUtc: null,
|
|
RevokedUtc: null);
|
|
|
|
public static TheoryData<ApiKeyRecord, string> OverLength() => new()
|
|
{
|
|
{ Record(keyId: LongKeyId), "KeyId" },
|
|
{ Record(prefix: new string('p', 17)), "KeyPrefix" },
|
|
{ Record(displayName: new string('d', 257)), "DisplayName" },
|
|
};
|
|
|
|
[Theory]
|
|
[MemberData(nameof(OverLength))]
|
|
public async Task Create_OverLengthValue_IsRejected_NotTruncated(ApiKeyRecord record, string field)
|
|
{
|
|
var store = new SqlServerApiKeyAdminStore(Unreachable);
|
|
|
|
var ex = await Assert.ThrowsAsync<ArgumentException>(() => store.CreateAsync(record, CancellationToken.None));
|
|
Assert.Contains(field, ex.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_AtTheColumnLimits_ReachesTheDatabase()
|
|
{
|
|
// Positive control: values exactly at the limits are not rejected — the call gets as far as
|
|
// the (unreachable) server.
|
|
var store = new SqlServerApiKeyAdminStore(Unreachable);
|
|
var record = Record(keyId: new string('k', 64), prefix: new string('p', 16), displayName: new string('d', 256));
|
|
|
|
await Assert.ThrowsAsync<Microsoft.Data.SqlClient.SqlException>(() => store.CreateAsync(record, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Lookups_OfAnOverLengthKeyId_FindNothing_WithoutQuerying()
|
|
{
|
|
var read = new SqlServerApiKeyStore(Unreachable);
|
|
var admin = new SqlServerApiKeyAdminStore(Unreachable);
|
|
var now = DateTimeOffset.UnixEpoch;
|
|
|
|
Assert.Null(await read.FindByKeyIdAsync(LongKeyId, CancellationToken.None));
|
|
Assert.Null(await read.FindActiveByKeyIdAsync(LongKeyId, CancellationToken.None));
|
|
await read.MarkUsedAsync(LongKeyId, now, CancellationToken.None);
|
|
Assert.False(await admin.RevokeAsync(LongKeyId, now, CancellationToken.None));
|
|
Assert.False(await admin.RotateAsync(LongKeyId, [1], CancellationToken.None));
|
|
Assert.False(await admin.SetScopesAsync(LongKeyId, new HashSet<string>(), CancellationToken.None));
|
|
Assert.False(await admin.SetEnabledAsync(LongKeyId, true, now, CancellationToken.None));
|
|
Assert.False(await admin.DeleteAsync(LongKeyId, CancellationToken.None));
|
|
}
|
|
}
|