Skip to content

Commit 2ed0199

Browse files
vbilopavclaude
andcommitted
fix: v3.15.1 — config-key validator honors mode + named-scheme typing
Two bug fixes around `ValidateConfigKeys`: - `Auth:Schemes:<name>` is now validated by the scheme's `Type` field (Cookies / BearerToken / Jwt), not by name. Documented example names (short_session / api_token / admin_jwt) no longer constrain validation to the partial example schema, so every per-type override key validates cleanly under any scheme name. - `--config` and `--validate` CLI paths now honor `Config:ValidateConfigKeys` mode. Previously both treated any warning as fatal regardless of mode, diverging from the runtime startup path. `--validate --json` gains a `warningsAreFatal` field. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 272d02d commit 2ed0199

9 files changed

Lines changed: 530 additions & 13 deletions

File tree

.github/workflows/build-test-publish.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
workflow_dispatch:
99

1010
env:
11-
RELEASE_VERSION: v3.15.0
11+
RELEASE_VERSION: v3.15.1
1212

1313
# Add workflow-level permissions
1414
permissions:
@@ -62,7 +62,7 @@ jobs:
6262
id: changelog
6363
run: |
6464
# Per-version changelog files live under changelog/v<X.Y.Z>.md.
65-
# Strip the "v" prefix from RELEASE_VERSION (e.g. "v3.15.0" -> "3.15.0") and read that file.
65+
# Strip the "v" prefix from RELEASE_VERSION (e.g. "v3.15.1" -> "3.15.1") and read that file.
6666
VERSION_NUM="${RELEASE_VERSION#v}"
6767
VERSION_FILE="changelog/v${VERSION_NUM}.md"
6868

NpgsqlRestClient/ConfigDefaults.cs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,6 +1225,24 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
12251225
{
12261226
var fullPath = string.IsNullOrEmpty(path) ? kvp.Key : $"{path}:{kvp.Key}";
12271227

1228+
// Auth:Schemes is a name-keyed open dictionary, but the keys *inside* each named scheme
1229+
// are not arbitrary — they depend on the scheme's Type. Pick a type-discriminated schema
1230+
// so any name (including the docs-example names short_session/api_token/admin_jwt) gets
1231+
// the same per-type validation. Without this, a scheme name that happens to match an
1232+
// example in defaults would be validated against that example's incomplete key set,
1233+
// flagging perfectly valid per-type override keys (Bug A in CONFIG_VALIDATION_NAMED_SCHEMES_BUG.md).
1234+
if (string.Equals(path, "Auth:Schemes", StringComparison.Ordinal) && kvp.Value is JsonObject schemeObj)
1235+
{
1236+
var schemeSchema = GetAuthSchemeSchemaByType(schemeObj);
1237+
if (schemeSchema is not null)
1238+
{
1239+
warnings.AddRange(FindUnknownConfigKeys(schemeSchema, schemeObj, fullPath));
1240+
}
1241+
// No/invalid Type: skip validation. RegisterAuthSchemes throws a clear error at
1242+
// startup for missing/invalid Type; no point double-reporting it here.
1243+
continue;
1244+
}
1245+
12281246
if (ContainsKeyIgnoreCase(defaultObj, kvp.Key, out var matchedKey))
12291247
{
12301248
var defaultChild = defaultObj[matchedKey!];
@@ -1252,6 +1270,77 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
12521270
return warnings;
12531271
}
12541272

1273+
/// <summary>
1274+
/// Returns the per-type schema for a named entry under <c>Auth:Schemes</c>, keyed off the scheme's
1275+
/// <c>Type</c> field. Returns null when Type is missing/invalid so the caller can skip validation
1276+
/// (RegisterAuthSchemes will throw a clearer error at startup).
1277+
/// </summary>
1278+
private static JsonObject? GetAuthSchemeSchemaByType(JsonObject scheme)
1279+
{
1280+
string? type = null;
1281+
foreach (var kvp in scheme)
1282+
{
1283+
if (string.Equals(kvp.Key, "Type", StringComparison.OrdinalIgnoreCase))
1284+
{
1285+
if (kvp.Value is JsonValue val && val.TryGetValue<string>(out var s))
1286+
{
1287+
type = s;
1288+
}
1289+
break;
1290+
}
1291+
}
1292+
if (string.IsNullOrWhiteSpace(type))
1293+
{
1294+
return null;
1295+
}
1296+
if (string.Equals(type, "Cookies", StringComparison.OrdinalIgnoreCase))
1297+
{
1298+
return new JsonObject
1299+
{
1300+
["Type"] = "Cookies",
1301+
["Enabled"] = true,
1302+
["CookieValid"] = "14 days",
1303+
["CookieName"] = null,
1304+
["CookiePath"] = null,
1305+
["CookieDomain"] = null,
1306+
["CookieMultiSessions"] = true,
1307+
["CookieHttpOnly"] = true,
1308+
["CookieSameSite"] = null,
1309+
["CookieSecure"] = null
1310+
};
1311+
}
1312+
if (string.Equals(type, "BearerToken", StringComparison.OrdinalIgnoreCase))
1313+
{
1314+
return new JsonObject
1315+
{
1316+
["Type"] = "BearerToken",
1317+
["Enabled"] = true,
1318+
["BearerTokenExpire"] = "1 hour",
1319+
["BearerTokenRefreshPath"] = null
1320+
};
1321+
}
1322+
if (string.Equals(type, "Jwt", StringComparison.OrdinalIgnoreCase))
1323+
{
1324+
return new JsonObject
1325+
{
1326+
["Type"] = "Jwt",
1327+
["Enabled"] = true,
1328+
["JwtSecret"] = null,
1329+
["JwtIssuer"] = null,
1330+
["JwtAudience"] = null,
1331+
["JwtExpire"] = "60 minutes",
1332+
["JwtRefreshExpire"] = "7 days",
1333+
["JwtClockSkew"] = "5 minutes",
1334+
["JwtValidateIssuer"] = false,
1335+
["JwtValidateAudience"] = false,
1336+
["JwtValidateLifetime"] = true,
1337+
["JwtValidateIssuerSigningKey"] = true,
1338+
["JwtRefreshPath"] = null
1339+
};
1340+
}
1341+
return null;
1342+
}
1343+
12551344
private static bool ContainsKeyIgnoreCase(JsonObject obj, string key, out string? matchedKey)
12561345
{
12571346
foreach (var kvp in obj)

NpgsqlRestClient/NpgsqlRestClient.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<PublishAot>true</PublishAot>
1111
<PublishTrimmed>true</PublishTrimmed>
1212
<TrimMode>full</TrimMode>
13-
<Version>3.15.0</Version>
13+
<Version>3.15.1</Version>
1414
</PropertyGroup>
1515

1616
<ItemGroup>

NpgsqlRestClient/Program.cs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,17 +155,24 @@
155155
if (args.Any(a => string.Equals(a, "--config", StringComparison.CurrentCultureIgnoreCase) || a.StartsWith("--config=", StringComparison.CurrentCultureIgnoreCase)))
156156
{
157157
var (valMode, valWarnings) = config.ValidateConfigKeys();
158+
bool warningsAreFatal = string.Equals(valMode, "Error", StringComparison.OrdinalIgnoreCase);
158159
if (valWarnings.Count > 0)
159160
{
160-
Console.ForegroundColor = ConsoleColor.Red;
161-
Console.Error.WriteLine($"Config validation failed: {valWarnings.Count} unknown key(s):");
161+
Console.ForegroundColor = warningsAreFatal ? ConsoleColor.Red : ConsoleColor.Yellow;
162+
var header = warningsAreFatal
163+
? $"Config validation failed: {valWarnings.Count} unknown key(s):"
164+
: $"Config validation ({valMode}): {valWarnings.Count} unknown key(s):";
165+
Console.Error.WriteLine(header);
162166
foreach (var warning in valWarnings)
163167
{
164168
Console.Error.WriteLine($" - {warning}");
165169
}
166170
Console.ResetColor();
167-
Environment.ExitCode = 1;
168-
return;
171+
if (warningsAreFatal)
172+
{
173+
Environment.ExitCode = 1;
174+
return;
175+
}
169176
}
170177
var outWriter = new Out();
171178
if (config.ConfigFilter is not null)
@@ -183,7 +190,8 @@
183190
{
184191
bool jsonOutput = args.Any(a => string.Equals(a, "--json", StringComparison.OrdinalIgnoreCase));
185192
var (valMode, valWarnings) = config.ValidateConfigKeys();
186-
bool configValid = valWarnings.Count == 0;
193+
bool warningsAreFatal = string.Equals(valMode, "Error", StringComparison.OrdinalIgnoreCase);
194+
bool configValid = valWarnings.Count == 0 || !warningsAreFatal;
187195

188196
// Test database connection
189197
string? connectionError = null;
@@ -210,6 +218,7 @@
210218
["valid"] = valid,
211219
["configValid"] = configValid,
212220
["validationMode"] = valMode,
221+
["warningsAreFatal"] = warningsAreFatal,
213222
["warnings"] = new System.Text.Json.Nodes.JsonArray(
214223
valWarnings.Select(w => (System.Text.Json.Nodes.JsonNode)System.Text.Json.Nodes.JsonValue.Create(w)!).ToArray()),
215224
["connectionTest"] = connectionError is null ? "ok" : connectionError
@@ -222,10 +231,11 @@
222231
_.Logo();
223232
if (valWarnings.Count > 0)
224233
{
225-
_.Line($"Config validation ({valMode}): {valWarnings.Count} unknown key(s):", ConsoleColor.Yellow);
234+
var color = warningsAreFatal ? ConsoleColor.Red : ConsoleColor.Yellow;
235+
_.Line($"Config validation ({valMode}): {valWarnings.Count} unknown key(s):", color);
226236
foreach (var warning in valWarnings)
227237
{
228-
_.Line($" - {warning}", ConsoleColor.Yellow);
238+
_.Line($" - {warning}", color);
229239
}
230240
}
231241
else

NpgsqlRestTests/CliTests/CliCommandTests.cs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,75 @@ public async Task Config_WithCaseInsensitiveOverride_UpdatesExistingKey()
188188
}
189189

190190
[Fact]
191-
public async Task Config_WithUnknownKey_FailsValidation()
191+
public async Task Config_WithUnknownKey_DefaultWarningMode_StillDumpsConfig()
192192
{
193193
var (stdout, stderr, exitCode) = await RunCliAsync("--xxx=test", "--config");
194194

195+
exitCode.Should().Be(0);
196+
stderr.Should().Contain("Warning");
197+
stderr.Should().Contain("unknown key");
198+
stderr.Should().Contain("xxx");
199+
stdout.TrimStart().Should().StartWith("{");
200+
var json = ParseJsonc(stdout);
201+
json.Should().NotBeNull();
202+
}
203+
204+
[Fact]
205+
public async Task Config_WithUnknownKey_ErrorMode_FailsValidation()
206+
{
207+
var (stdout, stderr, exitCode) = await RunCliAsync(
208+
"--Config:ValidateConfigKeys=Error",
209+
"--xxx=test",
210+
"--config");
211+
195212
exitCode.Should().Be(1);
213+
stderr.Should().Contain("failed");
196214
stderr.Should().Contain("unknown key");
197215
stderr.Should().Contain("xxx");
216+
stdout.Should().BeEmpty();
217+
}
218+
219+
[Fact]
220+
public async Task Config_WithUnknownKey_IgnoreMode_DumpsConfigSilently()
221+
{
222+
var (stdout, stderr, exitCode) = await RunCliAsync(
223+
"--Config:ValidateConfigKeys=Ignore",
224+
"--xxx=test",
225+
"--config");
226+
227+
exitCode.Should().Be(0);
228+
stderr.Should().BeEmpty();
229+
stdout.TrimStart().Should().StartWith("{");
230+
}
231+
232+
[Fact]
233+
public async Task Validate_Json_WithUnknownKey_DefaultWarningMode_ConfigStillValid()
234+
{
235+
var (stdout, _, _) = await RunCliAsync("--xxx=test", "--validate", "--json");
236+
237+
var json = JsonNode.Parse(stdout);
238+
json.Should().NotBeNull();
239+
json!["configValid"]!.GetValue<bool>().Should().BeTrue();
240+
json["warningsAreFatal"]!.GetValue<bool>().Should().BeFalse();
241+
json["validationMode"]!.GetValue<string>().Should().Be("Warning");
242+
json["warnings"]!.AsArray().Count.Should().BeGreaterThan(0);
243+
}
244+
245+
[Fact]
246+
public async Task Validate_Json_WithUnknownKey_ErrorMode_ConfigInvalid()
247+
{
248+
var (stdout, _, _) = await RunCliAsync(
249+
"--Config:ValidateConfigKeys=Error",
250+
"--xxx=test",
251+
"--validate",
252+
"--json");
253+
254+
var json = JsonNode.Parse(stdout);
255+
json.Should().NotBeNull();
256+
json!["configValid"]!.GetValue<bool>().Should().BeFalse();
257+
json["warningsAreFatal"]!.GetValue<bool>().Should().BeTrue();
258+
json["validationMode"]!.GetValue<string>().Should().Be("Error");
259+
json["warnings"]!.AsArray().Count.Should().BeGreaterThan(0);
198260
}
199261

200262
[Fact]

0 commit comments

Comments
 (0)