From 8a10e516c2719bbb4e9dc2d0c11eb1e9628ff89d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Wed, 13 May 2026 10:13:11 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20v3.15.2=20=E2=80=94=20validate=20Policie?= =?UTF-8?q?s/Profiles/Rules=20by=20shape,=20not=20by=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the config-validator fix started in 3.15.1 for the two "name-keyed open dictionary" sections that share Auth:Schemes's shape: - RateLimiterOptions:Policies is now validated against a per-Type schema (FixedWindow / SlidingWindow / TokenBucket / Concurrency). User-chosen policy names no longer fail `ValidateConfigKeys: "Error"` startup, and typos / cross-type keys inside a policy now surface. - CacheOptions:Profiles uses a single flat schema (Memory / Redis / Hybrid all share the same key set). Custom profile names validate; typos like `Expirashun` now surface. - ValidationOptions:Rules moved off the open-dict whitelist into a flat schema, so typos like `Patrn` inside a rule no longer pass silently. 15 new ConfigValidationTests; 84/84 targeted tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-test-publish.yml | 2 +- NpgsqlRestClient/ConfigDefaults.cs | 170 +++++++- NpgsqlRestClient/NpgsqlRestClient.csproj | 2 +- .../ConfigTests/ConfigValidationTests.cs | 369 ++++++++++++++++++ changelog/v3.15.2.md | 86 ++++ npm/package.json | 2 +- npm/postinstall.js | 2 +- 7 files changed, 621 insertions(+), 12 deletions(-) create mode 100644 changelog/v3.15.2.md diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 99be9ace..52550e0c 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: env: - RELEASE_VERSION: v3.15.1 + RELEASE_VERSION: v3.15.2 # Add workflow-level permissions permissions: diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index e67fa553..66b59809 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -1225,12 +1225,15 @@ public static List FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a { var fullPath = string.IsNullOrEmpty(path) ? kvp.Key : $"{path}:{kvp.Key}"; - // Auth:Schemes is a name-keyed open dictionary, but the keys *inside* each named scheme - // are not arbitrary — they depend on the scheme's Type. Pick a type-discriminated schema - // so any name (including the docs-example names short_session/api_token/admin_jwt) gets - // the same per-type validation. Without this, a scheme name that happens to match an - // example in defaults would be validated against that example's incomplete key set, - // flagging perfectly valid per-type override keys (Bug A in CONFIG_VALIDATION_NAMED_SCHEMES_BUG.md). + // Name-keyed open dictionaries: the named children are arbitrary (user-chosen) but each + // child has a well-defined inner shape. Pick that shape and validate against it. Without + // these intercepts, the validator falls back to looking up the user-chosen name in + // defaults and either flags it as unknown (if the name has no example match) or matches + // it against an example's partial key set (if the name *does* collide with an example, + // which falsely rejects valid per-type override keys). + // + // Each intercept short-circuits the rest of the loop body so neither the example-lookup + // path nor the IsOpenDictionarySection fallback fires. if (string.Equals(path, "Auth:Schemes", StringComparison.Ordinal) && kvp.Value is JsonObject schemeObj) { var schemeSchema = GetAuthSchemeSchemaByType(schemeObj); @@ -1242,6 +1245,26 @@ public static List FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a // startup for missing/invalid Type; no point double-reporting it here. continue; } + if (string.Equals(path, "RateLimiterOptions:Policies", StringComparison.Ordinal) && kvp.Value is JsonObject policyObj) + { + var policySchema = GetRateLimiterPolicySchemaByType(policyObj); + if (policySchema is not null) + { + warnings.AddRange(FindUnknownConfigKeys(policySchema, policyObj, fullPath)); + } + // BuildRateLimiter silently skips policies with no/invalid Type — match that here. + continue; + } + if (string.Equals(path, "CacheOptions:Profiles", StringComparison.Ordinal) && kvp.Value is JsonObject profileObj) + { + warnings.AddRange(FindUnknownConfigKeys(GetCacheProfileSchema(), profileObj, fullPath)); + continue; + } + if (string.Equals(path, "ValidationOptions:Rules", StringComparison.Ordinal) && kvp.Value is JsonObject ruleObj) + { + warnings.AddRange(FindUnknownConfigKeys(GetValidationRuleSchema(), ruleObj, fullPath)); + continue; + } if (ContainsKeyIgnoreCase(defaultObj, kvp.Key, out var matchedKey)) { @@ -1341,6 +1364,134 @@ public static List FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a return null; } + private static string? ReadStringField(JsonObject obj, string key) + { + foreach (var kvp in obj) + { + if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase)) + { + if (kvp.Value is JsonValue val && val.TryGetValue(out var s)) + { + return s; + } + return null; + } + } + return null; + } + + private static JsonObject PartitionSchema() => new() + { + ["Sources"] = new JsonArray(new JsonObject + { + ["Type"] = null, + ["Name"] = null, + ["Value"] = null + }), + ["BypassAuthenticated"] = false + }; + + /// + /// Returns the per-type schema for a named entry under RateLimiterOptions:Policies, keyed + /// off the policy's Type field (FixedWindow / SlidingWindow / TokenBucket / Concurrency). + /// Returns null when Type is missing/invalid — BuildRateLimiter silently skips such policies, so + /// the validator does the same to avoid double-reporting. + /// + private static JsonObject? GetRateLimiterPolicySchemaByType(JsonObject policy) + { + var type = ReadStringField(policy, "Type"); + if (string.IsNullOrWhiteSpace(type)) + { + return null; + } + if (string.Equals(type, "FixedWindow", StringComparison.OrdinalIgnoreCase)) + { + return new JsonObject + { + ["Type"] = "FixedWindow", + ["Enabled"] = true, + ["PermitLimit"] = 100, + ["WindowSeconds"] = 60, + ["QueueLimit"] = 10, + ["AutoReplenishment"] = true, + ["Partition"] = PartitionSchema() + }; + } + if (string.Equals(type, "SlidingWindow", StringComparison.OrdinalIgnoreCase)) + { + return new JsonObject + { + ["Type"] = "SlidingWindow", + ["Enabled"] = true, + ["PermitLimit"] = 100, + ["WindowSeconds"] = 60, + ["SegmentsPerWindow"] = 6, + ["QueueLimit"] = 10, + ["AutoReplenishment"] = true, + ["Partition"] = PartitionSchema() + }; + } + if (string.Equals(type, "TokenBucket", StringComparison.OrdinalIgnoreCase)) + { + return new JsonObject + { + ["Type"] = "TokenBucket", + ["Enabled"] = true, + ["TokenLimit"] = 100, + ["TokensPerPeriod"] = 10, + ["ReplenishmentPeriodSeconds"] = 10, + ["QueueLimit"] = 10, + ["AutoReplenishment"] = true, + ["Partition"] = PartitionSchema() + }; + } + if (string.Equals(type, "Concurrency", StringComparison.OrdinalIgnoreCase)) + { + return new JsonObject + { + ["Type"] = "Concurrency", + ["Enabled"] = true, + ["PermitLimit"] = 10, + ["QueueLimit"] = 5, + ["OldestFirst"] = true, + ["Partition"] = PartitionSchema() + }; + } + return null; + } + + /// + /// Returns the schema for any entry under CacheOptions:Profiles. All profiles share the same + /// key set regardless of Type (Memory / Redis / Hybrid); only the backend selection varies. + /// + private static JsonObject GetCacheProfileSchema() => new() + { + ["Enabled"] = false, + ["Type"] = null, + ["Expiration"] = null, + ["Parameters"] = new JsonArray(), + ["When"] = new JsonArray(new JsonObject + { + ["Parameter"] = null, + ["Value"] = null, + ["Then"] = null + }) + }; + + /// + /// Returns the schema for any entry under ValidationOptions:Rules. All rules share the same + /// key set regardless of Type (NotNull / NotEmpty / Required / Regex / MinLength / MaxLength). + /// + private static JsonObject GetValidationRuleSchema() => new() + { + ["Type"] = null, + ["Pattern"] = null, + ["MinLength"] = null, + ["MaxLength"] = null, + ["Message"] = null, + ["StatusCode"] = 400 + }; + private static bool ContainsKeyIgnoreCase(JsonObject obj, string key, out string? matchedKey) { foreach (var kvp in obj) @@ -1360,13 +1511,16 @@ private static bool ContainsKeyIgnoreCase(JsonObject obj, string key, out string /// private static bool IsOpenDictionarySection(string path) { + // NOTE: Auth:Schemes, RateLimiterOptions:Policies, CacheOptions:Profiles, and + // ValidationOptions:Rules are intentionally NOT in this list. They are name-keyed open + // dictionaries too, but each is intercepted earlier in FindUnknownConfigKeys with a + // type/shape-discriminated schema so user-chosen names get the same per-entry validation that + // matching example names get. Adding them here would silently disable that. if (path is "ConnectionStrings" or "Log:MinimalLevels" or "Log:OTLResourceAttributes" or "CommandRetryOptions:Strategies" or - "ValidationOptions:Rules" or - "Auth:Schemes" or "NpgsqlRest:AuthenticationOptions:ContextKeyClaimsMapping" or "NpgsqlRest:AuthenticationOptions:ParameterNameClaimsMapping" or "NpgsqlRest:ClientCodeGen:CustomHeaders") diff --git a/NpgsqlRestClient/NpgsqlRestClient.csproj b/NpgsqlRestClient/NpgsqlRestClient.csproj index b81d8afc..6d1f1a01 100644 --- a/NpgsqlRestClient/NpgsqlRestClient.csproj +++ b/NpgsqlRestClient/NpgsqlRestClient.csproj @@ -10,7 +10,7 @@ true true full - 3.15.1 + 3.15.2 diff --git a/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs b/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs index f64202a0..0c764b5c 100644 --- a/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs +++ b/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs @@ -258,4 +258,373 @@ public void TopLevelUnknown_StillFlagged() warnings.Should().Contain("TotallyMadeUp"); } + + // ----------------------------------------------------------------------------------------------- + // RateLimiterOptions:Policies — type-discriminated by Type (FixedWindow / SlidingWindow / + // TokenBucket / Concurrency). The defaults ship example names (fixed/sliding/bucket/concurrency/ + // per_user); custom names must validate by Type, not by name. Bug doc: + // RATE_LIMITER_CONFIG_VALIDATION_FIX.md. + // ----------------------------------------------------------------------------------------------- + + [Fact] + public void RateLimiter_PolicyWithCustomName_FixedWindow_AcceptsAllKeys() + { + var actual = ActualFromJson(""" + { + "RateLimiterOptions": { + "Policies": { + "login_throttle": { + "Type": "FixedWindow", + "Enabled": true, + "PermitLimit": 10, + "WindowSeconds": 60, + "QueueLimit": 0, + "AutoReplenishment": true, + "Partition": { + "Sources": [{ "Type": "IpAddress" }], + "BypassAuthenticated": false + } + } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } + + [Fact] + public void RateLimiter_PolicyWithCustomName_TokenBucket_AcceptsAllKeys() + { + var actual = ActualFromJson(""" + { + "RateLimiterOptions": { + "Policies": { + "api_burst": { + "Type": "TokenBucket", + "Enabled": true, + "TokenLimit": 100, + "TokensPerPeriod": 10, + "ReplenishmentPeriodSeconds": 1, + "QueueLimit": 0, + "AutoReplenishment": true + } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } + + [Fact] + public void RateLimiter_MultipleCustomPolicies_EachValidatedByOwnType() + { + var actual = ActualFromJson(""" + { + "RateLimiterOptions": { + "Policies": { + "login_throttle": { "Type": "FixedWindow", "PermitLimit": 10, "WindowSeconds": 60 }, + "search_window": { "Type": "SlidingWindow", "PermitLimit": 50, "WindowSeconds": 60, "SegmentsPerWindow": 6 }, + "api_burst": { "Type": "TokenBucket", "TokenLimit": 100, "TokensPerPeriod": 10, "ReplenishmentPeriodSeconds": 1 }, + "compute_concurrency": { "Type": "Concurrency", "PermitLimit": 4, "QueueLimit": 0, "OldestFirst": true } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } + + [Fact] + public void RateLimiter_ExampleNames_StillValidate() + { + // The example names (fixed/sliding/bucket/concurrency/per_user) must still validate cleanly + // — regression test against a fix that broke them. + var actual = ActualFromJson(""" + { + "RateLimiterOptions": { + "Policies": { + "fixed": { "Type": "FixedWindow", "PermitLimit": 100, "WindowSeconds": 60 }, + "sliding": { "Type": "SlidingWindow", "PermitLimit": 100, "WindowSeconds": 60, "SegmentsPerWindow": 6 }, + "bucket": { "Type": "TokenBucket", "TokenLimit": 100, "TokensPerPeriod": 10, "ReplenishmentPeriodSeconds": 10 }, + "concurrency": { "Type": "Concurrency", "PermitLimit": 10, "QueueLimit": 5, "OldestFirst": true } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } + + [Fact] + public void RateLimiter_TypoInsidePolicy_Flagged() + { + // Type-discriminated validation must still catch typos inside a policy. + var actual = ActualFromJson(""" + { + "RateLimiterOptions": { + "Policies": { + "login_throttle": { + "Type": "FixedWindow", + "PermitLimt": 10 + } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().ContainSingle() + .Which.Should().Be("RateLimiterOptions:Policies:login_throttle:PermitLimt"); + } + + [Fact] + public void RateLimiter_CrossTypeKey_Flagged() + { + // TokensPerPeriod belongs to TokenBucket — placing it on a FixedWindow policy is a real error. + var actual = ActualFromJson(""" + { + "RateLimiterOptions": { + "Policies": { + "login_throttle": { + "Type": "FixedWindow", + "TokensPerPeriod": 10 + } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().ContainSingle() + .Which.Should().Be("RateLimiterOptions:Policies:login_throttle:TokensPerPeriod"); + } + + [Fact] + public void RateLimiter_PolicyMissingType_NotValidated() + { + // Same precedent as Auth:Schemes — no Type means BuildRateLimiter silently skips the policy, + // so the validator silently skips it too. + var actual = ActualFromJson(""" + { + "RateLimiterOptions": { + "Policies": { + "login_throttle": { + "FooBar": "anything" + } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } + + [Fact] + public void RateLimiter_PartitionSubBlock_Validates() + { + // Partition is part of every per-type schema. Typos inside Partition should surface. + var actual = ActualFromJson(""" + { + "RateLimiterOptions": { + "Policies": { + "login_throttle": { + "Type": "FixedWindow", + "Partition": { + "Sources": [{ "Type": "IpAddress" }], + "BypassAuthnticated": true + } + } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().ContainSingle() + .Which.Should().Be("RateLimiterOptions:Policies:login_throttle:Partition:BypassAuthnticated"); + } + + // ----------------------------------------------------------------------------------------------- + // CacheOptions:Profiles — flat shared schema across cache types (Memory / Redis / Hybrid). + // ----------------------------------------------------------------------------------------------- + + [Fact] + public void CacheProfile_CustomName_AcceptsAllKeys() + { + var actual = ActualFromJson(""" + { + "CacheOptions": { + "Profiles": { + "session_cache": { + "Enabled": true, + "Type": "Memory", + "Expiration": "10 minutes", + "Parameters": ["user_id"] + } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } + + [Fact] + public void CacheProfile_WhenRule_Validates() + { + var actual = ActualFromJson(""" + { + "CacheOptions": { + "Profiles": { + "session_cache": { + "Enabled": true, + "Type": "Memory", + "When": [ + { "Parameter": "limit", "Value": "0", "Then": "skip" } + ] + } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } + + [Fact] + public void CacheProfile_TypoInsideProfile_Flagged() + { + var actual = ActualFromJson(""" + { + "CacheOptions": { + "Profiles": { + "session_cache": { + "Enabled": true, + "Type": "Memory", + "Expirashun": "10 minutes" + } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().ContainSingle() + .Which.Should().Be("CacheOptions:Profiles:session_cache:Expirashun"); + } + + [Fact] + public void CacheProfile_ExampleNames_StillValidate() + { + // The example names (fast_memory/shared_redis/date_range_hybrid) must continue to pass. + var actual = ActualFromJson(""" + { + "CacheOptions": { + "Profiles": { + "fast_memory": { "Enabled": false, "Type": "Memory", "Expiration": "30 seconds" }, + "shared_redis": { "Enabled": false, "Type": "Redis", "Expiration": "1 hour" }, + "date_range_hybrid": { "Enabled": false, "Type": "Hybrid", "Expiration": "5 minutes" } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } + + // ----------------------------------------------------------------------------------------------- + // ValidationOptions:Rules — flat shared schema across validation types. Previously in the open-dict + // whitelist (which let typos pass silently); now type-discriminated so typos inside any rule are + // caught. + // ----------------------------------------------------------------------------------------------- + + [Fact] + public void ValidationRule_CustomName_AcceptsAllKeys() + { + var actual = ActualFromJson(""" + { + "ValidationOptions": { + "Rules": { + "phone_number": { + "Type": "Regex", + "Pattern": "^[0-9-]+$", + "MinLength": 7, + "MaxLength": 15, + "Message": "Invalid phone number", + "StatusCode": 422 + } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } + + [Fact] + public void ValidationRule_TypoInsideRule_Flagged() + { + var actual = ActualFromJson(""" + { + "ValidationOptions": { + "Rules": { + "phone_number": { + "Type": "Regex", + "Patrn": "^[0-9]+$" + } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().ContainSingle() + .Which.Should().Be("ValidationOptions:Rules:phone_number:Patrn"); + } + + [Fact] + public void ValidationRule_ExampleNames_StillValidate() + { + var actual = ActualFromJson(""" + { + "ValidationOptions": { + "Rules": { + "not_null": { "Type": "NotNull", "Message": "is null", "StatusCode": 400 }, + "not_empty": { "Type": "NotEmpty", "Message": "is empty", "StatusCode": 400 }, + "required": { "Type": "Required", "Message": "required", "StatusCode": 400 }, + "email": { "Type": "Regex", "Pattern": "x", "Message": "bad", "StatusCode": 400 } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } } diff --git a/changelog/v3.15.2.md b/changelog/v3.15.2.md new file mode 100644 index 00000000..5dcd3f35 --- /dev/null +++ b/changelog/v3.15.2.md @@ -0,0 +1,86 @@ +# Changelog v3.15.2 (2026-05-11) + +## Version [3.15.2](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.15.2) (2026-05-11) + +[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.15.1...3.15.2) + +Patch release finishing the config-validator fix started in 3.15.1. That release made `Auth:Schemes` validate by `Type` rather than by name; this one applies the same treatment to the two sister sections (`RateLimiterOptions:Policies`, `CacheOptions:Profiles`) that share the same "name-keyed open dictionary" shape, plus a small consistency win for `ValidationOptions:Rules`. Library version unchanged — fixes live entirely in `NpgsqlRestClient/ConfigDefaults.cs`. + +## Fix: `RateLimiterOptions:Policies` validates by `Type`, not by name + +A configuration like + +```json +{ + "Config": { "ValidateConfigKeys": "Error" }, + "RateLimiterOptions": { + "Enabled": true, + "Policies": { + "login_throttle": { + "Type": "FixedWindow", + "PermitLimit": 10, + "WindowSeconds": 60, + "Partition": { "Sources": [{ "Type": "IpAddress" }] } + } + } + } +} +``` + +failed startup with `Unknown configuration key: RateLimiterOptions:Policies:login_throttle`. The rate limiter itself registered `login_throttle` and rejected over-limit requests correctly — only the validator was wrong. + +### Root cause + +`FindUnknownConfigKeys` walked the user's policy name (`login_throttle`) against the defaults schema, which contains illustrative example names (`fixed`, `sliding`, `bucket`, `concurrency`, `per_user`). Any other name was flagged unknown. With `ValidateConfigKeys: "Error"`, that killed startup. + +The 3.13.0 migration explicitly grouped `RateLimiterOptions:Policies`, `CacheOptions:Profiles`, and `ValidationOptions:Rules` as the same "object keyed by user-chosen name" shape, but only `ValidationOptions:Rules` was added to the validator's open-dictionary whitelist. The other two were missed. + +### What changed + +`FindUnknownConfigKeys` now intercepts the descent at `RateLimiterOptions:Policies:` and picks a per-`Type` schema: + +- **FixedWindow**: `Type, Enabled, PermitLimit, WindowSeconds, QueueLimit, AutoReplenishment, Partition` +- **SlidingWindow**: `Type, Enabled, PermitLimit, WindowSeconds, SegmentsPerWindow, QueueLimit, AutoReplenishment, Partition` +- **TokenBucket**: `Type, Enabled, TokenLimit, TokensPerPeriod, ReplenishmentPeriodSeconds, QueueLimit, AutoReplenishment, Partition` +- **Concurrency**: `Type, Enabled, PermitLimit, QueueLimit, OldestFirst, Partition` + +The shared `Partition` sub-schema (`Sources: [{ Type, Name, Value }]`, `BypassAuthenticated`) is appended to every type. When `Type` is missing/invalid the validator skips that policy silently, matching the runtime behavior in `BuildRateLimiter`. + +### Behavior after the fix + +- Any custom policy name validates by its declared `Type`; example names continue to validate as before. +- Typos inside a policy (e.g. `PermitLimt`) are caught — they were silently ignored when `Policies` was treated as an opaque dictionary. +- Cross-type keys are caught: e.g. `TokensPerPeriod` placed on a `FixedWindow` policy is flagged, since it belongs to `TokenBucket`. + +## Fix: `CacheOptions:Profiles` validates by shape + +Same root cause, same shape of fix. Custom profile names (`session_cache`, `api_responses`, etc.) failed validation when `ValidateConfigKeys: "Error"` was set, because the defaults contain example names (`fast_memory`, `shared_redis`, `date_range_hybrid`). + +All cache profiles share the same key set regardless of backend type (Memory / Redis / Hybrid) — only the backend selection varies — so a single flat schema covers every profile: + +``` +Enabled, Type, Expiration, Parameters, When +``` + +Each `When` rule validates as `{ Parameter, Value, Then }`. Typos inside a profile (e.g. `Expirashun`) are now caught. + +## Improvement: `ValidationOptions:Rules` now validates rule bodies + +`ValidationOptions:Rules` was previously on the open-dictionary whitelist, so custom rule names (`phone_number`, etc.) passed validation — but typos *inside* a rule (e.g. `Patrn` instead of `Pattern`) also passed silently. All validation rules share the same flat key set regardless of `Type` (NotNull / NotEmpty / Required / Regex / MinLength / MaxLength): + +``` +Type, Pattern, MinLength, MaxLength, Message, StatusCode +``` + +`ValidationOptions:Rules` has been removed from the whitelist and is now validated against this flat schema. Custom rule names still pass; typos inside any rule now surface. + +## Tests + +`NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs` gained 15 new tests covering all three sections: custom name acceptance, example-name regression coverage, typo flagging, cross-type key detection (rate limiter), `Partition` sub-block validation, `When`-rule sub-block validation, and missing-`Type` skip behavior for rate-limiter policies. + +## Files touched + +- `NpgsqlRestClient/ConfigDefaults.cs` — three new intercepts in `FindUnknownConfigKeys`, three new schema helpers, `ValidationOptions:Rules` removed from `IsOpenDictionarySection`. +- `NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs` — 15 new tests. + +No changes to runtime config reading, no breaking changes, no library version bump (the bug was in `NpgsqlRestClient` only). diff --git a/npm/package.json b/npm/package.json index 6b347659..173e5595 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "npgsqlrest", - "version": "3.15.1", + "version": "3.15.2", "description": "Automatic REST API for PostgreSQL Databases Client Build", "scripts": { "postinstall": "node postinstall.js", diff --git a/npm/postinstall.js b/npm/postinstall.js index 2d4670fb..099638a0 100644 --- a/npm/postinstall.js +++ b/npm/postinstall.js @@ -5,7 +5,7 @@ const path = require("path"); const os = require("os"); const https = require("https"); -const downloadFrom = "https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v3.15.1/"; +const downloadFrom = "https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v3.15.2/"; // Download binary next to this script, not to ../.bin/ const binDir = path.join(__dirname, "bin");