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