Skip to content

Commit f710f94

Browse files
committed
fix: reject the bare placeholder in a section, +5 edit-flow defects
Two of the six are worth carrying forward. A ref-based ADO report whose query lives in the registered definition got an empty overlay: those boxes hydrate blank, and the save wrote "sql": "" / "key": "" into the report-local overlay, which wins under D42 — so the next run failed with "requires a non-empty 'sql' property" from a save that changed only the page size. Blank now means "do not override" for a ref source, and still means "clear it" for an inline one where the query is the report's own. The unaddressed placeholder inside a section pulled the SOURCE's credential. AddressOf returns null for the bare form and null resolves to source.properties, so a bare placeholder hand-written into a destination — the only form the README and CHANGELOG document — handed that destination the source's connection string. Same wire-crossing the addressed form exists to prevent, third distinct route into it. Rejected outright: only Redact issues the bare form, and only for the source. The rest: a column name containing a comma was split in two by the single text box and both halves retyped as String, so an untouched list is now written back as the stored array and the step warns when the box cannot represent a name; validate?for= skipped the restore when the report was gone, producing a placeholder complaint instead of "that report no longer exists"; a 404 from GET .../config still degraded to a silent blank wizard; and destination card selection was still ordinal after the rest of the destination matching went case-insensitive. Each fix verified by reverting it and confirming the new test fails — one revert had to be redone because the first attempt changed only half the condition and the test passed against it. Full suite: 1 172 green.
1 parent 3d06ba7 commit f710f94

13 files changed

Lines changed: 269 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ The `NeoReports.Abstractions` contract follows SemVer strictly.
7474
taken" under every successful edit validation.
7575
- **Changing the source mid-edit now says the connection must be re-supplied**, rather than failing at
7676
save with a generic compile error.
77+
- **A `ref`-based ADO report keeps the query that lives in its registered definition.** Those boxes
78+
hydrate blank, and the save wrote an empty `sql`/`key` overlay — which wins under D42, so the next
79+
run failed from a save that changed only the page size.
80+
- **An unaddressed placeholder inside an output or destination is rejected**, rather than resolving
81+
against `source.properties` and handing that section the source's credential.
82+
- **A column name containing a comma survives an edit.** The single comma-separated box split it in
83+
two and retyped both as `String`; an untouched list is now written back as the stored array, and the
84+
step warns when the box cannot represent a name.
85+
- **`validate?for={name}` says when that report has no stored configuration**, instead of complaining
86+
about a placeholder the caller sent correctly.
87+
- **A `404` from `GET .../config` says so** instead of silently becoming a blank create wizard.
88+
- **Destination card selection matches case-insensitively**, like the rest of the destination matching.
7789

7890
### Added
7991
- **`GET /api/reports/{name}/config`** returns a config-origin report's stored document with

DECISIONS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2007,6 +2007,29 @@ breaking; all five were in what surrounds it.
20072007
nothing said so, and the save failed with a generic compile error. The Configure step now says what
20082008
has to be supplied.
20092009

2010+
### The sixth review pass
2011+
2012+
Two of these six are the ones worth carrying forward.
2013+
2014+
- **A `ref`-based ADO report whose query lives in the registered definition got an empty overlay.**
2015+
Those boxes hydrate blank, and the save wrote `"sql": ""` / `"key": ""` into the report-local
2016+
overlay, which under D42 wins — so the next run failed with *requires a non-empty 'sql' property*
2017+
from a save that changed only the page size. Blank now means "do not override" for a `ref` source
2018+
and still means "clear it" for an inline one, where the query is the report's own.
2019+
- **The unaddressed placeholder inside a section pulled the SOURCE's credential.** `AddressOf` returns
2020+
null for the bare form and null resolves to `source.properties`, so a bare placeholder hand-written
2021+
into a destination — the only form the README and CHANGELOG document — handed that destination the
2022+
source's connection string. This is the same wire-crossing the addressed form exists to prevent,
2023+
reached from the other direction, and it is the *third* distinct route into it. Rejected outright:
2024+
only `Redact` issues the bare form, and only for the source.
2025+
2026+
The other four: a column name containing a comma was split in two by the single text box and both
2027+
halves retyped as `String` (an untouched list is now written back as the stored array, and the step
2028+
warns when the box cannot represent a name); `validate?for=` skipped the restore when the report was
2029+
gone, producing a placeholder complaint instead of "that report no longer exists"; a `404` from
2030+
`GET .../config` still degraded to a silent blank wizard; and destination card selection was still an
2031+
ordinal comparison after the rest of the destination matching went case-insensitive.
2032+
20102033
### Verified end to end
20112034

20122035
Driven in a browser against `samples/09-web-ui-live`: a report carrying a literal password, a

src/Integrations/NeoReports.AspNetCore/NeoReportsEndpointRouteBuilderExtensions.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -721,11 +721,27 @@ private static async Task<IResult> ValidateReportAsync(
721721
// placeholder would reach a provider as a literal connection string and fail for a
722722
// reason that has nothing to do with the configuration under test.
723723
if (http.Request.Query.TryGetValue("for", out StringValues editing)
724-
&& editing.ToString() is { Length: > 0 } editingName
725-
&& DynamicReportName.IsValid(editingName)
726-
&& http.RequestServices.GetService<IReportConfigStore>() is { } store
727-
&& await store.TryGetAsync(editingName, cancellationToken).ConfigureAwait(false) is { } stored)
724+
&& editing.ToString() is { Length: > 0 } editingName)
728725
{
726+
string? stored = DynamicReportName.IsValid(editingName)
727+
&& http.RequestServices.GetService<IReportConfigStore>() is { } store
728+
? await store.TryGetAsync(editingName, cancellationToken).ConfigureAwait(false)
729+
: null;
730+
731+
// Saying so beats silently skipping the restore: the caller would otherwise get
732+
// "still holds the redaction placeholder" about a document they sent correctly, plus a
733+
// nameTaken flag, for the single real problem that the report is gone.
734+
if (stored is null)
735+
{
736+
return Results.Ok(new ValidateReportResponse(
737+
Valid: false,
738+
Error: $"There is no stored configuration for '{editingName}' to validate an edit against. " +
739+
"It may have been deleted, or it is code-registered.",
740+
Name: null,
741+
Columns: null,
742+
NameTaken: false));
743+
}
744+
729745
editingFor = editingName;
730746
document = ReportConfigSecrets.Restore(document, stored);
731747
}

src/NeoReports.Core/Configuration/ReportConfigSecrets.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ public static string Restore(string document, string storedDocument)
172172
// placeholders too, and resolving both would hand a second destination a credential the
173173
// editor cannot see, which is the outcome the address exists to prevent.
174174
var claims = new Dictionary<string, JsonObject>(StringComparer.Ordinal);
175-
foreach ((JsonObject bag, string? _) in PropertyBagSlots(root))
176-
RestoreMembers(bag, new RestoreContext(stored, bag, claims), []);
175+
foreach ((JsonObject bag, string? address) in PropertyBagSlots(root))
176+
RestoreMembers(bag, new RestoreContext(stored, bag, address, claims), []);
177177

178178
return root.ToJsonString();
179179
}
@@ -246,8 +246,10 @@ private static void RedactMembers(JsonObject owner, string sentinel)
246246

247247
/// <param name="Stored">The whole stored document — a placeholder names the slot it came from.</param>
248248
/// <param name="Bag">The incoming property bag currently being restored.</param>
249+
/// <param name="SlotAddress">The address of the slot <paramref name="Bag"/> is, or null for the source.</param>
249250
/// <param name="Claims">Which bag has already claimed each address, so no two can share one.</param>
250-
private sealed record RestoreContext(JsonObject Stored, JsonObject Bag, Dictionary<string, JsonObject> Claims);
251+
private sealed record RestoreContext(
252+
JsonObject Stored, JsonObject Bag, string? SlotAddress, Dictionary<string, JsonObject> Claims);
251253

252254
private static void RestoreMembers(JsonObject owner, RestoreContext context, IReadOnlyList<object> path)
253255
{
@@ -314,6 +316,18 @@ private static void RestoreElements(JsonArray array, RestoreContext context, IRe
314316

315317
private static void ClaimAddress(RestoreContext context, string? address)
316318
{
319+
// The bare placeholder addresses the source, and only Redact issues it — inside an output or
320+
// destination bag it can only have been written by hand or pasted from the docs, and honouring
321+
// it would hand that section the SOURCE's credential. That is the wire-crossing the addressed
322+
// form exists to prevent, reached from the other direction.
323+
if (address is null && context.SlotAddress is not null)
324+
{
325+
throw new ConfigurationException(
326+
$"The unaddressed placeholder '{RedactedValue}' was sent inside '{context.SlotAddress}'. It stands " +
327+
$"only for a source property; a section's placeholder names its own slot, as in " +
328+
$"'{SentinelFor(context.SlotAddress)}'. Send the real value instead.");
329+
}
330+
317331
string claimed = address ?? SourceMember;
318332
if (context.Claims.TryGetValue(claimed, out JsonObject? owner) && !ReferenceEquals(owner, context.Bag))
319333
{

src/UI/NeoReports.UI/Pages/Builder.razor

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,16 @@
3131
@if (_configUnavailable)
3232
{
3333
<Banner Variant="danger" Icon="alert-triangle">
34-
<b>Could not load that report's configuration.</b>
35-
<span style="font-size:12px">Nothing was prefilled, so this is a blank new reportsaving it would not edit the one you came from. Go back and try again.</span>
34+
@if (_configOutcome == ApiConfigOutcome.NotFound)
35+
{
36+
<b>That report has no stored configuration to edit.</b>
37+
<span style="font-size:12px">It was deleted, or it is registered in codeeither way there is nothing here to change. Nothing was prefilled, so this is a blank new report.</span>
38+
}
39+
else
40+
{
41+
<b>Could not load that report's configuration.</b>
42+
<span style="font-size:12px">Nothing was prefilled, so this is a blank new reportsaving it would not edit the one you came from. Go back and try again.</span>
43+
}
3644
</Banner>
3745
}
3846
@if (!Wizard.EngineAvailable)
@@ -106,6 +114,9 @@
106114
/// <summary>Set when an edit was asked for and the report's configuration could not be read.</summary>
107115
private bool _configUnavailable;
108116

117+
/// <summary>Why it could not be read, so the message can say which of the two happened.</summary>
118+
private ApiConfigOutcome _configOutcome = ApiConfigOutcome.Ok;
119+
109120
/// <summary>
110121
/// Set by ReportDetail's "Edit" button (<c>?edit=name</c>) — hydrates the wizard from that
111122
/// report instead of starting blank.
@@ -191,10 +202,14 @@
191202
// "Could not load it" is not "it is not editable" — the same conflation _sourcesLoaded fixes
192203
// above. Silently degrading to a blank create wizard on a transient failure would invite the
193204
// user to type a whole report over a working one.
194-
_configUnavailable = config.Outcome is ApiConfigOutcome.Unavailable
195-
|| (config.Document is not null && !BuilderConfigMapper.Hydrate(Wizard, config.Document, RegisteredSourceType));
205+
// NotFound counts too: Edit is only offered for a config-origin report, so a 404 here means it
206+
// was deleted or is code-registered — either way the user asked to edit something and must not
207+
// be handed a blank create form without a word.
208+
_configOutcome = config.Outcome;
209+
_configUnavailable = config.Outcome is not ApiConfigOutcome.Ok
210+
|| !BuilderConfigMapper.Hydrate(Wizard, config.Document!, RegisteredSourceType);
196211

197-
if (_configUnavailable || config.Document is null)
212+
if (_configUnavailable)
198213
return;
199214

200215
Wizard.IsEditing = true;

src/UI/NeoReports.UI/Pages/BuilderConfigure.razor

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@
9393
</div>
9494
<div class="field"><span class="label-sm">Page size</span><span class="input num"><input class="mono" type="number" min="1" @bind="Wizard.PageSize" @bind:event="oninput" /></span></div>
9595
}
96+
@if (Wizard.IsEditing && !Wizard.ColumnNamesAreSplittable)
97+
{
98+
<Banner Variant="warn" Icon="alert-triangle">
99+
<b>A column name contains a comma.</b>
100+
<span style="font-size:12px">This box separates columns with commas, so it cannot represent that name. Leave it exactly as it is and the stored columns are kept untouched; editing it would split that name into two columns.</span>
101+
</Banner>
102+
}
96103
<div class="field"><span class="label-sm">Output columns</span><span class="input mono"><input class="mono" @bind="Wizard.ColumnNames" @bind:event="oninput" placeholder="Id, Customer, Amount" style="width:100%" /></span></div>
97104
<div class="cgr"><span class="lbl">Track progress</span><div class="ctl"><Switch @bind-Value="Wizard.TrackProgress" /><span class="hint">counts the source rows once before each run · enables a real completion percentage</span></div></div>
98105
@if (!Wizard.TrackProgress)

src/UI/NeoReports.UI/Pages/BuilderDestination.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
</SelectableCard>
3636
@foreach (var d in _engineDestinations.Select(ToDestinationOption))
3737
{
38-
<SelectableCard Selected="@(Wizard.DestinationType == d.Id)" OnClick="@(() => Wizard.DestinationType = d.Id)">
38+
<SelectableCard Selected="@(string.Equals(Wizard.DestinationType, d.Id, StringComparison.OrdinalIgnoreCase))" OnClick="@(() => Wizard.DestinationType = d.Id)">
3939
<CatTile Kind="@d.Kind" Icon="@d.Icon" />
4040
<div class="name mono">@d.Id</div>
4141
<div class="conn" style="white-space:normal;font-family:var(--font-sans);font-size:12px">@d.Description</div>

src/UI/NeoReports.UI/Services/BuilderConfigMapper.cs

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,21 @@ public static bool Hydrate(BuilderState state, string document, Func<string, str
9898

9999
if (Get(root, "columns") is JsonArray columns)
100100
{
101-
state.ColumnNames = string.Join(", ", columns
101+
string[] names = columns
102102
.OfType<JsonObject>()
103103
.Select(column => (Get(column, "name") as JsonValue)?.ToString())
104-
.Where(columnName => !string.IsNullOrWhiteSpace(columnName)));
104+
.Where(columnName => !string.IsNullOrWhiteSpace(columnName))
105+
.Select(columnName => columnName!)
106+
.ToArray();
107+
108+
state.ColumnNames = string.Join(", ", names);
109+
110+
// One comma-separated box cannot represent a name that itself contains a comma, so an
111+
// untouched list is written back as the stored array rather than re-derived from the text
112+
// — otherwise "Total, net" came back as two columns, both retyped as untouched String,
113+
// which is the exact downgrade the patch-don't-regenerate rule exists to prevent.
114+
state.LoadedColumnNames = state.ColumnNames;
115+
state.ColumnNamesAreSplittable = !names.Any(columnName => columnName.Contains(',', StringComparison.Ordinal));
105116
}
106117

107118
if (Get(root, "outputs") is JsonArray outputs)
@@ -267,7 +278,7 @@ private static JsonObject BuildSourceProperties(BuilderState state, JsonObject c
267278
Remove(carried, ConnectionStringProperty);
268279

269280
JsonObject properties = state.UsesAdoSqlShape
270-
? AdoSourceProperties(state, carried)
281+
? AdoSourceProperties(state, carried, usesRef)
271282
: GenericSourceProperties(state, carried);
272283

273284
if (usesRef)
@@ -294,13 +305,29 @@ private static JsonObject BuildSourceProperties(BuilderState state, JsonObject c
294305

295306
// The SQL family has dedicated editors for its query and key column; every other stored property
296307
// is carried through untouched, since nothing in the wizard could have changed it.
297-
private static JsonObject AdoSourceProperties(BuilderState state, JsonObject carried)
308+
private static JsonObject AdoSourceProperties(BuilderState state, JsonObject carried, bool usesRef)
298309
{
299-
Set(carried, "sql", state.SqlQuery);
300-
Set(carried, "key", state.KeyColumn);
310+
SetOverlay(carried, "sql", state.SqlQuery, usesRef);
311+
SetOverlay(carried, "key", state.KeyColumn, usesRef);
301312
return carried;
302313
}
303314

315+
/// <summary>
316+
/// Writes one of the SQL family's own properties. For an inline source the query and key column
317+
/// belong to the report, so a cleared box clears the property and the engine rejects the config —
318+
/// the honest outcome. For a <c>ref</c>-based source they are an optional overlay over the
319+
/// registered definition (D42), where blank means "do not override": writing an empty one there
320+
/// wins over a perfectly good stored query and breaks the next run, from a save that changed
321+
/// nothing — because a report whose query lives in the definition hydrates these boxes empty.
322+
/// </summary>
323+
private static void SetOverlay(JsonObject properties, string key, string value, bool usesRef)
324+
{
325+
if (!usesRef || !string.IsNullOrWhiteSpace(value))
326+
Set(properties, key, value);
327+
else
328+
Remove(properties, key);
329+
}
330+
304331
// The generic editor shows the whole bag, so its rows are the whole bag: anything the user
305332
// deleted there is meant to be gone, and `carried` only supplies the original JSON types.
306333
private static JsonObject GenericSourceProperties(BuilderState state, JsonObject carried)
@@ -337,6 +364,11 @@ private static JsonObject GenericSourceProperties(BuilderState state, JsonObject
337364

338365
private static JsonArray BuildColumns(BuilderState state, JsonArray? original)
339366
{
367+
// Untouched: hand back exactly what was stored. The text box is a lossy view of the array,
368+
// and re-deriving from it is only safe once the user has actually said something new.
369+
if (original is not null && state.ColumnNamesUnchanged)
370+
return original.DeepClone().AsArray();
371+
340372
var columns = new JsonArray();
341373
foreach (string name in state.ColumnNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
342374
{

src/UI/NeoReports.UI/Services/BuilderState.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,23 @@ public string ConnectionSummary
110110
/// <summary>Comma-separated output column names.</summary>
111111
public string ColumnNames { get; set; } = "Id";
112112

113+
/// <summary>
114+
/// <see cref="ColumnNames"/> as the loaded document produced it, so an untouched list can be
115+
/// written back as the stored array instead of re-derived from a box that cannot represent a
116+
/// column name containing a comma.
117+
/// </summary>
118+
public string LoadedColumnNames { get; set; } = "";
119+
120+
/// <summary>
121+
/// False when a stored column name contains a comma, which the single text box cannot round-trip.
122+
/// The Configure step warns rather than letting an edit split one column into two.
123+
/// </summary>
124+
public bool ColumnNamesAreSplittable { get; set; } = true;
125+
126+
/// <summary>Whether the column box still holds exactly what the loaded document produced.</summary>
127+
public bool ColumnNamesUnchanged =>
128+
OriginalDocument is not null && string.Equals(ColumnNames, LoadedColumnNames, StringComparison.Ordinal);
129+
113130
/// <summary>Destination type id (e.g. "local", "s3"); empty means no destination configured.</summary>
114131
public string DestinationType { get; set; } = "";
115132

@@ -236,6 +253,8 @@ public void Reset()
236253
PageSize = 1000;
237254
TrackProgress = true;
238255
ColumnNames = "Id";
256+
LoadedColumnNames = "";
257+
ColumnNamesAreSplittable = true;
239258
DestinationType = "";
240259
DestinationPath = "";
241260
RetryMaxAttempts = 1;

tests/NeoReports.AspNetCore.IntegrationTests/ReportEditEndpointsTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,24 @@ public async Task A_corrupt_stored_document_is_a_500_on_put_not_a_400_blamed_on_
318318
response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError);
319319
}
320320

321+
[Fact]
322+
public async Task Validate_for_a_report_with_no_stored_document_says_that_plainly()
323+
{
324+
using var host = await StartAsync();
325+
HttpClient client = await CreateSalesAsync(host);
326+
327+
string redacted = await client.GetStringAsync("/api/reports/sales/config");
328+
329+
JsonElement result = await (await SendJsonAsync(client, HttpMethod.Post, "/api/reports/validate?for=gone", redacted))
330+
.Content.ReadFromJsonAsync<JsonElement>(Json);
331+
332+
// Skipping the restore silently produced "still holds the redaction placeholder" about a
333+
// document the caller sent correctly, for the single real problem that the report is gone.
334+
result.GetProperty("valid").GetBoolean().ShouldBeFalse();
335+
result.GetProperty("error").GetString()!.ShouldContain("no stored configuration for 'gone'");
336+
result.GetProperty("nameTaken").GetBoolean().ShouldBeFalse();
337+
}
338+
321339
[Fact]
322340
public async Task Put_rejects_a_document_whose_name_does_not_match_the_route()
323341
{

0 commit comments

Comments
 (0)