Skip to content

Commit fd85725

Browse files
LEGLINK-888: ValueSet $validate-code rejects a blank "system" with a 400 (#1794)
* LEGLINK-888: ValueSet $validate-code rejects a blank "system" with a 400 An empty "system" was folded into the absent case by string.IsNullOrEmpty and answered by searching every code system in the value set, so TestRail 10992's request returned 200 result=true. An absent system is legitimate FHIR and keeps that meaning; an empty string is not valid for any FHIR primitive, so the request is malformed and is now rejected rather than reinterpreted into a broader question than the caller asked. - Adds NormalizeSystem and validates every client-supplied system up front in ValidateCodeInValueSet: the query parameter, the body "system" parameter, coding.system and each codeableConcept.coding.system. Validating before the merges matters because they treat an empty string as "not supplied" and would overwrite a blank before anything could see it, and it keeps the verdict for a malformed codeableConcept independent of which coding happens to match first. - Whitespace-only is treated as blank, matching the FHIR rule that a primitive carries at least one character of non-whitespace content. - The literal "null" and "undefined" are treated as an omitted parameter. This is deliberate leniency for clients that interpolate an unset variable into a request rather than FHIR behavior, so it is pinned by tests. - The string.IsNullOrEmpty check in ValidateCodeInCodeGroup is left alone: it is shared with ValidateCodeInCodeSystem, which passes the code group's own Url there, and that is cache content rather than client input. - Registers PreserveEmptyStringMetadataProvider. MVC converts an empty query value to null before an action runs, so "?system=" reached the action indistinguishable from an omitted parameter and returned 200 regardless of the validation above. MvcOptions exposes no setting for this and DisplayFormatAttribute cannot target a parameter, so a display metadata provider is the supported route. Body binding is unaffected; FhirModelBinder deserializes with System.Text.Json and never consults MVC metadata. - Adds FhirControllerHttpTests, which drives the four QA requests (TestRail 10992, 11015, 11342, 11343) over real HTTP through the configured binding pipeline. Calling the action directly cannot reach the query-string case: a direct call passing string.Empty exercises a state real traffic cannot produce. Testing: 97 unit tests pass in UnitTests.Terminology, up from 77, 20 of them new; the full .NET unit suite passes at 1128. Confirmed the metadata provider is load-bearing by removing it and observing only the "?system=" test fail. Verified against the local docker-compose stack: before the change all four QA requests returned 200 result=true; after rebuilding the image each returns application/problem+json carrying type, title, status, the expected detail and a W3C traceId. An omitted system and a valid system are unchanged at 200; "?system=null" now searches all systems rather than being looked up as a system URL, so it returns result=true where it previously returned result=false. * LEGLINK-888: Trim a client-supplied "system" before matching it CodeRabbit finding on PR #1794. NormalizeSystem returned the value verbatim, but the lookup in ValidateCodeInSystem is an exact dictionary match, so a whitespace-padded " http://x " answered "Code system not found in ValueSet" for a system that is present -- the same confidently-wrong answer to a malformed request that this method exists to prevent. A padded placeholder likewise failed to resolve to null. Reproduced against the running service before changing anything: "?system=%20<sys>%20", a padded coding.system in the body, and "?system=%20null%20" all returned result=false with "Code system not found in ValueSet". - Trims after the whitespace-only guard, so " " is still rejected with a 400 rather than trimmed to empty and slipping through as "not supplied". - The trimmed value is what gets placeholder-matched and returned, so " null " resolves to null and " http://x " matches the loaded code system. Padded values are well-formed FHIR rather than malformed input, so this is normalization and not the silent repair of a bad request that LEGLINK-888 otherwise argues against: the Firely validating deserializer accepts " http://hl7.org/fhir/address-type " without complaint. Not addressed here: "url", "code" and "display" share the same exact-match behavior -- "?url=%20<valueset>%20" returns "Value set not found". Widening the treatment to those changes behavior across the endpoint and belongs in its own ticket with QA coverage. Testing: 1188 unit tests pass, 3 of them new -- a padded system still matching its code system, and the placeholder theory extended with " null " and "\tundefined\t". * LEGLINK-888: Assert the validation verdict instead of matching the payload text CodeRabbit finding on PR #1794. Both 200-response tests in FhirControllerHttpTests checked the raw payload with Assert.Contains rather than reading the result parameter. The placeholder test was the one that mattered. It asserted only that the body contained "result", never the value -- but "?system=null" being treated as an omitted parameter (search every system, code found, result=true) differs from it being looked up as a code system literally named "null" (result=false) in exactly that boolean. The assertion passed either way, so a test named TreatedAsAbsent was not testing that the value was treated as absent. That leniency is the one deliberately non-FHIR behaviour in this change and has no TestRail coverage, so this unit test was its only guard. - Adds AssertValidationResult beside AssertBadRequestDetail: parses the payload, checks it is a FHIR Parameters resource, locates the result parameter and asserts its valueBoolean. Reports a missing result parameter as a named failure rather than throwing on a null reference. - Both call sites now use it, replacing Assert.Contains("\"result\"", payload) and the Assert.Contains("true", payload) substring match over the whole body. Verified the new assertion can fail for the right reason: with the placeholder branch temporarily removed from NormalizeSystem, both PlaceholderSystem_TreatedAsAbsent cases fail where the previous assertions passed. Testing: 1188 unit tests pass, unchanged in count. Test-only change; no production code touched. * LEGLINK-888: Scope the empty-string binder override to the one parameter that needs it Reviewer feedback on PR #1794. PreserveEmptyStringMetadataProvider turned ConvertEmptyStringToNull off for every bound model in the service, when only ValueSet/$validate-code's "system" needs the distinction between an omitted parameter and a blank one. - Adds PreserveEmptyStringAttribute, a parameter-only marker, and narrows the provider to parameters carrying it. Every other bound value in the service goes back to MVC's default behavior. - Marks FhirController.ValidateCodeInValueSet's "system" with it. The requirement is now visible on the parameter itself rather than in a provider the reader has to go and find, and it survives a rename that a reflection match on the parameter name would not. - Drops the provider's dependency on the Controllers namespace, so the Application layer no longer reaches into Presentation. ValidateCodeInCodeSystem takes no "system" parameter -- the code system's system is its url -- so ValueSet/$validate-code is the whole surface. Verified the attribute is load-bearing: removing it fails exactly one test, ValidateCodeInValueSet_BlankSystemQueryParameter_Returns400, and restoring it passes again. Testing: full ServiceTests suite passes -- 1761 passed, 1 skipped, 0 failed.
1 parent 9f2bdc7 commit fd85725

8 files changed

Lines changed: 610 additions & 10 deletions

File tree

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
using System.Net;
2+
using System.Text;
3+
using System.Text.Json;
4+
using LantanaGroup.Link.Terminology.Application.Formatters;
5+
using LantanaGroup.Link.Terminology.Application.Interfaces;
6+
using LantanaGroup.Link.Terminology.Application.Models;
7+
using LantanaGroup.Link.Terminology.Controllers;
8+
using LantanaGroup.Link.Terminology.Services;
9+
using Microsoft.AspNetCore.Builder;
10+
using Microsoft.AspNetCore.Hosting;
11+
using Microsoft.AspNetCore.TestHost;
12+
using Microsoft.Extensions.DependencyInjection;
13+
using Moq;
14+
using Xunit;
15+
using Code = LantanaGroup.Link.Terminology.Application.Models.Code;
16+
using Task = System.Threading.Tasks.Task;
17+
18+
namespace UnitTests.Terminology;
19+
20+
/// <summary>
21+
/// Exercises ValueSet $validate-code over real HTTP, through the same model binding and MVC options
22+
/// <c>Program.cs</c> configures, for the four blank-system requests QA covers in TestRail 10992, 11015,
23+
/// 11342 and 11343 (LEGLINK-888).
24+
/// </summary>
25+
/// <remarks>
26+
/// Calling the action method directly cannot cover these. MVC converts an empty query value to null before
27+
/// an action runs unless <see cref="PreserveEmptyStringMetadataProvider"/> is registered, so a direct call
28+
/// passing <c>string.Empty</c> exercises a state real traffic could not produce and would report a blank
29+
/// <c>?system=</c> as rejected while the deployed service answered 200.
30+
///
31+
/// The MVC options below mirror <c>Program.cs</c> by hand rather than booting the real host, whose startup
32+
/// needs Kafka, the cache and App Configuration. The two must be kept in step: dropping the metadata
33+
/// provider from <c>Program.cs</c> alone would break the deployed service without failing these tests.
34+
/// </remarks>
35+
public class FhirControllerHttpTests
36+
{
37+
private const string ValueSetUrl = "http://hl7.org/fhir/ValueSet/address-type";
38+
private const string CodeSystemUrl = "http://hl7.org/fhir/address-type";
39+
private const string Endpoint = "/api/terminology/fhir/ValueSet/$validate-code";
40+
41+
/// <summary>
42+
/// Stands up the controller behind a real request pipeline, mirroring the MVC configuration in
43+
/// <c>Program.cs</c>. The value set is populated so a request that still fails did so on the system
44+
/// rather than on a failed lookup.
45+
/// </summary>
46+
private static TestServer BuildServer()
47+
{
48+
var cache = new Mock<ICodeGroupCacheService>();
49+
cache.Setup(x => x.GetCodeGroup(CodeGroup.CodeGroupTypes.ValueSet, ValueSetUrl, It.IsAny<string>()))
50+
.Returns(new CodeGroup
51+
{
52+
Id = "address-type",
53+
Type = CodeGroup.CodeGroupTypes.ValueSet,
54+
Url = ValueSetUrl,
55+
Codes = new Dictionary<string, List<Code>>
56+
{
57+
{ CodeSystemUrl, new List<Code> { new() { Value = "postal", Display = "Postal" } } }
58+
}
59+
});
60+
61+
var builder = new WebHostBuilder()
62+
.ConfigureServices(services =>
63+
{
64+
services.AddLogging();
65+
services.AddSingleton(cache.Object);
66+
services.AddSingleton<FhirService>();
67+
services.AddControllers(options =>
68+
{
69+
options.ModelBinderProviders.Insert(0, new FhirModelBinderProvider());
70+
options.OutputFormatters.Insert(0, new FhirOutputFormatter());
71+
options.ModelMetadataDetailsProviders.Add(new PreserveEmptyStringMetadataProvider());
72+
})
73+
.AddApplicationPart(typeof(FhirController).Assembly);
74+
})
75+
.Configure(app =>
76+
{
77+
app.UseRouting();
78+
app.UseEndpoints(endpoints => endpoints.MapControllers());
79+
});
80+
81+
return new TestServer(builder);
82+
}
83+
84+
private static async Task<(HttpStatusCode Status, string Body)> PostAsync(string query, string body)
85+
{
86+
using var server = BuildServer();
87+
using var client = server.CreateClient();
88+
89+
var content = new StringContent(body, Encoding.UTF8, "application/json");
90+
var response = await client.PostAsync($"{Endpoint}{query}", content);
91+
92+
return (response.StatusCode, await response.Content.ReadAsStringAsync());
93+
}
94+
95+
/// <summary>
96+
/// Asserts a 200 carrying a FHIR Parameters resource whose <c>result</c> is the expected verdict.
97+
/// </summary>
98+
/// <remarks>
99+
/// A substring check over the raw payload cannot tell result=true from result=false: the placeholder
100+
/// test below exists to pin that "?system=null" is treated as an omitted parameter rather than looked
101+
/// up as a code system literally named "null", and those two outcomes differ only in this boolean.
102+
/// </remarks>
103+
private static void AssertValidationResult(HttpStatusCode status, string body, bool expectedResult)
104+
{
105+
Assert.Equal(HttpStatusCode.OK, status);
106+
107+
using var document = JsonDocument.Parse(body);
108+
var root = document.RootElement;
109+
110+
Assert.Equal("Parameters", root.GetProperty("resourceType").GetString());
111+
112+
var found = false;
113+
var result = default(JsonElement);
114+
foreach (var parameter in root.GetProperty("parameter").EnumerateArray())
115+
{
116+
if (parameter.GetProperty("name").GetString() == "result")
117+
{
118+
result = parameter;
119+
found = true;
120+
break;
121+
}
122+
}
123+
124+
Assert.True(found, "no 'result' parameter in the response");
125+
Assert.Equal(expectedResult, result.GetProperty("valueBoolean").GetBoolean());
126+
}
127+
128+
private static void AssertBadRequestDetail(HttpStatusCode status, string body, string expectedDetail)
129+
{
130+
Assert.Equal(HttpStatusCode.BadRequest, status);
131+
132+
using var document = JsonDocument.Parse(body);
133+
var root = document.RootElement;
134+
135+
Assert.Equal("https://tools.ietf.org/html/rfc9110#section-15.5.1", root.GetProperty("type").GetString());
136+
Assert.Equal("Bad Request", root.GetProperty("title").GetString());
137+
Assert.Equal(400, root.GetProperty("status").GetInt32());
138+
Assert.Equal(expectedDetail, root.GetProperty("detail").GetString());
139+
}
140+
141+
/// <summary>TestRail 10992 — blank system inside the body's coding.</summary>
142+
[Fact]
143+
public async Task ValidateCodeInValueSet_BlankSystemInCodingBody_Returns400()
144+
{
145+
const string body = """
146+
{
147+
"resourceType" : "Parameters",
148+
"parameter" : [{
149+
"name": "url",
150+
"valueUri": "http://hl7.org/fhir/ValueSet/address-type"
151+
}, {
152+
"name" : "coding",
153+
"valueCoding": { "code": "postal", "system": "" }
154+
}]
155+
}
156+
""";
157+
158+
var (status, payload) = await PostAsync(string.Empty, body);
159+
160+
AssertBadRequestDetail(status, payload, "The 'coding.system' parameter cannot be blank.");
161+
}
162+
163+
/// <summary>TestRail 11015 — blank system on the query string, valid system in the body.</summary>
164+
[Fact]
165+
public async Task ValidateCodeInValueSet_BlankSystemQueryParameter_Returns400()
166+
{
167+
const string body = """
168+
{
169+
"resourceType" : "Parameters",
170+
"parameter" : [{
171+
"name": "url",
172+
"valueUri": "http://hl7.org/fhir/ValueSet/address-type"
173+
}, {
174+
"name" : "coding",
175+
"valueCoding": { "code": "postal", "system": "http://hl7.org/fhir/address-type" }
176+
}]
177+
}
178+
""";
179+
180+
var (status, payload) = await PostAsync("?system=", body);
181+
182+
AssertBadRequestDetail(status, payload, "The 'system' parameter cannot be blank.");
183+
}
184+
185+
/// <summary>TestRail 11342 — blank system as a top-level body parameter.</summary>
186+
[Fact]
187+
public async Task ValidateCodeInValueSet_BlankSystemParameterInBody_Returns400()
188+
{
189+
const string body = """
190+
{
191+
"resourceType" : "Parameters",
192+
"parameter" : [{
193+
"name": "url",
194+
"valueUri": "http://hl7.org/fhir/ValueSet/address-type"
195+
}, {
196+
"name" : "code",
197+
"valueCode": "postal"
198+
}, {
199+
"name": "system",
200+
"valueUri": ""
201+
}]
202+
}
203+
""";
204+
205+
var (status, payload) = await PostAsync(string.Empty, body);
206+
207+
AssertBadRequestDetail(status, payload, "The 'system' parameter cannot be blank.");
208+
}
209+
210+
/// <summary>TestRail 11343 — blank system inside the body's codeableConcept.</summary>
211+
[Fact]
212+
public async Task ValidateCodeInValueSet_BlankSystemInCodeableConcept_Returns400()
213+
{
214+
const string body = """
215+
{
216+
"resourceType" : "Parameters",
217+
"parameter" : [{
218+
"name": "url",
219+
"valueUri": "http://hl7.org/fhir/ValueSet/address-type"
220+
}, {
221+
"name" : "codeableConcept",
222+
"valueCodeableConcept": {
223+
"coding": [{ "code": "postal", "system": "" }]
224+
}
225+
}]
226+
}
227+
""";
228+
229+
var (status, payload) = await PostAsync(string.Empty, body);
230+
231+
AssertBadRequestDetail(status, payload, "The 'codeableConcept.coding.system' parameter cannot be blank.");
232+
}
233+
234+
/// <summary>
235+
/// An omitted system keeps its FHIR meaning of "search every code system in the value set", so the
236+
/// rejection above must not be reached by simply leaving the parameter out.
237+
/// </summary>
238+
[Fact]
239+
public async Task ValidateCodeInValueSet_AbsentSystem_SearchesAllSystemsAndSucceeds()
240+
{
241+
const string body = """
242+
{
243+
"resourceType" : "Parameters",
244+
"parameter" : [{
245+
"name": "url",
246+
"valueUri": "http://hl7.org/fhir/ValueSet/address-type"
247+
}, {
248+
"name" : "coding",
249+
"valueCoding": { "code": "postal" }
250+
}]
251+
}
252+
""";
253+
254+
var (status, payload) = await PostAsync(string.Empty, body);
255+
256+
AssertValidationResult(status, payload, expectedResult: true);
257+
}
258+
259+
/// <summary>
260+
/// Per LEGLINK-888, a client that interpolated an unset variable into the query string is treated as
261+
/// having omitted the parameter. This is deliberate non-FHIR leniency, so it is pinned by a test.
262+
/// </summary>
263+
[Theory]
264+
[InlineData("?system=null")]
265+
[InlineData("?system=undefined")]
266+
public async Task ValidateCodeInValueSet_PlaceholderSystem_TreatedAsAbsent(string query)
267+
{
268+
const string body = """
269+
{
270+
"resourceType" : "Parameters",
271+
"parameter" : [{
272+
"name": "url",
273+
"valueUri": "http://hl7.org/fhir/ValueSet/address-type"
274+
}, {
275+
"name" : "code",
276+
"valueCode": "postal"
277+
}]
278+
}
279+
""";
280+
281+
var (status, payload) = await PostAsync(query, body);
282+
283+
// result=true is the whole point: the placeholder must be treated as an omitted system, so the
284+
// code is found across every system in the value set. Looking "null" up as a code system would
285+
// answer result=false, which the previous payload-substring assertion could not distinguish.
286+
AssertValidationResult(status, payload, expectedResult: true);
287+
}
288+
}

DotNet/ServiceTests/UnitTests/Terminology/Controllers/FhirControllerTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,58 @@ public void ValidateCodeInValueSet_WithEmptyValueUriInBody_ReturnsBadRequest()
186186
AssertBadRequestProblem(result, "No id or url parameter specified.");
187187
}
188188

189+
[Fact]
190+
public void ValidateCodeInValueSet_WithBlankSystemInCodingBody_ReturnsBadRequest()
191+
{
192+
// Arrange - LEGLINK-888's reported request: a resolvable value set and a code that does match,
193+
// with the coding's system blank. The value set is wired up deliberately, so the 400 proves the
194+
// blank is rejected rather than the request merely failing to find anything.
195+
_mockCacheService
196+
.Setup(x => x.GetCodeGroup(CodeGroup.CodeGroupTypes.ValueSet, ValueSetUrl, It.IsAny<string>()))
197+
.Returns(BuildCodeGroup(CodeGroup.CodeGroupTypes.ValueSet, CodeSystemUrl));
198+
199+
var parameters = new Parameters();
200+
parameters.Add("url", new FhirUri(ValueSetUrl));
201+
parameters.Add("coding", new Coding { Code = LoincCode, System = string.Empty });
202+
203+
// Act
204+
var result = _controller.ValidateCodeInValueSet(null, null, null, null, null, parameters);
205+
206+
// Assert - previously this answered 200 result=true by searching every system in the value set
207+
AssertBadRequestProblem(result, "The 'coding.system' parameter cannot be blank.");
208+
}
209+
210+
[Fact]
211+
public void ValidateCodeInValueSet_WithBlankSystemQueryParameter_ReturnsBadRequest()
212+
{
213+
// Arrange
214+
_mockCacheService
215+
.Setup(x => x.GetCodeGroup(CodeGroup.CodeGroupTypes.ValueSet, ValueSetUrl, It.IsAny<string>()))
216+
.Returns(BuildCodeGroup(CodeGroup.CodeGroupTypes.ValueSet, CodeSystemUrl));
217+
218+
// Act - an empty query string value binds as "", not as an absent parameter
219+
var result = _controller.ValidateCodeInValueSet(ValueSetUrl, null, string.Empty, LoincCode, null, null);
220+
221+
// Assert
222+
AssertBadRequestProblem(result, "The 'system' parameter cannot be blank.");
223+
}
224+
225+
[Fact]
226+
public void ValidateCodeInValueSet_WithNullPlaceholderSystem_SearchesAllSystems()
227+
{
228+
// Arrange
229+
_mockCacheService
230+
.Setup(x => x.GetCodeGroup(CodeGroup.CodeGroupTypes.ValueSet, ValueSetUrl, It.IsAny<string>()))
231+
.Returns(BuildCodeGroup(CodeGroup.CodeGroupTypes.ValueSet, CodeSystemUrl));
232+
233+
// Act - a client that interpolated an unset variable sends the literal string "null"
234+
var result = _controller.ValidateCodeInValueSet(ValueSetUrl, null, "null", LoincCode, null, null);
235+
236+
// Assert - treated as if the system had been omitted rather than looked up as a system URL
237+
var parameters = AssertOkParameters(result);
238+
Assert.True(parameters.GetSingleValue<FhirBoolean>("result")?.Value);
239+
}
240+
189241
[Fact]
190242
public void GetValueSetById_WhenValueSetNotLoaded_ReturnsNotFoundProblem()
191243
{

0 commit comments

Comments
 (0)