Files
scadaproj/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/KeyIdFidelityLiveTests.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

115 lines
5.1 KiB
C#

using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live;
/// <summary>
/// Auth-008 (scadaproj#46), the live half: key ids match exactly, as they do in the SQLite store
/// this one ports. The <c>key_id</c> column uses the database's default collation — case-insensitive
/// on a stock SQL Server — so <c>LINE1</c> found the row stored as <c>line1</c>, and the importer
/// counted a SQLite <c>Line1</c> beside <c>line1</c> as an ordinary "skipped" duplicate.
/// </summary>
[Collection("live-sqlserver")]
public sealed class KeyIdFidelityLiveTests : IAsyncLifetime
{
private readonly string _schema = LiveSqlServer.NewSchemaName();
private readonly string _sqlitePath =
Path.Combine(Path.GetTempPath(), $"zbauth-keyid-{Guid.NewGuid():N}.db");
private AuthSqlServerConnectionFactory _factory = null!;
public async Task InitializeAsync()
{
if (!LiveSqlServer.IsEnabled)
return;
_factory = new AuthSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema));
await new SqlServerApiKeyStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
}
public async Task DisposeAsync()
{
SqliteConnection.ClearAllPools();
foreach (string path in new[] { _sqlitePath, _sqlitePath + "-wal", _sqlitePath + "-shm" })
{
if (File.Exists(path))
File.Delete(path);
}
await LiveSqlServer.DropSchemaAsync(_schema);
}
private static ApiKeyRecord Record(string keyId) => new(
KeyId: keyId,
KeyPrefix: "sbk",
SecretHash: [1, 2, 3, 4],
DisplayName: "Key " + keyId,
Scopes: new HashSet<string>(["read"], StringComparer.Ordinal),
ConstraintsJson: null,
CreatedUtc: new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero),
LastUsedUtc: null,
RevokedUtc: null);
[SkippableFact]
public async Task Lookups_AreCaseSensitive_LikeTheSqliteStore()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
var admin = new SqlServerApiKeyAdminStore(_factory);
var read = new SqlServerApiKeyStore(_factory);
await admin.CreateAsync(Record("line1"), CancellationToken.None);
Assert.NotNull(await read.FindByKeyIdAsync("line1", CancellationToken.None));
Assert.Null(await read.FindByKeyIdAsync("LINE1", CancellationToken.None));
Assert.Null(await read.FindActiveByKeyIdAsync("Line1", CancellationToken.None));
// Trailing spaces are significant too (SQL Server's '=' pads them away).
Assert.Null(await read.FindByKeyIdAsync("line1 ", CancellationToken.None));
Assert.False(await admin.RevokeAsync("LINE1", DateTimeOffset.UtcNow, CancellationToken.None));
Assert.Null((await read.FindByKeyIdAsync("line1", CancellationToken.None))!.RevokedUtc);
}
[SkippableFact]
public async Task Import_CaseVariantKeyIds_AreReportedAsCollisions_NotSkipped()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
var sqliteFactory = new AuthSqliteConnectionFactory(_sqlitePath);
await new SqliteAuthStoreMigrator(sqliteFactory).MigrateAsync(CancellationToken.None);
var sqliteAdmin = new SqliteApiKeyAdminStore(sqliteFactory);
await sqliteAdmin.CreateAsync(Record("line1"), CancellationToken.None);
await sqliteAdmin.CreateAsync(Record("Line1"), CancellationToken.None);
ApiKeyImportResult result = await new SqliteApiKeyStoreImporter(_factory).ImportAsync(_sqlitePath, CancellationToken.None);
Assert.Equal(1, result.KeysInserted);
Assert.Equal(0, result.KeysSkipped);
Assert.Single(result.KeyIdCollisions);
// Re-running: the inserted one is now an ordinary skip, the other still a collision.
ApiKeyImportResult again = await new SqliteApiKeyStoreImporter(_factory).ImportAsync(_sqlitePath, CancellationToken.None);
Assert.Equal(0, again.KeysInserted);
Assert.Equal(1, again.KeysSkipped);
Assert.Single(again.KeyIdCollisions);
}
[SkippableFact]
public async Task Import_OverLengthKey_IsRejected_NotTruncated()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
string longId = new('k', 70);
var sqliteFactory = new AuthSqliteConnectionFactory(_sqlitePath);
await new SqliteAuthStoreMigrator(sqliteFactory).MigrateAsync(CancellationToken.None);
var sqliteAdmin = new SqliteApiKeyAdminStore(sqliteFactory);
await sqliteAdmin.CreateAsync(Record(longId), CancellationToken.None);
await sqliteAdmin.CreateAsync(Record("ok-key"), CancellationToken.None);
ApiKeyImportResult result = await new SqliteApiKeyStoreImporter(_factory).ImportAsync(_sqlitePath, CancellationToken.None);
Assert.Equal(1, result.KeysInserted);
Assert.Equal(longId, Assert.Single(result.KeysRejected));
Assert.Equal(1L, Convert.ToInt64(await LiveSqlServer.ScalarAsync($"SELECT COUNT(*) FROM [{_schema}].[api_keys]")));
}
}