Skip to content

Commit 3d06ba7

Browse files
committed
fix(ui): keep a ref report's connection overlay, +4 edit-flow defects
None of these five touched the secrets mechanism, which the review pass traced end to end without breaking; all five were in what surrounds it. A ref-based report's report-local connectionString was deleted on save. The wizard offers no connection field for a registered source, so the save path dropped it — but a report-local overlay is legitimate config under D42 (report-local wins), and deleting it silently repointed the report at the registry's connection. A stored one is kept as it arrived; one is still never invented, since there is no field to invent it from. The matching banner also stopped rendering for ref sources, where it pointed at a control that is not on the page. A failed GET .../config was indistinguishable from "not editable", so Edit degraded to a blank create wizard with no message — inviting the user to retype a whole report over a working one. The more useful observation is that this is the same conflation _sourcesLoaded had just been added to fix one function above: I fixed the instance, not the pattern. A corrupt STORED document was a 400 on PUT while GET .../config answered the identical condition with a 500, blaming the caller for a file they never sent. validate?for= reported the report's own name as taken, putting "name already taken" under every successful edit validation. Changing the source mid-edit left no way forward: the old credential is correctly dropped, but nothing said so and the save failed with a generic compile error. Each fix verified by reverting it and confirming the new test fails, then the full suite: 1 156 tests green.
1 parent 4cf6f54 commit 3d06ba7

13 files changed

Lines changed: 243 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ The `NeoReports.Abstractions` contract follows SemVer strictly.
6464
dropped the stored properties.
6565
- **A `404` when saving an edit shows the engine's message**, not "the engine is not reachable" — a
6666
report deleted from another tab is a rejected request, not a network failure.
67+
- **A `ref`-based report keeps its report-local connection overlay.** The wizard offers no connection
68+
field for a registered source, so saving deleted any `connectionString` — silently repointing the
69+
report at the registry's connection, though D42 makes a report-local overlay win.
70+
- **A failed configuration load says so** instead of silently becoming a blank create wizard.
71+
- **A corrupt stored document is a `500` on `PUT`**, matching what `GET .../config` already returns
72+
for the same condition, rather than a `400` blaming the caller.
73+
- **`validate?for={name}` no longer reports that report's own name as taken**, which put "name already
74+
taken" under every successful edit validation.
75+
- **Changing the source mid-edit now says the connection must be re-supplied**, rather than failing at
76+
save with a generic compile error.
6777

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

DECISIONS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1983,6 +1983,30 @@ its address and always will; two different bags naming the same one is now a 400
19831983
rejected request carrying a usable message, and mapping it to *Unavailable* threw that message away
19841984
and blamed the network.
19851985

1986+
### The fifth review pass
1987+
1988+
None of these five touched the secrets mechanism itself, which the pass traced end to end without
1989+
breaking; all five were in what surrounds it.
1990+
1991+
- **A `ref`-based report's report-local connection overlay was deleted on save.** The wizard offers no
1992+
connection field for a registered source, so the save path dropped any `connectionString` — but a
1993+
report-local overlay is legitimate configuration under D42 (report-local wins), and deleting it
1994+
silently repointed the report at the registry's connection. A stored one is now kept as it arrived;
1995+
one is still never invented, since there is no field to invent it from. The matching "the stored
1996+
connection is kept" banner also stopped rendering for `ref` sources, where it pointed at a control
1997+
that is not on the page.
1998+
- **A failed `GET .../config` was indistinguishable from "not editable"**, so Edit degraded to a blank
1999+
create wizard with no message — inviting the user to retype a whole report over a working one. This
2000+
is the same conflation `_sourcesLoaded` had just been added to fix one function above, which is the
2001+
more useful observation: the fix was applied to the instance, not to the pattern.
2002+
- **A corrupt *stored* document was a 400 on `PUT`** while `GET .../config` answered the identical
2003+
condition with a 500 — blaming the caller for a file they never sent.
2004+
- **`validate?for=` reported the report's own name as taken**, putting "name already taken" under
2005+
every successful edit validation.
2006+
- **Changing the source mid-edit left no way forward**: the old credential is correctly dropped, but
2007+
nothing said so, and the save failed with a generic compile error. The Configure step now says what
2008+
has to be supplied.
2009+
19862010
### Verified end to end
19872011

19882012
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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,20 @@ private static async Task<IResult> ReplaceReportAsync(
583583
});
584584
}
585585

586+
// Checked before Restore merges the two, so a corrupt document on disk is not reported as a
587+
// bad request — the same condition GET .../config already answers with a 500.
588+
try
589+
{
590+
ReportConfigSecrets.EnsureReadable(stored);
591+
}
592+
catch (ConfigurationException ex)
593+
{
594+
return Results.Problem(
595+
title: $"The stored configuration for '{name}' could not be read.",
596+
detail: ex.Message,
597+
statusCode: StatusCodes.Status500InternalServerError);
598+
}
599+
586600
string document = await ReadBodyAsync(http, cancellationToken).ConfigureAwait(false);
587601

588602
ReportConfig config;
@@ -747,14 +761,18 @@ private static async Task<IResult> ValidateReportAsync(
747761
CompiledReport compiled = ReportConfigCompiler.Compile(substituted, rootServices);
748762
var columns = compiled.Schema.Columns.Select(c => c.Name).ToArray();
749763

764+
// Its own name is not "taken" when the dry run IS an edit of that report — reporting it
765+
// as taken put "name already taken" under every successful edit validation.
750766
return Results.Ok(new ValidateReportResponse(
751-
Valid: true, Error: null, Name: config.Name, Columns: columns, NameTaken: registry.Contains(config.Name)));
767+
Valid: true, Error: null, Name: config.Name, Columns: columns,
768+
NameTaken: registry.Contains(config.Name) && !string.Equals(config.Name, editingFor, StringComparison.Ordinal)));
752769
}
753770
catch (ConfigurationException ex)
754771
{
755772
return Results.Ok(new ValidateReportResponse(
756773
Valid: false, Error: ex.Message, Name: name, Columns: null,
757-
NameTaken: name is not null && registry.Contains(name)));
774+
NameTaken: name is not null && registry.Contains(name)
775+
&& !string.Equals(name, editingFor, StringComparison.Ordinal)));
758776
}
759777
}
760778

src/NeoReports.Core/Configuration/ReportConfigSecrets.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ public static string Redact(string document)
110110
public static bool ContainsRedactedValue(string document) =>
111111
PropertyBagSlots(ParseObject(document)).Any(slot => ContainsRedactedNode(slot.Bag));
112112

113+
/// <summary>
114+
/// Throws when <paramref name="document"/> is not a readable configuration document. Lets a
115+
/// caller tell a corrupt *stored* document from a bad request body before <see cref="Restore"/>
116+
/// merges the two and reports both the same way.
117+
/// </summary>
118+
/// <param name="document">The document to check.</param>
119+
/// <exception cref="ConfigurationException">Thrown when the document is missing or not a JSON object.</exception>
120+
public static void EnsureReadable(string document) => ParseObject(document);
121+
113122
/// <summary>True when <paramref name="text"/> is a redaction placeholder, addressed or not.</summary>
114123
/// <param name="text">The value to test.</param>
115124
public static bool IsRedactedPlaceholder(string? text) =>

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@
2828
<span style="font-size:12px">Every step is prefilled from the report's stored configuration. Values the engine holds backconnection strings, API tokensare kept as they are unless you replace them.</span>
2929
</Banner>
3030
}
31+
@if (_configUnavailable)
32+
{
33+
<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>
36+
</Banner>
37+
}
3138
@if (!Wizard.EngineAvailable)
3239
{
3340
<Banner Variant="info" Icon="info-circle">
@@ -96,6 +103,9 @@
96103
/// <summary>Whether the registered-source list actually loaded, as opposed to failing to.</summary>
97104
private bool _sourcesLoaded;
98105

106+
/// <summary>Set when an edit was asked for and the report's configuration could not be read.</summary>
107+
private bool _configUnavailable;
108+
99109
/// <summary>
100110
/// Set by ReportDetail's "Edit" button (<c>?edit=name</c>) — hydrates the wizard from that
101111
/// report instead of starting blank.
@@ -176,10 +186,15 @@
176186
{
177187
Wizard.Reset();
178188

179-
string? document = await Api.TryGetReportConfigAsync(name);
180-
// No stored document means a code-registered report (or a gone one), which is not editable
181-
// here at all — falls back to a blank "new report" wizard rather than a half-filled form.
182-
if (document is null || !BuilderConfigMapper.Hydrate(Wizard, document, RegisteredSourceType))
189+
ApiConfigResult config = await Api.TryGetReportConfigAsync(name);
190+
191+
// "Could not load it" is not "it is not editable" — the same conflation _sourcesLoaded fixes
192+
// above. Silently degrading to a blank create wizard on a transient failure would invite the
193+
// 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));
196+
197+
if (_configUnavailable || config.Document is null)
183198
return;
184199

185200
Wizard.IsEditing = true;

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,23 @@
4040
<Button Variant="outline" Size="sm" Icon="circle-check" Loading="_validating" OnClick="ValidateAsync">Validate</Button>
4141
</Action>
4242
<ChildContent>
43-
@if (Wizard.IsEditing && Wizard.ConnectionStringKept)
43+
@* Only where the connection field actually is: a ref-based source has none, so the banner
44+
would point at a control that is not on the page. *@
45+
@if (Wizard.IsEditing && Wizard.ConnectionStringKept && string.IsNullOrEmpty(Wizard.SourceRef))
4446
{
4547
<Banner Variant="info" Icon="lock">
4648
<b>The stored connection is kept.</b>
4749
<span style="font-size:12px">Its value is not shownthe engine holds back anything that could be the secret itself. Leave the field below blank to keep it, or name an environment variable to replace it.</span>
4850
</Banner>
4951
}
52+
@if (Wizard.IsEditing && Wizard.SourceChanged && string.IsNullOrEmpty(Wizard.SourceRef)
53+
&& string.IsNullOrWhiteSpace(Wizard.ConnectionStringVariable))
54+
{
55+
<Banner Variant="warn" Icon="alert-triangle">
56+
<b>Set a connection for the new source.</b>
57+
<span style="font-size:12px">You changed the source from what "@Wizard.EditingOriginalName" was reading, so its stored connection was not carried over — restoring it into a different source is not something the engine can assume. Name an environment variable below; saving without one is rejected.</span>
58+
</Banner>
59+
}
5060
<div class="col" style="gap:12px">
5161
<div class="grid-2">
5262
<div class="field"><span class="label-sm">Report name</span><span class="input mono"><input class="mono" @bind="Wizard.ReportName" @bind:event="oninput" placeholder="monthly-sales" readonly="@Wizard.IsEditing" style="width:100%" /></span></div>

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ private static JsonObject BuildSourceProperties(BuilderState state, JsonObject c
263263
// The connection has its own field on the Configure step, so the carried-over value never
264264
// survives on its own: it is re-derived below, from that field or from the "keep what is
265265
// stored" flag. Leaving it in place would make naming a new variable a silent no-op.
266+
JsonNode? carriedConnection = Get(carried, ConnectionStringProperty);
266267
Remove(carried, ConnectionStringProperty);
267268

268269
JsonObject properties = state.UsesAdoSqlShape
@@ -271,8 +272,12 @@ private static JsonObject BuildSourceProperties(BuilderState state, JsonObject c
271272

272273
if (usesRef)
273274
{
274-
// A registered source supplies the connection; sending one here would shadow it.
275-
Remove(properties, ConnectionStringProperty);
275+
// A registered source supplies the connection, so the wizard offers no field for one —
276+
// but a report-local overlay is legitimate configuration (D42: report-local wins), and
277+
// deleting a stored one repointed the report at the registry's connection without saying
278+
// so. Kept as it arrived; never invented, since there is no field to invent it from.
279+
if (carriedConnection is not null && !HasMember(properties, ConnectionStringProperty))
280+
SetNode(properties, ConnectionStringProperty, carriedConnection.DeepClone());
276281
}
277282
else if (!HasMember(properties, ConnectionStringProperty))
278283
{

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,15 @@ public string ConnectionSummary
207207
/// </summary>
208208
public int AdditionalOutputCount { get; set; }
209209

210+
/// <summary>
211+
/// True while editing when the source now selected is not the one the stored document described.
212+
/// Everything the previous source carried — its properties and its connection — is deliberately
213+
/// dropped in that case, so the Configure step has to say what the user now has to supply.
214+
/// </summary>
215+
public bool SourceChanged =>
216+
!string.IsNullOrEmpty(LoadedSourceIdentity)
217+
&& !string.Equals(LoadedSourceIdentity, SourceIdentity, StringComparison.Ordinal);
218+
210219
/// <summary>The source currently selected, in the same shape as <see cref="LoadedSourceIdentity"/>.</summary>
211220
public string SourceIdentity =>
212221
string.IsNullOrWhiteSpace(SourceRef) ? $"type:{SourceType}" : $"ref:{SourceRef.Trim()}";

src/UI/NeoReports.UI/Services/NeoReportsApiClient.cs

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,26 @@ public enum ApiCreateOutcome
5656
/// <summary>Result of <see cref="INeoReportsApiClient.TryCreateReportAsync"/>.</summary>
5757
public sealed record ApiCreateResult(ApiCreateOutcome Outcome, string? Name, string? Error);
5858

59+
/// <summary>Outcome of a <c>GET /api/reports/{name}/config</c> call.</summary>
60+
public enum ApiConfigOutcome
61+
{
62+
/// <summary>200 — the stored configuration document was returned.</summary>
63+
Ok,
64+
65+
/// <summary>404 — the report has no stored document: it is code-registered, or gone.</summary>
66+
NotFound,
67+
68+
/// <summary>The engine wasn't reachable, or failed to read the document.</summary>
69+
Unavailable,
70+
}
71+
72+
/// <summary>
73+
/// Result of <see cref="INeoReportsApiClient.TryGetReportConfigAsync"/>. "Not editable" and "could
74+
/// not load" are separate outcomes on purpose — collapsing them turned a transient failure into a
75+
/// silently blank create wizard.
76+
/// </summary>
77+
public sealed record ApiConfigResult(ApiConfigOutcome Outcome, string? Document);
78+
5979
/// <summary>A single output column, as returned by <c>GET /api/reports/{name}</c>.</summary>
6080
public sealed record ApiReportColumn(string Name, string Type, string? DisplayName, string? Format, bool Nullable);
6181

@@ -292,12 +312,11 @@ public interface INeoReportsApiClient
292312

293313
/// <summary>
294314
/// The report's stored configuration document, with credential-bearing values redacted
295-
/// (<c>GET /api/reports/{name}/config</c>, ADR D86), or <c>null</c> when the report has none —
296-
/// a code-registered report, or an unreachable engine.
315+
/// (<c>GET /api/reports/{name}/config</c>, ADR D86).
297316
/// </summary>
298317
/// <param name="name">The report name.</param>
299318
/// <param name="cancellationToken">Cancellation token.</param>
300-
Task<string?> TryGetReportConfigAsync(string name, CancellationToken cancellationToken = default);
319+
Task<ApiConfigResult> TryGetReportConfigAsync(string name, CancellationToken cancellationToken = default);
301320

302321
/// <summary>Removes a runtime-registered report. Returns whether the engine accepted the request.</summary>
303322
Task<bool> TryDeleteReportAsync(string name, CancellationToken cancellationToken = default);
@@ -680,22 +699,29 @@ public async Task<ApiCreateResult> TryReplaceReportAsync(
680699
}
681700
}
682701

683-
public async Task<string?> TryGetReportConfigAsync(string name, CancellationToken cancellationToken = default)
702+
public async Task<ApiConfigResult> TryGetReportConfigAsync(string name, CancellationToken cancellationToken = default)
684703
{
685704
var apiBase = ApiBase;
686705
try
687706
{
688707
using var response = await http.GetAsync(
689708
new Uri(apiBase, $"reports/{Uri.EscapeDataString(name)}/config"), cancellationToken).ConfigureAwait(false);
709+
710+
// 404 is "this report has no stored document" — a real answer. Anything else is a failure
711+
// to get one, and the caller has to be able to tell them apart.
712+
if (response.StatusCode == HttpStatusCode.NotFound)
713+
return new ApiConfigResult(ApiConfigOutcome.NotFound, null);
690714
if (!response.IsSuccessStatusCode)
691-
return null;
715+
return new ApiConfigResult(ApiConfigOutcome.Unavailable, null);
692716

693-
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
717+
return new ApiConfigResult(
718+
ApiConfigOutcome.Ok,
719+
await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
694720
}
695721
catch (Exception ex) when (IsTransient(ex))
696722
{
697723
logger.LogWarning(ex, "GET {ApiBase}reports/{Name}/config failed.", Sanitize(apiBase.ToString()), Sanitize(name));
698-
return null;
724+
return new ApiConfigResult(ApiConfigOutcome.Unavailable, null);
699725
}
700726
}
701727

tests/NeoReports.AspNetCore.IntegrationTests/ReportEditEndpointsTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,51 @@ public async Task Validate_for_rejects_a_document_that_is_not_the_report_it_targ
273273
result.GetProperty("error").GetString()!.ShouldContain("targets 'sales'");
274274
}
275275

276+
[Fact]
277+
public async Task Validate_for_does_not_report_the_reports_own_name_as_taken()
278+
{
279+
using var host = await StartAsync();
280+
HttpClient client = await CreateSalesAsync(host);
281+
282+
string redacted = await client.GetStringAsync("/api/reports/sales/config");
283+
284+
JsonElement result = await (await SendJsonAsync(client, HttpMethod.Post, "/api/reports/validate?for=sales", redacted))
285+
.Content.ReadFromJsonAsync<JsonElement>(Json);
286+
287+
// Its own name is not taken by anyone else; reporting it as taken put "name already taken"
288+
// under every successful edit validation in the Builder.
289+
result.GetProperty("valid").GetBoolean().ShouldBeTrue();
290+
result.GetProperty("nameTaken").GetBoolean().ShouldBeFalse();
291+
}
292+
293+
[Fact]
294+
public async Task Validate_without_for_still_reports_an_existing_name_as_taken()
295+
{
296+
using var host = await StartAsync();
297+
HttpClient client = await CreateSalesAsync(host);
298+
299+
JsonElement result = await (await SendJsonAsync(client, HttpMethod.Post, "/api/reports/validate", Original))
300+
.Content.ReadFromJsonAsync<JsonElement>(Json);
301+
302+
result.GetProperty("nameTaken").GetBoolean().ShouldBeTrue();
303+
}
304+
305+
[Fact]
306+
public async Task A_corrupt_stored_document_is_a_500_on_put_not_a_400_blamed_on_the_client()
307+
{
308+
using var host = await StartAsync();
309+
HttpClient client = await CreateSalesAsync(host);
310+
311+
var store = host.Services.GetRequiredService<IReportConfigStore>();
312+
await store.SaveAsync("sales", "{ this is not json", CancellationToken.None);
313+
314+
HttpResponseMessage response = await SendJsonAsync(client, HttpMethod.Put, "/api/reports/sales", Original);
315+
316+
// GET .../config already answers this exact condition with a 500; PUT reported it as a bad
317+
// request, blaming the caller for a document on disk they never sent.
318+
response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError);
319+
}
320+
276321
[Fact]
277322
public async Task Put_rejects_a_document_whose_name_does_not_match_the_route()
278323
{

0 commit comments

Comments
 (0)