Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/MeshWeaver.Blazor/Components/MeshNodeThumbnailView.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using MeshWeaver.Mesh.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace MeshWeaver.Blazor.Components;

Expand Down Expand Up @@ -61,7 +62,22 @@ protected override void BindData()
if (!string.IsNullOrEmpty(img)) ImageUrl = img;
InvokeAsync(StateHasChanged);
},
ex => SurfaceError(ex, $"Loading thumbnail for {NodePath}")));
ex =>
{
// #434: a subject the viewer cannot read (a peer's user partition — DENIED BY
// DESIGN) is a benign, expected state, NOT an error. Keep the seeded title /
// description and render the initials-avatar fallback; log at Debug and do NOT
// raise an error toast. Only a genuine infrastructure fault surfaces (once) via
// SurfaceError — the classifier draws the line.
if (!MeshNodeThumbnailControl.ShouldSurfaceStreamError(ex))
{
Logger.LogDebug(ex,
"Thumbnail subject {NodePath} is not readable by the viewer (access-denied) — rendering fallback card",
NodePath);
return;
}
SurfaceError(ex, $"Loading thumbnail for {NodePath}");
}));
}

private void Navigate()
Expand Down
28 changes: 20 additions & 8 deletions src/MeshWeaver.Graph/AccessControlLayoutArea.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,24 @@ public static class AccessControlLayoutArea
}

if (nodeStream is null)
return isAdminStream.Select(isAdmin => (UiControl?)BuildPage(host, node: null, nodePath, isAdmin));

return nodeStream.CombineLatest(isAdminStream, (change, isAdmin)
=> (UiControl?)BuildPage(host, change?.Value, nodePath, isAdmin))
.Catch<UiControl?, Exception>(_ => isAdminStream.Select(isAdmin =>
(UiControl?)BuildPage(host, node: null, nodePath, isAdmin)));
return isAdminStream.DistinctUntilChanged()
.Select(isAdmin => (UiControl?)BuildPage(host, nodeName: null, nodePath, isAdmin));

// The page's ONLY dependency on the node is its display name (the section header); its only
// dependency on the permission probe is the admin bool. Project each input down to exactly
// that and DistinctUntilChanged, so an unrelated node version bump or a repeated identical
// isAdmin emission does NOT recompose the whole tab. A recompose re-instantiates the
// per-row AccessAssignment thumbnails, which re-subscribes every subject stream — the #434
// flicker + repeated-toast loop. This keeps the binding LIVE (no .Take(1)); it only
// suppresses no-op re-renders on unrelated upstream emissions.
var nodeNameStream = nodeStream
.Select(change => change?.Value?.Name)
.DistinctUntilChanged();

return nodeNameStream.CombineLatest(isAdminStream.DistinctUntilChanged(), (nodeName, isAdmin)
=> (UiControl?)BuildPage(host, nodeName, nodePath, isAdmin))
.Catch<UiControl?, Exception>(_ => isAdminStream.DistinctUntilChanged().Select(isAdmin =>
(UiControl?)BuildPage(host, nodeName: null, nodePath, isAdmin)));
}

internal static AccessAssignment? DeserializeAssignment(MeshNode node)
Expand All @@ -77,11 +89,11 @@ public static class AccessControlLayoutArea
return null;
}

private static UiControl BuildPage(LayoutAreaHost host, MeshNode? node, string nodePath, bool isAdmin)
private static UiControl BuildPage(LayoutAreaHost host, string? nodeName, string nodePath, bool isAdmin)
{
var stack = Controls.Stack.WithStyle("padding: 24px; gap: 20px; width: 100%;");

var currentName = node?.Name ?? nodePath.Split('/').LastOrDefault() ?? nodePath;
var currentName = nodeName ?? nodePath.Split('/').LastOrDefault() ?? nodePath;
if (string.IsNullOrEmpty(currentName)) currentName = "Root";

stack = stack.WithView(Controls.H2($"Access Control — {currentName}"));
Expand Down
16 changes: 16 additions & 0 deletions src/MeshWeaver.Graph/MeshNodeThumbnailControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ public static MeshNodeThumbnailControl FromNode(MeshNode? node, string fallbackP
return new MeshNodeThumbnailControl(nodePath, title, description, imageUrl, nodeType);
}

/// <summary>
/// Whether a fault raised while streaming this thumbnail's SUBJECT node should be SURFACED as an
/// error (a genuine infrastructure fault → a single toast + log) rather than treated as a benign,
/// expected fallback.
///
/// <para>An access-denied read is DENIED BY DESIGN here (issue #434): an <c>AccessAssignment</c> row's
/// subject frequently lives in a partition the viewer is not a member of — two users can share access
/// to a node without being able to read each other's user partitions. The row already carries the
/// subject id + roles, so the card renders its seeded initials-avatar fallback; the denial is a normal
/// state, NOT "Something went wrong". Delegates to <see cref="AreaErrorClassifier.IsExpectedUserActionFailure"/>
/// (true for <see cref="UnauthorizedAccessException"/> / access-denied delivery failures), so only a
/// genuine infra fault surfaces. Pure — unit-tested without a renderer or a circuit.</para>
/// </summary>
public static bool ShouldSurfaceStreamError(Exception? ex)
=> !AreaErrorClassifier.IsExpectedUserActionFailure(ex);

/// <summary>
/// Gets the image URL for a node. Public so other builders can reuse.
/// Priority: content.avatar > content.logo > content.icon > MarkdownContent.Thumbnail > node.Icon > NodeType default.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using System.Threading.Tasks;
using MeshWeaver.Data;
using MeshWeaver.Graph;
using MeshWeaver.Hosting.Monolith.TestBase;
using MeshWeaver.Hosting.Security;
using MeshWeaver.Layout;
using MeshWeaver.Mesh;
using MeshWeaver.Mesh.Security;
using MeshWeaver.Messaging;
using Xunit;

namespace MeshWeaver.AccessControl.Test;

/// <summary>
/// Regression tests for issue #434 — the Access &amp; Control tab's per-assignment subject
/// thumbnails must treat a permission-denied subject read as a BENIGN fallback, not an error
/// toast.
///
/// <para>Each <c>AccessAssignment</c> row renders a <see cref="MeshNodeThumbnailControl"/> bound
/// to the SUBJECT's node path. The subject frequently lives in a partition the viewer is not a
/// member of (two users can share access to a node without being able to read each other's user
/// partitions), so the RLS gate denies the read BY DESIGN. Before the fix,
/// <c>MeshNodeThumbnailView.BindData</c> forwarded that denial straight to <c>SurfaceError</c> →
/// a log line + a <c>PortalErrorSink.Report</c> modal + a <c>StateHasChanged</c>, once per row
/// per recompose — the repeated toasts + flicker in the issue. The fix routes the onError through
/// <see cref="MeshNodeThumbnailControl.ShouldSurfaceStreamError"/>: an access-denied read renders
/// the seeded initials-avatar fallback and logs at Debug, and only a genuine infra fault surfaces.</para>
/// </summary>
public class AccessThumbnailDeniedFallbackTest(ITestOutputHelper output) : MonolithMeshTestBase(output)
{
protected override MeshBuilder ConfigureMesh(MeshBuilder builder)
=> ConfigureMeshBase(builder)
.AddRowLevelSecurity()
.AddMeshNodes(
new MeshNode("PeerHome") { Name = "Peer Home" },
new MeshNode("PeerHome/Subject") { Name = "Peer Subject" })
.ConfigureDefaultNodeHub(c => c.AddData(d => d));

private async Task EnsureHubStarted(Address address)
{
var client = GetClient();
await client.Observe(new PingRequest(), o => o.WithTarget(address)).Should().Emit();
}

/// <summary>
/// Integration (RLS, real denial): a non-member viewer subscribes to the subject's node stream —
/// exactly what <c>MeshNodeThumbnailView.BindData</c> does — and the read is denied. The fix
/// classifies that real <see cref="UnauthorizedAccessException"/> as a benign, expected fallback:
/// <see cref="MeshNodeThumbnailControl.ShouldSurfaceStreamError"/> is <c>false</c>, so NO error
/// toast / SurfaceError fires. Revert-proven: on current main BindData surfaces the denial
/// unconditionally, and the <c>ShouldSurfaceStreamError</c> seam does not exist.
/// </summary>
[Fact(Timeout = 20000)]
public async Task DeniedSubjectRead_IsClassifiedBenign_NoToast()
{
// Admin seeds the subject; then switch to a viewer who is NOT a member of its partition.
await EnsureHubStarted(new Address("PeerHome/Subject"));
TestUsers.DevLogin(Mesh, new AccessContext { ObjectId = "viewer-bob", Name = "Bob" });

Exception? surfaced = null;
MeshNode? emitted = null;
try
{
emitted = await Mesh.GetMeshNodeStream("PeerHome/Subject")
.Where(n => n is not null)
.FirstAsync()
.Timeout(10.Seconds())
.ToTask();
}
catch (Exception ex)
{
surfaced = ex;
Output.WriteLine($"Surfaced: {ex.GetType().Name}: {ex.Message}");
}

emitted.Should().BeNull("a denied subject read must not emit a node value");
surfaced.Should().NotBeNull(
"reading a subject in a partition the viewer isn't a member of is denied by design — "
+ "this is the toast source in #434");
surfaced.Should().NotBeOfType<TimeoutException>(
"the denial must surface as an access error, not hang to a timeout");

// The #434 root-cause fix — a denied subject read is BENIGN, never an error toast.
MeshNodeThumbnailControl.ShouldSurfaceStreamError(surfaced).Should().BeFalse(
"an access-denied subject read is an expected user-action failure — the thumbnail renders "
+ "its seeded fallback card, so BindData must NOT call SurfaceError (no log/PortalErrorSink/StateHasChanged)");
AreaErrorClassifier.IsExpectedUserActionFailure(surfaced).Should().BeTrue(
"the real RLS denial the mesh throws must classify as a benign, expected user-action failure");
}

/// <summary>
/// Pure boundary test of the decision seam: access-denied is swallowed (benign fallback),
/// a genuine infrastructure fault surfaces once.
/// </summary>
[Fact]
public void ShouldSurfaceStreamError_SwallowsAccessDenied_SurfacesInfraFaults()
{
MeshNodeThumbnailControl.ShouldSurfaceStreamError(
new UnauthorizedAccessException("User 'bob' lacks Read permission on 'PeerHome/Subject'"))
.Should().BeFalse("access-denied is denied-by-design — render the fallback, no toast");

MeshNodeThumbnailControl.ShouldSurfaceStreamError(new InvalidOperationException("boom"))
.Should().BeTrue("a genuine infrastructure fault must surface once");
}
}
Loading