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
115 lines
5.1 KiB
C#
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]")));
|
|
}
|
|
}
|