REORG
This commit is contained in:
parent
f0792a844f
commit
8c8ab70d38
82 changed files with 113 additions and 94 deletions
390
src/test/Yavsc.Blogs.Tests/BlogAclApiTests.cs
Normal file
390
src/test/Yavsc.Blogs.Tests/BlogAclApiTests.cs
Normal 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": <id> }</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());
|
||||
}
|
||||
|
||||
}
|
||||
161
src/test/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
Normal file
161
src/test/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
40
src/test/Yavsc.Blogs.Tests/BlogApiSmokeTests.cs
Normal file
40
src/test/Yavsc.Blogs.Tests/BlogApiSmokeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
719
src/test/Yavsc.Blogs.Tests/BlogApiTests.cs
Normal file
719
src/test/Yavsc.Blogs.Tests/BlogApiTests.cs
Normal 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 <jwt></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));
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src/test/Yavsc.Blogs.Tests/BlogSpotServiceIndexTests.cs
Normal file
164
src/test/Yavsc.Blogs.Tests/BlogSpotServiceIndexTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
199
src/test/Yavsc.Blogs.Tests/CircleMembersApiTests.cs
Normal file
199
src/test/Yavsc.Blogs.Tests/CircleMembersApiTests.cs
Normal 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<ApplicationUser></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);
|
||||
}
|
||||
}
|
||||
255
src/test/Yavsc.Blogs.Tests/CreateMultipartContentTests.cs
Normal file
255
src/test/Yavsc.Blogs.Tests/CreateMultipartContentTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
397
src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs
Normal file
397
src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
416
src/test/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs
Normal file
416
src/test/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/test/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs
Normal file
93
src/test/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs
Normal 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;
|
||||
}
|
||||
87
src/test/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs
Normal file
87
src/test/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs
Normal 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<IBlogPost></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<IBlogPost></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);
|
||||
}
|
||||
}
|
||||
6
src/test/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs
Normal file
6
src/test/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
[CollectionDefinition("JwtClaimMapping", DisableParallelization = true)]
|
||||
public sealed class JwtClaimMappingCollection
|
||||
{
|
||||
}
|
||||
147
src/test/Yavsc.Blogs.Tests/PublishEndpointTests.cs
Normal file
147
src/test/Yavsc.Blogs.Tests/PublishEndpointTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
35
src/test/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj
Normal file
35
src/test/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj
Normal 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>
|
||||
Loading…
Reference in a new issue