using System.Security.Claims; using Microsoft.EntityFrameworkCore; using Yavsc.Models.Blog; using Yavsc.Server.Exceptions; using Yavsc.Server.Tests.Fixtures; namespace Yavsc.Server.Tests; /// /// Service-level tests for . /// /// loads a /// (with Author, Tags, Comments, ACL and /// Publication navigations), runs a resource-based /// ReadPermission authorization against it, scrubs the ACL for /// non-owners, derives from the /// loaded Publication navigation, and eagerly loads each /// comment's Author. These tests pin each of those behaviours /// at the service layer against the real /// (the public/owner/ /// sponsor/Administrator disjunction) and a real SQLite store — no /// mocking. /// /// Seeding and the service call resolve through separate DI /// scopes so the DbContext that writes the seed rows is disposed /// before the one reads through, /// mirroring the per-request DbContext lifetime in production. /// [Collection("Yavsc Server")] public sealed class BlogSpotServiceDetailsTests : IClassFixture { private readonly ServerServicesFixture _fixture; public BlogSpotServiceDetailsTests(ServerServicesFixture fixture) { _fixture = fixture; } /// An authenticated principal whose sub claim is /// . /// reads sub first, which is what the JwtBearer middleware /// in production preserves (MapInboundClaims = false), and what /// compares against /// . private static ClaimsPrincipal Authenticated(string userId, params Claim[] extra) { var claims = new List { new("sub", userId) }; claims.AddRange(extra); return new ClaimsPrincipal( new ClaimsIdentity(claims, authenticationType: "Bearer")); } /// An unauthenticated principal: a /// with no authentication type has /// == false. The /// service still runs authorization — a published post's /// ReadPermission succeeds purely on IsPublic, with no /// identity required. private static ClaimsPrincipal Anonymous() => new(new ClaimsIdentity()); /// The Microsoft-format role claim /// /// reads when checking the "Administrator" bypass in /// . private static Claim AdminRole() => new("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "Administrator"); [Fact] public async Task Details_returns_the_post_for_its_owner_and_keeps_the_acl() { _fixture.ResetDatabase(); _fixture.SeedUser("alice"); var circleId = _fixture.SeedCircle("alice", "alice-private"); var postId = _fixture.SeedBlogPost("alice", "Owner reads own draft"); _fixture.SeedAcl(postId, circleId); var blog = await _fixture.WithServiceAsync(svc => svc.Details(Authenticated("alice"), postId)); Assert.Equal(postId, blog.Id); Assert.Equal("Owner reads own draft", blog.Title); // The owner is the one viewer ScrubAclForViewer keeps the ACL // for — a non-owner would see an empty list. Assert.Single(blog.ACL!); // No BlogSpotPublication row was seeded, so IsPublished is // derived from the (null) Publication navigation as false. Assert.False(blog.IsPublished); } [Fact] public async Task Details_serves_IsPublished_true_when_a_publication_row_exists() { _fixture.ResetDatabase(); _fixture.SeedUser("alice"); var postId = _fixture.SeedBlogPost("alice", "Published post"); _fixture.Publish(postId); var blog = await _fixture.WithServiceAsync(svc => svc.Details(Authenticated("alice"), postId)); // IsPublished is a wire-only value the service derives from // the loaded Publication navigation (Details Includes it). Assert.True(blog.IsPublished); } [Fact] public async Task Details_lets_an_anonymous_viewer_read_a_published_post_and_scrubs_its_acl() { _fixture.ResetDatabase(); _fixture.SeedUser("alice"); var circleId = _fixture.SeedCircle("alice", "alice-private"); var postId = _fixture.SeedBlogPost("alice", "Public post"); // The ACL is real, but a non-owner viewer must not see it. _fixture.SeedAcl(postId, circleId); _fixture.Publish(postId); var blog = await _fixture.WithServiceAsync(svc => svc.Details(Anonymous(), postId)); // ReadPermission succeeds purely on IsPublic (the publication // row) — no identity needed. Assert.Equal(postId, blog.Id); Assert.True(blog.IsPublished); // ScrubAclForViewer: the anonymous viewer is not the owner, so // the ACL is replaced with an empty list rather than leaked. Assert.Empty(blog.ACL!); } [Fact] public async Task Details_denies_a_draft_to_a_non_owner_who_is_not_a_sponsor() { _fixture.ResetDatabase(); _fixture.SeedUser("alice"); _fixture.SeedUser("bob"); var postId = _fixture.SeedBlogPost("alice", "Alice's draft"); // No publication row (not public), bob is not the owner, bob // is not a member of any circle alice owns, bob is not an // Administrator — every branch of ReadPermission fails. await Assert.ThrowsAsync(() => _fixture.WithServiceAsync(svc => svc.Details(Authenticated("bob"), postId))); } [Fact] public async Task Details_lets_a_sponsor_read_a_draft_and_scrubs_its_acl() { _fixture.ResetDatabase(); _fixture.SeedUser("alice"); _fixture.SeedUser("bob"); // Sponsorship = bob is a member of a circle alice owns. var circleId = _fixture.SeedCircle("alice", "alice-friends"); _fixture.SeedCircleMember(circleId, "bob"); var postId = _fixture.SeedBlogPost("alice", "Draft for friends"); _fixture.SeedAcl(postId, circleId); var blog = await _fixture.WithServiceAsync(svc => svc.Details(Authenticated("bob"), postId)); // ReadPermission succeeds on the IsSponsor branch. Assert.Equal(postId, blog.Id); Assert.False(blog.IsPublished); // bob is not the owner, so the ACL is scrubbed even though he // was granted read access via the circle. Assert.Empty(blog.ACL!); } [Fact] public async Task Details_lets_an_administrator_read_a_draft() { _fixture.ResetDatabase(); _fixture.SeedUser("alice"); _fixture.SeedUser("root"); var postId = _fixture.SeedBlogPost("alice", "Draft, admin view"); var blog = await _fixture.WithServiceAsync(svc => svc.Details(Authenticated("root", AdminRole()), postId)); // The Administrator bypass in PermissionHandler short-circuits // the public/owner/sponsor checks. Assert.Equal(postId, blog.Id); Assert.False(blog.IsPublished); } [Fact] public async Task Details_loads_each_comment_author() { _fixture.ResetDatabase(); _fixture.SeedUser("alice"); _fixture.SeedUser("bob"); var postId = _fixture.SeedBlogPost("alice", "Post with comments"); _fixture.SeedComment(postId, "bob", "Nice post!"); var blog = await _fixture.WithServiceAsync(svc => svc.Details(Authenticated("alice"), postId)); var comment = Assert.Single(blog.Comments); // Details explicitly loads c.Author from _context.Users for // every comment; the navigation must not be left null. Assert.NotNull(comment.Author); Assert.Equal("bob", comment.Author.UserName); } [Fact] public async Task Details_throws_for_a_missing_post() { _fixture.ResetDatabase(); // The query uses SingleAsync, which throws // InvalidOperationException when no row matches — the // `if (blog == null) return null` guard below it is unreachable. // Pin that current behaviour so a future switch to // SingleOrDefaultAsync (which would make the guard live) is a // deliberate, reviewed change rather than a silent one. await Assert.ThrowsAsync(() => _fixture.WithServiceAsync(svc => svc.Details(Authenticated("alice"), 424242))); } }