using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Yavsc.Models; using Yavsc.Models.Blog; using Yavsc.Tests.Shared; namespace Yavsc.Blogs.Tests; /// /// Behavioural tests for BlogApiController. Built on the /// scaffold: in-memory /// ApplicationDbContext, real BlogSpotService, and a /// real AddJwtBearer validating HS256 tokens signed by /// . The production BlogScope /// policy runs unmodified — sending Authorization: Bearer … /// 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. /// public sealed class BlogApiTests : IClassFixture { private readonly BlogsWebServerFixture _fixture; public BlogApiTests(BlogsWebServerFixture fixture) { _fixture = fixture; } /// Reset the in-memory database to a known empty state. /// UseInMemoryDatabase shares its store across the /// lifetime of the instance, /// so without a per-test reset the test order would leak /// state between tests. private void ResetDatabase() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureDeleted(); db.Database.EnsureCreated(); } /// The fixture's WebApplication is bound to /// https://localhost:<random> via /// . We pick the first /// https URL and append the controller route /// (/api/v1/blog, matching the production /// [Route(APIPrefix + "/blog")]). private string BlogsUrl => _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog"; /// Build an authenticated client: a real /// Authorization: Bearer <jwt> header where the JWT /// is signed by and carries /// sub = subject. The production BlogScope policy /// reads scope=blogs off the same token, so /// TestTokenIssuer.Issue's default scope is enough. 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; } /// Build an unauthenticated client. Used to assert that /// the BlogScope policy fails closed when no bearer /// token is presented. private HttpClient NewAnonymousClient() { var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }; return new HttpClient(handler) { BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://"))) }; } [Fact] public async Task GetBlogs_returns_200_with_empty_list_when_no_posts() { ResetDatabase(); using var http = NewClient(); var response = await http.GetAsync("/api/v1/blog"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); // 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() { ResetDatabase(); 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("/api/v1/blog", draft); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); // The POST returns the server-issued post (with a real Id). var created = await postResponse.Content.ReadFromJsonAsync(); 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("/api/v1/blog"); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync()); 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 GetBlog_returns_401_when_no_token_is_provided() { 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("/api/v1/blog"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update() { ResetDatabase(); // 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("/api/v1/blog", draft); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); var created = (await postResponse.Content.ReadFromJsonAsync())!; // 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($"/api/v1/blog/{created.Id}", update); Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode); // The list should now reflect the new title. var listResponse = await http.GetAsync("/api/v1/blog"); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync()); 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 DeleteBlog_removes_a_post_and_Get_returns_an_empty_list() { ResetDatabase(); 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("/api/v1/blog", draft); var created = (await postResponse.Content.ReadFromJsonAsync())!; var deleteResponse = await http.DeleteAsync($"/api/v1/blog/{created.Id}"); Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); // The list should now be empty. var listResponse = await http.GetAsync("/api/v1/blog"); using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync()); 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. ResetDatabase(); 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("/api/v1/blog", draft); // 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(); 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). 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("/api/v1/blog", draft); if (response.StatusCode != HttpStatusCode.BadRequest) { var body = await response.Content.ReadAsStringAsync(); Assert.Fail(string.Format("Expected 400 BadRequest (empty Title is invalid), got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body)); } } }