Revert "feat(blog): add Visibility { Private, Public } to gate post reads"
This reverts commit 33ecfa7ebd.
This commit is contained in:
parent
33ecfa7ebd
commit
42625f5ddd
16 changed files with 46 additions and 5427 deletions
|
|
@ -1,22 +1,12 @@
|
|||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:PostIt"
|
||||
xmlns:views="using:PostIt.Views"
|
||||
x:Class="PostIt.App">
|
||||
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</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>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
|
@ -36,25 +35,6 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
[ObservableProperty]
|
||||
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]
|
||||
public partial ViewModelBase? CurrentViewModel { get; set; }
|
||||
|
||||
|
|
@ -122,7 +102,6 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
WindowTitle = "PostIt";
|
||||
DraftTitle = string.Empty;
|
||||
DraftArticle = string.Empty;
|
||||
DraftVisibility = Visibility.Private;
|
||||
CurrentViewModel = this;
|
||||
}
|
||||
|
||||
|
|
@ -152,9 +131,6 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
// doesn't show stale content.
|
||||
DraftTitle = value?.Title ?? 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();
|
||||
}
|
||||
|
||||
|
|
@ -219,7 +195,6 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
Article = DraftArticle ?? string.Empty,
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
Visibility = DraftVisibility,
|
||||
};
|
||||
var created = await BlogClient.CreatePostAsync(draft);
|
||||
if (created is not null)
|
||||
|
|
@ -239,7 +214,6 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
Article = DraftArticle ?? string.Empty,
|
||||
DateCreated = SelectedPost.DateCreated,
|
||||
DateModified = DateTime.UtcNow,
|
||||
Visibility = DraftVisibility,
|
||||
};
|
||||
await BlogClient.UpdatePostAsync(SelectedPost.Id, update);
|
||||
StatusMessage = $"Saved post {SelectedPost.Id}.";
|
||||
|
|
|
|||
|
|
@ -81,26 +81,7 @@
|
|||
|
||||
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
|
||||
<TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
|
||||
<!-- 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"
|
||||
<AvaloniaEdit:TextEditor Grid.Row="2"
|
||||
views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}"
|
||||
ShowLineNumbers="True"
|
||||
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
|
||||
|
|
@ -109,7 +90,7 @@
|
|||
VerticalAlignment="Stretch"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Auto" />
|
||||
<TextBlock Grid.Row="4" Text="{Binding StatusMessage}" Foreground="Gray" />
|
||||
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
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,18 +19,6 @@ public class BlogPostDto : IBlogPost
|
|||
public string UserModified { 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)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,119 +0,0 @@
|
|||
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,6 +476,9 @@ namespace Yavsc.Migrations
|
|||
b.Property<int>("ClientId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ClientId1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("GrantType")
|
||||
.HasColumnType("text");
|
||||
|
||||
|
|
@ -483,6 +486,8 @@ namespace Yavsc.Migrations
|
|||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("ClientId1");
|
||||
|
||||
b.ToTable("ClientGrantTypes");
|
||||
});
|
||||
|
||||
|
|
@ -578,6 +583,9 @@ namespace Yavsc.Migrations
|
|||
b.Property<int>("ClientId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ClientId1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("RedirectUri")
|
||||
.HasColumnType("text");
|
||||
|
||||
|
|
@ -585,6 +593,8 @@ namespace Yavsc.Migrations
|
|||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("ClientId1");
|
||||
|
||||
b.ToTable("ClientRedirectUris");
|
||||
});
|
||||
|
||||
|
|
@ -599,6 +609,9 @@ namespace Yavsc.Migrations
|
|||
b.Property<int>("ClientId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ClientId1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.HasColumnType("text");
|
||||
|
||||
|
|
@ -606,6 +619,8 @@ namespace Yavsc.Migrations
|
|||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("ClientId1");
|
||||
|
||||
b.ToTable("ClientScopes");
|
||||
});
|
||||
|
||||
|
|
@ -1493,11 +1508,6 @@ namespace Yavsc.Migrations
|
|||
b.Property<string>("UserModified")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
|
@ -3450,12 +3460,16 @@ namespace Yavsc.Migrations
|
|||
|
||||
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b =>
|
||||
{
|
||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
|
||||
.WithMany("AllowedGrantTypes")
|
||||
.HasForeignKey("ClientId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||
.WithMany()
|
||||
.HasForeignKey("ClientId1");
|
||||
|
||||
b.Navigation("Client");
|
||||
});
|
||||
|
||||
|
|
@ -3506,23 +3520,31 @@ namespace Yavsc.Migrations
|
|||
|
||||
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b =>
|
||||
{
|
||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
|
||||
.WithMany("RedirectUris")
|
||||
.HasForeignKey("ClientId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||
.WithMany()
|
||||
.HasForeignKey("ClientId1");
|
||||
|
||||
b.Navigation("Client");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b =>
|
||||
{
|
||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
|
||||
.WithMany("AllowedScopes")
|
||||
.HasForeignKey("ClientId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||
.WithMany()
|
||||
.HasForeignKey("ClientId1");
|
||||
|
||||
b.Navigation("Client");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
|
||||
|
|
@ -24,21 +23,10 @@ namespace Yavsc.Server.Helpers
|
|||
dbContext.Circle.Include(c => c.Members)
|
||||
.Where(c => c.Members.Any(m => m.MemberId == readerId))
|
||||
.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(
|
||||
b => b.Author
|
||||
).Include(p => p.ACL).Where(x => x.Author.Id == posterId &&
|
||||
(
|
||||
(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)))))
|
||||
));
|
||||
(x.ACL.Count == 0 || x.ACL.Any(a => readerCirclesMemberships.Contains(a.CircleId))));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ namespace Yavsc.Models
|
|||
using Bank;
|
||||
using Billing;
|
||||
using Blog;
|
||||
using Blogspot;
|
||||
using Chat;
|
||||
using Drawing;
|
||||
using Forms;
|
||||
|
|
@ -223,17 +222,6 @@ namespace Yavsc.Models
|
|||
.WithMany(u => u.Posts)
|
||||
.HasForeignKey(b => b.AuthorId)
|
||||
.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>()
|
||||
.HasOne(c => c.Author)
|
||||
.WithMany(u => u.BlogComments)
|
||||
|
|
|
|||
|
|
@ -30,21 +30,6 @@ namespace Yavsc.Models.Blog
|
|||
[Display(Name = "Liste de contrôle d'accès")]
|
||||
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")]
|
||||
[ForeignKey("Author")]
|
||||
public string? AuthorId { get; set; }
|
||||
|
|
|
|||
|
|
@ -200,46 +200,28 @@ public class BlogSpotService
|
|||
Where(c => c.Members.Any(m => m.MemberId == viewerId))
|
||||
.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
|
||||
.Include(b => b.Author)
|
||||
.Include(p => p.ACL)
|
||||
.Include(p => p.Tags)
|
||||
.Include(p => p.Comments)
|
||||
.Where(p =>
|
||||
(p.Visibility == Visibility.Private && p.AuthorId == viewerId)
|
||||
|| (p.Visibility == Visibility.Public
|
||||
&& (p.ACL == null
|
||||
|| p.ACL.Count == 0
|
||||
|| p.AuthorId == viewerId
|
||||
|| (userCircles != null
|
||||
&& p.ACL.Any(a => userCircles.Contains(a.CircleId))))));
|
||||
.Where(p => p.ACL == null
|
||||
|| p.ACL.Count == 0
|
||||
|| (p.AuthorId == viewerId)
|
||||
|| (userCircles != null &&
|
||||
p.ACL.Any(a => userCircles.Contains(a.CircleId)))
|
||||
);
|
||||
}
|
||||
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
|
||||
.Include(p => p.BlogPost)
|
||||
.Include(b => b.BlogPost.Author)
|
||||
.Include(p => p.BlogPost.ACL)
|
||||
.Include(p => p.BlogPost.Tags)
|
||||
.Include(p => p.BlogPost.Comments)
|
||||
.Where(p => p.BlogPost.Visibility == Visibility.Public
|
||||
&& (p.BlogPost.ACL == null
|
||||
|| p.BlogPost.ACL.Count == 0))
|
||||
.Where(p => p.BlogPost.ACL == null
|
||||
|| p.BlogPost.ACL.Count == 0)
|
||||
.Select(p => p.BlogPost).ToArray();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Authorization;
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
|
@ -56,15 +55,9 @@ public class PermissionHandler : IAuthorizationHandler
|
|||
{
|
||||
if (resource is BlogPost blogPost)
|
||||
{
|
||||
// IsPublic is the authz twin of the Index/listing
|
||||
// filter in BlogSpotService: a post is "publicly
|
||||
// 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
|
||||
applicationDbContext.blogSpotPublications
|
||||
.Any(p=>p.BlogpostId == blogPost.Id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue