This commit is contained in:
Paul Schneider 2026-09-26 00:35:43 +01:00
commit 8c8ab70d38
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
82 changed files with 113 additions and 94 deletions

View file

@ -0,0 +1,390 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Behavioral tests for <c>BlogAclApiController.PostCircleAuthorizationToBlogPost</c>:
/// <c>POST /api/v1/blogacl</c> with a JSON body of
/// <c>CircleAuthorizationToBlogPost</c> (CircleId + BlogPostId).
///
/// <para>Same fixture as <see cref="CircleMembersApiTests"/>:
/// <see cref="BlogsWebServerFixture"/> provides a SQLite
/// <c>:memory:</c> <c>ApplicationDbContext</c> (so FKs are
/// enforced the way a real relational engine would) and JWT
/// bearer auth via <c>TestTokenIssuer</c>. No mocks — the real
/// DbContext receives the real INSERT attempt.</para>
///
/// <para>The bug being pinned by these tests: the POST endpoint
/// calls <c>_context.CircleAuthorizationToBlogPost.Add(...)</c>
/// then <c>SaveChangesAsync</c>. The entity has a composite
/// key (CircleId + BlogPostId) and two FKs; EF Core refuses
/// the INSERT with
/// <c>System.InvalidOperationException: The value of
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown when
/// attempting to save changes</c> when the principal entities
/// (the existing <c>BlogPost</c> and <c>Circle</c>) are not
/// attached to the DbContext in the same change-tracker graph.</para>
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogAclApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
private string BlogUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogSpotPath}";
private string BlogAclUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogAclPath}";
/// <summary>Delete any ACL rows tied to the specified
/// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store
/// persists across tests, so tests that POST a successful ACL
/// row would otherwise conflict with whichever other test runs
/// next against the same pair — xUnit does not guarantee
/// execution order. Calling this at the start of each
/// insert-bearing test guarantees a clean slate regardless of
/// the previous test's outcome.</summary>
private void CleanupAcl(long circleId, long blogPostId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.CircleAuthorizationToBlogPost
.Where(a => a.CircleId == circleId
&& a.BlogPostId == blogPostId)
.ExecuteDelete();
}
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://")))
};
// The Blogs fixture disables JwtSecurityTokenHandler's
// inbound claim-type remap, so the JWT's "sub" stays "sub"
// rather than being rewritten to ClaimTypes.NameIdentifier.
// The controller, however, reads the user id via
// User.FindFirstValue(ClaimTypes.NameIdentifier), so we add
// an explicit NameIdentifier claim to keep the legacy lookup happy.
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer",
TestTokenIssuer.Issue(
subject,
extraClaims: new[]
{
new System.Security.Claims.Claim(
System.Security.Claims.ClaimTypes.NameIdentifier,
subject),
}));
return http;
}
/// <summary>
/// Reproduces the prod 500 logged on 2026-08-21 on mercure:
/// <c>InvalidOperationException: The value of
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown</c>
/// when <see cref="PostAclDialogViewModel.AddAsync"/> POSTs the
/// shape <c>{ "circleId": &lt;id&gt; }</c> — the exact body the
/// PostIt client builds from <see cref="CircleAuthorization"/>
/// (which only carries <c>CircleId</c>). The server deserializes
/// it into <see cref="CircleAuthorizationToBlogPost"/>, leaves
/// <c>BlogPostId</c> at its <c>default(long) = 0</c>, attaches
/// no <c>Target</c> navigation, and EF Core refuses to INSERT
/// during <c>PrepareToSave()</c>. The fix lives in PostIt
/// (enrich the payload with <c>blogPostId</c> + <c>comment</c>)
/// and on the wire DTO (<see cref="CircleAuthorization"/> must
/// carry those fields); the server validates. Until that ships,
/// this test stays red.
/// </summary>
[Fact]
public async Task PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape_against_existing_circle_named_test()
{
// The prod circle already exists with Name="test", Public=true,
// owned by the caller. We seed the same shape pre-POST so the
// test reproduces the prod scenario end-to-end.
_fixture.SeedUser(_fixture.DefaultUserLogin);
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test");
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-target");
CleanupAcl(seededCircleId, seededBlogPostId);
using var http = NewClient(_fixture.DefaultUserLogin);
var payload = new PostAccessControlRulePayload
{
CircleId = seededCircleId,
BlogPostId = seededBlogPostId
};
var response = await http.PostAsJsonAsync(
BlogAclUrl(), payload,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
/// <summary>
/// Payload templates for <see cref="PostCircleAuthorization_never_returns_500"/>.
/// Each row carries the shape we want to POST; <c>-1L</c> and
/// <c>-2L</c> are negative sentinels that the test substitutes
/// with the ids of freshly seeded <c>Circle</c> / <c>BlogPost</c>
/// rows before sending, so every shape lands against a real
/// principal entity and the seeded fixtures are not dead.
/// </summary>
public static IEnumerable<object[]> BlogAclPayloadsForNever500()
{
// circleId only (the historical bug shape, 2026-08-21 mercure):
// must be rejected, never 500.
return new object[][]
{
[
new PostAccessControlRulePayload
{
BlogPostId = -2,
CircleId = -1
}
],
[new PostAccessControlRulePayload
{
BlogPostId = 1,
CircleId = -1
}
],
[new PostAccessControlRulePayload
{
BlogPostId = 1,
CircleId = 1
}
]
} ;
}
/// <summary>
/// Hard rule (Paul, 2026-08-21): a 500 is never acceptable
/// </summary>
[Theory]
[MemberData(nameof(BlogAclPayloadsForNever500))]
public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload)
{
using var http = NewClient(_fixture.DefaultUserLogin);
var response = await http.PostAsJsonAsync(
BlogAclUrl(), payload,
TestContext.Current.CancellationToken);
Assert.NotEqual(HttpStatusCode.InternalServerError, response.StatusCode);
}
[Fact]
async Task PostCircleAuthorization_dosent_return_500 ()
{
_fixture.SeedUser(_fixture.DefaultUserLogin);
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"));
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-never-500");
CleanupAcl(seededCircleId, seededBlogPostId);
await PostCircleAuthorization_never_returns_500(
new PostAccessControlRulePayload
{
BlogPostId = -1,
CircleId = seededCircleId
}
);
}
[Fact]
async Task PostCircleAuthorization_dosent_return_500_on_success ()
{
_fixture.SeedUser(_fixture.DefaultUserLogin);
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"));
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-never-500-success");
CleanupAcl(seededCircleId, seededBlogPostId);
await PostCircleAuthorization_never_returns_500(
new PostAccessControlRulePayload
{
BlogPostId = seededBlogPostId,
CircleId = seededCircleId
}
);
}
[Fact]
public async Task PostBlog_with_ACL_creates_a_post_and_Get_returns_it_in_the_list()
{
_fixture.SeedUser(_fixture.DefaultUserLogin);
_fixture.SeedUser("tester");
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"),
false,
new String[]
{
_fixture.DefaultUserLogin,
"tester"
});
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-seeded-target");
CleanupAcl(seededCircleId, seededBlogPostId);
using var http = NewClient(_fixture.DefaultUserLogin );
// Create a minimal BlogPost. The server assigns Id, so we
// send 0 + an explicit AuthorId; the production
// BlogSpotService.Create() tolerates that.
var draft = new BlogPost
{
Id = 0,
Title = "Premier billet",
AuthorId = "tester",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
ACL = new List<CircleAuthorizationToBlogPost>(
new CircleAuthorizationToBlogPost[]
{
new CircleAuthorizationToBlogPost
{
CircleId = seededCircleId,
BlogPostId = seededBlogPostId
}
}
)
};
var postResponse = await http.PostAsJsonAsync(
BlogUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
// The POST returns the server-issued post (with a real Id).
var created = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken
);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal(draft.Title, created.Title);
// The list should now contain exactly one entry.
var listResponse = await http.GetAsync(
BlogUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.True(doc.RootElement.GetArrayLength() >= 1);
Assert.Contains(doc.RootElement.EnumerateArray(), p => p.GetProperty("id").GetInt64() == created.Id);
// detail should return the same post, with ACL and tags.
var detailResponse = await http.GetAsync(
$"{BlogUrl()}/{created.Id}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
using var detailDoc = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind);
Assert.Equal(created.Id, detailDoc.RootElement.GetProperty("id").GetInt64());
Assert.True(detailDoc.RootElement.TryGetProperty("acl", out var acl));
Assert.False(detailDoc.RootElement.TryGetProperty("ACL", out _));
Assert.Equal(JsonValueKind.Array, acl.ValueKind);
Assert.Equal(1, acl.GetArrayLength());
var aclEntry = acl[0];
Assert.Equal(JsonValueKind.Object, aclEntry.ValueKind);
Assert.True(aclEntry.TryGetProperty("circleId", out var returnedCircleId));
Assert.Equal(seededCircleId, returnedCircleId.GetInt64());
}
[Fact]
public async Task Non_owner_can_read_restricted_post_but_receives_empty_acl_in_list_and_detail()
{
_fixture.SeedUser(_fixture.DefaultUserLogin);
_fixture.SeedUser("tester");
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"), false,
new[] { _fixture.DefaultUserLogin, "tester" });
using var ownerHttp = NewClient(_fixture.DefaultUserLogin);
using var readerHttp = NewClient("tester");
var draft = new BlogPost
{
Id = 0,
Title = "ACL scrub test",
Article = "Visible to circle member",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await ownerHttp.PostAsJsonAsync(
BlogUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
var grantResponse = await ownerHttp.PostAsJsonAsync(
BlogAclUrl(),
new PostAccessControlRulePayload
{
CircleId = seededCircleId,
BlogPostId = created.Id
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, grantResponse.StatusCode);
var listResponse = await readerHttp.GetAsync(
BlogUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var listDoc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken));
Assert.Equal(JsonValueKind.Array, listDoc.RootElement.ValueKind);
foreach (var listed in listDoc.RootElement.EnumerateArray())
{
var authorId = listed.GetProperty("authorId").GetString();
if (string.Equals(authorId, "tester", StringComparison.Ordinal))
continue;
Assert.True(listed.TryGetProperty("acl", out var listedAcl));
Assert.Equal(0, listedAcl.GetArrayLength());
}
var detailResponse = await readerHttp.GetAsync(
$"{BlogUrl()}/{created.Id}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
using var detailDoc = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken));
Assert.True(detailDoc.RootElement.TryGetProperty("acl", out var detailAcl));
Assert.Equal(0, detailAcl.GetArrayLength());
}
}

View file

@ -0,0 +1,161 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
namespace Yavsc.Blogs.Tests;
[Collection("JwtClaimMapping")]
public sealed class BlogApiMappedClaimsTests :
IClassFixture<MappedClaimsBlogsWebServerFixture>
{
private readonly MappedClaimsBlogsWebServerFixture _fixture;
public BlogApiMappedClaimsTests(MappedClaimsBlogsWebServerFixture fixture)
{
_fixture = fixture;
}
private void ResetDatabase()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
private HttpClient NewClient(string subject = "tester")
{
var http = new HttpClient
{
BaseAddress = new Uri(_fixture.Addresses.First())
};
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
IssueMappedClaimsToken(subject));
return http;
}
private static string IssueMappedClaimsToken(string subject)
{
var now = DateTime.UtcNow;
var claims = new List<Claim>
{
new("sub", subject),
new("scope", "blogs"),
};
var token = new JwtSecurityToken(
issuer: TestTokenIssuer.Issuer,
audience: TestTokenIssuer.Audience,
claims: claims,
notBefore: now,
expires: now.AddHours(1),
signingCredentials: new SigningCredentials(
TestTokenIssuer.SigningKey,
SecurityAlgorithms.HmacSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
[Fact]
public async Task PostBlog_with_mapped_sub_claim_sets_AuthorId_from_authenticated_user()
{
ResetDatabase();
using var http = NewClient(subject: "mapped-user");
var draft = new BlogPost
{
Id = 0,
Title = "Billet JWT re-mappé",
AuthorId = "payload-attacker",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var response = await http.PostAsJsonAsync(
_fixture.BlogSpotUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.Equal("mapped-user", created!.AuthorId);
}
[Fact]
public async Task PutBlog_with_mapped_sub_claim_allows_owner_to_update()
{
ResetDatabase();
using var http = NewClient(subject: "mapped-owner");
var createdResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), new BlogPost
{
Id = 0,
Title = "Billet à modifier",
AuthorId = "payload-attacker",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
var updateResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl() + $"/{created!.Id}", new BlogPost
{
Id = created.Id,
Title = "Billet modifié",
AuthorId = created.AuthorId,
Article = "Contenu mis à jour.",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode);
}
[Fact]
public async Task PutBlog_with_mapped_sub_claim_rejects_non_owner()
{
ResetDatabase();
using var ownerHttp = NewClient(subject: "mapped-owner");
var createdResponse = await ownerHttp.PostAsJsonAsync(_fixture.BlogSpotUrl(), new BlogPost
{
Id = 0,
Title = "Billet protégé",
AuthorId = "payload-attacker",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
using var otherHttp = NewClient(subject: "mapped-other");
var updateResponse = await otherHttp.PutAsJsonAsync(_fixture.BlogSpotUrl() + $"/{created!.Id}", new BlogPost
{
Id = created.Id,
Title = "Tentative de modification",
AuthorId = created.AuthorId,
Article = "Contenu non autorisé.",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, updateResponse.StatusCode);
}
}

View file

@ -0,0 +1,40 @@
using Microsoft.Extensions.DependencyInjection;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Smoke tests for the Yavsc.Blogs API host. These tests only assert
/// that the fixture boots and the test HTTP client reaches the
/// controller pipeline — they do not yet exercise the controller
/// surface. The first behavioural test (GET /api/v1/blog returns
/// 200) lands in a follow-up commit.
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogApiSmokeTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogApiSmokeTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
[Fact]
public void Fixture_Binds_At_Least_One_Https_Address()
{
Assert.NotEmpty(_fixture.Addresses);
Assert.Contains(_fixture.Addresses, a => a.StartsWith("https://"));
}
[Fact]
public void Fixture_Exposes_Resolving_ServiceProvider()
{
// If the host built correctly, the service provider should
// be available and resolvable. We don't need to assert a
// specific service here — the GET 200 test will exercise
// the BlogSpotService indirectly.
Assert.NotNull(_fixture.Services);
using var scope = _fixture.Services.CreateScope();
Assert.NotNull(scope.ServiceProvider);
}
}

View file

@ -0,0 +1,719 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Client;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers;
using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
using BlogPostDto = Yavsc.Blogspot.BlogPostDto;
using NuGet.Protocol;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Behavioural tests for <c>BlogApiController</c>. Built on the
/// <see cref="BlogsWebServerFixture"/> scaffold: in-memory
/// <c>ApplicationDbContext</c>, real <c>BlogSpotService</c>, and a
/// real <c>AddJwtBearer</c> validating HS256 tokens signed by
/// <see cref="TestTokenIssuer"/>. The production <c>BlogScope</c>
/// policy runs unmodified — sending <c>Authorization: Bearer …</c>
/// with a valid token is what gets a request through, omitting the
/// header (or sending a token signed with the wrong key) gets a
/// 401 back from the framework.
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>Reset the database and seed the
/// <c>tester</c> <see cref="ApplicationUser"/> row. Required
/// for any test that POST/PUT/DELETE a <c>BlogPost</c>:
/// <c>BlogPost.AuthorId</c> is a FK to
/// <c>AspNetUsers.Id</c>, and SQLite (unlike the EF Core
/// InMemory provider) enforces it. Without the seed, the
/// POST handler hits
/// <c>SQLite Error 19: 'FOREIGN KEY constraint failed'</c>
/// at <c>SaveChanges</c> and the controller returns 500.</summary>
private void ResetAndSeedDefaultUser()
{
_fixture.ResetDatabase();
_fixture.SeedUser("tester");
}
/// <summary>Build an authenticated client: a real
/// <c>Authorization: Bearer &lt;jwt&gt;</c> header where the JWT
/// is signed by <see cref="TestTokenIssuer"/> and carries
/// <c>sub = subject</c>. The production <c>BlogScope</c> policy
/// reads <c>scope=blogs</c> off the same token, so
/// <c>TestTokenIssuer.Issue</c>'s default scope is enough.</summary>
private HttpClient NewClient(string subject = "tester")
{
// The fixture's self-signed certificate is not in the user's
// trust store, so we accept anything (same pattern as
// Yavsc.Org.Tests' BypassSslValidationHandler).
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;
}
/// <summary>Build an unauthenticated client. Used to assert that
/// the <c>BlogScope</c> policy fails closed when no bearer
/// token is presented.</summary>
private HttpClient NewAnonymousClient()
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
return new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
}
private int CountAttachmentsForPost(long postId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.BlogAttachedFiles.Count(a => a.PostId == postId);
}
[Fact]
public async Task GetBlogs_returns_200_with_empty_list_when_no_posts()
{
_fixture.ResetDatabase();
using var http = NewClient();
var response = await http.GetAsync(
_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
// Empty table → empty JSON array. We compare as a JsonDocument
// so a future change in formatting (whitespace, indentation)
// doesn't break the assertion.
using var doc = JsonDocument.Parse(body);
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public async Task PostBlog_creates_a_post_and_Get_returns_it_in_the_list()
{
ResetAndSeedDefaultUser();
using var http = NewClient();
// Create a minimal BlogPost. The server assigns Id, so we
// send 0 + an explicit AuthorId; the production
// BlogSpotService.Create() tolerates that.
var draft = new BlogPost
{
Id = 0,
Title = "Premier billet",
AuthorId = "tester",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
// The POST returns the server-issued post (with a real Id).
var created = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken
);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal(draft.Title, created.Title);
// The list should now contain exactly one entry.
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64());
}
[Fact]
public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry()
{
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
{
Id = 0,
Title = "Billet avec auteur",
AuthorId = "payload-attacker",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken
);
Assert.NotNull(created);
Assert.Equal("tester", created!.AuthorId);
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString());
}
[Fact]
public async Task PostBlogComment_returns_201_for_existing_post()
{
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
{
Id = 0,
Title = "Billet commentable",
AuthorId = "payload-attacker",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var createdPost = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken
);
Assert.NotNull(createdPost);
Thread.Sleep(100);
var commentResponse = await http.PostAsJsonAsync("/api/v1/blogcomments", new
{
Article = "Premier commentaire",
ReceiverId = createdPost!.Id
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, commentResponse.StatusCode);
using var doc = JsonDocument.Parse(
await commentResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.True(doc.RootElement.TryGetProperty("id", out var id));
Assert.True(id.GetInt64() > 0);
Assert.True(doc.RootElement.TryGetProperty("dateCreated", out _));
}
[Fact]
public void GetUserId_reads_NameIdentifier_when_sub_was_mapped()
{
var principal = new ClaimsPrincipal(
new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, "tester")],
authenticationType: "Bearer"));
Assert.Equal("tester", principal.GetUserId());
}
[Fact]
public async Task GetBlog_returns_401_when_no_token_is_provided()
{
_fixture.ResetDatabase();
using var http = NewAnonymousClient();
// No Authorization header → the JwtBearer middleware
// produces an unauthenticated principal, the BlogScope
// policy's RequireAuthenticatedUser requirement fails, and
// the framework returns 401. This is the proof that the
// production policy is wired in the test host and not
// short-circuited by a test-only auth bypass.
var response = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update()
{
ResetAndSeedDefaultUser();
// The JWT's sub must match the post's AuthorId:
// PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(),
// and UserHelpers.GetUserId reads "sub" off the principal.
// A mismatched sub → AuthorizationFailureException →
// Challenge() (401) from the controller. The 204 in this
// test is the proof that the real authorization chain
// accepted the request, end-to-end.
using var http = NewClient(subject: "tester");
// Seed a post we can update.
var draft = new BlogPost
{
Id = 0,
Title = "Avant",
AuthorId = "tester",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken
))!;
// PUT with the server-issued Id; the controller rejects
// mismatched id/blog.Id with 400, so we keep them aligned.
var update = new BlogPost
{
Id = created.Id,
Title = "Après",
AuthorId = created.AuthorId,
Article = created.Article,
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
};
var putResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl()+$"/{created.Id}",
update,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
// The list should now reflect the new title.
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse(
await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
Assert.Equal("Après", doc.RootElement[0].GetProperty("title").GetString());
}
[Fact]
public async Task PutBlog_multipart_with_blog_and_file_returns_204_and_persists_attachment()
{
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
// AttachFiles writes under the fixture-configured
// SiteSettings.Blog (BlogFilesRoot), not a local temp dir.
var filesRoot = _fixture.BlogFilesRoot;
try
{
var draft = new BlogPost
{
Id = 0,
Title = "Initial",
AuthorId = "tester",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(
_fixture.BlogSpotUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken))!;
var attachmentCount = CountAttachmentsForPost(created.Id);
Assert.True(attachmentCount == 0);
var update = new BlogPost
{
Id = created.Id,
Title = "Mis a jour via multipart",
AuthorId = created.AuthorId,
Article = "Contenu mis a jour.",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
};
var form = new MultipartFormDataContent
{
{ new StringContent(JsonSerializer.Serialize(update)), "blog" }
};
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
using var request = new HttpRequestMessage(
HttpMethod.Put,
_fixture.BlogSpotUrl() + $"/{created.Id}")
{
Content = form
};
var putResponse = await http.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
var detailsResponse = await http.GetAsync(
_fixture.BlogSpotUrl() + $"/{created.Id}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, detailsResponse.StatusCode);
using var detailsDoc = JsonDocument.Parse(
await detailsResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.Equal("Mis a jour via multipart", detailsDoc.RootElement.GetProperty("title").GetString());
attachmentCount = CountAttachmentsForPost(created.Id);
Assert.True(attachmentCount >= 1);
}
finally
{
// BlogFilesRoot is the fixture-shared temp root (one per
// test run); leave it in place for the other multipart
// tests. The OS reclaims it.
}
}
[Fact]
public async Task PutBlog_multipart_without_blog_field_returns_400()
{
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
{
Id = 0,
Title = "Initial",
AuthorId = "tester",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(
_fixture.BlogSpotUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken))!;
var form = new MultipartFormDataContent();
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
using var request = new HttpRequestMessage(
HttpMethod.Put,
_fixture.BlogSpotUrl() + $"/{created.Id}")
{
Content = form
};
var putResponse = await http.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, putResponse.StatusCode);
}
[Fact]
public async Task PutBlog_from_PostIt_client_with_attachment_returns_204_and_persists_attachment()
{
// Full PostIt "Save" flow for an existing post with a new
// attachment, driven through the real BlogApiClient — the
// same code PostIt runs — so any drift between the
// client's wire shape and the controller's contract fails
// here before it reaches a user:
//
// 1. BlogsViewModel.SaveAsync (update branch) calls
// BlogApiClient.UpdatePostAsync(id, update, attachments)
// with a non-empty attachments list →
// multipart/form-data PUT built by
// CreateMultipartContent: a "blog" field holding the
// BlogPostDto serialised camelCase + WhenWritingNull,
// plus one "file" part per attachment.
// 2. TryAppendAttachmentLinks then appends a markdown
// link per attachment to the article, and the VM
// issues a second UpdatePostAsync without files →
// plain JSON PUT.
//
// The test asserts the final state: the article carries
// the markdown link, the attachment row is persisted, and
// the file physically landed under the user-files root
// the link points to.
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
// AttachFiles writes under the fixture-configured
// SiteSettings.Blog (BlogFilesRoot), not a local temp dir.
var filesRoot = _fixture.BlogFilesRoot;
try
{
// BlogApiClient takes the versioned base address
// (…/api/v1/) and prepends its "blogspot" prefix —
// exactly how PostIt wires it against production.
var client = new BlogApiClient(
new TestApiTransport(http),
_fixture.ApiUrl(""));
// Seed the post through the same path PostIt uses for
// a first Save on a new draft: CreatePostAsync without
// files → plain JSON POST.
var draft = new BlogPostDto
{
Title = "Avant PostIt",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var created = await client.CreatePostAsync(draft,
ct: TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal("tester", created.AuthorId);
// Step 1 — the update DTO the VM builds from the
// editor buffer (SelectedPost is the read-only hint
// for Id, AuthorId, Photo and DateCreated), sent with
// one attachment → multipart PUT.
var update = new BlogPostDto
{
Id = created.Id,
AuthorId = created.AuthorId,
Photo = created.Photo,
Title = "Modifié depuis PostIt",
Article = "Contenu modifié.",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
};
var attachments = new[]
{
new BlogUploadFile(
"note.txt",
System.Text.Encoding.UTF8.GetBytes("payload test"),
"text/plain")
};
await client.UpdatePostAsync(created.Id, update, attachments,
TestContext.Current.CancellationToken);
// Step 2 — TryAppendAttachmentLinks appended a
// markdown link to the article (the payload does not
// embed Author, so the owner segment falls back to
// AuthorId), and the VM PUTs the article again,
// without files → JSON path of UpdatePostAsync.
var fileUrl = $"{Yavsc.Constants.UserFilesPath}/tester/blogs/{created.Id}/note.txt";
var linkUpdate = new BlogPostDto
{
Id = created.Id,
AuthorId = created.AuthorId,
Photo = created.Photo,
Title = update.Title,
Article = update.Article + $"\n\n- [note.txt]({fileUrl})",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
};
await client.UpdatePostAsync(created.Id, linkUpdate,
ct: TestContext.Current.CancellationToken);
// Final state, read back through the client.
var details = await client.GetPostAsync(created.Id,
TestContext.Current.CancellationToken);
Assert.NotNull(details);
Assert.Equal("Modifié depuis PostIt", details!.Title);
Assert.Contains($"- [note.txt]({fileUrl})", details.Article);
// The attachment row is persisted…
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
// …and the file physically landed where the appended
// markdown link points:
// {UserFilesDirName}/{user}/blogs/{postId}/{fileName}.
var expectedFile = Path.Combine(
filesRoot, "tester", "blogs", created.Id.ToString(), "note.txt");
var written = Directory.Exists(filesRoot)
? string.Join(", ", Directory.EnumerateFiles(filesRoot, "*", SearchOption.AllDirectories))
: "<none>";
Assert.True(File.Exists(expectedFile),
$"Expected uploaded file at {expectedFile}. Actually written: {written}");
}
finally
{
// BlogFilesRoot is the fixture-shared temp root (one per
// test run); leave it in place for the other multipart
// tests. The OS reclaims it.
}
}
[Fact]
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
{
ResetAndSeedDefaultUser();
using var http = NewClient();
// Seed a post we can delete.
var draft = new BlogPost
{
Id = 0,
Title = "À supprimer",
AuthorId = "tester",
Article = "Contenu.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken
))!;
var deleteResponse = await http.DeleteAsync(_fixture.BlogSpotUrl()+$"/{created.Id}",
TestContext.Current.CancellationToken
);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
// The list should now be empty.
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
String response = await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
);
using var doc = JsonDocument.Parse(response);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public async Task PostBlog_from_PostIt_shape_returns_201_not_400()
{
// Regression test for the "Save" button in PostIt: from the
// user's point of view, they type a Title and an Article in
// the editor pane and tap "Save". The VM serialises the
// SelectedPost via JsonContent.Create (camelCase, System.Text.Json
// defaults) and POSTs it to /api/v1/blog. This test sends
// exactly that payload — same fields, same types, same
// serialiser (PostAsJsonAsync is wired to the same
// System.Net.Http.Json pipeline that YavscApiClient uses on
// the PostIt side) — and asserts that the server accepts it
// with 201 Created, not 400 BadRequest. If the controller's
// ModelState validation starts rejecting the PostIt payload
// (missing field, wrong casing, etc.), this test fails
// before the regression reaches a user.
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
// Mirrors what MainPageViewModel.Save builds: a BlogPost with
// Id=0 (so the controller treats it as a create), Title and
// Article filled in by the user, and DateCreated/DateModified
// stamped by the VM. AuthorId is what the OIDC sub resolves
// to in the test fixture.
var draft = new BlogPost
{
Id = 0,
Title = "Mon premier billet",
AuthorId = "tester",
Article = "Contenu du billet de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
// Dump the body on failure so the test name + the response
// payload are enough to start a fix; the framework's
// assertion message is otherwise opaque (just "Expected
// Created, got BadRequest").
if (response.StatusCode != HttpStatusCode.Created)
{
var body = await response.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
);
Assert.Fail(string.Format("Expected 201 Created, got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body));
}
}
[Fact]
public async Task PostBlog_with_empty_title_returns_400()
{
// Mirrors the buggy branch in MainPageViewModel.Save: when
// the user taps "Save" without a SelectedPost (e.g. they
// typed into the editor without first clicking an item in
// the list, so the {Binding SelectedPost.Title, Mode=TwoWay}
// XAML binding had no target and the keystrokes were
// silently dropped), the VM builds a BlogPost with
// Title = string.Empty and POSTs it. BlogPost.Title carries
// [Required] → ModelState.IsValid fails → 400 BadRequest.
// This is the regression we are hunting. The 400 is
// expected here: the test pins the *current* controller
// behaviour so a future change that, say, makes Title
// nullable in the model or drops [Required], triggers a
// conscious update of the test (and probably of the VM).
_fixture.ResetDatabase();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
{
Id = 0,
Title = string.Empty,
AuthorId = "tester",
Article = "Article non vide, mais titre vide.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
if (response.StatusCode != HttpStatusCode.BadRequest)
{
var body = await response.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
);
Assert.Fail(string.Format("Expected 400 BadRequest (empty Title is invalid), got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body));
}
}
}

View file

@ -0,0 +1,164 @@
using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Blogs.Tests.Fixtures;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Direct service-level tests for <see cref="BlogSpotService.Index"/>.
///
/// <para>The HTTP tests in <see cref="BlogApiTests"/> all drive the
/// <c>[Authorize("BlogScope")]</c>-protected <c>BlogApiController</c>,
/// so the request is always authenticated by the time it reaches the
/// service. The MVC <c>BlogspotController.Index</c> action, by
/// contrast, is <c>[AllowAnonymous]</c> — that is the one caller that
/// actually exercises the unauthenticated branch of
/// <see cref="BlogSpotService.Index"/>, the branch that reads from
/// <c>blogSpotPublications</c> and projects to <c>p.Post</c>.</para>
///
/// <para>These tests pin that behaviour at the service layer: a
/// <c>BlogPost</c> that has a <c>BlogSpotPublication</c> row is
/// listed for an anonymous viewer, and one without is not. Seeding
/// runs against the same shared SQLite store as the rest of the
/// <c>Yavsc Blogs</c> collection, so the real
/// <see cref="BlogSpotService"/> (registered by
/// <see cref="BlogsWebServerFixture"/>) is resolved from DI and run
/// end-to-end — no mocking.</para>
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogSpotServiceIndexTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogSpotServiceIndexTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>An unauthenticated principal: a <see cref="ClaimsIdentity"/>
/// with no authentication type has
/// <see cref="ClaimsIdentity.IsAuthenticated"/> == false, so
/// <see cref="BlogSpotService.Index"/> takes its anonymous
/// branch. This mirrors what the JwtBearer middleware hands the
/// MVC <c>[AllowAnonymous]</c> action when no bearer token is
/// presented.</summary>
private static ClaimsPrincipal Anonymous()
=> new(new ClaimsIdentity());
/// <summary>Resolve the real <see cref="BlogSpotService"/> from the
/// fixture's DI container and run <paramref name="body"/> against it,
/// disposing the request scope (and its scoped DbContext) afterwards.
/// The service itself is not <see cref="IDisposable"/>; the scope is,
/// so the scope is what we must dispose to avoid leaking a DbContext
/// against the shared SQLite connection across tests.</summary>
private async Task<T> WithServiceAsync<T>(
Func<BlogSpotService, Task<T>> body)
{
using var scope = _fixture.Services.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<BlogSpotService>();
return await body(service);
}
private void Publish(long postId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// The publication flag is the existence of a row in
// blogSpotPublications — same write SetPublishAsync performs
// in production. Inserting it directly is the most faithful
// way to seed "this post is published" without routing
// through the authorize-gated PUT/publish endpoint.
db.blogSpotPublications.Add(new BlogSpotPublication { PostId = postId });
db.SaveChanges();
}
[Fact]
public async Task Index_lists_a_published_post_for_anonymous_access()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var publishedId = _fixture.SeedBlogPost("alice", "Published for all");
Publish(publishedId);
var posts = await WithServiceAsync(async svc =>
{
var result = await svc.Index(Anonymous(), id: "", skip: 0, take: 25);
return result.OfType<BlogPost>().ToArray();
});
var listed = Assert.Single(posts);
Assert.Equal("Published for all", listed.Title);
// The service serves IsPublished from the
// blogSpotPublications table — the anonymous branch does
// not Include the Publication navigation, so this proves
// the value is hydrated by the service, not derived from
// the EF graph.
Assert.True(listed.IsPublished);
}
[Fact]
public async Task Index_serves_IsPublished_false_for_a_draft_for_anonymous_access()
{
// Anonymous viewers never receive a draft (the branch
// queries blogSpotPublications), so this is checked through
// the authenticated branch: the service must still serve
// IsPublished == false for a post with no publication row,
// not the default of a freshly loaded entity.
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var draftId = _fixture.SeedBlogPost("alice", "Draft, not published");
var viewer = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("sub", "alice")], authenticationType: "Bearer"));
var posts = await WithServiceAsync(async svc =>
{
var result = await svc.Index(viewer, id: "", skip: 0, take: 25);
return result.OfType<BlogPost>().ToArray();
});
var draft = Assert.Single(posts);
Assert.Equal(draftId, draft.Id);
Assert.False(draft.IsPublished);
}
[Fact]
public async Task Index_does_not_list_an_unpublished_post_for_anonymous_access()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var publishedId = _fixture.SeedBlogPost("alice", "Published for all");
_fixture.SeedBlogPost("alice", "Draft, not published");
// Only the first post is published; the draft has no
// BlogSpotPublication row.
Publish(publishedId);
var titles = await WithServiceAsync(async svc =>
{
var result = await svc.Index(Anonymous(), id: "", skip: 0, take: 25);
return result.Select(p => p.Title).ToArray();
});
Assert.Contains("Published for all", titles);
Assert.DoesNotContain("Draft, not published", titles);
}
[Fact]
public async Task Index_returns_an_empty_list_for_anonymous_access_when_nothing_is_published()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
// A draft with no publication row: invisible to anonymous.
_fixture.SeedBlogPost("alice", "Draft, not published");
var result = await WithServiceAsync(svc =>
svc.Index(Anonymous(), id: "", skip: 0, take: 25));
Assert.Empty(result);
}
}

View file

@ -0,0 +1,199 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Behavioural tests for the circle-members endpoints on
/// <c>CircleApiController</c>:
/// <c>GET /api/circle/{id}/members</c>,
/// <c>POST /api/circle/{id}/members</c>,
/// <c>DELETE /api/circle/{id}/members/{userId}</c>.
///
/// <para>Same fixture as <see cref="BlogApiTests"/>:
/// <see cref="BlogsWebServerFixture"/> provides an in-memory
/// <c>ApplicationDbContext</c>, JWT bearer auth with HS256,
/// and the production <c>BlogScope</c> policy. Tests use
/// <c>TestTokenIssuer</c> to mint tokens whose <c>sub</c>
/// claim identifies the caller.</para>
///
/// <para>Test users (<c>alice</c>, <c>bob</c>) are seeded
/// directly via <see cref="ApplicationDbContext.Users"/>:
/// the Blogs fixture doesn't stand up
/// <c>UserManager&lt;ApplicationUser&gt;</c>, so we go
/// through the DbContext the same way the production code
/// would.</para>
/// </summary>
[Collection("JwtClaimMapping")]
public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public CircleMembersApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>Reset the in-memory database and seed
/// <c>alice</c> + <c>bob</c>. <c>UseInMemoryDatabase</c>
/// shares its store across the fixture lifetime, so each
/// test starts from a clean slate.</summary>
private void ResetDatabaseWithUsers()
{
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,
FullName = "Alice Dupont",
Avatar = "/avatars/alice.png",
});
db.Users.Add(new ApplicationUser
{
Id = "bob",
UserName = "bob",
Email = "bob@example.com",
EmailConfirmed = true,
FullName = "Bob Martin",
Avatar = "/avatars/bob.png",
});
db.SaveChanges();
}
/// <summary>Create a circle owned by <paramref name="ownerId"/>
/// directly in the in-memory store and return its server-assigned
/// id. The tests below use this to bypass the controller's POST
/// (which is already covered by other tests on the branch);
/// the focus here is the members endpoints.</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 MembersUrl(long circleId)
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/circle/{circleId}/members";
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;
}
[Fact]
public async Task GetMembers_returns_200_with_empty_list_when_no_members()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
var response = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public async Task PostMember_returns_201_then_Get_returns_the_member()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
var postResponse = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var getResponse = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
var member = doc.RootElement[0];
Assert.Equal("bob", member.GetProperty("id").GetString());
Assert.Equal("bob", member.GetProperty("userName").GetString());
Assert.Equal("Bob Martin", member.GetProperty("fullName").GetString());
}
[Fact]
public async Task PostMember_returns_409_when_user_already_in_circle()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
var first = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
var second = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}
[Fact]
public async Task DeleteMember_returns_200_then_Get_does_not_include_member()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
await http.PostAsJsonAsync(MembersUrl(circleId), new { userId = "bob" }, TestContext.Current.CancellationToken);
var deleteResponse = await http.DeleteAsync(
$"{MembersUrl(circleId)}/bob", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
var getResponse = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public async Task GetMembers_returns_404_when_circle_not_owned_by_caller()
{
ResetDatabaseWithUsers();
// Alice's circle, Bob tries to read its members.
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("bob");
var response = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
// 404, not 403 — the controller deliberately avoids leaking
// the existence of someone else's circle.
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View file

@ -0,0 +1,255 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Client;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
using BlogPostDto = Yavsc.Blogspot.BlogPostDto;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Direct tests for <see cref="BlogApiClient.CreateMultipartContent"/>
/// — the body builder PostIt runs when a post is saved with
/// attachments. Two layers:
///
/// <list type="number">
/// <item><description><b>Shape</b>: call the method with a sample
/// <see cref="BlogPostDto"/> + a couple of
/// <see cref="BlogUploadFile"/>s and assert the produced
/// <see cref="MultipartFormDataContent"/> carries a <c>blog</c>
/// part (camelCase JSON, <c>WhenWritingNull</c> so unset graph
/// members are dropped) and one <c>file</c> part per attachment
/// (right filename, right bytes, declared Content-Type, and the
/// <c>application/octet-stream</c> fallback when the type is
/// unset). This is the contract the controller's
/// <c>ReadBlogRequestAsync</c> + <c>AttachFiles</c> rely on; if
/// the wire shape drifts, the round-trip below fails first, but
/// the shape assertions pin down <i>which</i> field broke.</description></item>
/// <item><description><b>Round-trip</b>: POST the very content the
/// method built to the live <see cref="BlogsWebServerFixture"/>
/// host (authenticated as <c>tester</c>) and assert 201 Created
/// + the attachment row persisted + the file physically landed
/// under <c>{BlogFilesRoot}/tester/blogs/{id}/{fileName}</c>.
/// This proves the shape the method produces is one the server
/// actually accepts — not just well-formed multipart, but the
/// contract <c>BlogApiController.PostBlog</c> binds.</description></item>
/// </list>
///
/// Built on <see cref="BlogsWebServerFixture"/> for the round-trip
/// leg (real SQLite FK enforcement, real <c>BlogSpotService</c>,
/// real <c>AddJwtBearer</c> validating <see cref="TestTokenIssuer"/>
/// tokens). The shape leg needs no host, but the class is
/// <c>[Collection("Yavsc Blogs")]</c> + <c>IClassFixture</c> so the
/// one shared host serves both legs without spinning a second one.
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class CreateMultipartContentTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public CreateMultipartContentTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>Reset the database and seed the
/// <c>tester</c> <see cref="ApplicationUser"/> row — required
/// for the round-trip POST, whose <c>BlogPost.AuthorId</c> is
/// a FK to <c>AspNetUsers.Id</c> that SQLite enforces. See
/// <see cref="BlogApiTests.ResetAndSeedDefaultUser"/> for the
/// same rationale.</summary>
private void ResetAndSeedDefaultUser()
{
_fixture.ResetDatabase();
_fixture.SeedUser("tester");
}
private HttpClient NewClient(string subject = "tester")
{
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 AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject));
return http;
}
private int CountAttachmentsForPost(long postId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.BlogAttachedFiles.Count(a => a.PostId == postId);
}
[Fact]
public async Task Build_produces_blog_part_camelCase_with_WhenWritingNull_and_one_file_part_per_attachment()
{
// A DTO that exercises both branches of the serialiser:
// filled scalar fields (title, article, id, dates, authorId)
// must come through in camelCase; unset graph members
// (Author, Tags, Photo) must be dropped by
// WhenWritingNull — the controller's
// ReadBlogRequestAsync would otherwise receive explicit
// nulls that confuse the resource-based ownership check.
var post = new BlogPostDto
{
Id = 42,
AuthorId = "tester",
Author = null,
Title = "Billet multipart",
Article = "Contenu du billet.",
Photo = null,
Tags = null,
DateCreated = new DateTime(2026, 9, 18, 12, 0, 0, DateTimeKind.Utc),
DateModified = new DateTime(2026, 9, 18, 12, 30, 0, DateTimeKind.Utc)
};
var noteBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
var binBytes = new byte[] { 0x00, 0xFF, 0x42 };
var files = new BlogUploadFile[]
{
new("note.txt", noteBytes, "text/plain"),
new("bin.dat", binBytes, null) // no ContentType → octet-stream fallback
};
var content = BlogApiClient.CreateMultipartContent(post, files);
// --- Top-level shape: a multipart/form-data body. ---
var form = Assert.IsAssignableFrom<MultipartFormDataContent>(content);
Assert.Equal("multipart/form-data", form.Headers.ContentType?.MediaType);
// Index parts by their form field name. The "blog" field
// is unique; the "file" field repeats once per attachment
// (distinguished by ContentDisposition.FileName), so a
// flat dictionary would collide — group instead.
var byName = form
.GroupBy(p => p.Headers.ContentDisposition?.Name?.Trim('"'))
.ToDictionary(g => g.Key, g => g.ToList());
Assert.Contains("blog", byName.Keys);
Assert.Single(byName["blog"]);
// --- "blog" part: camelCase JSON + WhenWritingNull. ---
var blogJson = await byName["blog"][0].ReadAsStringAsync(
TestContext.Current.CancellationToken);
using var doc = JsonDocument.Parse(blogJson);
var root = doc.RootElement;
// camelCase: the serialiser used JsonNamingPolicy.CamelCase,
// so the properties land as title/article/id/dateCreated/….
Assert.True(root.TryGetProperty("title", out var titleEl));
Assert.Equal(post.Title, titleEl.GetString());
Assert.True(root.TryGetProperty("article", out var articleEl));
Assert.Equal(post.Article, articleEl.GetString());
Assert.True(root.TryGetProperty("id", out var idEl));
Assert.Equal(post.Id, idEl.GetInt64());
Assert.True(root.TryGetProperty("authorId", out var authorIdEl));
Assert.Equal(post.AuthorId, authorIdEl.GetString());
Assert.True(root.TryGetProperty("dateCreated", out _));
Assert.True(root.TryGetProperty("dateModified", out _));
// WhenWritingNull: the null graph members are omitted, not
// emitted as explicit nulls.
Assert.False(root.TryGetProperty("author", out _),
$"Expected 'author' to be omitted (WhenWritingNull), got: {blogJson}");
Assert.False(root.TryGetProperty("tags", out _),
$"Expected 'tags' to be omitted (WhenWritingNull), got: {blogJson}");
Assert.False(root.TryGetProperty("photo", out _),
$"Expected 'photo' to be omitted (WhenWritingNull), got: {blogJson}");
// --- "file" parts: one per attachment, right name, type,
// and bytes. The "file" form field carries several parts;
// collect them by filename. ---
var fileParts = form
.Where(p => p.Headers.ContentDisposition?.Name?.Trim('"') == "file")
.ToDictionary(p => p.Headers.ContentDisposition?.FileName?.Trim('"'));
Assert.Equal(2, fileParts.Count);
var notePart = fileParts["note.txt"];
Assert.Equal("text/plain", notePart.Headers.ContentType?.MediaType);
Assert.Equal(noteBytes, await notePart.ReadAsByteArrayAsync(
TestContext.Current.CancellationToken));
// The attachment with no ContentType falls back to
// application/octet-stream — the branch in
// CreateMultipartContent that guards
// string.IsNullOrWhiteSpace(file.ContentType).
var binPart = fileParts["bin.dat"];
Assert.Equal("application/octet-stream", binPart.Headers.ContentType?.MediaType);
Assert.Equal(binBytes, await binPart.ReadAsByteArrayAsync(
TestContext.Current.CancellationToken));
}
[Fact]
public async Task Build_then_POST_round_trips_through_live_host_and_persists_attachment()
{
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var filesRoot = _fixture.BlogFilesRoot;
// Create-shape DTO (Id=0 → the controller treats it as a
// create), mirroring what PostIt's MainPageViewModel.Save
// builds for a first Save with an attachment.
var draft = new BlogPostDto
{
Id = 0,
AuthorId = "tester",
Title = "Billet avec pièce jointe",
Article = "Contenu du billet multipart.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var attachments = new BlogUploadFile[]
{
new("note.txt", System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")
};
// The exact body PostIt would send — built by the method
// under test, not hand-rolled here.
using var content = BlogApiClient.CreateMultipartContent(draft, attachments);
using var request = new HttpRequestMessage(HttpMethod.Post, _fixture.BlogSpotUrl())
{
Content = content
};
var response = await http.SendAsync(request, TestContext.Current.CancellationToken);
// Dump the body on failure so the assertion message is
// enough to start a fix (the framework default is opaque).
if (response.StatusCode != HttpStatusCode.Created)
{
var body = await response.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken);
Assert.Fail(
$"Expected 201 Created, got {(int)response.StatusCode} {response.StatusCode}. Body: {body}");
}
var created = await response.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal("tester", created.AuthorId);
// The attachment row is persisted…
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
// …and the file physically landed where AttachFiles writes
// it: {BlogFilesRoot}/{user}/blogs/{postId}/{fileName}.
var expectedFile = Path.Combine(
filesRoot, "tester", "blogs", created.Id.ToString(), "note.txt");
var written = Directory.Exists(filesRoot)
? string.Join(", ", Directory.EnumerateFiles(filesRoot, "*", SearchOption.AllDirectories))
: "<none>";
Assert.True(File.Exists(expectedFile),
$"Expected uploaded file at {expectedFile}. Actually written: {written}");
}
}

View file

@ -0,0 +1,397 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogs.Tests.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Integration tests for the personal-storage file endpoints
/// (<c>api/v1/fs</c>) served by <c>FileSystemApiController</c> on the
/// Blogs host.
///
/// <para>Two concerns:</para>
/// <list type="number">
/// <item><description><b>Upload records metadata</b>: a multipart
/// <c>POST api/v1/fs</c> persists an <see cref="UploadedFile"/> row
/// owned by the caller and returns its <c>fileId</c>, so the client
/// can later attach that file to an estimate/query by
/// reference.</description></item>
/// <item><description><b>Download authorization</b>:
/// <c>GET api/v1/fs/file/{fileId}</c> serves the bytes from the
/// owner's personal space. The owner may always read; a party
/// (client or provider) of an estimate/query the file is attached
/// to may read; a third party is refused. The bytes live on the
/// Blogs host's disk — this is the cross-container reason the
/// download endpoint lives here and not on the API host.</description></item>
/// </list>
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public FileSystemApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string? name = null)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
// Pass a distinct name to mirror production (sub = GUID, name =
// login); defaults to subject for the existing tests where the
// two are equal.
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, name: name));
return http;
}
private static string PhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName) =>
Path.Combine(fixture.BlogFilesRoot, owner, fileName);
/// <summary>Write a physical file into the owner's personal space,
/// so <c>GetFile</c> can stream it back. Creates the directory if
/// needed.</summary>
private static void WritePhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName, byte[] content)
{
var path = PhysicalFile(fixture, owner, fileName);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, content);
}
private long SeedUploadedFile(string ownerId, string path, string contentType, long length)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var file = new UploadedFile
{
OwnerId = ownerId,
Path = path,
ContentType = contentType,
Length = length,
};
db.UploadedFiles.Add(file);
db.SaveChanges();
return file.Id;
}
[Fact]
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
{
_fixture.ResetDatabase();
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
// once usage >= DiskQuota, and the controller skips the metadata
// row when QuotaOffense is set. A fresh user has quota 0.
_fixture.SeedUser("alice", u => u.DiskQuota = 10_000_000);
var payload = System.Text.Encoding.UTF8.GetBytes("hello personal storage");
using var form = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(payload);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
using var http = NewClient("alice");
var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var item = Assert.Single(doc.RootElement.EnumerateArray());
var fileId = item.GetProperty("fileId").GetInt64();
Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
Assert.Equal("note.txt", item.GetProperty("fileName").GetString());
Assert.False(item.GetProperty("quotaOffense").GetBoolean());
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
Assert.Equal("note.txt", row.Path);
Assert.Equal("text/plain", row.ContentType);
Assert.Equal(payload.Length, row.Length);
// The file physically landed under {BlogFilesRoot}/alice/note.txt.
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
"Expected the uploaded file on disk under the owner's personal root");
}
/// <summary>
/// Regression for the "Mes fichiers" page listing nothing: the
/// personal-storage tree is keyed by the user's <b>login</b>
/// (<c>UserName</c>) — <c>EnsureDestinationDirectory</c> writes
/// under <c>{Blog}/{name-claim}</c>, the download endpoint and
/// avatar paths use <c>user.UserName</c> — but <c>GET api/v1/fs</c>
/// passed <c>User.GetUserId()</c> (the <c>sub</c> claim, a GUID in
/// production) to <c>GetUserFiles</c>, so the listing looked under
/// <c>{Blog}/{sub-GUID}</c>, an empty tree. This test decouples
/// <c>sub</c> from the login (mirroring production) and asserts the
/// listing returns the file stored under the login.
/// </summary>
[Fact]
public async Task Get_fs_lists_files_stored_under_the_user_login()
{
_fixture.ResetDatabase();
// sub = "alice-sub" (GUID-like), login = "alice" — the shape
// ProfileService mints in production. DiskQuota non-zero so the
// upload persists the UploadedFile row (see Post_fs test).
_fixture.SeedUser("alice", u =>
{
u.Id = "alice-sub";
u.DiskQuota = 10_000_000;
});
var payload = System.Text.Encoding.UTF8.GetBytes("list me");
using var form = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(payload);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
// Upload with a token whose sub is the GUID-like id and whose
// name is the login — EnsureDestinationDirectory keys off "name".
using (var http = NewClient("alice-sub", name: "alice"))
{
var post = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
var postBody = await post.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(post.StatusCode == HttpStatusCode.OK,
$"Upload failed: {(int)post.StatusCode} ({post.StatusCode}): {postBody}");
}
// The file landed under the login, not the subject.
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
"Expected the uploaded file under {BlogFilesRoot}/alice/note.txt (the login)");
Assert.False(Directory.Exists(Path.Combine(_fixture.BlogFilesRoot, "alice-sub")),
"The subject-named directory must NOT exist — storage is keyed by the login");
// GET /api/v1/fs must list under the login and find the file.
using var get = NewClient("alice-sub", name: "alice");
var response = await get.GetAsync("/api/v1/fs", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"List failed: {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
Assert.True(doc.RootElement.TryGetProperty("files", out var filesEl),
$"Response has no 'files' property. Body: {body}");
var names = filesEl.EnumerateArray().Select(f => f.GetProperty("name").GetString()).ToArray();
Assert.Contains("note.txt", names);
// The UploadedFile row (OwnerId = sub) is enriched onto the
// entry, so the client can attach the file by reference.
var noteEntry = filesEl.EnumerateArray()
.First(f => f.GetProperty("name").GetString() == "note.txt");
Assert.True(noteEntry.TryGetProperty("id", out var idEl) && idEl.GetInt64() > 0,
"Expected the enriched UploadedFile id on the listed entry");
}
[Fact]
public async Task GetFile_owner_can_download_their_own_file()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var bytes = System.Text.Encoding.UTF8.GetBytes("owner payload");
WritePhysicalFile(_fixture, "alice", "owner-note.txt", bytes);
var fileId = SeedUploadedFile("alice", "owner-note.txt", "text/plain", bytes.Length);
using var http = NewClient("alice");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
Assert.Equal(bytes, downloaded);
}
[Fact]
public async Task GetFile_refuses_a_third_party()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("carol");
var bytes = System.Text.Encoding.UTF8.GetBytes("private to alice");
WritePhysicalFile(_fixture, "alice", "secret.txt", bytes);
var fileId = SeedUploadedFile("alice", "secret.txt", "text/plain", bytes.Length);
// carol is neither the owner nor a party to any estimate/query
// this file is attached to.
using var http = NewClient("carol");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
/// <summary>
/// Seed the graph needed to prove a party (the client of an
/// estimate the file is attached to) can download a file they do
/// not own: alice owns the file and is the provider; bob is the
/// client; the file is attached to the estimate by reference.
/// </summary>
private long SeedPartyDownloadGraph(out long estimateId, out string fileName)
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
fileName = "party-spec.pdf";
var bytes = System.Text.Encoding.UTF8.GetBytes("party payload");
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// Location + PerformerProfile(alice): Estimate.OwnerId is a FK
// to PerformerProfile.PerformerId, so the provider profile must
// exist before the estimate can be inserted.
var location = new Location { Address = "1 rue du Test", Latitude = 48.8, Longitude = 2.3 };
db.Locations.Add(location);
db.SaveChanges();
db.Performers.Add(new PerformerProfile
{
PerformerId = "alice",
SIREN = "12345678901234",
OrganizationAddressId = location.Id,
ExerciseCountryCode = "fr",
AcceptNotifications = true,
AcceptPublicContact = true,
Active = true,
Rate = 5,
WebSite = "https://alice.dev",
});
db.SaveChanges();
var estimate = new Estimate
{
OwnerId = "alice",
ClientId = "bob",
CommandType = "Rdv",
Title = "Devis party test",
Description = "Devis",
AttachedFiles = new List<string>(),
AttachedGraphics = new List<string>(),
Bill = new List<CommandLine>
{
new() { Name = "Prestation", Description = "Prestation", Count = 1, UnitaryCost = 10m, Currency = "EUR" },
},
};
db.Estimates.Add(estimate);
db.SaveChanges();
estimateId = estimate.Id;
var file = new UploadedFile
{
OwnerId = "alice",
Path = fileName,
ContentType = "application/pdf",
Length = bytes.Length,
};
db.UploadedFiles.Add(file);
db.SaveChanges();
db.EstimateAttachedFiles.Add(new EstimateAttachedFile
{
FileId = file.Id,
EstimateId = estimate.Id,
});
db.SaveChanges();
// The bytes must exist on disk for GetFile to stream them.
WritePhysicalFile(_fixture, "alice", fileName, bytes);
return file.Id;
}
[Fact]
public async Task GetFile_party_client_of_an_attached_estimate_can_download()
{
var fileId = SeedPartyDownloadGraph(out _, out _);
// bob is the client of the estimate the file is attached to:
// a party, authorized to read even though alice owns the file.
using var http = NewClient("bob");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
Assert.Equal(System.Text.Encoding.UTF8.GetBytes("party payload"), downloaded);
}
[Fact]
public async Task GetFile_party_provider_of_an_attached_estimate_can_download()
{
var fileId = SeedPartyDownloadGraph(out _, out _);
// alice is the owner too, so this also exercises the owner path;
// the provider of an attached estimate is authorized regardless.
using var http = NewClient("alice");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetFile_unknown_id_returns_404()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
using var http = NewClient("alice");
var response = await http.GetAsync(
"/api/v1/fs/file/999999", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
/// <summary>
/// Regression for the "delete returns 40x yet the file is gone"
/// report: <c>DeleteUserDirOrFile</c> read <c>FileInfo.Length</c>
/// <b>after</b> <c>fi.Delete()</c>, and <c>FileInfo.Length</c>
/// throws <c>FileNotFoundException</c> once the file no longer
/// exists. The controller's <c>catch</c> turned that into a 400
/// even though the file had already been removed — the user saw
/// "le fichier n'existe pas sur le serveur" while the delete in
/// fact succeeded. This test asserts a clean 200 and that the
/// file is gone from disk.
/// </summary>
[Fact]
public async Task Delete_fs_removes_the_file_and_returns_200()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var bytes = System.Text.Encoding.UTF8.GetBytes("delete me");
WritePhysicalFile(_fixture, "alice", "doomed.txt", bytes);
using var http = NewClient("alice");
var response = await http.DeleteAsync(
"/api/v1/fs/doomed.txt", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
Assert.False(File.Exists(PhysicalFile(_fixture, "alice", "doomed.txt")),
"The file must be gone from disk after a successful delete.");
}
}

View file

@ -0,0 +1,416 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Yavsc.Blogs.Controllers;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Services;
using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Shared integration-test host for the Yavsc.Blogs API surface.
/// Specialisation of <see cref="WebHostFixture"/> that wires up
/// only the bits the blog API actually depends on:
///
/// <list type="bullet">
/// <item><description>A SQLite <c>:memory:</c> database
/// (<see cref="Microsoft.EntityFrameworkCore.Sqlite"/>) backed
/// by a single shared <see cref="SqliteConnection"/> held open
/// for the lifetime of the host. SQLite enforces real foreign
/// keys and real transactional semantics, so the tests see the
/// same INSERT-time FK validation a production Postgres host
/// would — unlike the EF Core InMemory provider, which silently
/// ignores FKs and masks bugs that surface only against a real
/// relational engine.</description></item>
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
/// stub: the GET index path doesn't read the file system, so any
/// implementation is fine.</description></item>
/// <item><description>The real <c>BlogSpotService</c>, which calls
/// <c>IAuthorizationService.AuthorizeAsync(user, blog, new EditPermission())</c>
/// on PUT. The fixture registers the real
/// <see cref="PermissionHandler"/> so the resource-based ownership
/// check runs end-to-end; tests that want a 204 PUT must sign a
/// JWT whose <c>sub</c> matches the post's <c>AuthorId</c>.</description></item>
/// <item><description>A real <c>AddJwtBearer</c> with HS256,
/// sharing its <see cref="TestTokenIssuer.SigningKey"/> with the
/// token issuer. The production OIDC discovery path is bypassed:
/// the test host validates tokens locally, against the static
/// signing key, so no IdP is required to exercise auth.</description></item>
/// <item><description>The production <c>BlogScope</c> policy
/// (RequireAuthenticatedUser + RequireClaim("scope", "blogs"))
/// registered verbatim. Tests that omit the bearer header exercise
/// the unauthenticated path and get 401.</description></item>
/// </list>
///
/// No IdentityServer, no SMTP, no static assets — the Org fixture
/// owns all of that and we don't need any of it for blog integration
/// tests. Marked <see cref="CollectionDefinitionAttribute"/> so the
/// host is shared across every <c>[Collection("Yavsc Blogs")]</c>
/// test class: one host, one SQLite DB, one Kestrel port.
/// </summary>
[CollectionDefinition("Yavsc Blogs")]
public sealed class BlogsWebServerFixture : WebHostFixture
{
protected override int HttpsPort => 5103;
public long CircleId { get; private set; }
public long PostId { get; private set; }
public string DefaultUserLogin { get => "alice"; }
/// <summary>Root directory under which
/// <c>BlogSpotService.AttachFiles</c> writes uploaded files
/// during tests (the configured <c>SiteSettings.Blog</c>).
/// Attachments land at
/// <c>{BlogFilesRoot}/{userName}/blogs/{postId}/{fileName}</c>.
/// A fresh per-run temp directory, so tests can assert on the
/// physical file without touching the developer's real
/// user-files tree.</summary>
public string BlogFilesRoot { get; private set; } = "files-root";
// A single SqliteConnection held open at the static level,
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
// shared configuration into static slots. Closing the
// connection destroys the in-memory database — so we close
// it only when the last fixture instance is disposed (see
// Dispose below), exactly when WebHostFixture tears down the
// host.
private static SqliteConnection? _sharedSqliteConnection;
private static readonly object _sqliteLock = new();
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
// Open the shared in-memory connection lazily on the first
// fixture construction. Subsequent constructions (xUnit
// creates one fixture instance per IClassFixture) reuse
// the same connection so all DbContexts across all tests
// see the same database.
SqliteConnection sharedConnection;
lock (_sqliteLock)
{
if (_sharedSqliteConnection is null)
{
// Mode=Memory + Cache=Shared gives us a named
// in-memory database that every connection string
// referencing "File:YavscBlogsTests?mode=memory&cache=shared"
// will resolve to the same backing store, as long
// as at least one SqliteConnection stays open
// against it.
_sharedSqliteConnection = new SqliteConnection(
"Data Source=YavscBlogsTests;Mode=Memory;Cache=Shared");
_sharedSqliteConnection.Open();
}
sharedConnection = _sharedSqliteConnection;
}
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
// UseSqlite(DbConnection) keeps the connection we just
// opened alive for the DbContext's lifetime, instead of
// letting EF open and close its own. Without this,
// each DbContext would get a fresh connection pointing
// at an empty :memory: store and nothing would persist
// across requests.
opt.UseSqlite(sharedConnection));
// Trivial file-system auth: the GET index path never calls
// into it, but the DI container needs an instance.
builder.Services.AddSingleton<IFileSystemAuthManager>(
new NoopFileSystemAuthManager());
// BlogSpotService.AttachFiles writes uploaded files under
// siteSettings.Blog. Without a configured SiteSettings the
// IOptions<SiteSettings> value has Blog == null and
// Path.Combine(null, …) throws inside AttachFiles' catch-all,
// silently persisting no attachment. Point Blog at a
// per-run temp directory so the multipart tests can assert
// on real files.
builder.Services.Configure<SiteSettings>(s =>
{
s.Blog = BlogFilesRoot;
});
// Real BlogSpotService — same instance the production host
// builds (ApplicationDbContext, IAuthorizationService,
// IFileSystemAuthManager). With PermissionHandler registered
// below, Modify() now answers "is the caller the author of
// the post?" for real, which is exactly what we want to
// assert in the PUT tests.
builder.Services.AddScoped<BlogSpotService>();
// The real PermissionHandler: BlogSpotService calls
// IAuthorizationService.AuthorizeAsync(user, blog, new
// EditPermission()) on Modify, and PermissionHandler
// resolves it via IsOwner(user, blog) — i.e. blog.AuthorId
// == user.GetUserId(). To PUT a post, the test JWT must
// carry sub == post.AuthorId.
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
// The BlogApiController is reached through MVC. AddControllers()
// by default scans the test assembly only; we explicitly add the
// Yavsc.Blogs application part so the controller is discovered
// and routed.
builder.Services.AddControllers()
.AddApplicationPart(typeof(BlogApiController).Assembly);
// Production BlogScope policy, verbatim. Two requirements:
// 1. RequireAuthenticatedUser: a request with no bearer
// token (or an invalid one) will be rejected.
// 2. RequireClaim("scope", "blogs"): the JWT must carry a
// "scope" claim whose value is "blogs".
// TestTokenIssuer.Issue() defaults to scope=blogs; the
// GetBlog_returns_401_when_no_token test omits the token
// entirely and asserts the policy fails closed.
builder.Services.AddAuthorization(opt =>
{
opt.AddPolicy("BlogScope", policy =>
{
policy.RequireAuthenticatedUser()
.RequireClaim("scope", "blogs");
});
});
// Real JWT Bearer authentication, sharing the signing key
// with TestTokenIssuer. No Authority → no OIDC discovery,
// no IdP roundtrip; the middleware validates the signature
// and the standard claims against the static configuration
// below. Production uses AddYavscJwtBearer with an IdP, but
// for the unit-test host that path is unwanted coupling.
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.IncludeErrorDetails = true;
// MapInboundClaims = false here mirrors the
// JwtSecurityTokenHandler.DefaultInboundClaimTypeMap
// .Clear() in TestTokenIssuer: the validation
// pipeline must not rewrite "sub" to
// ClaimTypes.NameIdentifier, otherwise the
// PermissionHandler ownership check sees a null
// user id and rejects every PUT.
options.MapInboundClaims = false;
options.TokenValidationParameters
= new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = TestTokenIssuer.Issuer,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestTokenIssuer.SigningKey,
// "sub" stays "sub" (MapInboundClaims only
// remaps long Microsoft claim URIs, not sub).
// UserHelpers.GetUserId reads sub directly.
NameClaimType = "sub",
RoleClaimType = Yavsc.Constants.RoleClaimType,
};
});
return builder.Build();
}
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
{
// UseDeveloperExceptionPage gives full stack traces on
// 500s during tests — much easier to debug than the
// default empty InternalServerError body. Production
// (Yavsc.Org) wires its own exception handler; this
// fixture is test-only.
app.UseDeveloperExceptionPage();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// EnsureCreated + seed alice, run once at host startup.
// EnsureCreated is idempotent (creates only the tables that
// don't exist yet) and runs against the shared
// SqliteConnection (Cache=Shared), so every DbContext that
// resolves through this fixture's host sees the same schema.
// We do NOT call EnsureDeleted: the SqliteConnection is held
// open at the static level and closing it destroys the
// :memory: store for every other DbContext — the org
// fixture can afford EnsureDeleted because its store is
// built fresh per fixture, but the blogs fixture's static
// connection outlives a single fixture instance.
using (var seedScope = app.Services.CreateScope())
{
var db = seedScope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureCreated();
if (!db.Users.Any(u => u.Id == "alice"))
{
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
FullName = "Alice Dupont",
Avatar = "/avatars/alice.png",
});
db.SaveChanges();
// Inline the seed of the circle + post. We don't
// call SeedCircle/SeedBlogPost (the instance helpers)
// because those resolve through this.Services, which
// is null until WebHostFixture.InitializeAsync has
// finished wiring the shared slot — i.e. after this
// method returns. Use app.Services directly.
var circle = new Circle
{
OwnerId = "alice",
Name = "test",
Public = true,
};
db.Circle.Add(circle);
db.SaveChanges();
CircleId = circle.Id;
var post = new BlogPost
{
AuthorId = "alice",
Title = "Billet ACL test",
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
PostId = post.Id;
}
}
await Task.CompletedTask;
return app;
}
/// <summary>Reset the in-memory database to a known empty state.
/// <c>UseInMemoryDatabase</c> shares its store across the
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance,
/// so without a per-test reset the test order would leak
/// state between tests.</summary>
public void ResetDatabase()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
public void CleanupAcl()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.CircleAuthorizationToBlogPost
.Where(a => a.CircleId == CircleId
&& a.BlogPostId == PostId)
.ExecuteDelete();
}
public override void Dispose()
{
// Keep the shared in-memory SQLite connection alive for the
// whole test process. Closing it from one fixture instance can
// destroy the database while other collections are still using
// it, which surfaces as intermittent "no such table" failures.
base.Dispose();
}
/// <summary>Seed an <see cref="ApplicationUser"/> in the shared
/// SQLite store, so tests that POST/PUT/DELETE a
/// <c>BlogPost</c> (whose <c>AuthorId</c> is a FK to
/// <c>AspNetUsers.Id</c>) don't trip the FK constraint that
/// SQLite enforces but the EF Core InMemory provider silently
/// ignored. Idempotent on <paramref name="userName"/>: a
/// second call for the same id is a no-op (the user already
/// exists).</summary>
/// <param name="userName">Both the PK id and the login name.
/// The JWT subject in tests is this same string, so seeding
/// this id is enough to make the FK from a
/// <c>BlogPost.AuthorId</c> resolve.</param>
/// <param name="configure">Optional hook to fill in fields
/// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c>
/// that downstream tests assert on.</param>
public ApplicationUser SeedUser(string userName,
Action<ApplicationUser>? configure = null)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var existing = db.Users.SingleOrDefault(u => u.Id == userName);
if (existing != null) return existing;
// Email is an alternate key on ApplicationUser; seeding
// it explicitly avoids the InMemory provider's null-claim
// tracking quirk (cf. PublishEndpointTests.ResetDatabase)
// and keeps the column shape realistic for prod.
var user = new ApplicationUser
{
Id = userName,
UserName = userName,
Email = $"{userName}@example.test",
};
configure?.Invoke(user);
db.Users.Add(user);
db.SaveChanges();
return user;
}
/// <summary>Trivial <see cref="IFileSystemAuthManager"/> stub. The
/// blog API endpoints exercised by the first tests don't read the
/// file system, so the implementation can be a no-op.</summary>
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
{
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)
=> FileAccessRight.None;
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
{
}
}
/// <summary>Create a circle owned by <paramref name="ownerId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
public long SeedCircle(string ownerId, string name, bool isPublic = false,
ICollection<String>? members = null
)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic };
db.Circle.Add(circle);
db.SaveChanges();
if (members != null && members.Count > 0)
{
foreach (String memberId in members)
{
var member = new CircleMember { CircleId = circle.Id, MemberId = memberId };
db.CircleMembers.Add(member);
}
db.SaveChanges();
}
return circle.Id;
}
/// <summary>Create a blog post owned by <paramref name="authorId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
public long SeedBlogPost(string authorId, string title)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var post = new BlogPost
{
AuthorId = authorId,
Title = title,
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
return post.Id;
}
}

View file

@ -0,0 +1,122 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Yavsc.Blogs.Controllers;
using Yavsc.Models;
using Yavsc.Services;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Dedicated integration-test host that mirrors the production JWT
/// remapping behavior: MapInboundClaims remains enabled and the
/// default inbound map rewrites "sub" to ClaimTypes.NameIdentifier.
/// This is the closest in-process reproduction of the production
/// authentication surface for the blog API.
/// </summary>
public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFixture
{
private readonly InMemoryDatabaseRoot _inMemoryRoot = new();
private readonly Dictionary<string, string> _savedInboundMap;
private WebApplication? _app = null;
public MappedClaimsBlogsWebServerFixture()
{
_savedInboundMap = new Dictionary<string, string>(JwtSecurityTokenHandler.DefaultInboundClaimTypeMap);
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier;
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:5105");
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot));
builder.Services.AddSingleton<IFileSystemAuthManager>(new NoopFileSystemAuthManager());
// BlogSpotService's constructor throws InvalidOperationException
// ("SiteSettings.Blog is not configured.") when Blog is null, so
// every request hitting BlogApiController would 500 resolving the
// service. These tests never upload files (JSON-only POST/PUT), so
// AttachFiles short-circuits — but the service still has to be
// constructible. Point Blog at a per-run temp directory, mirroring
// BlogsWebServerFixture.
builder.Services.Configure<SiteSettings>(s =>
{
s.Blog = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
});
builder.Services.AddScoped<BlogSpotService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
builder.Services.AddControllers()
.AddApplicationPart(typeof(BlogApiController).Assembly);
builder.Services.AddAuthorization(opt =>
{
opt.AddPolicy("BlogScope", policy =>
{
policy.RequireAuthenticatedUser()
.RequireClaim("scope", "blogs");
});
});
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.IncludeErrorDetails = true;
options.MapInboundClaims = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = TestTokenIssuer.Issuer,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestTokenIssuer.SigningKey,
RoleClaimType = Yavsc.Constants.RoleClaimType,
NameClaimType = Yavsc.Constants.NameClaimType,
};
});
_app = builder.Build();
_app.UseRouting();
_app.UseAuthentication();
_app.UseAuthorization();
_app.MapControllers();
_app.StartAsync().GetAwaiter().GetResult();
Addresses = ["http://127.0.0.1:5105"];
Services = _app.Services;
}
public IReadOnlyList<string> Addresses { get; }
public IServiceProvider Services { get; }
public void Dispose()
{
if (_app is null) return;
_app.StopAsync().GetAwaiter().GetResult();
_app.DisposeAsync().AsTask().GetAwaiter().GetResult();
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
foreach (var kvp in _savedInboundMap)
{
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap[kvp.Key] = kvp.Value;
}
}
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
{
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)
=> FileAccessRight.None;
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
{
}
}
}

View file

@ -0,0 +1,93 @@
using System.Net.Http.Json;
using System.Text.Json;
using Yavsc.Api.Client;
namespace Yavsc.Blogs.Tests.Fixtures;
/// <summary>
/// Minimal <see cref="IYavscApiClient"/> transport for integration
/// tests: it forwards <see cref="BlogApiClient"/> calls over a
/// test-owned <see cref="HttpClient"/> (already authenticated by
/// the caller) and applies the contract documented on the
/// interface — JSON bodies serialised with web defaults
/// (camelCase, case-insensitive reads), and an
/// <see cref="HttpRequestException"/> carrying the response body
/// on non-2xx statuses so assertion failures stay actionable.
///
/// <para>Unlike PostIt's <c>YavscApiClient</c>, there is no
/// silent-refresh-on-401: the test token never expires mid-test,
/// and a 401 is a failure we want surfaced, not retried.</para>
///
/// The <see cref="HttpClient"/> is owned by the test, not by this
/// transport, so <see cref="DisposeAsync"/> is a no-op.
/// </summary>
public sealed class TestApiTransport : IYavscApiClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public TestApiTransport(HttpClient http)
{
Http = http ?? throw new ArgumentNullException(nameof(http));
}
public HttpClient Http { get; }
public Task<T> CallAsync<T>(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default)
=> SendAsync<T>(method, path,
body is null ? null : () => JsonContent.Create(body, options: JsonOptions),
ct);
public Task<T> CallAsync<T>(
HttpMethod method,
string path,
Func<HttpContent> contentFactory,
CancellationToken ct = default)
=> SendAsync<T>(method, path, contentFactory, ct);
public async Task CallAsync(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default)
=> await SendAsync<object>(method, path,
body is null ? null : () => JsonContent.Create(body, options: JsonOptions),
ct);
public async Task CallAsync(
HttpMethod method,
string path,
Func<HttpContent> contentFactory,
CancellationToken ct = default)
=> await SendAsync<object>(method, path, contentFactory, ct);
private async Task<T> SendAsync<T>(
HttpMethod method,
string path,
Func<HttpContent>? contentFactory,
CancellationToken ct)
{
using var request = new HttpRequestMessage(method, path);
if (contentFactory is not null)
request.Content = contentFactory();
using var response = await Http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException(
$"HTTP {(int)response.StatusCode} {response.StatusCode} on {method} {path}. Body: {error}");
}
var payload = await response.Content.ReadAsStringAsync(ct);
if (string.IsNullOrWhiteSpace(payload))
return default!; // 201/204 with an empty body (PUT returns 204).
return JsonSerializer.Deserialize<T>(payload, JsonOptions)!;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

View file

@ -0,0 +1,87 @@
using System.Text.Json;
using Yavsc.Blogspot;
using Yavsc.Models.Blog;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Wire-shape tests pinning that <see cref="IBlogPost.IsPublished"/>
/// is carried on the JSON the blog API list endpoint emits.
///
/// <para>The blog API host (<c>Yavsc.Blogs/Program.cs</c>) serialises
/// with System.Text.Json (the <c>.AddControllers()</c> default — no
/// <c>AddNewtonsoftJson</c>), and <c>BlogApiController.GetBlogspot</c>
/// returns <c>IEnumerable&lt;IBlogPost&gt;</c>. System.Text.Json emits
/// only the members of the <b>declared</b> type, so a property that
/// exists solely on the concrete <c>BlogPost</c> entity is dropped from
/// the payload. <c>IsPublished</c> used to be one such property: it was
/// on <c>BlogPost</c> and <c>BlogPostDto</c> but not on
/// <see cref="IBlogPost"/>, so the list response omitted it and PostIt
/// read <c>IsPublished == false</c> for every post — including
/// published ones.</para>
///
/// <para>These tests reproduce that serialisation path directly
/// (declared type <c>IEnumerable&lt;IBlogPost&gt;</c> through
/// <see cref="JsonSerializer.Serialize(object, Type)"/>) and assert
/// <c>isPublished</c> survives the round trip. They live in
/// <c>Yavsc.Blogs.Tests</c> — the assembly that owns the blog API
/// contract — so a regression at the wire boundary is caught here,
/// where it manifested in production (PostIt showing a published
/// post's status as <c>false</c>).</para>
/// </summary>
public sealed class IBlogPostWireShapeTests
{
private static readonly JsonSerializerOptions WebDefaults
= new(JsonSerializerDefaults.Web);
[Fact]
public void IsPublished_is_emitted_when_list_is_serialised_as_IEnumerable_IBlogPost()
{
var post = new BlogPost
{
Id = 13,
Title = "Published",
AuthorId = "alice",
Article = "x",
};
post.IsPublished = true;
// Declared type mirrors GetBlogspot's return: IEnumerable<IBlogPost>.
IEnumerable<IBlogPost> list = new List<BlogPost> { post };
// Serialize with the DECLARED type, the way ASP.NET Core's
// SystemTextJsonOutputFormatter hands context.ObjectType (the
// action return type) to JsonSerializer.Serialize.
var json = JsonSerializer.Serialize(list, typeof(IEnumerable<IBlogPost>),
WebDefaults);
using var doc = JsonDocument.Parse(json);
var element = doc.RootElement[0];
// Web defaults use camelCase, so the property is "isPublished".
Assert.True(element.TryGetProperty("isPublished", out var isPublished),
$"isPublished was dropped from the list payload. JSON: {json}");
Assert.True(isPublished.GetBoolean());
}
[Fact]
public void IsPublished_round_trips_through_BlogPostDto_on_the_client()
{
// The shape the client (PostIt) receives and deserialises with
// PropertyNameCaseInsensitive = true (cf. YavscApiClient).
var json = """
[{
"id": 13,
"title": "Published",
"article": "x",
"authorId": "alice",
"isPublished": true
}]
""";
var posts = JsonSerializer.Deserialize<List<BlogPostDto>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var post = Assert.Single(posts);
Assert.True(post.IsPublished);
}
}

View file

@ -0,0 +1,6 @@
namespace Yavsc.Blogs.Tests;
[CollectionDefinition("JwtClaimMapping", DisableParallelization = true)]
public sealed class JwtClaimMappingCollection
{
}

View file

@ -0,0 +1,147 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Behavioural tests for the publication toggle endpoint:
/// <c>PUT /api/BlogApi/{id}/publish</c> with body
/// <c>{ "publish": bool }</c>.
///
/// <para>The endpoint is the PostIt-facing way to toggle
/// whether a post is publicly readable (via
/// <c>BlogSpotPublication</c>). It does NOT change the
/// ACL — a Public post with a non-empty ACL is still
/// restricted to the ACL's circles for authenticated
/// callers; only anonymous reads open up.</para>
///
/// <para>Same fixture as <see cref="BlogApiTests"/>:
/// in-memory <c>ApplicationDbContext</c>, JWT bearer auth
/// via <see cref="TestTokenIssuer"/>.</para>
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public PublishEndpointTests(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();
// ApplicationUser has an AlternateKey on Email; the
// InMemory provider refuses to track entities whose
// alternate key is null, so we set it explicitly.
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
});
db.SaveChanges();
}
private long SeedPost(string authorId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var post = new BlogPost
{
AuthorId = authorId,
Title = $"post-by-{authorId}",
Article = "test",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
return post.Id;
}
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;
}
[Fact]
public async Task PutPublish_true_returns_204_and_sets_IsPublished_in_subsequent_GET()
{
ResetDatabase();
var postId = SeedPost("alice");
using var http = NewClient("alice");
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var get = await http.GetAsync(_fixture.BlogSpotUrl() + $"/{postId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.Equal($"post-by-alice", doc.RootElement.GetProperty("title").GetString());
Assert.True(doc.RootElement.GetProperty("isPublished").GetBoolean());
}
[Fact]
public async Task PutPublish_false_returns_204_and_clears_IsPublished()
{
ResetDatabase();
var postId = SeedPost("alice");
using var http = NewClient("alice");
await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var get = await http.GetAsync(_fixture.BlogSpotUrl() + $"/{postId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean());
}
[Fact]
public async Task PutPublish_on_unknown_post_returns_404()
{
ResetDatabase();
using var http = NewClient("alice");
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(99999L), new { publish = true }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, put.StatusCode);
}
[Fact]
public async Task PutPublish_by_non_author_returns_challenge()
{
ResetDatabase();
var postId = SeedPost("alice");
using var http = NewClient("bob");
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
// 401 Challenge (the controller returns Challenge()
// for AuthorizationFailureException). The exact code
// is framework-dependent; what matters is "not 204".
Assert.NotEqual(HttpStatusCode.NoContent, put.StatusCode);
}
}

View file

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Blogs.Tests</RootNamespace>
<UserSecretsId>b1a9d0d6-3f5e-4a07-9f0a-7e4d5b6c1a82</UserSecretsId>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\..\Yavsc.Api.Client\Yavsc.Api.Client.csproj" />
<ProjectReference Include="..\..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\..\Yavsc.Blogs\Yavsc.Blogs.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>