diff --git a/AGENTS.md b/AGENTS.md index 61d2d08..65e0638 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,8 +52,8 @@ ASP.NET Core Data Protection (IXmlRepository) - `DeletableStorageWrapper.DeleteElements` offers the parsed keys (malformed rows skipped), then passes the chosen `GetAll()` instances, ordered by `DeletionOrder`, to a single `Delete()` call. A storage exception is logged (`ErrorDeletingKeysFromStorage`) and returns `false`. Note: in ASP.NET Core 10.0, `XmlKeyManager` exposes `DeleteKeys` but does not implement `IDeletableKeyManager`. - `StorageWrapper` (internal) creates a new DI scope and resolves `TStorage` on every `GetAllElements` / `StoreElement` call, so scoped storage implementations work. It trims `FriendlyName`, normalizes empty or whitespace-only names to `null`, and wraps any `Insert` exception in `KeyInsertException`. `GetAllElements` logs and skips rows with malformed XML (`SkippingMalformedKey`) instead of failing the whole key ring. - Never log or expose key XML (it may contain unencrypted key material); this includes `XmlException` messages, which can quote the input. -- **DB providers:** `PersistKeysWithDapperInSqlServer(...)` / `PersistKeysWithDapperInPostgreSQL(...)` configure `IOptions`, register the connection source as a singleton (`SqlConnectionFactory`; `NpgsqlDataSource` as a keyed singleton under the internal `Constants.DataSourceServiceKey`, created by a factory so the container disposes it, and so it never collides with the application's own data source), register the repository as scoped `IDbDataProtectionStorage`, then call `PersistKeysToStorage()`. -- `IServiceProvider.UseDapperDataProtection()` (in the shared Dapper project) must be called at startup. It resolves the registrations and runs `InitializeDb()` when `DapperDataProtectionConfig.InitializeTable` is `true` (the default). +- **DB providers:** `PersistKeysWithDapperInSqlServer(...)` / `PersistKeysWithDapperInPostgreSQL(...)` configure `IOptions` with an internal per-provider `DapperDataProtectionConfigValidator` and `ValidateOnStart()`, register the connection source as a singleton (`SqlConnectionFactory`; `NpgsqlDataSource` as a keyed singleton under the internal `Constants.DataSourceServiceKey`, created by a factory so the container disposes it, and so it never collides with the application's own data source), register the repository as scoped `IDbDataProtectionStorage`, then call `PersistKeysToStorage()`. +- `IServiceProvider.UseDapperDataProtection()` (in the shared Dapper project) must be called at startup. It resolves the registrations and runs `InitializeDb()` when `DapperDataProtectionConfig.InitializeTable` is `true` (the default). Reading the options there also triggers validation when no host runs `ValidateOnStart`. ### Database Implementations @@ -67,7 +67,9 @@ Both repositories use raw SQL via Dapper. They differ in naming conventions: - The PostgreSQL `SELECT` aliases each snake_case column to its `DataProtectionKeyEntity` property name (`AS "{nameof(...)}"`). Never set the process-wide `Dapper.DefaultTypeMap.MatchNamesWithUnderscores`: it changes column mapping for every Dapper query in the host application. - `Delete()` requires `DataProtectionKeyEntity` instances (as returned by `GetAll()`) and deletes by `Id`, row by row in order, in one transaction. -- Schema and table names are interpolated directly into SQL strings, not parameterized or validated. +- Schema and table names are validated at startup (SQL Server: `^[A-Za-z_][A-Za-z0-9_]*`, schema <= 128, table <= 112 to fit `IX_{table}_FriendlyName`; PostgreSQL: lowercase only, <= 63) and always quoted (`[...]` / `"..."`, with the closing quote escaped). PostgreSQL names are lowercase because earlier versions did not quote them: a mixed-case name would silently target a new, empty table. +- SQL Server passes names as parameters wherever T-SQL accepts them (existence checks, `QUOTENAME`, the duplicate-index error message). +- `InitializeDb()` creates a missing schema. It checks existence first (`SCHEMA_ID` / `pg_namespace`), because PostgreSQL's `CREATE SCHEMA IF NOT EXISTS` requires the database CREATE privilege even when the schema exists. - Defaults are in each provider's `Constants.cs`. Keep the table DDL in sync with the "Database schema" section of `README.md`. ### Testing diff --git a/README.md b/README.md index 8fcc0a8..8c2ebe1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The base package lets you plug any backend into the Data Protection key ring by - [SQL Server](#sql-server) - [PostgreSQL](#postgresql) - [Configuration](#configuration) + - [Schema and table names](#schema-and-table-names) - [Database schema](#database-schema) - [Custom storage backend](#custom-storage-backend) - [Deleting keys](#deleting-keys) @@ -137,14 +138,15 @@ builder.Services.AddDataProtection() | Option | Type | Default (SQL Server) | Default (PostgreSQL) | Description | |--------|------|----------------------|----------------------|-------------| -| `SchemaName` | `string` | `dbo` | `public` | Schema that contains the keys table. | -| `TableName` | `string` | `DataProtectionKeys` | `data_protection_keys` | Name of the keys table. | -| `InitializeTable` | `bool` | `true` | `true` | When `true`, `UseDapperDataProtection()` creates the table and its unique index, each one only if it is missing. | +| `SchemaName` | `string` | `dbo` | `public` | Schema that contains the keys table. See [Schema and table names](#schema-and-table-names). | +| `TableName` | `string` | `DataProtectionKeys` | `data_protection_keys` | Name of the keys table. See [Schema and table names](#schema-and-table-names). | +| `InitializeTable` | `bool` | `true` | `true` | When `true`, `UseDapperDataProtection()` creates the schema, the table and its unique index, each one only if it is missing. | `UseDapperDataProtection()` is an extension on `IServiceProvider`. Call it once at startup (`app.Services.UseDapperDataProtection()`): it validates the service registrations and, when -`InitializeTable` is `true`, creates the table and the friendly-name unique index if they are -missing. The index is also created on an existing table that lacks it; if that table already +`InitializeTable` is `true`, creates the schema, the table and the friendly-name unique index if +they are missing. The schema is created only when it does not exist, so the database user needs the +permission to create schemas only in that case. The index is also created on an existing table that lacks it; if that table already contains duplicate non-null friendly names, initialization fails and the duplicates must be removed before retrying. Initialization is safe when several instances start at the same time: it runs in a transaction that holds a database-wide lock (`sp_getapplock` on SQL Server, @@ -152,6 +154,26 @@ it runs in a transaction that holds a database-wide lock (`sp_getapplock` on SQL `InitializeTable = false` you are responsible for creating the schema (see below) before the app stores keys. +### Schema and table names + +`SchemaName` and `TableName` are validated when the host starts, and at the latest when +`UseDapperDataProtection()` runs. An invalid name throws an `OptionsValidationException` that names +the option and the rule: + +| | SQL Server | PostgreSQL | +|---|---|---| +| Allowed characters | letters, digits and `_`; must not start with a digit | lowercase letters, digits and `_`; must not start with a digit | +| Maximum length | 128 for the schema, 112 for the table (room for the `IX_{TableName}_FriendlyName` index name) | 63 | + +The names are always quoted in the generated SQL, so reserved words such as `order` or `user` are +allowed. + +PostgreSQL folds unquoted names to lowercase, and earlier versions of this library did not quote +them. Quoted names are case-sensitive, so a mixed-case name such as `DpKeys` would now refer to a +new, empty table instead of the existing `dpkeys` table, and the existing keys would be ignored. +Mixed-case names are therefore rejected at startup: if you configured one, replace it with its +lowercase form, which is the name of the table created by the earlier version. + ## Database schema When `InitializeTable` is enabled, the provider creates a table equivalent to the following. diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionConfigValidator.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionConfigValidator.cs new file mode 100644 index 0000000..fb03099 --- /dev/null +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionConfigValidator.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; + +namespace AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL; + +/// +/// Validates the schema and table names of for PostgreSQL. +/// +/// +/// Names must be lowercase. Identifiers are quoted, which makes them case-sensitive, while earlier versions +/// used unquoted identifiers that PostgreSQL folds to lowercase. Accepting a mixed-case name would silently +/// target a different, empty table instead of the one created by an earlier version. +/// +internal sealed partial class DapperDataProtectionConfigValidator : IValidateOptions +{ + /// + /// Maximum length of a PostgreSQL identifier (NAMEDATALEN - 1). + /// + internal const int MaxNameLength = 63; + + /// + public ValidateOptionsResult Validate(string? name, DapperDataProtectionConfig options) + { + var failures = new List(); + Validate(nameof(DapperDataProtectionConfig.SchemaName), options.SchemaName, failures); + Validate(nameof(DapperDataProtectionConfig.TableName), options.TableName, failures); + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + private static void Validate(string optionName, string? value, List failures) + { + if (value is null || value.Length > MaxNameLength || !IdentifierRegex().IsMatch(value)) + { + failures.Add( + $"{nameof(DapperDataProtectionConfig)}.{optionName} '{value}' is not a valid PostgreSQL name: " + + "it must start with a lowercase letter or an underscore, contain only lowercase letters, digits " + + $"and underscores, and be at most {MaxNameLength} characters long."); + } + } + + [GeneratedRegex(@"^[a-z_][a-z0-9_]*\z", RegexOptions.CultureInvariant)] + private static partial Regex IdentifierRegex(); +} diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionExtensions.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionExtensions.cs index 40e6e71..2918c95 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionExtensions.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionExtensions.cs @@ -1,6 +1,8 @@ using System; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using Npgsql; namespace AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL; @@ -30,12 +32,16 @@ public static IDataProtectionBuilder PersistKeysWithDapperInPostgreSQL(this IDat TableName = Constants.TableName }; configAction?.Invoke(config); - builder.Services.Configure(c => - { - c.InitializeTable = config.InitializeTable; - c.SchemaName = config.SchemaName; - c.TableName = config.TableName; - }); + builder.Services.AddOptions() + .Configure(c => + { + c.InitializeTable = config.InitializeTable; + c.SchemaName = config.SchemaName; + c.TableName = config.TableName; + }) + .ValidateOnStart(); + builder.Services.TryAddEnumerable(ServiceDescriptor + .Singleton, DapperDataProtectionConfigValidator>()); // Keyed, so that a data source registered by the application is neither replaced nor used; // created by a factory, so that the container owns and disposes it. diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/PostgreSQLDataProtectionRepository.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/PostgreSQLDataProtectionRepository.cs index babd271..d427a46 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/PostgreSQLDataProtectionRepository.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/PostgreSQLDataProtectionRepository.cs @@ -16,6 +16,7 @@ public class PostgreSQLDataProtectionRepository : IDbDataProtectionStorage private const string InitializeDbLockName = "AspNetCore.DataProtection.CustomStorage.InitializeDb"; private readonly NpgsqlDataSource _dataSource; private readonly DapperDataProtectionConfig _config; + private readonly string _tableName; private string GetAllQuery() => $""" SELECT @@ -23,7 +24,7 @@ private string GetAllQuery() => insert_date AS "{nameof(DataProtectionKeyEntity.InsertDate)}", friendly_name AS "{nameof(DataProtectionKeyEntity.FriendlyName)}", xml AS "{nameof(DataProtectionKeyEntity.Xml)}" - FROM {_config.SchemaName}.{_config.TableName}; + FROM {_tableName}; """; /// @@ -36,6 +37,7 @@ public PostgreSQLDataProtectionRepository([FromKeyedServices(Constants.DataSourc { _dataSource = dataSource; _config = options.Value; + _tableName = $"{QuoteIdentifier(_config.SchemaName)}.{QuoteIdentifier(_config.TableName)}"; } /// @@ -59,7 +61,7 @@ public void Insert(DataProtectionKey key) using var connection = _dataSource.CreateConnection(); connection.Execute( $""" - INSERT INTO {_config.SchemaName}.{_config.TableName}( + INSERT INTO {_tableName}( friendly_name, xml) VALUES ( @@ -88,7 +90,7 @@ public void Delete(IReadOnlyCollection keys) // Dapper executes the statement once per id, in order. connection.Execute( $""" - DELETE FROM {_config.SchemaName}.{_config.TableName} + DELETE FROM {_tableName} WHERE id = @{nameof(DataProtectionKeyEntity.Id)} """, ids.Select(id => new { Id = id }), @@ -108,6 +110,7 @@ private static List GetIds(IReadOnlyCollection keys) /// /// + /// Creates the schema, the table and the friendly-name unique index, each one only if it is missing. /// Safe to call concurrently from several instances: the initialization is serialized /// with a transaction-level advisory lock. /// @@ -117,25 +120,39 @@ public void InitializeDb() using var transaction = connection.BeginTransaction(); // Without the lock, concurrent IF NOT EXISTS statements can all create the same relation // and fail with a unique violation in the system catalogs. - connection.Execute($""" - SELECT pg_advisory_xact_lock(hashtext(@LockName)); + connection.Execute("SELECT pg_advisory_xact_lock(hashtext(@LockName));", + new { LockName = InitializeDbLockName }, + transaction); + // CREATE SCHEMA IF NOT EXISTS requires the CREATE privilege on the database even when the schema + // exists, so it is executed only when the schema is missing. + var schemaExists = connection.ExecuteScalar( + "SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = @SchemaName);", + new { _config.SchemaName }, + transaction); + if (!schemaExists) + { + connection.Execute($"CREATE SCHEMA IF NOT EXISTS {QuoteIdentifier(_config.SchemaName)};", + transaction: transaction); + } - CREATE TABLE IF NOT EXISTS {_config.SchemaName}.{_config.TableName} + connection.Execute($""" + CREATE TABLE IF NOT EXISTS {_tableName} ( id INTEGER generated always as identity, insert_date timestamp with time zone NOT NULL default NOW(), friendly_name character varying(256) COLLATE pg_catalog."default" NULL, xml text COLLATE pg_catalog."default" NOT NULL, - CONSTRAINT pk_{_config.SchemaName}_{_config.TableName} PRIMARY KEY (id) + CONSTRAINT {QuoteIdentifier($"pk_{_config.SchemaName}_{_config.TableName}")} PRIMARY KEY (id) ); - CREATE UNIQUE INDEX IF NOT EXISTS ix_{_config.SchemaName}_{_config.TableName}_friendly_name - ON {_config.SchemaName}.{_config.TableName} USING btree + CREATE UNIQUE INDEX IF NOT EXISTS {QuoteIdentifier($"ix_{_config.SchemaName}_{_config.TableName}_friendly_name")} + ON {_tableName} USING btree (friendly_name COLLATE pg_catalog."default" ASC NULLS LAST) TABLESPACE pg_default; """, - new { LockName = InitializeDbLockName }, - transaction); + transaction: transaction); transaction.Commit(); } + + private static string QuoteIdentifier(string name) => $"\"{name.Replace("\"", "\"\"")}\""; } \ No newline at end of file diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionConfigValidator.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionConfigValidator.cs new file mode 100644 index 0000000..fbdde21 --- /dev/null +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionConfigValidator.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; + +namespace AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer; + +/// +/// Validates the schema and table names of for SQL Server. +/// +internal sealed partial class DapperDataProtectionConfigValidator : IValidateOptions +{ + /// + /// Maximum length of a SQL Server identifier (sysname). + /// + internal const int MaxSchemaNameLength = 128; + + /// + /// Maximum table name length, which leaves room for the derived index name IX_{TableName}_FriendlyName. + /// + internal const int MaxTableNameLength = MaxSchemaNameLength - 16; + + /// + public ValidateOptionsResult Validate(string? name, DapperDataProtectionConfig options) + { + var failures = new List(); + Validate(nameof(DapperDataProtectionConfig.SchemaName), options.SchemaName, MaxSchemaNameLength, failures); + Validate(nameof(DapperDataProtectionConfig.TableName), options.TableName, MaxTableNameLength, failures); + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + private static void Validate(string optionName, string? value, int maxLength, List failures) + { + if (value is null || value.Length > maxLength || !IdentifierRegex().IsMatch(value)) + { + failures.Add( + $"{nameof(DapperDataProtectionConfig)}.{optionName} '{value}' is not a valid SQL Server name: " + + "it must start with a letter or an underscore, contain only letters, digits and underscores, " + + $"and be at most {maxLength} characters long."); + } + } + + [GeneratedRegex(@"^[A-Za-z_][A-Za-z0-9_]*\z", RegexOptions.CultureInvariant)] + private static partial Regex IdentifierRegex(); +} diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionExtensions.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionExtensions.cs index 4772cf9..5047386 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionExtensions.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionExtensions.cs @@ -1,6 +1,8 @@ using System; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer; @@ -28,12 +30,16 @@ public static IDataProtectionBuilder PersistKeysWithDapperInSqlServer(this IData TableName = Constants.TableName }; configAction?.Invoke(config); - builder.Services.Configure(c => - { - c.InitializeTable = config.InitializeTable; - c.SchemaName = config.SchemaName; - c.TableName = config.TableName; - }); + builder.Services.AddOptions() + .Configure(c => + { + c.InitializeTable = config.InitializeTable; + c.SchemaName = config.SchemaName; + c.TableName = config.TableName; + }) + .ValidateOnStart(); + builder.Services.TryAddEnumerable(ServiceDescriptor + .Singleton, DapperDataProtectionConfigValidator>()); builder.Services.AddSingleton(_=>new SqlConnectionFactory(connectionString)); builder.Services.AddScoped(); diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs index 71c8021..1570694 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs @@ -14,6 +14,7 @@ public class SQLServerDataProtectionRepository : IDbDataProtectionStorage private const string InitializeDbLockResource = "AspNetCore.DataProtection.CustomStorage.InitializeDb"; private readonly SqlConnectionFactory _connectionFactory; private readonly DapperDataProtectionConfig _config; + private readonly string _tableName; private string GetAllQuery() => $""" @@ -22,7 +23,7 @@ private string GetAllQuery() => [{nameof(DataProtectionKeyEntity.InsertDate)}], [{nameof(DataProtectionKeyEntity.FriendlyName)}], [{nameof(DataProtectionKeyEntity.Xml)}] - FROM [{_config.SchemaName}].[{_config.TableName}]; + FROM {_tableName}; """; /// /// create an instance of @@ -34,6 +35,7 @@ public SQLServerDataProtectionRepository(SqlConnectionFactory connectionFactory, { _connectionFactory = connectionFactory; _config = options.Value; + _tableName = $"{QuoteIdentifier(_config.SchemaName)}.{QuoteIdentifier(_config.TableName)}"; } /// @@ -58,7 +60,7 @@ public void Insert(DataProtectionKey key) using var connection = _connectionFactory.CreateConnection(); connection.Execute( $""" - INSERT INTO [{_config.SchemaName}].[{_config.TableName}]( + INSERT INTO {_tableName}( {nameof(DataProtectionKeyEntity.FriendlyName)}, {nameof(DataProtectionKeyEntity.Xml)}) VALUES ( @@ -87,7 +89,7 @@ public void Delete(IReadOnlyCollection keys) // Dapper executes the statement once per id, in order. connection.Execute( $""" - DELETE FROM [{_config.SchemaName}].[{_config.TableName}] + DELETE FROM {_tableName} WHERE [{nameof(DataProtectionKeyEntity.Id)}] = @{nameof(DataProtectionKeyEntity.Id)} """, ids.Select(id => new { Id = id }), @@ -107,6 +109,7 @@ private static List GetIds(IReadOnlyCollection keys) /// /// + /// Creates the schema, the table and the friendly-name unique index, each one only if it is missing. /// Safe to call concurrently from several instances: the initialization is serialized /// with an exclusive application lock held until the transaction ends. /// @@ -117,6 +120,8 @@ public void InitializeDb() connection.Open(); using var transaction = connection.BeginTransaction(); // Without the lock, concurrent existence checks can all pass and the later CREATE statements fail. + // Names are passed as parameters wherever SQL accepts them; elsewhere they are quoted identifiers. + // CREATE SCHEMA must be the only statement in its batch, hence the dynamic SQL. // The unique index is ensured independently of the table, so that an existing table // without the index (created manually, or with the index dropped) gets it restored. connection.Execute( @@ -125,34 +130,46 @@ public void InitializeDb() EXEC @lockResult = sp_getapplock @Resource = @LockResource, @LockMode = N'Exclusive', @LockOwner = N'Transaction'; IF @lockResult < 0 THROW 50000, N'Cannot acquire the application lock to initialize the data protection keys table.', 1; + IF SCHEMA_ID(@SchemaName) IS NULL + BEGIN + DECLARE @createSchema nvarchar(max) = N'CREATE SCHEMA ' + QUOTENAME(@SchemaName); + EXEC(@createSchema); + END; IF NOT EXISTS(SELECT t.[object_id] FROM [sys].[tables] t JOIN sys.schemas s ON s.schema_id=t.schema_id - WHERE t.[name] = N'{_config.TableName}' AND s.name=N'{_config.SchemaName}') + WHERE t.[name] = @TableName AND s.name = @SchemaName) BEGIN - CREATE TABLE [{_config.SchemaName}].[{_config.TableName}]( + CREATE TABLE {_tableName}( [{nameof(DataProtectionKeyEntity.Id)}] [int] IDENTITY(1,1) NOT NULL, - [{nameof(DataProtectionKeyEntity.InsertDate)}] [datetime2] NOT NULL CONSTRAINT [DF_{_config.TableName}_{nameof(DataProtectionKeyEntity.InsertDate)}] DEFAULT sysutcdatetime(), + [{nameof(DataProtectionKeyEntity.InsertDate)}] [datetime2] NOT NULL CONSTRAINT {QuoteIdentifier($"DF_{_config.TableName}_{nameof(DataProtectionKeyEntity.InsertDate)}")} DEFAULT sysutcdatetime(), [{nameof(DataProtectionKeyEntity.FriendlyName)}] [nvarchar](256) NULL, [{nameof(DataProtectionKeyEntity.Xml)}] [nvarchar](max) NOT NULL, - CONSTRAINT [PK_{_config.TableName}] PRIMARY KEY CLUSTERED ([{nameof(DataProtectionKeyEntity.Id)}] ASC) ON [PRIMARY] + CONSTRAINT {QuoteIdentifier($"PK_{_config.TableName}")} PRIMARY KEY CLUSTERED ([{nameof(DataProtectionKeyEntity.Id)}] ASC) ON [PRIMARY] ) ON [PRIMARY]; END; IF NOT EXISTS(SELECT i.[index_id] FROM [sys].[indexes] i - WHERE i.[object_id] = OBJECT_ID(N'[{_config.SchemaName}].[{_config.TableName}]') AND i.[name] = N'{indexName}') + WHERE i.[object_id] = OBJECT_ID(QUOTENAME(@SchemaName) + N'.' + QUOTENAME(@TableName)) AND i.[name] = @IndexName) BEGIN IF EXISTS(SELECT [{nameof(DataProtectionKeyEntity.FriendlyName)}] - FROM [{_config.SchemaName}].[{_config.TableName}] + FROM {_tableName} WHERE [{nameof(DataProtectionKeyEntity.FriendlyName)}] IS NOT NULL GROUP BY [{nameof(DataProtectionKeyEntity.FriendlyName)}] HAVING COUNT(*) > 1) - THROW 50000, N'Cannot create unique index [{indexName}] on [{_config.SchemaName}].[{_config.TableName}]: the table contains duplicate non-null [{nameof(DataProtectionKeyEntity.FriendlyName)}] values. Remove the duplicates and initialize again.', 1; - CREATE UNIQUE NONCLUSTERED INDEX [{indexName}] ON [{_config.SchemaName}].[{_config.TableName}]([{nameof(DataProtectionKeyEntity.FriendlyName)}] ASC) WHERE [{nameof(DataProtectionKeyEntity.FriendlyName)}] IS NOT NULL ON [PRIMARY]; + BEGIN + DECLARE @message nvarchar(2048) = N'Cannot create unique index ' + QUOTENAME(@IndexName) + + N' on ' + QUOTENAME(@SchemaName) + N'.' + QUOTENAME(@TableName) + + N': the table contains duplicate non-null [{nameof(DataProtectionKeyEntity.FriendlyName)}] values. Remove the duplicates and initialize again.'; + THROW 50000, @message, 1; + END; + CREATE UNIQUE NONCLUSTERED INDEX {QuoteIdentifier(indexName)} ON {_tableName}([{nameof(DataProtectionKeyEntity.FriendlyName)}] ASC) WHERE [{nameof(DataProtectionKeyEntity.FriendlyName)}] IS NOT NULL ON [PRIMARY]; END """, - new { LockResource = InitializeDbLockResource }, + new { LockResource = InitializeDbLockResource, _config.SchemaName, _config.TableName, IndexName = indexName }, transaction); transaction.Commit(); } + + private static string QuoteIdentifier(string name) => $"[{name.Replace("]", "]]")}]"; } \ No newline at end of file diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper/DapperDataProtectionConfig.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper/DapperDataProtectionConfig.cs index 88b932b..a1b71d9 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper/DapperDataProtectionConfig.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper/DapperDataProtectionConfig.cs @@ -8,14 +8,21 @@ public record DapperDataProtectionConfig /// /// schema name /// + /// + /// Validated at startup by the database provider: letters, digits and underscores, not starting with a digit + /// (lowercase only for PostgreSQL), within the database identifier length limit. + /// public required string SchemaName { get; set; } /// /// table name /// + /// + /// Validated at startup with the same rules as . + /// public required string TableName { get; set; } /// - /// if true at start it will create the table if not present + /// if true at start it will create the schema, the table and its unique index if not present /// Default=true /// public bool InitializeTable { get; set; } = true; diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionConfigValidatorTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionConfigValidatorTests.cs new file mode 100644 index 0000000..0ab82fb --- /dev/null +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionConfigValidatorTests.cs @@ -0,0 +1,82 @@ +using AspNetCore.DataProtection.CustomStorage.Dapper; +using AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL; +using FluentAssertions; +using Xunit; + +namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL; + +public class PostgreSqlDapperDataProtectionConfigValidatorTests +{ + private readonly DapperDataProtectionConfigValidator _sut = new(); + + [Theory] + [InlineData(Constants.DefaultSchema, Constants.TableName)] + [InlineData("_schema1", "_keys_2")] + [InlineData("security", "user")] + public void Validate_ValidNames_Succeeds(string schemaName, string tableName) + { + var result = _sut.Validate(null, Config(schemaName, tableName)); + + result.Succeeded.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(InvalidNames))] + public void Validate_InvalidSchemaName_FailsNamingTheOption(string? schemaName) + { + var result = _sut.Validate(null, Config(schemaName!, Constants.TableName)); + + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain($"{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.SchemaName)}") + .And.NotContain(nameof(DapperDataProtectionConfig.TableName)); + } + + [Theory] + [MemberData(nameof(InvalidNames))] + public void Validate_InvalidTableName_FailsNamingTheOption(string? tableName) + { + var result = _sut.Validate(null, Config(Constants.DefaultSchema, tableName!)); + + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain($"{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.TableName)}") + .And.NotContain(nameof(DapperDataProtectionConfig.SchemaName)); + } + + [Fact] + public void Validate_MixedCaseName_FailsExplainingLowercaseRequirement() + { + var result = _sut.Validate(null, Config(Constants.DefaultSchema, "DataProtectionKeys")); + + result.FailureMessage.Should().Contain("lowercase"); + } + + [Theory] + [InlineData(63, true)] + [InlineData(64, false)] + public void Validate_NameLength_IsLimitedToPostgreSqlIdentifierLength(int length, bool succeeded) + { + var name = new string('n', length); + + _sut.Validate(null, Config(name, Constants.TableName)).Succeeded.Should().Be(succeeded); + _sut.Validate(null, Config(Constants.DefaultSchema, name)).Succeeded.Should().Be(succeeded); + } + + public static TheoryData InvalidNames() => + [ + null!, + "", + " ", + "1keys", + "my-keys", + "my keys", + "Keys", + "keys\"", + "keys'", + "public.keys", + "keys\n", + "chiav\u00e8" + ]; + + private static DapperDataProtectionConfig Config(string schemaName, string tableName) => + new() { SchemaName = schemaName, TableName = tableName }; +} diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionExtensionsTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionExtensionsTests.cs index 376c77a..31792de 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionExtensionsTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionExtensionsTests.cs @@ -1,9 +1,11 @@ using System; +using AspNetCore.DataProtection.CustomStorage.Dapper; using AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL; using Dapper; using FluentAssertions; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Npgsql; using Xunit; @@ -60,4 +62,40 @@ public void PersistKeysWithDapperInPostgreSQL_DoesNotEnableMatchNamesWithUndersc DefaultTypeMap.MatchNamesWithUnderscores.Should().BeFalse(); } + + [Fact] + public void PersistKeysWithDapperInPostgreSQL_DefaultConfig_PassesStartupValidation() + { + using var provider = BuildServiceProvider(_ => { }); + + provider.GetRequiredService().Invoking(x => x.Validate()) + .Should().NotThrow(); + } + + [Fact] + public void PersistKeysWithDapperInPostgreSQL_MixedCaseTableName_FailsStartupValidation() + { + using var provider = BuildServiceProvider(config => config.TableName = "DataProtectionKeys"); + + provider.GetRequiredService().Invoking(x => x.Validate()) + .Should().Throw() + .WithMessage($"*{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.TableName)}*"); + } + + [Fact] + public void UseDapperDataProtection_InvalidSchemaName_ThrowsBeforeTouchingTheDatabase() + { + using var provider = BuildServiceProvider(config => config.SchemaName = "my-schema"); + + provider.Invoking(x => x.UseDapperDataProtection()) + .Should().Throw() + .WithMessage($"*{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.SchemaName)}*"); + } + + private static ServiceProvider BuildServiceProvider(Action configAction) + { + var services = new ServiceCollection(); + services.AddDataProtection().PersistKeysWithDapperInPostgreSQL("Host=localhost;Database=test", configAction); + return services.BuildServiceProvider(); + } } diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs index d03de89..8c58431 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs @@ -183,6 +183,38 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() } } + [Fact] + public void InitializeDb_SchemaMissing_CreatesSchemaAndTable() + { + var schemaName = $"schema_{Guid.NewGuid().ToString("N")[..12]}"; + var tableName = $"keys_{Guid.NewGuid().ToString("N")[..12]}"; + var sut = CreateSut(schemaName, tableName); + + sut.InitializeDb(); + sut.Insert(new DataProtectionKey { FriendlyName = "MissingSchemaKey", Xml = "Xml" }); + + CountRows(schemaName, tableName).Should().Be(1); + sut.Invoking(x => x.InitializeDb()).Should().NotThrow(); + } + + [Fact] + public void InitializeDb_ReservedWordSchemaName_PersistsKeys() + { + // A reserved word is accepted unquoted after a schema qualifier, but not as the schema name itself. + const string schemaName = "order"; + var tableName = $"keys_{Guid.NewGuid().ToString("N")[..12]}"; + var sut = CreateSut(schemaName, tableName); + var key = new DataProtectionKey { FriendlyName = "ReservedWordKey", Xml = "Xml" }; + + sut.InitializeDb(); + sut.Insert(key); + + var stored = sut.GetAll().Should().ContainSingle().Subject; + stored.FriendlyName.Should().Be(key.FriendlyName); + sut.Delete([stored]); + CountRows(schemaName, tableName).Should().Be(0); + } + [Fact] public void Delete_NullKeys_ThrowsArgumentNullException() { @@ -262,4 +294,17 @@ CREATE TRIGGER tr_{tableName}_fail_delete BEFORE DELETE ON {Constants.DefaultSch sut.GetAll().Select(x => x.FriendlyName).Should().BeEquivalentTo("DeleteFirst", "DeleteFails"); } + + private PostgreSQLDataProtectionRepository CreateSut(string schemaName, string tableName) + { + var options = Substitute.For>(); + options.Value.Returns(new DapperDataProtectionConfig { SchemaName = schemaName, TableName = tableName }); + return new PostgreSQLDataProtectionRepository(_fixture.NpgsqlDataSource, options); + } + + private long CountRows(string schemaName, string tableName) + { + using var connection = _fixture.NpgsqlDataSource.OpenConnection(); + return connection.ExecuteScalar($"""SELECT COUNT(*) FROM "{schemaName}"."{tableName}";"""); + } } diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs index 36b6634..8c9568f 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs @@ -87,7 +87,8 @@ public void Protect_ApplicationRegistersOwnDataSource_DataSourcesStaySeparate(bo Database = "application_database_does_not_exist" }.ConnectionString; using var applicationDataSource = NpgsqlDataSource.Create(applicationConnectionString); - using var services = BuildServiceProvider(tableName, applicationDataSource, applicationRegistersFirst); + using var services = BuildServiceProvider(tableName, + applicationDataSource: applicationDataSource, applicationRegistersFirst: applicationRegistersFirst); var protector = services.GetRequiredService().CreateProtector(Purpose); var protectedPayload = protector.Protect("payload"); @@ -97,10 +98,22 @@ public void Protect_ApplicationRegistersOwnDataSource_DataSourcesStaySeparate(bo GetStoredFriendlyNames(tableName).Should().ContainSingle(); } - // PostgreSQL folds unquoted identifiers to lower case; keep the name short enough for derived index names. + [Fact] + public void Protect_MissingReservedWordSchemaAndTable_StoresKeysInConfiguredTable() + { + using var services = BuildServiceProvider("order", "user"); + var protector = services.GetRequiredService().CreateProtector(Purpose); + + protector.Unprotect(protector.Protect("payload")).Should().Be("payload"); + + GetStoredFriendlyNames("order", "user").Should().ContainSingle(); + } + + // Names must be lowercase; keep the name short enough for derived index names. private static string NewTableName() => $"keys_{Guid.NewGuid().ToString("N")[..12]}"; private ServiceProvider BuildServiceProvider(string tableName, + string schemaName = Constants.DefaultSchema, NpgsqlDataSource? applicationDataSource = null, bool applicationRegistersFirst = false) { @@ -112,7 +125,11 @@ private ServiceProvider BuildServiceProvider(string tableName, } services.AddDataProtection() .SetApplicationName(ApplicationName) - .PersistKeysWithDapperInPostgreSQL(_fixture.ConnectionString, config => config.TableName = tableName); + .PersistKeysWithDapperInPostgreSQL(_fixture.ConnectionString, config => + { + config.SchemaName = schemaName; + config.TableName = tableName; + }); if (applicationDataSource is not null && !applicationRegistersFirst) { services.AddSingleton(applicationDataSource); @@ -123,10 +140,10 @@ private ServiceProvider BuildServiceProvider(string tableName, return provider; } - private List GetStoredFriendlyNames(string tableName) + private List GetStoredFriendlyNames(string tableName, string schemaName = Constants.DefaultSchema) { using var connection = _fixture.NpgsqlDataSource.OpenConnection(); return connection.Query( - $"SELECT friendly_name FROM {Constants.DefaultSchema}.{tableName};").AsList(); + $"""SELECT friendly_name FROM "{schemaName}"."{tableName}";""").AsList(); } } diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionConfigValidatorTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionConfigValidatorTests.cs new file mode 100644 index 0000000..a133a7c --- /dev/null +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionConfigValidatorTests.cs @@ -0,0 +1,91 @@ +using AspNetCore.DataProtection.CustomStorage.Dapper; +using AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer; +using FluentAssertions; +using Xunit; + +namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer; + +public class SqlServerDapperDataProtectionConfigValidatorTests +{ + private readonly DapperDataProtectionConfigValidator _sut = new(); + + [Theory] + [InlineData(Constants.DefaultSchema, Constants.TableName)] + [InlineData("_Schema1", "_Keys_2")] + [InlineData("Security", "Order")] + public void Validate_ValidNames_Succeeds(string schemaName, string tableName) + { + var result = _sut.Validate(null, Config(schemaName, tableName)); + + result.Succeeded.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(InvalidNames))] + public void Validate_InvalidSchemaName_FailsNamingTheOption(string? schemaName) + { + var result = _sut.Validate(null, Config(schemaName!, Constants.TableName)); + + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain($"{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.SchemaName)}") + .And.NotContain(nameof(DapperDataProtectionConfig.TableName)); + } + + [Theory] + [MemberData(nameof(InvalidNames))] + public void Validate_InvalidTableName_FailsNamingTheOption(string? tableName) + { + var result = _sut.Validate(null, Config(Constants.DefaultSchema, tableName!)); + + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain($"{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.TableName)}") + .And.NotContain(nameof(DapperDataProtectionConfig.SchemaName)); + } + + [Fact] + public void Validate_BothNamesInvalid_ReportsBoth() + { + var result = _sut.Validate(null, Config("my schema", "my-keys")); + + result.Failures.Should().HaveCount(2); + } + + [Theory] + [InlineData(128, true)] + [InlineData(129, false)] + public void Validate_SchemaNameLength_IsLimitedToSqlServerIdentifierLength(int length, bool succeeded) + { + var result = _sut.Validate(null, Config(new string('s', length), Constants.TableName)); + + result.Succeeded.Should().Be(succeeded); + } + + [Theory] + [InlineData(112, true)] + [InlineData(113, false)] + public void Validate_TableNameLength_LeavesRoomForDerivedIndexName(int length, bool succeeded) + { + var result = _sut.Validate(null, Config(Constants.DefaultSchema, new string('t', length))); + + result.Succeeded.Should().Be(succeeded); + } + + public static TheoryData InvalidNames() => + [ + null!, + "", + " ", + "1Keys", + "My-Keys", + "My Keys", + "Keys]", + "Keys'", + "Keys\"", + "dbo.Keys", + "Keys\n", + "Chiav\u00e8" + ]; + + private static DapperDataProtectionConfig Config(string schemaName, string tableName) => + new() { SchemaName = schemaName, TableName = tableName }; +} diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionExtensionsTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionExtensionsTests.cs new file mode 100644 index 0000000..010fedb --- /dev/null +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionExtensionsTests.cs @@ -0,0 +1,51 @@ +using System; +using AspNetCore.DataProtection.CustomStorage.Dapper; +using AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer; +using FluentAssertions; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer; + +public class SqlServerDapperDataProtectionExtensionsTests +{ + private const string ConnectionString = "Server=localhost;Database=test"; + + [Fact] + public void PersistKeysWithDapperInSqlServer_DefaultConfig_PassesStartupValidation() + { + using var provider = BuildServiceProvider(_ => { }); + + provider.GetRequiredService().Invoking(x => x.Validate()) + .Should().NotThrow(); + } + + [Fact] + public void PersistKeysWithDapperInSqlServer_InvalidTableName_FailsStartupValidation() + { + using var provider = BuildServiceProvider(config => config.TableName = "My-Keys"); + + provider.GetRequiredService().Invoking(x => x.Validate()) + .Should().Throw() + .WithMessage($"*{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.TableName)}*"); + } + + [Fact] + public void UseDapperDataProtection_InvalidSchemaName_ThrowsBeforeTouchingTheDatabase() + { + using var provider = BuildServiceProvider(config => config.SchemaName = ""); + + provider.Invoking(x => x.UseDapperDataProtection()) + .Should().Throw() + .WithMessage($"*{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.SchemaName)}*"); + } + + private static ServiceProvider BuildServiceProvider(Action configAction) + { + var services = new ServiceCollection(); + services.AddDataProtection().PersistKeysWithDapperInSqlServer(ConnectionString, configAction); + return services.BuildServiceProvider(); + } +} diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs index ee13160..731f152 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs @@ -307,6 +307,20 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() } } + [Fact] + public void InitializeDb_SchemaMissing_CreatesSchemaAndTable() + { + var schemaName = $"Schema_{Guid.NewGuid():N}"; + var tableName = NewTableName(); + var sut = CreateSut(tableName, schemaName); + + sut.InitializeDb(); + sut.Insert(new DataProtectionKey { FriendlyName = "MissingSchemaKey", Xml = "Xml" }); + + CountRows(schemaName, tableName).Should().Be(1); + sut.Invoking(x => x.InitializeDb()).Should().NotThrow(); + } + private static string NewTableName() => $"Keys_{Guid.NewGuid():N}"; private SQLServerDataProtectionRepository CreateSut(string tableName, string schemaName = Constants.DefaultSchema) diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs index 80cbfb2..eea116e 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using AspNetCore.DataProtection.CustomStorage.Dapper; @@ -74,25 +74,41 @@ public void DeleteKeys_KeySelected_DeletesItFromTable() GetStoredFriendlyNames(tableName).Should().Equal($"key-{kept.KeyId:D}"); } + [Fact] + public void Protect_MissingCustomSchemaAndReservedWordTable_StoresKeysInConfiguredTable() + { + var schemaName = $"Schema_{Guid.NewGuid():N}"; + using var services = BuildServiceProvider("Order", schemaName); + var protector = services.GetRequiredService().CreateProtector(Purpose); + + protector.Unprotect(protector.Protect("payload")).Should().Be("payload"); + + GetStoredFriendlyNames("Order", schemaName).Should().ContainSingle(); + } + private static string NewTableName() => $"Keys_{Guid.NewGuid():N}"; - private ServiceProvider BuildServiceProvider(string tableName) + private ServiceProvider BuildServiceProvider(string tableName, string schemaName = Constants.DefaultSchema) { var services = new ServiceCollection(); services.AddSingleton(NullLoggerFactory.Instance); services.AddDataProtection() .SetApplicationName(ApplicationName) - .PersistKeysWithDapperInSqlServer(_fixture.ConnectionString, config => config.TableName = tableName); + .PersistKeysWithDapperInSqlServer(_fixture.ConnectionString, config => + { + config.SchemaName = schemaName; + config.TableName = tableName; + }); var provider = services.BuildServiceProvider(); provider.UseDapperDataProtection(); return provider; } - private List GetStoredFriendlyNames(string tableName) + private List GetStoredFriendlyNames(string tableName, string schemaName = Constants.DefaultSchema) { using var connection = _fixture.SqlConnectionFactory.CreateConnection(); return connection.Query( - $"SELECT [{nameof(DataProtectionKeyEntity.FriendlyName)}] FROM [{Constants.DefaultSchema}].[{tableName}];").AsList(); + $"SELECT [{nameof(DataProtectionKeyEntity.FriendlyName)}] FROM [{schemaName}].[{tableName}];").AsList(); } }