Revert "feat(blog): add Visibility { Private, Public } to gate post reads"

This reverts commit 33ecfa7ebd.
This commit is contained in:
Paul Schneider 2026-08-18 15:40:52 +01:00
commit 42625f5ddd
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
16 changed files with 46 additions and 5427 deletions

View file

@ -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());
}
}

View file

@ -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);
}
}