Files
scadaproj/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerColumnLimitTests.cs
T
Joseph Doherty b33f0f93c9 fix(auth): SQL Server key store refuses truncation and matches key ids exactly (Auth-008)
- 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
2026-11-09 15:26:03 -05:00

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));
}
}