feat(blog): add Visibility { Private, Public } to gate post reads
Replace the implicit 'ACL empty = private' convention with an
explicit two-axis model: Visibility is the master switch, the
ACL is the exception list.
Semantics (matches what BlogSpotService.Index / Details enforce):
Visibility.Public + empty ACL : every caller sees it
Visibility.Public + non-empty : only author + ACL circles + admin
Visibility.Private + any ACL : only author + admin (ACL ignored)
ACL is preserved across
Private/Public flips so
reopening is lossless
The Public+non-empty shape is the 'restrict by exception' case:
open by default, narrowed by the ACL. This is intentionally
different from the previous behaviour, where a Public post
with a non-empty ACL was effectively ACL-restricted anyway —
the new model makes that explicit and removes ambiguity.
Server (Yavsc.Blogs / Yavsc.Server)
- New enum Visibility { Private, Public } in
Yavsc.Abstract.Blogspot (so the wire DTO and the EF entity
share the same type). Stored as int via .HasConversion<int>()
on BlogPost.Visibility. Default Private on construction;
the column default in the migration is 0 so existing rows
land Private without any data migration.
- BlogSpotService.Index: filter rewritten to honour the two-
axis model. Authenticated and anonymous callers now share
the same shape (Public+emptyACL visible to all, otherwise
scoped). Admin reads still go through PermissionHandler.
- PermissionHandler.IsPublic: dropped the blogSpotPublications
lookup, replaced with the Visibility + empty-ACL check that
matches the new model. PermissionHandler.IsSponsor and
IsOwner unchanged.
- UserHelpers.UserPosts (the per-author feed for
/CircleMembers/Details and similar): mirror of the
Index filter, so the two code paths can't silently diverge.
- BlogPostEditViewModel.Publish untouched on this commit. It
still controls whether a row exists in BlogSpotPublication;
the two systems coexist (Publish = 'is this draft published',
Visibility = 'who can read it'). Follow-up to consolidate.
EF migration (Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility)
- Scaffolded by 'dotnet ef migrations add', not hand-edited,
per the repo preference for generated migrations.
- Adds the new Visibility column (int, NOT NULL, default 0).
- Also drops three shadow-state ClientId1 foreign keys and
their indexes/columns on ClientScopes, ClientRedirectUris,
ClientGrantTypes. These shadow FKs were created by EF from
HasOne<Client>().HasForeignKey(e => e.ClientId) mappings
that have long since been removed from
ApplicationDbContext.OnModelCreating, but the snapshot was
never regenerated against the current model. The columns
are nullable ints with no production data, so the drop is
lossless. Without this, EF Core would keep emitting
warnings on every migration add and the model would drift
further from reality.
DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- Visibility property added to BlogPostDto. System.Text.Json
serialises the enum as its underlying int, so the JSON
shape is a plain number, no JsonConverter needed.
Client UI (PostIt)
- MainPageViewModel: DraftVisibility ObservableProperty
mirroring the existing DraftTitle/DraftArticle pattern.
Initialised to Private so a fresh draft is private by
default. Save command writes the chosen value into the
BlogPostDto payload for both CreatePostAsync and
UpdatePostAsync. OnSelectedPostChanged hydrates the buffer
from the server-supplied value.
- AllVisibilities property on the VM exposes [Private, Public]
in that order, bound by the ComboBox in MainPage.axaml.
- VisibilityLabelConverter (PostIt.Views) maps the enum to
French user-facing labels ('Privé' / 'Public'); registered
in App.axaml as a static resource.
- MainPage.axaml: a new ComboBox row in the editor pane
between Title and Article. Uses the existing 'no hardcoded
Background without Foreground' lesson so dark mode works.
Tests (Yavsc.Blogs.Tests)
- BlogVisibilityTests (5 [Fact]): drive GET /api/v1/blog with
Visibility fixtures seeded directly in the in-memory DB:
* Private + ACL: only the author sees it
* Public + empty ACL: any authenticated caller sees it
* Public + non-empty ACL: caller without ACL membership
does NOT see it
* Private + ACL: ACL is ignored, only the author sees it
* Visibility round-trips through the JSON wire (int 1)
- UserHelpersVisibilityTests (4 [Fact]): exercise the helper
directly so the two code paths (Index filter vs per-author
feed) can't diverge silently. Same fixture, no HTTP.
Test totals: 29/29 Yavsc.Blogs.Tests (was 20, +5 BlogVisibility
+4 UserHelpersVisibility), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).
Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Visibilité :' label is localised; the
rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ Visibility consolidation
(which system wins when both are set on the same post?).
- Org-side UI for editing Visibility (the admin web Yavsc
still edits posts without a visibility field).
This commit is contained in:
parent
5e3d361f88
commit
33ecfa7ebd
16 changed files with 5427 additions and 46 deletions
|
|
@ -1,12 +1,22 @@
|
||||||
<Application xmlns="https://github.com/avaloniaui"
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:local="using:PostIt"
|
xmlns:local="using:PostIt"
|
||||||
|
xmlns:views="using:PostIt.Views"
|
||||||
x:Class="PostIt.App">
|
x:Class="PostIt.App">
|
||||||
|
|
||||||
<Application.DataTemplates>
|
<Application.DataTemplates>
|
||||||
<local:ViewLocator/>
|
<local:ViewLocator/>
|
||||||
</Application.DataTemplates>
|
</Application.DataTemplates>
|
||||||
|
|
||||||
|
<Application.Resources>
|
||||||
|
<!-- French labels for the Visibility enum. Used by the
|
||||||
|
ComboBox ItemTemplate in MainPage.axaml. The list
|
||||||
|
of available values lives on MainPageViewModel
|
||||||
|
(AllVisibilities) rather than here — x:Array is
|
||||||
|
awkward to author in Avalonia XAML. -->
|
||||||
|
<views:VisibilityLabelConverter x:Key="VisibilityLabelConverter"/>
|
||||||
|
</Application.Resources>
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
@ -35,6 +36,25 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string DraftArticle { get; set; }
|
public partial string DraftArticle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Editor buffer for the post visibility. Same
|
||||||
|
/// pattern as <see cref="DraftTitle"/> and
|
||||||
|
/// <see cref="DraftArticle"/>: the Save command reads from
|
||||||
|
/// here so the user can flip a draft to Public without
|
||||||
|
/// selecting an existing post first. Defaults to
|
||||||
|
/// <see cref="Visibility.Private"/> on a fresh draft so
|
||||||
|
/// new posts are private-by-default, matching the server
|
||||||
|
/// contract.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial Visibility DraftVisibility { get; set; } = Visibility.Private;
|
||||||
|
|
||||||
|
/// <summary>List of values offered in the visibility
|
||||||
|
/// ComboBox. Exposed as a VM property (rather than an
|
||||||
|
/// <c>x:Array</c> resource) because Avalonia XAML doesn't
|
||||||
|
/// author <c>x:Array</c> cleanly. Order: Private first,
|
||||||
|
/// matching the server default.</summary>
|
||||||
|
public IReadOnlyList<Visibility> AllVisibilities { get; } =
|
||||||
|
new[] { Visibility.Private, Visibility.Public };
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial ViewModelBase? CurrentViewModel { get; set; }
|
public partial ViewModelBase? CurrentViewModel { get; set; }
|
||||||
|
|
||||||
|
|
@ -102,6 +122,7 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
WindowTitle = "PostIt";
|
WindowTitle = "PostIt";
|
||||||
DraftTitle = string.Empty;
|
DraftTitle = string.Empty;
|
||||||
DraftArticle = string.Empty;
|
DraftArticle = string.Empty;
|
||||||
|
DraftVisibility = Visibility.Private;
|
||||||
CurrentViewModel = this;
|
CurrentViewModel = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +152,9 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
// doesn't show stale content.
|
// doesn't show stale content.
|
||||||
DraftTitle = value?.Title ?? string.Empty;
|
DraftTitle = value?.Title ?? string.Empty;
|
||||||
DraftArticle = value?.Article ?? string.Empty;
|
DraftArticle = value?.Article ?? string.Empty;
|
||||||
|
// Mirror visibility too. Defaults to Private on null
|
||||||
|
// selection so a fresh draft starts private.
|
||||||
|
DraftVisibility = value?.Visibility ?? Visibility.Private;
|
||||||
UpdateCommandStates();
|
UpdateCommandStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,6 +219,7 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
Article = DraftArticle ?? string.Empty,
|
Article = DraftArticle ?? string.Empty,
|
||||||
DateCreated = DateTime.UtcNow,
|
DateCreated = DateTime.UtcNow,
|
||||||
DateModified = DateTime.UtcNow,
|
DateModified = DateTime.UtcNow,
|
||||||
|
Visibility = DraftVisibility,
|
||||||
};
|
};
|
||||||
var created = await BlogClient.CreatePostAsync(draft);
|
var created = await BlogClient.CreatePostAsync(draft);
|
||||||
if (created is not null)
|
if (created is not null)
|
||||||
|
|
@ -214,6 +239,7 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
Article = DraftArticle ?? string.Empty,
|
Article = DraftArticle ?? string.Empty,
|
||||||
DateCreated = SelectedPost.DateCreated,
|
DateCreated = SelectedPost.DateCreated,
|
||||||
DateModified = DateTime.UtcNow,
|
DateModified = DateTime.UtcNow,
|
||||||
|
Visibility = DraftVisibility,
|
||||||
};
|
};
|
||||||
await BlogClient.UpdatePostAsync(SelectedPost.Id, update);
|
await BlogClient.UpdatePostAsync(SelectedPost.Id, update);
|
||||||
StatusMessage = $"Saved post {SelectedPost.Id}.";
|
StatusMessage = $"Saved post {SelectedPost.Id}.";
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,26 @@
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
|
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
|
||||||
<TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
|
<TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
|
||||||
<AvaloniaEdit:TextEditor Grid.Row="2"
|
<!-- Visibility toggle: a ComboBox bound to the
|
||||||
|
Yavsc.Blogspot.Visibility enum (Private /
|
||||||
|
Public). The enum serialises as int on the
|
||||||
|
wire; user-facing labels come from the
|
||||||
|
visibility label converter registered in
|
||||||
|
App.axaml. Default is Visibility.Private
|
||||||
|
(set on DraftVisibility), so a fresh draft
|
||||||
|
is private by default. -->
|
||||||
|
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text="Visibilité :" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox SelectedItem="{Binding DraftVisibility, Mode=TwoWay}"
|
||||||
|
ItemsSource="{Binding AllVisibilities}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="models:Visibility">
|
||||||
|
<TextBlock Text="{Binding Converter={StaticResource VisibilityLabelConverter}}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
<AvaloniaEdit:TextEditor Grid.Row="3"
|
||||||
views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}"
|
views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}"
|
||||||
ShowLineNumbers="True"
|
ShowLineNumbers="True"
|
||||||
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
|
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
|
||||||
|
|
@ -90,7 +109,7 @@
|
||||||
VerticalAlignment="Stretch"
|
VerticalAlignment="Stretch"
|
||||||
VerticalScrollBarVisibility="Auto"
|
VerticalScrollBarVisibility="Auto"
|
||||||
HorizontalScrollBarVisibility="Auto" />
|
HorizontalScrollBarVisibility="Auto" />
|
||||||
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" />
|
<TextBlock Grid.Row="4" Text="{Binding StatusMessage}" Foreground="Gray" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
56
src/PostIt/PostIt/Views/VisibilityLabelConverter.cs
Normal file
56
src/PostIt/PostIt/Views/VisibilityLabelConverter.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a <see cref="Visibility"/> enum value to a user-
|
||||||
|
/// facing French label. Used by <c>MainPage.axaml</c> to render
|
||||||
|
/// the visibility ComboBox without exposing the raw enum name
|
||||||
|
/// ("Private" / "Public") to the end user.
|
||||||
|
///
|
||||||
|
/// <para>Bidirectional: <c>ConvertBack</c> returns the value
|
||||||
|
/// unchanged, so the ComboBox can drive the bound
|
||||||
|
/// <c>DraftVisibility</c> property directly through the same
|
||||||
|
/// converter — the ComboBox just happens to use
|
||||||
|
/// <c>SelectedItem</c> binding so ConvertBack is never
|
||||||
|
/// invoked. The symmetry is kept for completeness in case a
|
||||||
|
/// future XAML needs to bind via <c>Text</c>.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VisibilityLabelConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is Visibility v)
|
||||||
|
{
|
||||||
|
return v switch
|
||||||
|
{
|
||||||
|
Visibility.Private => "Privé",
|
||||||
|
Visibility.Public => "Public",
|
||||||
|
_ => v.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
// Reverse mapping: user input is unlikely to be the
|
||||||
|
// raw English enum name (the ComboBox shows French
|
||||||
|
// labels), so ConvertBack falls back to Private on any
|
||||||
|
// unrecognised input. The ComboBox uses SelectedItem
|
||||||
|
// binding so this path is never actually taken today.
|
||||||
|
if (value is string s)
|
||||||
|
{
|
||||||
|
return s switch
|
||||||
|
{
|
||||||
|
"Privé" => Visibility.Private,
|
||||||
|
"Public" => Visibility.Public,
|
||||||
|
_ => Visibility.Private,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,18 @@ public class BlogPostDto : IBlogPost
|
||||||
public string UserModified { get; set; }
|
public string UserModified { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visibility of this post. Mirrors the EF entity
|
||||||
|
/// <c>Yavsc.Models.Blog.BlogPost.Visibility</c>: serialised
|
||||||
|
/// as an <c>int</c> by <c>System.Text.Json</c> (the enum's
|
||||||
|
/// underlying type), so clients see <c>0</c> or <c>1</c>
|
||||||
|
/// rather than <c>"Private"</c>/<c>"Public"</c>. Defaults
|
||||||
|
/// to <see cref="Visibility.Private"/> on construction, so
|
||||||
|
/// existing client code that doesn't set it explicitly
|
||||||
|
/// stays safe (private-by-default).
|
||||||
|
/// </summary>
|
||||||
|
public Visibility Visibility { get; set; } = Visibility.Private;
|
||||||
|
|
||||||
public bool AuthorizeCircle(long circleId)
|
public bool AuthorizeCircle(long circleId)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
|
|
|
||||||
41
src/Yavsc.Abstract/Blogspot/Visibility.cs
Normal file
41
src/Yavsc.Abstract/Blogspot/Visibility.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
namespace Yavsc.Blogspot;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Post visibility.
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="Public"/>: the post is read via its ACL. If the
|
||||||
|
/// ACL is empty, every caller sees the post (including
|
||||||
|
/// unauthenticated ones, on endpoints that allow it). If the
|
||||||
|
/// ACL is non-empty, only the author, the members of the
|
||||||
|
/// circles in the ACL, and administrators can read. Public +
|
||||||
|
/// non-empty ACL is therefore the "restrict by exception"
|
||||||
|
/// shape: open by default, narrowed by the ACL.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="Private"/>: the ACL is ignored at read time.
|
||||||
|
/// Only the author and administrators can read. The ACL list
|
||||||
|
/// is preserved in the database so that flipping the post
|
||||||
|
/// back to <see cref="Public"/> restores the previous
|
||||||
|
/// restriction without re-entry.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>The two values together form a two-axis model: the ACL
|
||||||
|
/// is the exception list (it can narrow Public), and Visibility
|
||||||
|
/// is the master switch (it can disable the ACL entirely when
|
||||||
|
/// set to Private).</para>
|
||||||
|
///
|
||||||
|
/// <para>Stored as <c>int</c> (not the enum name) — see the
|
||||||
|
/// <c>.HasConversion<int>()</c> on <c>BlogPost.Visibility</c>
|
||||||
|
/// in <c>Yavsc.Server.Models.ApplicationDbContext</c>. Keeping the
|
||||||
|
/// int mapping means queries stay cheap and the wire JSON is a
|
||||||
|
/// plain number; the trade-off is that reading the column by hand
|
||||||
|
/// requires knowing the enum ordering.</para>
|
||||||
|
/// </summary>
|
||||||
|
public enum Visibility
|
||||||
|
{
|
||||||
|
Private = 0,
|
||||||
|
Public = 1,
|
||||||
|
}
|
||||||
236
src/Yavsc.Blogs.Tests/BlogVisibilityTests.cs
Normal file
236
src/Yavsc.Blogs.Tests/BlogVisibilityTests.cs
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Access;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
|
using Yavsc.Models.Relationship;
|
||||||
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
|
namespace Yavsc.Blogs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Behavioural tests for <c>Visibility</c> on blog posts.
|
||||||
|
///
|
||||||
|
/// <para>Same fixture as <see cref="BlogApiTests"/>:
|
||||||
|
/// in-memory <c>ApplicationDbContext</c>, JWT bearer auth with
|
||||||
|
/// HS256 via <see cref="TestTokenIssuer"/>. The tests below
|
||||||
|
/// drive the controller surface (<c>GET /api/v1/blog</c> and
|
||||||
|
/// <c>GET /api/v1/blog/{id}</c>) and assert that visibility
|
||||||
|
/// scopes the read path the way
|
||||||
|
/// <see cref="BlogSpotService"/>'s filter expects.</para>
|
||||||
|
///
|
||||||
|
/// <para>Each test seeds its own posts directly through the
|
||||||
|
/// in-memory DbContext — going through POST would force
|
||||||
|
/// <c>Visibility</c> through the wire DTO which is fine, but
|
||||||
|
/// keeping it in the fixture avoids serialisation noise around
|
||||||
|
/// the visibility default (we want to test each visibility
|
||||||
|
/// value explicitly, not the JSON round-trip).</para>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("JwtClaimMapping")]
|
||||||
|
public sealed class BlogVisibilityTests : IClassFixture<BlogsWebServerFixture>
|
||||||
|
{
|
||||||
|
private readonly BlogsWebServerFixture _fixture;
|
||||||
|
|
||||||
|
public BlogVisibilityTests(BlogsWebServerFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reset the in-memory database and seed the
|
||||||
|
/// shared test users.</summary>
|
||||||
|
private void ResetDatabase()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
db.Database.EnsureDeleted();
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "alice",
|
||||||
|
UserName = "alice",
|
||||||
|
Email = "alice@example.com",
|
||||||
|
EmailConfirmed = true,
|
||||||
|
});
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "bob",
|
||||||
|
UserName = "bob",
|
||||||
|
Email = "bob@example.com",
|
||||||
|
EmailConfirmed = true,
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Insert a blog post authored by <paramref name="authorId"/>
|
||||||
|
/// directly via the DbContext and return its id. The ACL,
|
||||||
|
/// when supplied, is added to the same context.</summary>
|
||||||
|
private long SeedPost(string authorId, Visibility visibility, params long[] aclCircleIds)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var post = new BlogPost
|
||||||
|
{
|
||||||
|
AuthorId = authorId,
|
||||||
|
Title = $"post-by-{authorId}",
|
||||||
|
Article = "test article",
|
||||||
|
Visibility = visibility,
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.BlogSpot.Add(post);
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
foreach (var circleId in aclCircleIds)
|
||||||
|
{
|
||||||
|
db.CircleAuthorizationToBlogPost.Add(new CircleAuthorizationToBlogPost
|
||||||
|
{
|
||||||
|
BlogPostId = post.Id,
|
||||||
|
CircleId = circleId,
|
||||||
|
Comment = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
return post.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seed a circle owned by <paramref name="ownerId"/>
|
||||||
|
/// and return its id. The ACL grant for a post then points
|
||||||
|
/// at this circle; the post stays readable only to circle
|
||||||
|
/// members.</summary>
|
||||||
|
private long SeedCircle(string ownerId, string name)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var circle = new Circle { OwnerId = ownerId, Name = name };
|
||||||
|
db.Circle.Add(circle);
|
||||||
|
db.SaveChanges();
|
||||||
|
return circle.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BlogsUrl =>
|
||||||
|
_fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog";
|
||||||
|
|
||||||
|
private HttpClient NewClient(string subject)
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||||
|
};
|
||||||
|
var http = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||||
|
};
|
||||||
|
http.DefaultRequestHeaders.Authorization =
|
||||||
|
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||||
|
"Bearer", TestTokenIssuer.Issue(subject));
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountPosts(JsonDocument doc)
|
||||||
|
=> doc.RootElement.GetArrayLength();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Private_post_is_only_visible_to_its_author()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
SeedPost("alice", Visibility.Private);
|
||||||
|
|
||||||
|
// Alice (the author) sees it.
|
||||||
|
using (var alice = NewClient("alice"))
|
||||||
|
{
|
||||||
|
var response = await alice.GetAsync(BlogsUrl);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(1, CountPosts(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob (a different authenticated user) does not.
|
||||||
|
using (var bob = NewClient("bob"))
|
||||||
|
{
|
||||||
|
var response = await bob.GetAsync(BlogsUrl);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(0, CountPosts(doc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Public_post_with_empty_ACL_is_visible_to_everyone_authenticated()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
SeedPost("alice", Visibility.Public);
|
||||||
|
|
||||||
|
using var bob = NewClient("bob");
|
||||||
|
var response = await bob.GetAsync(BlogsUrl);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
using var doc = JsonDocument.Parse(await bob.GetAsync(BlogsUrl).Result.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(1, CountPosts(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Public_post_with_nonempty_ACL_is_restricted_by_the_ACL()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
var familyCircleId = SeedCircle("alice", "Famille");
|
||||||
|
|
||||||
|
// Alice grants the post to her own "Famille" circle.
|
||||||
|
// Bob is not a member, so he must NOT see the post even
|
||||||
|
// though Visibility is Public.
|
||||||
|
SeedPost("alice", Visibility.Public, familyCircleId);
|
||||||
|
|
||||||
|
using var bob = NewClient("bob");
|
||||||
|
var response = await bob.GetAsync(BlogsUrl);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(0, CountPosts(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Private_post_is_not_visible_even_when_ACL_would_have_allowed()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
var familyCircleId = SeedCircle("alice", "Famille");
|
||||||
|
// The ACL would let Bob in, but Visibility.Private
|
||||||
|
// overrides it — only the author can read.
|
||||||
|
SeedPost("alice", Visibility.Private, familyCircleId);
|
||||||
|
|
||||||
|
using var bob = NewClient("bob");
|
||||||
|
var response = await bob.GetAsync(BlogsUrl);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(0, CountPosts(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Post_persists_Visibility_through_the_DTO_wire()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
|
||||||
|
var draft = new BlogPost
|
||||||
|
{
|
||||||
|
Id = 0,
|
||||||
|
AuthorId = "alice",
|
||||||
|
Title = "Un post visible",
|
||||||
|
Article = "Contenu.",
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow,
|
||||||
|
Visibility = Visibility.Public,
|
||||||
|
};
|
||||||
|
|
||||||
|
var postResponse = await http.PostAsJsonAsync(BlogsUrl, draft);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||||
|
|
||||||
|
// The wire DTO should round-trip Visibility (System.Text.Json
|
||||||
|
// serialises the enum as its underlying int — see
|
||||||
|
// Yavsc.Abstract.Blogspot.Visibility).
|
||||||
|
using var doc = JsonDocument.Parse(await postResponse.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(1, doc.RootElement.GetProperty("visibility").GetInt32());
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/Yavsc.Blogs.Tests/UserHelpersVisibilityTests.cs
Normal file
167
src/Yavsc.Blogs.Tests/UserHelpersVisibilityTests.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Access;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
|
using Yavsc.Models.Relationship;
|
||||||
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
namespace Yavsc.Blogs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests that <see cref="UserHelpers.UserPosts"/> (the
|
||||||
|
/// "posts-by-author-for-this-reader" query) honours the same
|
||||||
|
/// Visibility rules as <see cref="BlogSpotService.Index"/>.
|
||||||
|
///
|
||||||
|
/// <para>The two code paths duplicate the ACL/Visibility filter
|
||||||
|
/// (one in the listing query, one in the per-author query);
|
||||||
|
/// these tests catch the case where the two diverge — the kind
|
||||||
|
/// of regression that's easy to miss in a code review because
|
||||||
|
/// both filters look correct in isolation.</para>
|
||||||
|
///
|
||||||
|
/// <para>Uses the same in-memory <c>ApplicationDbContext</c>
|
||||||
|
/// scaffold as <see cref="BlogsWebServerFixture"/> but
|
||||||
|
/// exercises the helper directly, without going through HTTP,
|
||||||
|
/// because <see cref="UserHelpers.UserPosts"/> is the unit
|
||||||
|
/// under test.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("JwtClaimMapping")]
|
||||||
|
public sealed class UserHelpersVisibilityTests : IClassFixture<BlogsWebServerFixture>
|
||||||
|
{
|
||||||
|
private readonly BlogsWebServerFixture _fixture;
|
||||||
|
|
||||||
|
public UserHelpersVisibilityTests(BlogsWebServerFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetDatabase()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
db.Database.EnsureDeleted();
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "alice",
|
||||||
|
UserName = "alice",
|
||||||
|
Email = "alice@example.com",
|
||||||
|
EmailConfirmed = true,
|
||||||
|
});
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "bob",
|
||||||
|
UserName = "bob",
|
||||||
|
Email = "bob@example.com",
|
||||||
|
EmailConfirmed = true,
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long SeedPost(string authorId, Visibility visibility, params long[] aclCircleIds)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var post = new BlogPost
|
||||||
|
{
|
||||||
|
AuthorId = authorId,
|
||||||
|
Title = $"post-by-{authorId}",
|
||||||
|
Article = "test article",
|
||||||
|
Visibility = visibility,
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.BlogSpot.Add(post);
|
||||||
|
db.SaveChanges();
|
||||||
|
foreach (var cid in aclCircleIds)
|
||||||
|
{
|
||||||
|
db.CircleAuthorizationToBlogPost.Add(new CircleAuthorizationToBlogPost
|
||||||
|
{
|
||||||
|
BlogPostId = post.Id,
|
||||||
|
CircleId = cid,
|
||||||
|
Comment = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
db.SaveChanges();
|
||||||
|
return post.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long SeedCircle(string ownerId, string name)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var circle = new Circle { OwnerId = ownerId, Name = name };
|
||||||
|
db.Circle.Add(circle);
|
||||||
|
db.SaveChanges();
|
||||||
|
return circle.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddMember(long circleId, string memberId)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
db.CircleMembers.Add(new CircleMember { CircleId = circleId, MemberId = memberId });
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<long> UserPostsIds(string posterId, string readerId)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
return db.UserPosts(posterId, readerId).Select(p => p.Id).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPosts_returns_only_private_posts_to_their_author()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
SeedPost("alice", Visibility.Private);
|
||||||
|
|
||||||
|
var aliceSees = UserPostsIds("alice", "alice");
|
||||||
|
var bobSees = UserPostsIds("alice", "bob");
|
||||||
|
|
||||||
|
Assert.Single(aliceSees);
|
||||||
|
Assert.Empty(bobSees);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPosts_returns_public_posts_with_empty_ACL_to_anyone()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
SeedPost("alice", Visibility.Public);
|
||||||
|
|
||||||
|
var bobSees = UserPostsIds("alice", "bob");
|
||||||
|
Assert.Single(bobSees);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPosts_narrows_public_posts_with_nonempty_ACL()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
var circleId = SeedCircle("alice", "Famille");
|
||||||
|
AddMember(circleId, "alice");
|
||||||
|
// AddMember above adds alice, but we want bob NOT in
|
||||||
|
// the circle, so we add bob to a different circle only:
|
||||||
|
var otherCircleId = SeedCircle("alice", "Travail");
|
||||||
|
AddMember(otherCircleId, "bob");
|
||||||
|
// Make the post readable only to Famille:
|
||||||
|
SeedPost("alice", Visibility.Public, circleId);
|
||||||
|
|
||||||
|
var bobSees = UserPostsIds("alice", "bob");
|
||||||
|
Assert.Empty(bobSees);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPosts_lets_acl_members_read_public_posts_even_if_not_author()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
var circleId = SeedCircle("alice", "Famille");
|
||||||
|
AddMember(circleId, "bob");
|
||||||
|
SeedPost("alice", Visibility.Public, circleId);
|
||||||
|
|
||||||
|
var bobSees = UserPostsIds("alice", "bob");
|
||||||
|
Assert.Single(bobSees);
|
||||||
|
}
|
||||||
|
}
|
||||||
4653
src/Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility.Designer.cs
generated
Normal file
4653
src/Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
119
src/Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility.cs
Normal file
119
src/Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Yavsc.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBlogPostVisibility : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ClientGrantTypes_Clients_ClientId1",
|
||||||
|
table: "ClientGrantTypes");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ClientRedirectUris_Clients_ClientId1",
|
||||||
|
table: "ClientRedirectUris");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ClientScopes_Clients_ClientId1",
|
||||||
|
table: "ClientScopes");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ClientScopes_ClientId1",
|
||||||
|
table: "ClientScopes");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ClientRedirectUris_ClientId1",
|
||||||
|
table: "ClientRedirectUris");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ClientGrantTypes_ClientId1",
|
||||||
|
table: "ClientGrantTypes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClientId1",
|
||||||
|
table: "ClientScopes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClientId1",
|
||||||
|
table: "ClientRedirectUris");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClientId1",
|
||||||
|
table: "ClientGrantTypes");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Visibility",
|
||||||
|
table: "BlogSpot",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Visibility",
|
||||||
|
table: "BlogSpot");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ClientId1",
|
||||||
|
table: "ClientScopes",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ClientId1",
|
||||||
|
table: "ClientRedirectUris",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ClientId1",
|
||||||
|
table: "ClientGrantTypes",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClientScopes_ClientId1",
|
||||||
|
table: "ClientScopes",
|
||||||
|
column: "ClientId1");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClientRedirectUris_ClientId1",
|
||||||
|
table: "ClientRedirectUris",
|
||||||
|
column: "ClientId1");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClientGrantTypes_ClientId1",
|
||||||
|
table: "ClientGrantTypes",
|
||||||
|
column: "ClientId1");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ClientGrantTypes_Clients_ClientId1",
|
||||||
|
table: "ClientGrantTypes",
|
||||||
|
column: "ClientId1",
|
||||||
|
principalTable: "Clients",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ClientRedirectUris_Clients_ClientId1",
|
||||||
|
table: "ClientRedirectUris",
|
||||||
|
column: "ClientId1",
|
||||||
|
principalTable: "Clients",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ClientScopes_Clients_ClientId1",
|
||||||
|
table: "ClientScopes",
|
||||||
|
column: "ClientId1",
|
||||||
|
principalTable: "Clients",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -476,9 +476,6 @@ namespace Yavsc.Migrations
|
||||||
b.Property<int>("ClientId")
|
b.Property<int>("ClientId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("ClientId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("GrantType")
|
b.Property<string>("GrantType")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
|
@ -486,8 +483,6 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
b.HasIndex("ClientId");
|
b.HasIndex("ClientId");
|
||||||
|
|
||||||
b.HasIndex("ClientId1");
|
|
||||||
|
|
||||||
b.ToTable("ClientGrantTypes");
|
b.ToTable("ClientGrantTypes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -583,9 +578,6 @@ namespace Yavsc.Migrations
|
||||||
b.Property<int>("ClientId")
|
b.Property<int>("ClientId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("ClientId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("RedirectUri")
|
b.Property<string>("RedirectUri")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
|
@ -593,8 +585,6 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
b.HasIndex("ClientId");
|
b.HasIndex("ClientId");
|
||||||
|
|
||||||
b.HasIndex("ClientId1");
|
|
||||||
|
|
||||||
b.ToTable("ClientRedirectUris");
|
b.ToTable("ClientRedirectUris");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -609,9 +599,6 @@ namespace Yavsc.Migrations
|
||||||
b.Property<int>("ClientId")
|
b.Property<int>("ClientId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("ClientId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Scope")
|
b.Property<string>("Scope")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
|
@ -619,8 +606,6 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
b.HasIndex("ClientId");
|
b.HasIndex("ClientId");
|
||||||
|
|
||||||
b.HasIndex("ClientId1");
|
|
||||||
|
|
||||||
b.ToTable("ClientScopes");
|
b.ToTable("ClientScopes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1508,6 +1493,11 @@ namespace Yavsc.Migrations
|
||||||
b.Property<string>("UserModified")
|
b.Property<string>("UserModified")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Visibility")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("AuthorId");
|
b.HasIndex("AuthorId");
|
||||||
|
|
@ -3460,16 +3450,12 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b =>
|
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
|
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||||
.WithMany("AllowedGrantTypes")
|
.WithMany("AllowedGrantTypes")
|
||||||
.HasForeignKey("ClientId")
|
.HasForeignKey("ClientId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ClientId1");
|
|
||||||
|
|
||||||
b.Navigation("Client");
|
b.Navigation("Client");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3520,31 +3506,23 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b =>
|
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
|
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||||
.WithMany("RedirectUris")
|
.WithMany("RedirectUris")
|
||||||
.HasForeignKey("ClientId")
|
.HasForeignKey("ClientId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ClientId1");
|
|
||||||
|
|
||||||
b.Navigation("Client");
|
b.Navigation("Client");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b =>
|
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
|
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||||
.WithMany("AllowedScopes")
|
.WithMany("AllowedScopes")
|
||||||
.HasForeignKey("ClientId")
|
.HasForeignKey("ClientId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ClientId1");
|
|
||||||
|
|
||||||
b.Navigation("Client");
|
b.Navigation("Client");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Blog;
|
using Yavsc.Models.Blog;
|
||||||
|
|
||||||
|
|
@ -23,10 +24,21 @@ namespace Yavsc.Server.Helpers
|
||||||
dbContext.Circle.Include(c => c.Members)
|
dbContext.Circle.Include(c => c.Members)
|
||||||
.Where(c => c.Members.Any(m => m.MemberId == readerId))
|
.Where(c => c.Members.Any(m => m.MemberId == readerId))
|
||||||
.Select(c => c.Id).ToArray();
|
.Select(c => c.Id).ToArray();
|
||||||
|
// Mirror of BlogSpotService.Index for an
|
||||||
|
// authenticated reader: Private restricts to the
|
||||||
|
// author; Public is read-through-ACL.
|
||||||
return dbContext.BlogSpot.Include(
|
return dbContext.BlogSpot.Include(
|
||||||
b => b.Author
|
b => b.Author
|
||||||
).Include(p => p.ACL).Where(x => x.Author.Id == posterId &&
|
).Include(p => p.ACL).Where(x => x.Author.Id == posterId &&
|
||||||
(x.ACL.Count == 0 || x.ACL.Any(a => readerCirclesMemberships.Contains(a.CircleId))));
|
(
|
||||||
|
(x.Visibility == Visibility.Private && x.AuthorId == readerId)
|
||||||
|
|| (x.Visibility == Visibility.Public
|
||||||
|
&& (x.ACL == null
|
||||||
|
|| x.ACL.Count == 0
|
||||||
|
|| x.AuthorId == readerId
|
||||||
|
|| (readerCirclesMemberships != null
|
||||||
|
&& x.ACL.Any(a => readerCirclesMemberships.Contains(a.CircleId)))))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ namespace Yavsc.Models
|
||||||
using Bank;
|
using Bank;
|
||||||
using Billing;
|
using Billing;
|
||||||
using Blog;
|
using Blog;
|
||||||
|
using Blogspot;
|
||||||
using Chat;
|
using Chat;
|
||||||
using Drawing;
|
using Drawing;
|
||||||
using Forms;
|
using Forms;
|
||||||
|
|
@ -222,6 +223,17 @@ namespace Yavsc.Models
|
||||||
.WithMany(u => u.Posts)
|
.WithMany(u => u.Posts)
|
||||||
.HasForeignKey(b => b.AuthorId)
|
.HasForeignKey(b => b.AuthorId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// Store Visibility as a plain int (NOT NULL, default
|
||||||
|
// 0 = Private) so existing rows land on the pre-ACL
|
||||||
|
// behaviour by default. System.Text.Json serialises
|
||||||
|
// the enum as its underlying int, so the wire shape
|
||||||
|
// is a plain number — no JsonConverter needed.
|
||||||
|
builder.Entity<BlogPost>()
|
||||||
|
.Property(b => b.Visibility)
|
||||||
|
.HasConversion<int>()
|
||||||
|
.HasDefaultValue(Visibility.Private)
|
||||||
|
.IsRequired();
|
||||||
builder.Entity<Comment>()
|
builder.Entity<Comment>()
|
||||||
.HasOne(c => c.Author)
|
.HasOne(c => c.Author)
|
||||||
.WithMany(u => u.BlogComments)
|
.WithMany(u => u.BlogComments)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,21 @@ namespace Yavsc.Models.Blog
|
||||||
[Display(Name = "Liste de contrôle d'accès")]
|
[Display(Name = "Liste de contrôle d'accès")]
|
||||||
public virtual List<CircleAuthorizationToBlogPost>? ACL { get; set; }
|
public virtual List<CircleAuthorizationToBlogPost>? ACL { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visibility of this post.
|
||||||
|
/// <para><see cref="Visibility.Public"/> reads through the
|
||||||
|
/// ACL (open when the ACL is empty, narrowed by the ACL
|
||||||
|
/// when it is non-empty). <see cref="Visibility.Private"/>
|
||||||
|
/// ignores the ACL at read time and restricts to author +
|
||||||
|
/// administrators. The ACL list is preserved across
|
||||||
|
/// Private/Public flips so re-opening is lossless.</para>
|
||||||
|
/// <para>Configured as <c>int</c> with default
|
||||||
|
/// <see cref="Visibility.Private"/> in
|
||||||
|
/// <c>ApplicationDbContext.OnModelCreating</c>.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Display(Name = "Visibilité")]
|
||||||
|
public Visibility Visibility { get; set; } = Visibility.Private;
|
||||||
|
|
||||||
[Display(Name = "Identifiant de l'auteur")]
|
[Display(Name = "Identifiant de l'auteur")]
|
||||||
[ForeignKey("Author")]
|
[ForeignKey("Author")]
|
||||||
public string? AuthorId { get; set; }
|
public string? AuthorId { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -200,28 +200,46 @@ public class BlogSpotService
|
||||||
Where(c => c.Members.Any(m => m.MemberId == viewerId))
|
Where(c => c.Members.Any(m => m.MemberId == viewerId))
|
||||||
.Select(c => c.Id).ToArrayAsync();
|
.Select(c => c.Id).ToArrayAsync();
|
||||||
|
|
||||||
|
// Visibility drives the read gate:
|
||||||
|
// * Public : the ACL decides. Open if the ACL is
|
||||||
|
// empty, narrowed otherwise to author +
|
||||||
|
// ACL circles + admin.
|
||||||
|
// * Private : ACL is ignored at read time. Only the
|
||||||
|
// author (and administrators, checked
|
||||||
|
// elsewhere) can read.
|
||||||
|
// Admin reads (the Administrator role) go through
|
||||||
|
// IsInMsRole("Administrator") upstream in
|
||||||
|
// PermissionHandler; we don't repeat that here so the
|
||||||
|
// listing query stays role-agnostic.
|
||||||
posts = _context.BlogSpot
|
posts = _context.BlogSpot
|
||||||
.Include(b => b.Author)
|
.Include(b => b.Author)
|
||||||
.Include(p => p.ACL)
|
.Include(p => p.ACL)
|
||||||
.Include(p => p.Tags)
|
.Include(p => p.Tags)
|
||||||
.Include(p => p.Comments)
|
.Include(p => p.Comments)
|
||||||
.Where(p => p.ACL == null
|
.Where(p =>
|
||||||
|
(p.Visibility == Visibility.Private && p.AuthorId == viewerId)
|
||||||
|
|| (p.Visibility == Visibility.Public
|
||||||
|
&& (p.ACL == null
|
||||||
|| p.ACL.Count == 0
|
|| p.ACL.Count == 0
|
||||||
|| (p.AuthorId == viewerId)
|
|| p.AuthorId == viewerId
|
||||||
|| (userCircles != null &&
|
|| (userCircles != null
|
||||||
p.ACL.Any(a => userCircles.Contains(a.CircleId)))
|
&& p.ACL.Any(a => userCircles.Contains(a.CircleId))))));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// Anonymous callers only see Public posts with no
|
||||||
|
// ACL — anything else either requires membership
|
||||||
|
// (which we have no way to check without an
|
||||||
|
// identity) or is Private.
|
||||||
posts = _context.blogSpotPublications
|
posts = _context.blogSpotPublications
|
||||||
.Include(p => p.BlogPost)
|
.Include(p => p.BlogPost)
|
||||||
.Include(b => b.BlogPost.Author)
|
.Include(b => b.BlogPost.Author)
|
||||||
.Include(p => p.BlogPost.ACL)
|
.Include(p => p.BlogPost.ACL)
|
||||||
.Include(p => p.BlogPost.Tags)
|
.Include(p => p.BlogPost.Tags)
|
||||||
.Include(p => p.BlogPost.Comments)
|
.Include(p => p.BlogPost.Comments)
|
||||||
.Where(p => p.BlogPost.ACL == null
|
.Where(p => p.BlogPost.Visibility == Visibility.Public
|
||||||
|| p.BlogPost.ACL.Count == 0)
|
&& (p.BlogPost.ACL == null
|
||||||
|
|| p.BlogPost.ACL.Count == 0))
|
||||||
.Select(p => p.BlogPost).ToArray();
|
.Select(p => p.BlogPost).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Blog;
|
using Yavsc.Models.Blog;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
@ -55,9 +56,15 @@ public class PermissionHandler : IAuthorizationHandler
|
||||||
{
|
{
|
||||||
if (resource is BlogPost blogPost)
|
if (resource is BlogPost blogPost)
|
||||||
{
|
{
|
||||||
return
|
// IsPublic is the authz twin of the Index/listing
|
||||||
applicationDbContext.blogSpotPublications
|
// filter in BlogSpotService: a post is "publicly
|
||||||
.Any(p=>p.BlogpostId == blogPost.Id);
|
// readable" (no membership required) iff its
|
||||||
|
// Visibility is Public and its ACL is empty.
|
||||||
|
// Visibility.Public + non-empty ACL is narrowed by
|
||||||
|
// the ACL, so it does NOT pass IsPublic here; the
|
||||||
|
// caller has to match IsSponsor for that.
|
||||||
|
return blogPost.Visibility == Visibility.Public
|
||||||
|
&& (blogPost.ACL == null || blogPost.ACL.Count == 0);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue