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

View file

@ -0,0 +1,161 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class ActivityApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public ActivityApiControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
[Fact]
public async Task GetUsers_returns_declared_user_for_exact_activity_code()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient();
var response = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Single(payload!);
Assert.Equal("alice", payload[0].PerformerId);
Assert.Equal("alice", payload[0].UserName);
Assert.True(payload[0].HasPerformerProfile);
Assert.True(payload[0].Active);
Assert.Equal("dev", payload[0].ActivityCode);
}
[Fact]
public async Task GetUsers_returns_user_even_when_performer_inactive()
{
_fixture.ResetAndSeedActivityGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
var performer = db.Performers.Single(p => p.PerformerId == "alice");
performer.Active = false;
db.SaveChanges();
}
using var http = NewClient();
var response = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Single(payload!);
Assert.Equal("alice", payload[0].PerformerId);
Assert.False(payload[0].Active);
}
[Fact]
public async Task Catalog_and_Performers_are_consistent_for_dev_activity()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient();
var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityInfo>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
var dev = catalog!.Single(a => a.Code == "dev");
Assert.True(dev.PerformerCount > 0);
var performersResponse = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, performersResponse.StatusCode);
var performers = await performersResponse.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
Assert.NotNull(performers);
Assert.Equal(dev.PerformerCount, performers!.Count);
Assert.Contains(performers, p => p.PerformerId == "alice");
}
[Fact]
public async Task Catalog_does_not_list_activity_without_declaration()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient();
var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityInfo>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
Assert.DoesNotContain(catalog!, a => a.Code == "ghost");
Assert.Contains(catalog!, a => a.Code == "dev");
}
[Fact]
public async Task Catalog_lists_declared_activity_even_when_performer_inactive()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient();
var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityInfo>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
Assert.Contains(catalog!, a => a.Code == "declared-only");
var response = await http.GetAsync("/api/v1/activity/declared-only/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Single(payload!);
Assert.Equal("bob", payload[0].PerformerId);
Assert.Equal("bob", payload[0].UserName);
Assert.True(payload[0].HasPerformerProfile);
Assert.False(payload[0].Active);
}
[Fact]
public async Task Performers_alias_returns_same_payload_as_users_endpoint()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient();
var usersResponse = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
var performersResponse = await http.GetAsync("/api/v1/activity/dev/performers", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, usersResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, performersResponse.StatusCode);
var usersPayload = await usersResponse.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
var performersPayload = await performersResponse.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
Assert.NotNull(usersPayload);
Assert.NotNull(performersPayload);
Assert.Equal(usersPayload!.Count, performersPayload!.Count);
Assert.Equal(usersPayload[0].PerformerId, performersPayload[0].PerformerId);
Assert.Equal(usersPayload[0].UserName, performersPayload[0].UserName);
}
}

View file

@ -0,0 +1,6 @@
namespace Yavsc.Api.Test;
[CollectionDefinition("Yavsc Api")]
public sealed class ApiCollection
{
}

View file

@ -0,0 +1,236 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class BillingControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public BillingControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
[Fact]
public async Task GetProviderOngoingCommands_returns_current_provider_requests()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedActivityGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single();
db.RdvQueries.Add(new RdvQuery
{
ActivityCode = "dev",
ClientId = "bob",
PerformerId = "alice",
Consent = true,
UserCreated = "alice",
UserModified = "alice",
DateCreated = DateTime.UtcNow.AddMinutes(-10),
DateModified = DateTime.UtcNow.AddMinutes(-8),
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Rendez-vous fournisseur",
Status = QueryStatus.InProgress,
Description = "Commande fournisseur en cours",
});
db.RdvQueries.Add(new RdvQuery
{
ActivityCode = "dev",
ClientId = "alice",
PerformerId = "bob",
Consent = true,
UserCreated = "bob",
UserModified = "bob",
DateCreated = DateTime.UtcNow.AddMinutes(-20),
DateModified = DateTime.UtcNow.AddMinutes(-20),
EventDate = DateTime.UtcNow.AddDays(2),
Location = location,
Reason = "Commande d'un autre prestataire",
Status = QueryStatus.Accepted,
Description = "Autre prestataire",
});
db.SaveChanges();
}
using var http = NewClient();
var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
var payload = await response.Content.ReadFromJsonAsync<List<ProviderOngoingCommandDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.NotEmpty(payload!);
Assert.All(payload!, item => Assert.Equal("alice", item.PerformerId));
Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Rdv);
}
[Fact]
public async Task GetProviderOngoingCommands_ignores_rows_with_invalid_discriminator()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedActivityGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.ExecuteSqlInterpolated($@"
INSERT INTO ""NominativeServiceCommands""
(""ActivityCode"", ""ClientId"", ""Consent"", ""DateCreated"", ""DateModified"", ""Description"", ""Discriminator"", ""PerformerId"", ""Status"", ""UserCreated"", ""UserModified"")
VALUES
({"dev"}, {"bob"}, {true}, {DateTime.UtcNow.AddMinutes(-5)}, {DateTime.UtcNow.AddMinutes(-4)}, {"Legacy malformed row"}, {""}, {"alice"}, {(int)QueryStatus.Accepted}, {"alice"}, {"alice"});
");
var location = db.Locations.Single();
db.RdvQueries.Add(new RdvQuery
{
ActivityCode = "dev",
ClientId = "bob",
PerformerId = "alice",
Consent = true,
UserCreated = "alice",
UserModified = "alice",
DateCreated = DateTime.UtcNow.AddMinutes(-3),
DateModified = DateTime.UtcNow.AddMinutes(-2),
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Commande valide",
Status = QueryStatus.InProgress,
Description = "Commande fournisseur valide",
});
db.SaveChanges();
}
using var http = NewClient();
var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
var payload = await response.Content.ReadFromJsonAsync<List<ProviderOngoingCommandDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.NotEmpty(payload!);
Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Rdv && item.PerformerId == "alice");
Assert.DoesNotContain(payload!, item => string.IsNullOrWhiteSpace(item.BillingCode));
}
[Fact]
public async Task GetProviderOngoingCommands_returns_haircut_and_grouped_haircut_requests()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedHaircutGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.UserActivities.Add(new UserActivity
{
UserId = "alice",
DoesCode = "brush",
Weight = 50,
});
db.UserActivities.Add(new UserActivity
{
UserId = "alice",
DoesCode = "mbrush",
Weight = 50,
});
db.CommandForm.Add(new CommandForm
{
ActivityCode = "brush",
ActionName = BillingCodes.Brush,
Title = "Brush",
});
db.CommandForm.Add(new CommandForm
{
ActivityCode = "mbrush",
ActionName = BillingCodes.MBrush,
Title = "MBrush",
});
db.SaveChanges();
}
using var http = NewClient();
var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
var payload = await response.Content.ReadFromJsonAsync<List<ProviderOngoingCommandDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Brush && item.PerformerId == "alice");
Assert.Contains(payload!, item => item.BillingCode == BillingCodes.MBrush && item.PerformerId == "alice");
}
[Fact]
public async Task GetProviderOngoingCommands_excludes_requests_outside_performer_declared_activities()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedHaircutGraph();
using var http = NewClient();
var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
var payload = await response.Content.ReadFromJsonAsync<List<ProviderOngoingCommandDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.Brush);
Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.MBrush);
}
private sealed class ProviderOngoingCommandDto
{
public long Id { get; set; }
public string BillingCode { get; set; } = string.Empty;
public string ActivityCode { get; set; } = string.Empty;
public string PerformerId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public QueryStatus Status { get; set; }
public string Description { get; set; } = string.Empty;
}
}

View file

@ -0,0 +1,342 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public EstimateApiControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
/// <summary>
/// Seed a provider (alice) and a client (bob) with a pending
/// <see cref="RdvQuery"/> from bob to alice, and return the
/// command id.
/// </summary>
private long SeedPendingCommand()
{
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single();
var query = new RdvQuery
{
ActivityCode = "dev",
ClientId = "bob",
PerformerId = "alice",
Consent = true,
UserCreated = "bob",
UserModified = "bob",
DateCreated = DateTime.UtcNow.AddMinutes(-10),
DateModified = DateTime.UtcNow.AddMinutes(-10),
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Demande de devis",
Status = QueryStatus.InProgress,
Description = "Demande en attente de devis",
};
db.RdvQueries.Add(query);
db.SaveChanges();
return query.Id;
}
private static object NewEstimatePayload(long? commandId, string clientId, string? ownerId = null)
=> new
{
CommandId = commandId,
ClientId = clientId,
OwnerId = ownerId,
CommandType = BillingCodes.Rdv,
Title = "Devis prestation",
Description = "Devis détaillé",
AttachedFiles = Array.Empty<string>(),
AttachedGraphics = Array.Empty<string>(),
Bill = new[]
{
new { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
new { Name = "Remise", Description = "Remise fidélité", Count = 1, UnitaryCost = -20m, Currency = "EUR" },
},
};
[Fact]
public async Task PostEstimate_creates_the_estimate_and_validates_the_linked_command()
{
var commandId = SeedPendingCommand();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId, clientId: "bob"),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var estimateId = doc.RootElement.GetProperty("id").GetInt64();
Assert.True(estimateId > 0);
Assert.Equal(2, doc.RootElement.GetProperty("bill").GetArrayLength());
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = db.Estimates.Include(e => e.Bill).Single(e => e.Id == estimateId);
Assert.Equal("alice", estimate.OwnerId);
Assert.Equal("bob", estimate.ClientId);
Assert.Equal(commandId, estimate.CommandId);
Assert.Equal(BillingCodes.Rdv, estimate.CommandType);
Assert.Equal(2, estimate.Bill.Count);
Assert.Contains(estimate.Bill, line => line.UnitaryCost == -20m);
// PostEstimate stamps the linked command as validated.
var query = db.RdvQueries.Single(q => q.Id == commandId);
Assert.NotNull(query.ValidationDate);
}
[Fact]
public async Task PostEstimate_without_command_creates_a_standalone_estimate()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: null, clientId: "bob"),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var estimateId = doc.RootElement.GetProperty("id").GetInt64();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = db.Estimates.Single(e => e.Id == estimateId);
Assert.Null(estimate.CommandId);
Assert.Equal("alice", estimate.OwnerId);
}
[Fact]
public async Task PostEstimate_for_another_owner_is_rejected()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: null, clientId: "bob", ownerId: "bob"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Empty(db.Estimates);
}
[Fact]
public async Task PostEstimate_with_an_unknown_command_id_is_rejected()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: 999999, clientId: "bob"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Empty(db.Estimates);
}
[Fact]
public async Task PostEstimate_without_token_is_unauthorized()
{
_fixture.ResetAndSeedActivityGraph();
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: null, clientId: "bob"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
/// <summary>
/// Seeds an estimate owned by alice (the provider) for the given
/// client, optionally already validated by the client. By default the
/// estimate is also provider-validated (i.e. the provider has submitted
/// it); pass <paramref name="providerValidated"/> = false to seed a
/// freshly inserted request the provider hasn't worked on yet.
/// </summary>
private long SeedEstimate(string clientId, bool clientValidated, bool providerValidated = true)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = new Estimate
{
ClientId = clientId,
OwnerId = "alice",
CommandType = BillingCodes.Rdv,
Title = "Devis prestation",
Description = "Devis de test",
AttachedFiles = new List<string>(),
AttachedGraphics = new List<string>(),
Bill = new List<CommandLine>
{
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
},
ProviderValidationDate = providerValidated ? DateTime.UtcNow : default,
ClientValidationDate = clientValidated ? DateTime.UtcNow : default,
};
db.Estimates.Add(estimate);
db.SaveChanges();
return estimate.Id;
}
[Fact]
public async Task GetOngoingEstimatesAsClient_returns_only_the_current_users_ongoing_estimates()
{
_fixture.ResetAndSeedActivityGraph();
var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
SeedEstimate(clientId: "alice", clientValidated: false); // another client's: excluded
using var http = NewClient("bob");
var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var items = doc.RootElement.EnumerateArray().ToArray();
var item = Assert.Single(items);
Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
Assert.Equal("bob", item.GetProperty("clientId").GetString());
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
}
[Fact]
public async Task GetOngoingEstimatesAsClient_excludes_inserted_requests_not_yet_provider_validated()
{
_fixture.ResetAndSeedActivityGraph();
var submittedId = SeedEstimate(clientId: "bob", clientValidated: false); // provider submitted: visible
SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: false); // inserted, not worked on: excluded
using var http = NewClient("bob");
var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var items = doc.RootElement.EnumerateArray().ToArray();
var item = Assert.Single(items);
Assert.Equal(submittedId, item.GetProperty("id").GetInt64());
}
[Fact]
public async Task GetOngoingEstimatesAsProvider_returns_estimates_the_provider_has_not_yet_signed()
{
_fixture.ResetAndSeedActivityGraph();
// Not yet provider-signed: visible in the provider's "awaiting
// provider signature" list.
var pendingId = SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: false);
// Provider already signed: must leave the list immediately.
SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: true);
using var http = NewClient("alice");
var response = await http.GetAsync("/api/v1/estimate/asprovider", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var items = doc.RootElement.EnumerateArray().ToArray();
var item = Assert.Single(items);
Assert.Equal(pendingId, item.GetProperty("id").GetInt64());
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
Assert.Single(item.GetProperty("bill").EnumerateArray());
}
[Fact]
public async Task GetOngoingEstimatesAsClient_without_token_is_unauthorized()
{
_fixture.ResetAndSeedActivityGraph();
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GetOngoingEstimatesAsProvider_without_token_is_unauthorized()
{
_fixture.ResetAndSeedActivityGraph();
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
var response = await http.GetAsync("/api/v1/estimate/asprovider", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}

View file

@ -0,0 +1,303 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
/// <summary>
/// Integration tests for the estimate attachment endpoints
/// (<c>GET/POST/DELETE api/v1/estimate/{id}/attachments</c>), which
/// attach a file from the provider's personal storage to an estimate
/// **by reference** (<see cref="EstimateAttachedFile"/>).
///
/// <para>Coverage mirrors the authorization contract documented on the
/// controller: only the provider (<c>estimate.OwnerId</c>) may attach or
/// detach, the file must belong to the caller, and listing is open to
/// both parties (provider/client) plus admin. A third party is refused.
/// The download of the bytes themselves is served by the Blogs host and
/// exercised in <c>Yavsc.Blogs.Tests</c>; these tests cover only the
/// metadata/relationship endpoints that live on the API host.</para>
/// </summary>
[Collection("Yavsc Api")]
public sealed class EstimateAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public EstimateAttachmentsApiTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
/// <summary>
/// Reset the graph (seeding alice the provider and bob the client),
/// create an estimate owned by alice for bob, and seed two personal
/// <see cref="UploadedFile"/> rows: one owned by alice (the provider's
/// own file) and one owned by bob (a file that does NOT belong to the
/// provider, used to assert the ownership guard on attach).
/// </summary>
private (long estimateId, long aliceFileId, long bobFileId) Seed()
{
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = new Estimate
{
ClientId = "bob",
OwnerId = "alice",
CommandType = BillingCodes.Rdv,
Title = "Devis prestation",
Description = "Devis de test",
AttachedFiles = new List<string>(),
AttachedGraphics = new List<string>(),
Bill = new List<CommandLine>
{
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
},
};
db.Estimates.Add(estimate);
db.SaveChanges();
var aliceFile = new UploadedFile
{
OwnerId = "alice",
Path = "provider-doc.pdf",
ContentType = "application/pdf",
Length = 2048,
};
var bobFile = new UploadedFile
{
OwnerId = "bob",
Path = "client-note.txt",
ContentType = "text/plain",
Length = 64,
};
db.UploadedFiles.AddRange(aliceFile, bobFile);
db.SaveChanges();
return (estimate.Id, aliceFile.Id, bobFile.Id);
}
private int CountAttachments(long estimateId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.EstimateAttachedFiles.Count(a => a.EstimateId == estimateId);
}
[Fact]
public async Task Provider_attaching_own_file_creates_the_link()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
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.Equal(1, CountAttachments(estimateId));
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var link = Assert.Single(db.EstimateAttachedFiles.Where(a => a.EstimateId == estimateId));
Assert.Equal(aliceFileId, link.FileId);
}
[Fact]
public async Task Attaching_the_same_file_twice_is_idempotent()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
var second = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
Assert.Equal(1, CountAttachments(estimateId));
}
[Fact]
public async Task Non_provider_cannot_attach_to_an_estimate()
{
var (estimateId, aliceFileId, _) = Seed();
// bob is the client of the estimate, not the provider.
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(estimateId));
}
[Fact]
public async Task Provider_cannot_attach_a_file_they_do_not_own()
{
var (estimateId, _, bobFileId) = Seed();
// alice is the provider, but the file belongs to bob.
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(estimateId));
}
[Fact]
public async Task Provider_detaches_a_file()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(1, CountAttachments(estimateId));
var response = await http.DeleteAsync(
$"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(0, CountAttachments(estimateId));
}
[Fact]
public async Task Non_provider_cannot_detach()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
using var bob = NewClient("bob");
var response = await bob.DeleteAsync(
$"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(1, CountAttachments(estimateId));
}
[Fact]
public async Task Provider_lists_attachments()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
var response = await http.GetAsync(
$"/api/v1/estimate/{estimateId}/attachments",
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());
Assert.Equal(aliceFileId, item.GetProperty("fileId").GetInt64());
Assert.Equal("provider-doc.pdf", item.GetProperty("path").GetString());
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
}
[Fact]
public async Task Client_can_list_attachments_as_a_party()
{
var (estimateId, aliceFileId, _) = Seed();
using var alice = NewClient("alice");
await alice.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
// bob is the client of the estimate: a party, may list.
using var bob = NewClient("bob");
var response = await bob.GetAsync(
$"/api/v1/estimate/{estimateId}/attachments",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Third_party_cannot_list_attachments()
{
var (estimateId, aliceFileId, _) = Seed();
using var alice = NewClient("alice");
await alice.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
// carol is neither the provider nor the client.
using var carol = NewClient("carol");
var response = await carol.GetAsync(
$"/api/v1/estimate/{estimateId}/attachments",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Listing_an_unknown_estimate_returns_404()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.GetAsync(
"/api/v1/estimate/999999/attachments",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View file

@ -0,0 +1,191 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Reflection;
using Yavsc.Helpers;
using Yavsc.Models.Billing;
using Yavsc.ViewModels.Gen;
namespace Yavsc.Api.Test;
// Unit test of the estimate PDF generation pipeline (no HTTP fixture
// needed): it exercises TeXHelpers.SignatureToTikz — the vector drawing
// of the provider and client signatures — and TeXHelpers.GenerateEstimatePdf
// — the lualatex stdin compile that writes <name>.pdf into DestDir. The
// TeX source mirrors what the Estimate_tex template emits for a devis
// signed by both parties: two side-by-side minipages, each with a
// tikzpicture bounded by \useasboundingbox and the strokes drawn by
// SignatureToTikz.
//
// The PDF test requires the system `lualatex` (and the TeX Live packages
// listed in the README runtime-deps section) on the host running the
// tests. On hosts without lualatex it skips cleanly rather than failing.
public sealed class EstimatePdfGenerationTests
{
// A provider signature: a downstroke, a cross, and a tap (dot).
// PostIt wire format — [k, x0,y0, x1,y1, …] per stroke, coords in
// [0, 10000], y-down.
private static readonly int[] ProStrokes =
{
4, 2000, 8000, 2000, 5000, 2000, 2000, 2100, 1900,
3, 2000, 5000, 3500, 4500, 3500, 5100,
1, 4000, 3000,
};
// A client signature: a wavy stroke and a shorter one.
private static readonly int[] CliStrokes =
{
5, 1000, 7000, 1500, 4000, 2200, 3000, 3000, 2500, 4000, 1500,
2, 3000, 6000, 4200, 5500,
};
[Fact]
public void SignatureToTikz_RendersBothSignaturesAsVectorDraws()
{
var pro = MakeSig(SignatureType.Pro, ProStrokes);
var cli = MakeSig(SignatureType.Client, CliStrokes);
var proTex = pro.SignatureToTikz(5, 2.5).ToString();
var cliTex = cli.SignatureToTikz(5, 2.5).ToString();
Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", proTex);
Assert.Contains("\\fill", proTex); // the single-point tap → dot
Assert.Contains("(1.000,0.500)", proTex); // first point (2000,8000), y flipped
Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", cliTex);
Assert.Contains("--", cliTex);
}
[Fact]
public void SignatureToTikz_EmptySignatureIsEmpty()
{
var empty = MakeSig(SignatureType.Pro, Array.Empty<int>());
Assert.Equal("", empty.SignatureToTikz(5, 2.5).ToString());
}
[Fact]
public void GenerateEstimatePdf_WithBothSignatures_ProducesPdfInDestDir()
{
if (!IsLualatexAvailable())
throw Xunit.Sdk.SkipException.ForSkip(
"lualatex is not installed on this host; skipping PDF generation test. " +
"Install the TeX Live runtime deps documented in the README.");
var destDir = Path.Combine(Path.GetTempPath(),
"yavsc-estimate-pdf-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(destDir);
try
{
var pro = MakeSig(SignatureType.Pro, ProStrokes);
var cli = MakeSig(SignatureType.Client, CliStrokes);
var baseName = "estimate-test-both-signed";
var model = new PdfGenerationViewModel
{
TeXSource = BuildEstimateTeX(pro, cli),
BaseFileName = baseName,
DestDir = destDir,
};
var fo = model.GenerateEstimatePdf();
// The PDF must exist, at the expected path, and be non-empty.
var expectedPath = Path.Combine(destDir, baseName + ".pdf");
Assert.True(fo is not null && fo.Exists,
"GenerateEstimatePdf returned a non-existent file. Error: "
+ model.GenerationErrorMessage?.Value);
Assert.Equal(expectedPath, fo.FullName);
Assert.True(fo.Length > 0, "generated PDF is empty");
// Only the PDF should persist in DestDir — aux/log artifacts
// are cleaned up by GenerateEstimatePdf on success.
var leftovers = Directory.GetFiles(destDir)
.Where(p => !Path.GetFileName(p).Equals(baseName + ".pdf", StringComparison.Ordinal))
.ToArray();
Assert.Empty(leftovers);
}
finally
{
if (Directory.Exists(destDir))
Directory.Delete(destDir, recursive: true);
}
}
private static Signature MakeSig(SignatureType type, int[] strokes)
=> new()
{
Type = type,
CoordinateMax = 10_000,
Strokes = strokes,
CapturedAtUtc = new DateTime(2026, 9, 22, 0, 0, 0, DateTimeKind.Utc),
};
// Builds the LaTeX source the Estimate_tex template would emit for a
// devis signed by both parties: same preamble (incl. tikz + bera) and
// the two-minipage signature foot, with the strokes drawn by
// SignatureToTikz.
private static string BuildEstimateTeX(Signature pro, Signature cli)
{
var fr = CultureInfo.CreateSpecificCulture("fr-FR");
var date = new DateTime(2026, 9, 22).ToString("dddd dd MMMM yyyy", fr);
var proTikz = pro.SignatureToTikz(5, 2.5).ToString();
var cliTikz = cli.SignatureToTikz(5, 2.5).ToString();
return $@"\documentclass[french,11pt]{{article}}
\usepackage{{eurosym}}
\usepackage{{babel}}
\usepackage[T1]{{fontenc}}
\usepackage[utf8]{{inputenc}}
\usepackage[a4paper]{{geometry}}
\usepackage{{units}}
\usepackage{{bera}}
\usepackage{{graphicx}}
\usepackage{{fancyhdr}}
\usepackage{{fp}}
\usepackage{{tikz}}
\geometry{{verbose,tmargin=4em,bmargin=8em,lmargin=6em,rmargin=6em}}
\setlength{{\parindent}}{{0pt}}
\begin{{document}}
Devis n°1 --- test signe par les deux parties
\vspace{{2em}}
\noindent
\begin{{minipage}}[t]{{0.45\textwidth}}
\textbf{{Signature du fournisseur}}\\
\small Valide le {date}\\
\begin{{tikzpicture}}
\useasboundingbox (0,0) rectangle (5,2.5);
{proTikz}
\end{{tikzpicture}}
\end{{minipage}}\hfill
\begin{{minipage}}[t]{{0.45\textwidth}}
\textbf{{Signature du client}}\\
\small Valide le {date}\\
\begin{{tikzpicture}}
\useasboundingbox (0,0) rectangle (5,2.5);
{cliTikz}
\end{{tikzpicture}}
\end{{minipage}}
\end{{document}}
";
}
private static bool IsLualatexAvailable()
{
try
{
using var p = Process.Start(new ProcessStartInfo("lualatex", "--version")
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
});
if (p is null) return false;
return p.WaitForExit(5000) && p.ExitCode == 0;
}
catch
{
return false;
}
}
}

View file

@ -0,0 +1,500 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Npgsql;
using Yavsc.Controllers;
using Yavsc.Interfaces.Workflow;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Google.Messaging;
using Yavsc.Models.Haircut;
using Yavsc.Models.Messaging;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Services;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test.Fixtures;
public sealed class ApiWebServerFixture : WebHostFixture
{
private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
protected override int HttpsPort => 5104;
private static SqliteConnection? _sharedSqliteConnection;
private static readonly object _sqliteLock = new();
private static readonly object _npgsqlLock = new();
private static string? _sharedNpgsqlConnectionString;
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
if (UseNpgsqlProvider())
{
var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated();
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseNpgsql(npgsqlConnectionString,
x => x.MigrationsAssembly("Yavsc.Org")));
}
else
{
SqliteConnection sharedConnection;
lock (_sqliteLock)
{
if (_sharedSqliteConnection is null)
{
_sharedSqliteConnection = new SqliteConnection(
"Data Source=YavscApiTests;Mode=Memory;Cache=Shared");
_sharedSqliteConnection.Open();
}
sharedConnection = _sharedSqliteConnection;
}
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseSqlite(sharedConnection));
}
builder.Services.AddControllers()
.AddApplicationPart(typeof(ActivityApiController).Assembly);
builder.Services.AddLocalization();
builder.Services.Configure<GoogleAuthSettings>(_ => { });
builder.Services.AddTransient<IBillingService, BillingService>();
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
builder.Services.AddAuthorization();
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.IncludeErrorDetails = true;
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = TestTokenIssuer.Issuer,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestTokenIssuer.SigningKey,
NameClaimType = "sub",
RoleClaimType = Yavsc.Constants.RoleClaimType,
};
});
return builder.Build();
}
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
{
app.UseDeveloperExceptionPage();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
if (UseNpgsqlProvider())
{
// Apply the EF Core migrations (Yavsc.Org assembly) so the
// test database schema matches the production provider.
// EnsureCreated must not be used here: it would create the
// schema without the migrations history and break Migrate().
db.Database.Migrate();
}
else
{
db.Database.EnsureCreated();
}
}
await Task.CompletedTask;
return app;
}
public string BaseAddress => Addresses.First(a => a.StartsWith("https://", StringComparison.Ordinal));
public static bool UseNpgsqlProvider()
=> string.Equals(
Environment.GetEnvironmentVariable(DbProviderEnvVar),
"npgsql",
StringComparison.OrdinalIgnoreCase);
private static string EnsureNpgsqlDatabaseCreated()
{
lock (_npgsqlLock)
{
if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString))
{
return _sharedNpgsqlConnectionString;
}
var adminConnectionString = BuildAdminConnectionString();
var databaseName = DedicatedNpgsqlDatabaseName;
using (var adminConnection = new NpgsqlConnection(adminConnectionString))
{
adminConnection.Open();
using var existsCommand = adminConnection.CreateCommand();
existsCommand.CommandText = "SELECT 1 FROM pg_database WHERE datname = @databaseName";
existsCommand.Parameters.AddWithValue("databaseName", databaseName);
if (existsCommand.ExecuteScalar() is null)
{
using var createCommand = adminConnection.CreateCommand();
createCommand.CommandText = $"CREATE DATABASE \"{databaseName}\"";
createCommand.ExecuteNonQuery();
}
}
var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
{
Database = databaseName,
Pooling = false,
IncludeErrorDetail = true
};
_sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
return _sharedNpgsqlConnectionString;
}
}
private static string BuildAdminConnectionString()
{
// The admin connection string (host + credentials) is supplied
// via environment, never embedded in source — committing a
// real dev password would leak it into git history. The Npgsql
// path is opt-in, so requiring the env var is fine: a missing
// value produces a clear failure rather than a silent fallback
// onto someone's local dev database.
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
if (string.IsNullOrWhiteSpace(configured))
throw new InvalidOperationException(
$"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test "
+ "provider requires an admin PostgreSQL connection string "
+ "in that environment variable (e.g. "
+ "'Server=localhost;Port=5432;Database=postgres;"
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
var builder = new NpgsqlConnectionStringBuilder(configured)
{
Pooling = false,
IncludeErrorDetail = true
};
// The admin connection targets the maintenance database
// (postgres) so we can CREATE/DROP the dedicated test database;
// whatever database the env var names is overridden here.
if (string.IsNullOrWhiteSpace(builder.Database))
builder.Database = "postgres";
return builder.ToString();
}
private sealed class NoopMessageSender : IYavscMessageSender
{
public Task<MessageWithPayloadResponse> NotifyBookQueryAsync(IEnumerable<string> connectionIds, RdvQueryEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyEstimateAsync(IEnumerable<string> connectionIds, EstimationEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyHairCutQueryAsync(IEnumerable<string> connectionIds, HairCutQueryEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyAsync(IEnumerable<string> connectionIds, IEvent yaev)
=> Task.FromResult(new MessageWithPayloadResponse());
}
public void ResetAndSeedActivityGraph()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
ResetDatabase(db);
db.Database.EnsureCreated();
var user = new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.test",
EmailConfirmed = true,
FullName = "Alice",
};
db.Users.Add(user);
var location = new Location
{
Address = "1 rue du Test",
Latitude = 48.8566,
Longitude = 2.3522,
};
db.Add(location);
db.SaveChanges();
var activity = new Activity
{
Code = "dev",
Name = "Dev",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.Activities.Add(activity);
db.Activities.Add(new Activity
{
Code = "ghost",
Name = "Ghost",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
var performer = new PerformerProfile
{
PerformerId = "alice",
SIREN = "123456789",
OrganizationAddressId = location.Id,
AcceptNotifications = true,
AcceptPublicContact = true,
Active = true,
Rate = 5,
WebSite = "https://alice.dev",
};
db.Performers.Add(performer);
db.UserActivities.Add(new UserActivity
{
UserId = "alice",
DoesCode = "dev",
Weight = 100,
});
db.Users.Add(new ApplicationUser
{
Id = "bob",
UserName = "bob",
Email = "bob@example.test",
EmailConfirmed = true,
FullName = "Bob",
});
db.Activities.Add(new Activity
{
Code = "declared-only",
Name = "Declared Only",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
db.Performers.Add(new PerformerProfile
{
PerformerId = "bob",
SIREN = "987654321",
OrganizationAddressId = location.Id,
AcceptNotifications = false,
AcceptPublicContact = false,
Active = false,
Rate = 0,
WebSite = "",
});
db.UserActivities.Add(new UserActivity
{
UserId = "bob",
DoesCode = "declared-only",
Weight = 10,
});
db.SaveChanges();
}
private static void ResetDatabase(ApplicationDbContext db)
{
if (UseNpgsqlProvider())
{
// Purge only the tables of the test graph, children before
// parents, so no DELETE violates a foreign key. Estimate and
// CommandLine reference NominativeServiceCommand (CommandId) and
// must be deleted before the Rdv/HairCut/HairMultiCut queries.
db.Set<CommandLine>().RemoveRange(db.Set<CommandLine>());
db.Set<Estimate>().RemoveRange(db.Set<Estimate>());
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());
db.Set<HairMultiCutQuery>().RemoveRange(db.Set<HairMultiCutQuery>());
db.Set<HairPrestationCollectionItem>().RemoveRange(db.Set<HairPrestationCollectionItem>());
db.Set<HairPrestation>().RemoveRange(db.Set<HairPrestation>());
db.Set<UserActivity>().RemoveRange(db.Set<UserActivity>());
db.Set<PerformerProfile>().RemoveRange(db.Set<PerformerProfile>());
db.Set<Activity>().RemoveRange(db.Set<Activity>());
db.Set<ApplicationUser>().RemoveRange(db.Set<ApplicationUser>());
db.Set<Location>().RemoveRange(db.Set<Location>());
db.SaveChanges();
return;
}
db.Database.EnsureDeleted();
}
public void ResetAndSeedRdvQueryGraph()
{
ResetAndSeedActivityGraph();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
db.RdvQueries.Add(new RdvQuery
{
ActivityCode = "dev",
ClientId = "alice",
PerformerId = "alice",
Consent = true,
UserCreated = "alice",
UserModified = "alice",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Initial rendez-vous",
Status = Yavsc.QueryStatus.Inserted,
});
db.SaveChanges();
}
public void ResetAndSeedHaircutGraph()
{
ResetAndSeedActivityGraph();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
if (!db.Activities.Any(a => a.Code == "brush"))
{
db.Activities.Add(new Activity
{
Code = "brush",
Name = "Brush",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
}
if (!db.Activities.Any(a => a.Code == "mbrush"))
{
db.Activities.Add(new Activity
{
Code = "mbrush",
Name = "MBrush",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
}
if (!db.BrusherProfile.Any(p => p.UserId == "alice"))
{
db.BrusherProfile.Add(new BrusherProfile
{
UserId = "alice",
ActionDistance = 25,
WomenLongCutPrice = 50m,
WomenHalfCutPrice = 40m,
WomenShortCutPrice = 30m,
ManCutPrice = 20m,
KidCutPrice = 15m,
ShampooPrice = 5m,
});
}
var prestation1 = new HairPrestation
{
Gender = HairCutGenders.Women,
Length = HairLength.HalfLong,
Cut = true,
Shampoo = true,
Dressing = HairDressings.Brushing,
Tech = HairTechnos.NoTech,
Cares = false,
Taints = new List<HairTaintInstance>(),
};
var prestation2 = new HairPrestation
{
Gender = HairCutGenders.Man,
Length = HairLength.Short,
Cut = true,
Shampoo = false,
Dressing = HairDressings.Brushing,
Tech = HairTechnos.NoTech,
Cares = false,
Taints = new List<HairTaintInstance>(),
};
db.HairPrestation.AddRange(prestation1, prestation2);
db.SaveChanges();
db.HairCutQueries.Add(new HairCutQuery
{
ActivityCode = "brush",
ClientId = "alice",
PerformerId = "alice",
Consent = true,
UserCreated = "alice",
UserModified = "alice",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
EventDate = DateTime.UtcNow.AddDays(3),
Location = location,
PrestationId = prestation1.Id,
Prestation = prestation1,
AdditionalInfo = "Coupe test",
Status = Yavsc.QueryStatus.Inserted,
Description = "Haircut seed",
});
db.HairMultiCutQueries.Add(new HairMultiCutQuery
{
ActivityCode = "mbrush",
ClientId = "alice",
PerformerId = "alice",
Consent = true,
UserCreated = "alice",
UserModified = "alice",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
EventDate = DateTime.UtcNow.AddDays(4),
Location = location,
Prestations = new List<HairPrestationCollectionItem>
{
new() { PrestationId = prestation1.Id, Prestation = prestation1 },
new() { PrestationId = prestation2.Id, Prestation = prestation2 },
},
Status = Yavsc.QueryStatus.Inserted,
});
db.SaveChanges();
}
public override void Dispose()
{
// Keep the shared in-memory SQLite connection alive for the
// whole test process. Closing it from one fixture instance can
// drop the schema while other tests are still running.
base.Dispose();
}
}

View file

@ -0,0 +1,342 @@
using System.Linq;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public FrontOfficeApiControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
// Minimal valid PostIt wire-format signature: one stroke of one
// point (k=1, then 2*k coords). See PostIt.Models.SignaturePadData.
private static readonly int[] SampleStrokes = { 1, 5000, 5000 };
private static HttpContent NoSignature() => JsonBody(new { });
private static HttpContent WithSignature(SignatureType? type = null) =>
type is null
? JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 })
: JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000, signatureType = type });
private static JsonContent JsonBody(object value) =>
JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json"));
/// <summary>
/// Seeds an Rdv query and, when <paramref name="withEstimate"/> is
/// true, an estimate linked to it via CommandId. The provider is
/// <paramref name="performer"/> (default alice) and the client is
/// <paramref name="client"/> (default bob) — pass the same value
/// for both to model a user who is both parties. Returns the query
/// id and (when seeded) the estimate id.
/// </summary>
private (long queryId, long estimateId) SeedQuery(
bool withEstimate,
string performer = "alice",
string client = "bob")
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
var query = new RdvQuery
{
ActivityCode = "dev",
ClientId = client,
PerformerId = performer,
Consent = true,
UserCreated = performer,
UserModified = performer,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Initial rendez-vous",
Status = Yavsc.QueryStatus.Inserted,
};
db.RdvQueries.Add(query);
db.SaveChanges();
long estimateId = 0;
if (withEstimate)
{
var estimate = new Estimate
{
CommandId = query.Id,
ClientId = client,
OwnerId = performer,
CommandType = BillingCodes.Rdv,
Title = "Devis prestation",
Description = "Devis de test",
AttachedFiles = new List<string>(),
AttachedGraphics = new List<string>(),
Bill = new List<CommandLine>
{
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
},
};
db.Estimates.Add(estimate);
db.SaveChanges();
estimateId = estimate.Id;
}
return (query.Id, estimateId);
}
[Fact]
public async Task Front_accept_query_updates_status_without_server_error()
{
var (queryId, _) = SeedQuery(withEstimate: false);
using var http = NewClient();
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
NoSignature(),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var updated = assertDb.RdvQueries.Single(q => q.Id == queryId);
// Default caller is "alice" (the provider) → ProAccepted.
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
}
[Fact]
public async Task Provider_accept_with_signature_sets_ProValidationDate_and_Pro_signature()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true);
using var http = NewClient(subject: "alice");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.ProAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
Assert.True(estimate.ProviderValidationDate != default);
Assert.True(estimate.ClientValidationDate == default);
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Pro, signature.Type);
Assert.Equal("alice", signature.SignerId);
}
[Fact]
public async Task Client_accept_with_signature_sets_ClientValidationDate_and_Client_signature()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true);
using var http = NewClient(subject: "bob");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
Assert.True(estimate.ClientValidationDate != default);
Assert.True(estimate.ProviderValidationDate == default);
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Client, signature.Type);
Assert.Equal("bob", signature.SignerId);
}
[Fact]
public async Task Reject_sets_status_Rejected()
{
var (queryId, _) = SeedQuery(withEstimate: false);
using var http = NewClient(subject: "alice");
var response = await http.PostAsync(
$"/api/v1/front/query/reject?billingCode=Rdv&queryId={queryId}",
NoSignature(),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.Rejected, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
}
[Fact]
public async Task Non_party_actor_is_forbidden()
{
var (queryId, _) = SeedQuery(withEstimate: false);
using var http = NewClient(subject: "carol");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
NoSignature(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Accept_with_signature_but_no_estimate_returns_400()
{
var (queryId, _) = SeedQuery(withEstimate: false);
using var http = NewClient(subject: "alice");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Reject_with_signature_returns_400()
{
var (queryId, _) = SeedQuery(withEstimate: true);
using var http = NewClient(subject: "alice");
var response = await http.PostAsync(
$"/api/v1/front/query/reject?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Estimate_payload_carries_existing_signature_strokes()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true);
// Provider accepts with a signature.
using (var pro = NewClient(subject: "alice"))
{
var r = await pro.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
Assert.True(r.IsSuccessStatusCode, await r.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
// The estimate read endpoint carries the signature strokes on
// the estimate payload, so PostIt can repaint them on reopen.
using var reader = NewClient(subject: "alice");
var response = await reader.GetAsync(
$"/api/v1/estimate/{estimateId}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
// Signatures ride on the estimate payload as two dedicated
// slots, not a list: the provider signed here, so signaturePro
// is populated and signatureClient is absent/null.
var proSig = body.GetProperty("signaturePro");
Assert.Equal(JsonValueKind.Object, proSig.ValueKind);
Assert.Equal(JsonValueKind.Array, proSig.GetProperty("strokes").ValueKind);
Assert.True(body.TryGetProperty("signatureClient", out var cli)
&& cli.ValueKind == JsonValueKind.Null,
"signatureClient should be null when only the provider has signed");
}
// When one user is both the provider and the client of a query, the
// server cannot infer which side they are signing as from identity
// alone (it would always pick the provider). The caller declares the
// side (signatureType=1 here = Client); the server honors it because
// the caller is that party, and stores a Client signature.
[Fact]
public async Task Accept_declared_client_side_stores_client_signature_when_user_is_both_parties()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true, performer: "alice", client: "alice");
using var http = NewClient(subject: "alice");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(SignatureType.Client), // signing as the client
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
Assert.True(estimate.ClientValidationDate != default);
Assert.True(estimate.ProviderValidationDate == default);
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Client, signature.Type);
Assert.Equal("alice", signature.SignerId);
}
// A provider may not sign as the client (and vice versa): declaring
// the other party's side is refused even though the caller is a
// party to the query. Write is authorized only for the signature's
// author.
[Fact]
public async Task Accept_declaring_other_party_side_is_forbidden()
{
var (queryId, _) = SeedQuery(withEstimate: true); // provider alice, client bob
using var http = NewClient(subject: "alice"); // alice is the provider
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(SignatureType.Client), // alice tries to sign as the client
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View file

@ -0,0 +1,256 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
/// <summary>
/// Integration tests for the query (demande) attachment endpoints
/// (<c>GET/POST/DELETE api/v1/front/query/{queryId}/attachments</c>),
/// which attach a file from the client's personal storage to their
/// <c>NominativeServiceCommand</c> **by reference**
/// (<see cref="QueryAttachedFile"/>).
///
/// <para>Coverage mirrors the controller contract: only the client
/// (the demande's <c>ClientId</c>) may attach or detach, the file must
/// belong to the caller, and listing is open to both parties
/// (client/provider) plus admin. A third party is refused.</para>
/// </summary>
[Collection("Yavsc Api")]
public sealed class FrontOfficeQueryAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public FrontOfficeQueryAttachmentsApiTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "bob", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
/// <summary>
/// Seed an Rdv query from bob (client) to alice (provider) and two
/// personal <see cref="UploadedFile"/> rows: one owned by bob (the
/// client's own file) and one owned by alice (a file that does NOT
/// belong to the client, used to assert the ownership guard).
/// </summary>
private (long queryId, long bobFileId, long aliceFileId) Seed()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
var query = new RdvQuery
{
ActivityCode = "dev",
ClientId = "bob",
PerformerId = "alice",
Consent = true,
UserCreated = "bob",
UserModified = "bob",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Demande de devis",
Status = QueryStatus.InProgress,
Description = "Demande en attente",
};
db.RdvQueries.Add(query);
db.SaveChanges();
var bobFile = new UploadedFile
{
OwnerId = "bob",
Path = "demande-photo.jpg",
ContentType = "image/jpeg",
Length = 4096,
};
var aliceFile = new UploadedFile
{
OwnerId = "alice",
Path = "provider-spec.pdf",
ContentType = "application/pdf",
Length = 1024,
};
db.UploadedFiles.AddRange(bobFile, aliceFile);
db.SaveChanges();
return (query.Id, bobFile.Id, aliceFile.Id);
}
private int CountAttachments(long queryId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.QueryAttachedFiles.Count(a => a.CommandId == queryId);
}
private string AttachUrl(long queryId) =>
$"/api/v1/front/query/{queryId}/attachments?billingCode=Rdv";
private string DetachUrl(long queryId, long fileId) =>
$"/api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=Rdv";
[Fact]
public async Task Client_attaching_own_file_creates_the_link()
{
var (queryId, bobFileId, _) = Seed();
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
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.Equal(1, CountAttachments(queryId));
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var link = Assert.Single(db.QueryAttachedFiles.Where(a => a.CommandId == queryId));
Assert.Equal(bobFileId, link.FileId);
}
[Fact]
public async Task Provider_cannot_attach_to_a_query()
{
var (queryId, bobFileId, _) = Seed();
// alice is the provider of the query; only the client attaches.
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(queryId));
}
[Fact]
public async Task Client_cannot_attach_a_file_they_do_not_own()
{
var (queryId, _, aliceFileId) = Seed();
// bob is the client, but the file belongs to alice.
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(queryId));
}
[Fact]
public async Task Client_detaches_a_file()
{
var (queryId, bobFileId, _) = Seed();
using var http = NewClient("bob");
await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
Assert.Equal(1, CountAttachments(queryId));
var response = await http.DeleteAsync(
DetachUrl(queryId, bobFileId),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(0, CountAttachments(queryId));
}
[Fact]
public async Task Provider_cannot_detach()
{
var (queryId, bobFileId, _) = Seed();
using var bob = NewClient("bob");
await bob.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
using var alice = NewClient("alice");
var response = await alice.DeleteAsync(
DetachUrl(queryId, bobFileId),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(1, CountAttachments(queryId));
}
[Fact]
public async Task Provider_lists_attachments_as_a_party()
{
var (queryId, bobFileId, _) = Seed();
using var bob = NewClient("bob");
await bob.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
// alice is the provider: a party, may list.
using var alice = NewClient("alice");
var response = await alice.GetAsync(AttachUrl(queryId), 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());
Assert.Equal(bobFileId, item.GetProperty("fileId").GetInt64());
Assert.Equal("bob", item.GetProperty("ownerId").GetString());
}
[Fact]
public async Task Third_party_cannot_list_attachments()
{
var (queryId, bobFileId, _) = Seed();
using var bob = NewClient("bob");
await bob.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
using var carol = NewClient("carol");
var response = await carol.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View file

@ -0,0 +1,120 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Haircut;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class HairCutQueryApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public HairCutQueryApiControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
[Fact]
public async Task Billing_brush_route_supports_crud()
{
_fixture.ResetAndSeedHaircutGraph();
using var http = NewClient();
var prestationId = await GetPrestationIdAsync();
var createPayload = new HairCutQuery
{
ActivityCode = "brush",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(5),
Location = new Location
{
Address = "2 rue de la Coupe",
Latitude = 48.8570,
Longitude = 2.3525,
},
PrestationId = prestationId,
AdditionalInfo = "Brushing test",
Status = QueryStatus.Inserted,
Description = "Haircut create",
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Brush", createPayload, TestContext.Current.CancellationToken);
if (createResponse.StatusCode != HttpStatusCode.Created)
{
var body = await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Fail($"Unexpected status {createResponse.StatusCode}: {body}");
}
var created = await createResponse.Content.ReadFromJsonAsync<HairCutQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal("alice", created.ClientId);
var getResponse = await http.GetAsync($"/api/v1/billing/Brush/{created.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var fetched = await getResponse.Content.ReadFromJsonAsync<HairCutQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal(created.Id, fetched!.Id);
Assert.Equal("Brushing test", fetched.AdditionalInfo);
fetched.AdditionalInfo = "Brushing modifié";
var putResponse = await http.PutAsJsonAsync($"/api/v1/billing/Brush/{fetched.Id}", fetched, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
var deleteResponse = await http.DeleteAsync($"/api/v1/billing/Brush/{fetched.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
var missingResponse = await http.GetAsync($"/api/v1/billing/Brush/{fetched.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, missingResponse.StatusCode);
}
[Fact]
public async Task Billing_brush_route_exposes_prestation_catalog()
{
_fixture.ResetAndSeedHaircutGraph();
using var http = NewClient();
var response = await http.GetAsync("/api/v1/billing/Brush/prestations", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var catalog = await response.Content.ReadFromJsonAsync<List<HairPrestationDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
Assert.NotEmpty(catalog!);
Assert.All(catalog!, item => Assert.False(string.IsNullOrWhiteSpace(item.Title)));
Assert.Contains(catalog!, item => item.Title == "Femme · Cheveux mi-longs"
&& item.Details == "Coupe · Brushing · Aucune technique spécifique · Shampoing · Sans soins");
}
private async Task<long> GetPrestationIdAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return await db.HairPrestation.Select(p => p.Id).FirstAsync(TestContext.Current.CancellationToken);
}
}

View file

@ -0,0 +1,123 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Haircut;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class HairMultiCutQueryApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public HairMultiCutQueryApiControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
[Fact]
public async Task Billing_mbrush_route_supports_crud()
{
_fixture.ResetAndSeedHaircutGraph();
using var http = NewClient();
var prestationIds = await GetPrestationIdsAsync();
var createPayload = new HairMultiCutQuery
{
ActivityCode = "mbrush",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(6),
Location = new Location
{
Address = "3 rue du Groupe",
Latitude = 48.8580,
Longitude = 2.3530,
},
Prestations = prestationIds.Select(id => new HairPrestationCollectionItem { PrestationId = id }).ToList(),
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/MBrush", createPayload, TestContext.Current.CancellationToken);
if (createResponse.StatusCode != HttpStatusCode.Created)
{
var body = await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Fail($"Unexpected status {createResponse.StatusCode}: {body}");
}
var created = await createResponse.Content.ReadFromJsonAsync<HairMultiCutQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal("alice", created.ClientId);
Assert.Equal(2, created.Prestations.Count);
var getResponse = await http.GetAsync($"/api/v1/billing/MBrush/{created.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var fetched = await getResponse.Content.ReadFromJsonAsync<HairMultiCutQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal(created.Id, fetched!.Id);
Assert.Equal(2, fetched.Prestations.Count);
fetched.Status = QueryStatus.Accepted;
var putResponse = await http.PutAsJsonAsync($"/api/v1/billing/MBrush/{fetched.Id}", fetched, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
var deleteResponse = await http.DeleteAsync($"/api/v1/billing/MBrush/{fetched.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
var missingResponse = await http.GetAsync($"/api/v1/billing/MBrush/{fetched.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, missingResponse.StatusCode);
}
[Fact]
public async Task Billing_mbrush_route_exposes_prestation_catalog()
{
_fixture.ResetAndSeedHaircutGraph();
using var http = NewClient();
var response = await http.GetAsync("/api/v1/billing/MBrush/prestations", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var catalog = await response.Content.ReadFromJsonAsync<List<HairPrestationDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
Assert.NotEmpty(catalog!);
Assert.All(catalog!, item => Assert.False(string.IsNullOrWhiteSpace(item.Details)));
Assert.Contains(catalog!, item => item.Title == "Femme · Cheveux mi-longs"
&& item.Details == "Coupe · Brushing · Aucune technique spécifique · Shampoing · Sans soins");
}
private async Task<List<long>> GetPrestationIdsAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return await db.HairPrestation
.OrderBy(p => p.Id)
.Select(p => p.Id)
.Take(2)
.ToListAsync(TestContext.Current.CancellationToken);
}
}

View file

@ -0,0 +1,69 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Controllers;
using Yavsc.Models.Workflow;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class HomeControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public HomeControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task Index_does_not_list_activity_without_declaration()
{
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
var controller = new HomeController(
NullLogger<HomeController>.Instance,
localizer: null!,
context: db,
settingsOptions: Options.Create(new SiteSettings()),
env: new TestEnvironment());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("sub", "alice"),
new Claim(ClaimTypes.NameIdentifier, "alice")
}, "Bearer"))
}
};
var result = await controller.Index(id: null);
var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsAssignableFrom<IEnumerable<Activity>>(view.Model);
Assert.Contains(model, a => a.Code == "dev");
Assert.DoesNotContain(model, a => a.Code == "ghost");
}
private sealed class TestEnvironment : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "Yavsc.Api.Test";
public IFileProvider WebRootFileProvider { get; set; } = null!;
public string WebRootPath { get; set; } = string.Empty;
public string EnvironmentName { get; set; } = "Development";
public string ContentRootPath { get; set; } = string.Empty;
public IFileProvider ContentRootFileProvider { get; set; } = null!;
}
}

View file

@ -0,0 +1,229 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Yavsc;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public RdvQueryApiControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
[Fact]
public async Task Billing_rdv_route_supports_crud()
{
_fixture.ResetAndSeedRdvQueryGraph();
using var http = NewClient();
var createPayload = new RdvQuery
{
ActivityCode = "dev",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(2),
Location = new Yavsc.Models.Relationship.Location
{
Address = "1 rue du Test",
Latitude = 48.8566,
Longitude = 2.3522,
},
Reason = "Second rendez-vous",
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal("alice", created.ClientId);
var getResponse = await http.GetAsync($"/api/v1/billing/Rdv/{created.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var fetched = await getResponse.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal(created.Id, fetched!.Id);
Assert.Equal("Second rendez-vous", fetched.Reason);
fetched.Reason = "Rendez-vous modifié";
var putResponse = await http.PutAsJsonAsync($"/api/v1/billing/Rdv/{fetched.Id}", fetched, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
var deleteResponse = await http.DeleteAsync($"/api/v1/billing/Rdv/{fetched.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
var missingResponse = await http.GetAsync($"/api/v1/billing/Rdv/{fetched.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, missingResponse.StatusCode);
}
[Fact]
public async Task PostQuery_ignores_client_field_and_uses_authenticated_user()
{
_fixture.ResetAndSeedRdvQueryGraph();
using var http = NewClient(subject: "alice");
var createPayload = new
{
ActivityCode = "dev",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(2),
Location = new
{
Address = "2 rue du Test",
Latitude = 48.8567,
Longitude = 2.3523,
},
Reason = "Rendez-vous sans champ client",
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.Equal("alice", created!.ClientId);
}
[Fact]
public async Task PostQuery_accepts_local_datetime_and_persists_as_utc()
{
_fixture.ResetAndSeedRdvQueryGraph();
using var http = NewClient(subject: "alice");
var localEventDate = DateTime.Now.AddDays(2);
var createPayload = new
{
ActivityCode = "dev",
PerformerId = "alice",
Consent = true,
EventDate = localEventDate,
Location = new
{
Address = "3 rue du Test",
Latitude = 48.8568,
Longitude = 2.3524,
},
Reason = "Rendez-vous date locale",
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.Equal(DateTimeKind.Utc, created!.EventDate.Kind);
}
[Fact]
public async Task PostQuery_with_unknown_location_id_creates_location_and_succeeds()
{
_fixture.ResetAndSeedRdvQueryGraph();
using var http = NewClient(subject: "alice");
var createPayload = new
{
ActivityCode = "dev",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(3),
Location = new
{
Id = 999999L,
Address = "4 rue du Test",
Latitude = 48.8569,
Longitude = 2.3525,
},
Reason = "Rendez-vous id location inconnu",
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
var body = await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(createResponse.StatusCode == HttpStatusCode.Created, $"Unexpected status {(int)createResponse.StatusCode} ({createResponse.StatusCode}): {body}");
var created = await createResponse.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.NotNull(created!.Location);
Assert.True(created.Location.Id > 0);
Assert.NotEqual(999999L, created.Location.Id);
Assert.Equal("alice", created.ClientId);
}
[Fact]
public async Task PostQuery_without_location_returns_bad_request()
{
_fixture.ResetAndSeedRdvQueryGraph();
using var http = NewClient(subject: "alice");
var createPayload = new
{
ActivityCode = "dev",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(1),
Reason = "Rendez-vous sans location",
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
}
[Fact]
public async Task PostQuery_with_unknown_location_id_and_missing_address_returns_bad_request()
{
_fixture.ResetAndSeedRdvQueryGraph();
using var http = NewClient(subject: "alice");
var createPayload = new
{
ActivityCode = "dev",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(1),
Location = new
{
Id = 777777L,
Address = "",
Latitude = 0.0,
Longitude = 0.0,
},
Reason = "Rendez-vous location invalide",
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
}
}

View file

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Api.Tests</RootNamespace>
<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.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.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\..\Yavsc.Api\Yavsc.Api.csproj" />
<ProjectReference Include="..\..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,162 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Xunit;
using Yavsc.Extensions;
namespace Yavsc.Org.Tests;
/// <summary>
/// Tests for <see cref="HostingExtensions.ComputeKid"/>, the
/// helper that derives the JWT <c>kid</c> header / JWKS key id
/// from the signing certificate. The kid is consumed by every
/// resource server (Yavsc.Blogs, Yavsc.Api) to match a token to
/// the right key in the JWKS, so getting its shape and stability
/// right is the whole point of the fix in commit 2c6d1157
/// (IDX10500 regression).
/// </summary>
/// <remarks>
/// We don't load the production cert (Let's Encrypt PEM + RSA
/// private key) — we generate throwaway self-signed certs in a
/// temp dir. The contract under test is the truncation /
/// encoding of the thumbprint, which is independent of the key
/// type and the cert issuer.
/// </remarks>
public class ComputeKidTests : IDisposable
{
private readonly string _tempDir;
public ComputeKidTests()
{
_tempDir = Path.Combine(
Path.GetTempPath(),
"yavsc-compute-kid-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); }
catch { /* best effort — Temp gets cleaned eventually */ }
}
[Fact]
public void ComputeKid_returns_first_16_hex_chars_of_cert_thumbprint()
{
var certPath = WriteSelfSignedCertRsa(out var expectedThumbHex);
var kid = HostingExtensions.ComputeKid(certPath);
// 16 hex chars = 64 bits, enough to be globally unique
// within a deployment and compact enough for a JWT header.
Assert.Equal(16, kid.Length);
Assert.True(
kid.All(c => "0123456789ABCDEF".Contains(c)),
$"kid '{kid}' contains non-uppercase-hex characters");
// Match the first 16 chars of the thumbprint exactly. We
// compute the expected value from the same cert the helper
// was given — no magic constants, no copy-paste of the
// truncation logic under test.
Assert.Equal(expectedThumbHex[..16], kid);
}
[Fact]
public void ComputeKid_is_stable_across_repeated_reads()
{
var certPath = WriteSelfSignedCertRsa(out _);
var first = HostingExtensions.ComputeKid(certPath);
var second = HostingExtensions.ComputeKid(certPath);
var third = HostingExtensions.ComputeKid(certPath);
// Stability matters: a non-deterministic kid would
// invalidate tokens on every IdentityServer restart.
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void ComputeKid_differs_between_distinct_certificates()
{
var certPathA = WriteSelfSignedCertRsa(out _);
var certPathB = WriteSelfSignedCertRsa(out _);
var kidA = HostingExtensions.ComputeKid(certPathA);
var kidB = HostingExtensions.ComputeKid(certPathB);
// Two independent RNG-drawn RSA keys will (in practice
// always) yield different thumbprints. A 64-bit truncated
// space has collisions at ~2^32 certs; we won't get there.
Assert.NotEqual(kidA, kidB);
}
[Fact]
public void ComputeKid_uses_thumbprint_not_subject_or_serial()
{
// The previous fix-message claimed SHA-256; the helper
// actually reads X509Certificate2.GetCertHash() which is
// SHA-1. Pin that behaviour so a future refactor that
// switches to SHA-256 (or any other digest) is forced to
// update the test deliberately.
var certPath = WriteSelfSignedCertRsa(out var thumbHex);
var kid = HostingExtensions.ComputeKid(certPath);
// 16 hex chars is half of a 20-byte SHA-1 thumbprint.
// SHA-256 would be 32 bytes (64 hex chars) before
// truncation; SHA-1 is the only common digest whose
// hex encoding fits the 16-char prefix we observe.
Assert.Equal(20, thumbHex.Length / 2);
Assert.Equal(thumbHex[..16], kid);
}
[Fact]
public void ComputeKid_propagates_cryptographic_exception_for_missing_file()
{
// The wrapper LoadSigningCredentials wraps this in an
// InvalidOperationException, but ComputeKid itself is a
// plain helper — it must surface the parser error so the
// wrapper can attach the cert path to the message. We
// assert against the base CryptographicException rather
// than the concrete subtype because the runtime picks
// different leaf types per platform (on Linux/OpenSSL we
// get Interop+Crypto+OpenSslCryptographicException, on
// Windows we'd get the older CryptographicException
// directly); the contract is the same either way.
var missing = Path.Combine(_tempDir, "does-not-exist.pem");
Assert.ThrowsAny<CryptographicException>(
() => HostingExtensions.ComputeKid(missing));
}
// --- helpers ----------------------------------------------------
/// <summary>
/// Generate a throwaway self-signed RSA-2048 cert, export it
/// as PEM to a file inside the test temp dir, and return the
/// path. The out parameter receives the upper-case hex form
/// of the cert's SHA-1 thumbprint so tests can pin the
/// expected kid without re-implementing the helper.
/// </summary>
private string WriteSelfSignedCertRsa(out string thumbHex)
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest(
"CN=yavsc-test",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
using var cert = req.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddYears(1));
// Capture the thumbprint before exporting — the cert is
// disposed by `using` and the exported PEM is what the
// helper will read.
thumbHex = Convert.ToHexString(cert.GetCertHash());
var path = Path.Combine(_tempDir, "cert-" + Guid.NewGuid().ToString("N") + ".pem");
File.WriteAllText(path, cert.ExportCertificatePem());
return path;
}
}

View file

@ -0,0 +1,309 @@
using System.Net;
using IdentityServer8.EntityFramework.Entities;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Tests.Shared;
using Xunit;
namespace Yavsc.Org.Tests.Controllers;
/// <summary>
/// Integration tests for the per-collection edit pages of the OAuth2
/// client editor. Hits the real ASP.NET pipeline against
/// <see cref="WebServerFixture"/>: the WebApplication is built once per
/// collection and shared, so these tests must be defensive about row
/// IDs (they pick their own dedicated client to mutate).
///
/// Admin auth is satisfied by sending an <c>X-Test-Role: Administrator</c>
/// header. The <see cref="TestAuthPolicyProvider"/> short-circuits the
/// production policy to honour that header, sidestepping the login flow
/// (which is itself not exercised by these tests).
/// </summary>
[Collection("Yavsc Server")]
public class ClientControllerCollectionTests : IClassFixture<TestWebApplicationFactory>
{
private const string TargetClientId = "collection-tests-target";
private readonly TestWebApplicationFactory _factory;
public ClientControllerCollectionTests(TestWebApplicationFactory factory)
{
_factory = factory;
EnsureTargetClient();
}
private void EnsureTargetClient()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
if (db.Clients.Any(c => c.ClientId == TargetClientId)) return;
db.Clients.Add(new Client
{
ClientId = TargetClientId,
ClientName = "Collection-edit tests target",
Enabled = true,
RequireClientSecret = false,
RequirePkce = true,
ProtocolType = "oidc",
AllowedGrantTypes = new List<ClientGrantType>
{
new() { GrantType = "authorization_code" },
},
AllowedScopes = new List<ClientScope>
{
new() { Scope = "openid" },
},
RedirectUris = new List<ClientRedirectUri>
{
new() { RedirectUri = "https://app.example.com/cb" },
},
});
db.SaveChanges();
}
private int TargetClientDbId()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.Clients.Single(c => c.ClientId == TargetClientId).Id;
}
private HttpClient CreateAdminClient()
{
// WebApplicationFactory.CreateClient() returns an HttpClient wired
// directly to the in-memory test server — no Kestrel socket, no
// self-signed cert, no IServerAddressesFeature lookup. Keep
// cookies enabled so the antiforgery cookie set on the GET that
// fetches the form is replayed on the POST that submits it.
var http = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = true,
});
http.DefaultRequestHeaders.Add(TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole);
return http;
}
[Fact]
public async Task Edit_GET_returns_200_for_admin()
{
var http = CreateAdminClient();
var id = TargetClientDbId();
var resp = await http.GetAsync($"/Client/Edit/{id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains(TargetClientId, body);
}
[Fact]
public async Task EditRedirectUris_GET_returns_200_and_lists_seeded_uri()
{
var http = CreateAdminClient();
var id = TargetClientDbId();
var resp = await http.GetAsync($"/Client/EditRedirectUris/{id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains("https://app.example.com/cb", body);
}
// Repro: GET /Client/EditRedirectUris/{id} après qu'une seconde
// RedirectUri a été ajoutée via POST. LoadClientAsync(id) réhydrate
// le Client avec 9 Includes (RedirectUris, AllowedScopes, ClientSecrets,
// etc.) via l'InMemory provider. Sur l'Id=2 seedé, la matérialisation
// des nav properties sur les entités IdentityServer8 lève
// IndexOutOfRangeException — l'action renvoie un 500 et la page
// d'erreur masque le diagnostic.
//
// Ce test reproduit le chemin qui plante : GET initial (lit l'AF
// token) → POST AddRedirectUri → GET final qui exerce LoadClientAsync
// avec une collection RedirectUris non triviale (2 entrées).
// Il doit ÉCHOUER tant que le bug n'est pas traité.
[Fact]
public async Task EditRedirectUris_GET_after_add_lists_both_uris()
{
var http = CreateAdminClient();
var id = TargetClientDbId();
const string newUri = "https://app.example.com/cb-repro";
// Premier GET pour récupérer l'antiforgery token du form Add.
var pageResp = await http.GetAsync(
$"/Client/EditRedirectUris/{id}", TestContext.Current.CancellationToken);
pageResp.EnsureSuccessStatusCode();
var token = ExtractAntiforgeryToken(
await pageResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.False(string.IsNullOrEmpty(token));
// Ajout d'une seconde RedirectUri — c'est l'état non-trivial
// qui déclenche la matérialisation problématique côté
// InMemory provider.
var form = new MultipartFormDataContent
{
{ new StringContent(newUri), "redirectUri" },
{ new StringContent(token!), "__RequestVerificationToken" },
};
var addResp = await http.PostAsync(
$"/Client/AddRedirectUri/{id}", form, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, addResp.StatusCode);
try
{
// GET final : exerce LoadClientAsync(id) sur le client
// avec 2 RedirectUris, 1 AllowedScope, 1 GrantType, etc.
// Si la matérialisation échoue (IndexOutOfRange), ce GET
// renvoie 500 et EnsureSuccessStatusCode fait échouer le test.
var finalResp = await http.GetAsync(
$"/Client/EditRedirectUris/{id}", TestContext.Current.CancellationToken);
finalResp.EnsureSuccessStatusCode();
var finalBody = await finalResp.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken);
// La page doit lister les DEUX URIs.
Assert.Contains("https://app.example.com/cb", finalBody);
Assert.Contains(newUri, finalBody);
}
finally
{
// Cleanup idempotent : on retire l'URI ajoutée pour ne
// pas polluer les autres tests de la collection.
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var row = db.ClientRedirectUris
.FirstOrDefault(r => r.RedirectUri == newUri);
if (row is not null)
{
db.ClientRedirectUris.Remove(row);
db.SaveChanges();
}
}
}
[Fact]
public async Task AddRedirectUri_POST_appends_to_database()
{
var http = CreateAdminClient();
var id = TargetClientDbId();
const string newUri = "https://app.example.com/cb2";
// Read the GET page first to grab the antiforgery token attached
// to the Add form on EditRedirectUris.
var pageResp = await http.GetAsync($"/Client/EditRedirectUris/{id}", TestContext.Current.CancellationToken);
pageResp.EnsureSuccessStatusCode();
var pageHtml = await pageResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
var token = ExtractAntiforgeryToken(pageHtml);
Assert.False(string.IsNullOrEmpty(token));
var form = new MultipartFormDataContent
{
{ new StringContent(newUri), "redirectUri" },
{ new StringContent(token!), "__RequestVerificationToken" },
};
var postResp = await http.PostAsync($"/Client/AddRedirectUri/{id}", form, TestContext.Current.CancellationToken);
var postBody = await postResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(postResp.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)postResp.StatusCode}. Body[0..500]: {postBody.Substring(0, Math.Min(500, postBody.Length))}");
Assert.Equal(HttpStatusCode.OK, postResp.StatusCode);
// Verify in DB.
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var client = db.Clients
.Include(c => c.RedirectUris)
.Single(c => c.ClientId == TargetClientId);
Assert.Contains(client.RedirectUris, r => r.RedirectUri == newUri);
// Cleanup so the test is idempotent across runs.
var toRemove = client.RedirectUris.First(r => r.RedirectUri == newUri);
db.ClientRedirectUris.Remove(toRemove);
db.SaveChanges();
}
[Fact]
public async Task RemoveRedirectUri_POST_with_foreign_rowId_returns_NotFound()
{
// SECURITY: a Remove call for a row that belongs to a different
// client must be rejected. The test creates a second client,
// gets a real rowId from it, then calls Remove on the target
// client with that foreign rowId. Expected: 404, target
// client unchanged.
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
if (!db.Clients.Any(c => c.ClientId == "collection-tests-other"))
{
db.Clients.Add(new Client
{
ClientId = "collection-tests-other",
ClientName = "Other target",
Enabled = true,
ProtocolType = "oidc",
RequireClientSecret = false,
RequirePkce = true,
});
db.SaveChanges();
}
}
int otherRowId;
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var other = db.Clients.Include(c => c.RedirectUris)
.Single(c => c.ClientId == "collection-tests-other");
if (!other.RedirectUris.Any())
{
other.RedirectUris.Add(new ClientRedirectUri { RedirectUri = "https://other.example/cb" });
db.SaveChanges();
}
otherRowId = other.RedirectUris.First().Id;
}
var http = CreateAdminClient();
var targetId = TargetClientDbId();
var pageResp = await http.GetAsync($"/Client/EditRedirectUris/{targetId}", TestContext.Current.CancellationToken);
var token = ExtractAntiforgeryToken(await pageResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var form = new MultipartFormDataContent
{
{ new StringContent(targetId.ToString()), "id" },
{ new StringContent(otherRowId.ToString()), "rowId" },
{ new StringContent(token!), "__RequestVerificationToken" },
};
var resp = await http.PostAsync($"/Client/RemoveRedirectUri", form,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
// ---------- Bisection de l'Include qui plante ----------
//
// LoadClientAsync charge le Client avec 9 Includes :
// RedirectUris, PostLogoutRedirectUris, AllowedScopes,
// AllowedGrantTypes, AllowedCorsOrigins,
// IdentityProviderRestrictions, Claims, Properties, ClientSecrets.
//
// Le test EditRedirectUris_GET_after_add_lists_both_uris échoue
// avec IndexOutOfRangeException au shaper de l'InMemory provider,
// sans préciser lequel des Includes pose problème. La stack
// indique IncludeCollection (donc un Include de collection, pas
// de référence).
//
// 2026-07-11 fix/issue-3-splitquery: tests retirés car leur hypothèse
// ("bug de provider InMemory sur ces 3 entités") n'est pas la cause
// racine — c'est une redondance de mapping HasOne dans
// ApplicationDbContext.OnModelCreating. Le test gardien
// EditRedirectUris_GET_after_add_lists_both_uris reste en place
// comme sentinelle de régression sur le fix.
private static string? ExtractAntiforgeryToken(string html)
{
const string marker = "name=\"__RequestVerificationToken\"";
var idx = html.IndexOf(marker, StringComparison.Ordinal);
if (idx < 0) return null;
var valueStart = html.IndexOf("value=\"", idx, StringComparison.Ordinal);
if (valueStart < 0) return null;
valueStart += "value=\"".Length;
var valueEnd = html.IndexOf('"', valueStart);
return valueEnd < 0 ? null : html[valueStart..valueEnd];
}
}

View file

@ -0,0 +1,37 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Yavsc.Tests.Shared;
using Xunit;
namespace Yavsc.Org.Tests.Controllers;
public class CommandFormsControllerTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public CommandFormsControllerTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
private HttpClient CreateAdminClient()
{
var http = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = true,
});
http.DefaultRequestHeaders.Add(TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole);
return http;
}
[Fact]
public async Task Create_GET_returns_200_for_admin()
{
var http = CreateAdminClient();
var response = await http.GetAsync("/CommandForms/Create", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains("Create", body);
}
}

View file

@ -0,0 +1,93 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
using Xunit;
namespace Yavsc.Org.Tests.Controllers;
public class CommentsApiIntegrationTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public CommentsApiIntegrationTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Post_blogcomments_json_returns_201_and_persists_comment()
{
long postId;
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
if (!db.Users.Any(u => u.Id == TestUserMiddleware.UserId))
{
db.Users.Add(new ApplicationUser
{
Id = TestUserMiddleware.UserId,
UserName = "test-user",
NormalizedUserName = "TEST-USER",
Email = "test-user@example.com",
NormalizedEmail = "TEST-USER@EXAMPLE.COM",
EmailConfirmed = true,
SecurityStamp = Guid.NewGuid().ToString("N"),
ConcurrencyStamp = Guid.NewGuid().ToString("N")
});
}
var post = new BlogPost
{
Title = "Post for comment API test",
AuthorId = TestUserMiddleware.UserId,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
db.BlogSpot.Add(post);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
postId = post.Id;
}
var http = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = true,
AllowAutoRedirect = false
});
http.DefaultRequestHeaders.Add(TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole);
var response = await http.PostAsJsonAsync(
"/api/v1/blogcomments",
new
{
Article = "Comment API integration test",
ReceiverId = postId
},
TestContext.Current.CancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(
response.StatusCode != HttpStatusCode.InternalServerError,
$"Unexpected 500 on POST /api/v1/blogcomments. Body: {responseBody}");
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.Contains("\"id\"", responseBody, StringComparison.OrdinalIgnoreCase);
Assert.Contains("\"dateCreated\"", responseBody, StringComparison.OrdinalIgnoreCase);
using var verifyScope = _factory.Services.CreateScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var stored = await verifyDb.Comment
.OrderByDescending(c => c.Id)
.FirstOrDefaultAsync(c => c.ReceiverId == postId, TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("Comment API integration test", stored!.Article);
Assert.Equal(TestUserMiddleware.UserId, stored.AuthorId);
}
}

View file

@ -0,0 +1,64 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Controllers;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Xunit;
namespace Yavsc.Org.Tests.Controllers;
public class CommentsControllerTests
{
[Fact]
public async Task Create_sets_author_and_persists_comment()
{
var dbName = $"comments-controller-{Guid.NewGuid():N}";
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(dbName)
.Options;
await using var db = new ApplicationDbContext(options);
var post = new BlogPost
{
Title = "Post de test",
AuthorId = "post-author",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
db.BlogSpot.Add(post);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
var controller = new CommentsController(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, "comment-author")
], "TestAuth"))
}
}
};
var comment = new Comment
{
ReceiverId = post.Id,
Article = "Commentaire de test",
Visible = true
};
var result = await controller.Create(comment);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
var stored = await db.Comment.SingleAsync(TestContext.Current.CancellationToken);
Assert.Equal("comment-author", stored.AuthorId);
Assert.Equal(post.Id, stored.ReceiverId);
Assert.Equal("Commentaire de test", stored.Article);
}
}

View file

@ -0,0 +1,68 @@
using IdentityServer8.EntityFramework.DbContexts;
using IdentityServer8.EntityFramework.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Yavsc.Org.Tests.Controllers;
/// <summary>
/// Regression sentinel: two <see cref="TestWebApplicationFactory"/>
/// instances must not see each other's clients.
///
/// EF Core's <c>UseInMemoryDatabase(name)</c> returns the same
/// backing store to every <c>DbContext</c> that asks for it under
/// the same name, in the same process. Before the per-fixture GUID
/// fix, both <see cref="TestWebApplicationFactory"/> and
/// <see cref="WebServerFixture"/> used the bare <c>"InMemory"</c>
/// connection string, so every fixture shared one store and tests
/// were silently order-dependent.
///
/// We assert against <see cref="ConfigurationDbContext"/> directly
/// rather than via <c>IClientStore</c>: the validating wrapper around
/// <c>IClientStore</c> raises events through <c>IEventService</c>,
/// which is not registered in the test host and crashes with a
/// <c>NullReferenceException</c> before it can return a result. Going
/// straight to the DbContext is the same code path the production
/// code uses, so it is the right surface to assert against.
/// </summary>
public class TestWebApplicationFactoryIsolationTests
{
[Fact]
public async Task Second_factory_does_not_see_clients_seeded_into_first()
{
var marker = $"marker-A-{Guid.NewGuid():N}";
// First factory: seed a distinctive client.
using (var first = new TestWebApplicationFactory())
{
await using var scope = first.Services.CreateAsyncScope();
var configDb = scope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
var firstCs = scope.ServiceProvider.GetRequiredService<IConfiguration>()
.GetConnectionString("YavscConnection");
configDb.Clients.Add(new Client { ClientId = marker, ClientName = "marker-A" });
await configDb.SaveChangesAsync(TestContext.Current.CancellationToken);
// Sanity: the first factory can see its own seed.
var seenByFirst = await configDb.Clients
.AsNoTracking()
.AnyAsync(c => c.ClientId == marker, TestContext.Current.CancellationToken);
Assert.True(seenByFirst);
}
// Second factory: must start from a clean slate. If the
// in-memory store leaked from the first factory, this
// assertion fails.
using var second = new TestWebApplicationFactory();
await using var secondScope = second.Services.CreateAsyncScope();
var secondCs = secondScope.ServiceProvider.GetRequiredService<IConfiguration>()
.GetConnectionString("YavscConnection");
Assert.StartsWith("InMemory-", secondCs);
var secondDb = secondScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
var seenBySecond = await secondDb.Clients
.AsNoTracking()
.AnyAsync(c => c.ClientId == marker, TestContext.Current.CancellationToken);
Assert.False(seenBySecond);
}
}

View file

@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Workflow;
using Yavsc.Server.Services;
using Xunit;
namespace Yavsc.Org.Tests;
public class DictionnaireMetierTests
{
[Fact]
public void DictionnaireMetier_and_TermeMetier_can_be_constructed()
{
var dictionary = new DictionnaireMetier
{
Id = 1,
Nom = "Droit",
Langue = "fr",
DomaineActiviteCode = "Droit"
};
var term = new TermeMetier
{
Id = 2,
DictionnaireMetierId = dictionary.Id,
DictionnaireMetier = dictionary,
Mot = "contrat",
Definition = "Accord de volontés",
Langue = "fr",
StatutValidation = StatutValidationTerme.Propose,
ProposeParId = "user-1"
};
Assert.Equal("Droit", dictionary.DomaineActiviteCode);
Assert.Equal("contrat", term.Mot);
Assert.Equal(StatutValidationTerme.Propose, term.StatutValidation);
}
[Fact]
public async Task DictionnaireMetier_moderation_flow_allows_propose_validate_and_reject()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
context.Activities.Add(new Activity
{
Code = "Droit",
Name = "Droit",
ParentCode = null,
Description = "Domaine de référence",
Hidden = false,
Forms = new List<CommandForm>()
});
context.DictionnaireMetier.Add(new DictionnaireMetier
{
Nom = "Droit civil",
Langue = "fr",
DomaineActiviteCode = "Droit"
});
await context.SaveChangesAsync();
var service = new DictionnaireMetierModerationService(context);
var proposed = await service.ProposerTermAsync(1, "contrat", "Accord de volontés", "fr", "user-proposer");
Assert.Equal(StatutValidationTerme.Propose, proposed.StatutValidation);
var validated = await service.ValiderTermAsync(proposed.Id, "user-moderator");
Assert.Equal(StatutValidationTerme.Valide, validated.StatutValidation);
Assert.Equal("user-moderator", validated.ValideParId);
var rejected = await service.RejeterTermAsync(1, "user-moderator");
Assert.Equal(StatutValidationTerme.Rejete, rejected.StatutValidation);
}
}

View file

@ -0,0 +1,42 @@
using Xunit;
namespace Yavsc.Org.Tests
{
[Collection("Yavsc Server")]
public class DumpHtml : IClassFixture<WebServerFixture>
{
readonly WebServerFixture _server;
public DumpHtml(WebServerFixture server) { _server = server; }
// FIXME [Fact]
[Trait("debug", "html")]
public void DumpHomePageHtml()
{
var settings = _server.SiteSettings;
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true,
};
var httpsUrl = _server.Addresses.FirstOrDefault(u => u.StartsWith("https:"))
?? _server.Addresses.FirstOrDefault() ?? "";
// var httpsUrl = "https://localhost:5001" ;
using var client = new HttpClient(handler) { BaseAddress = new Uri(httpsUrl) };
var paths = new[] { "/Home/About", "/css/site.css", "/lib/bootstrap.quartz.min.css", "/nonexistent" };
var sb = new System.Text.StringBuilder();
sb.AppendLine($"BaseAddress = {httpsUrl}");
foreach (var p in paths)
{
var r = client.GetAsync(p).GetAwaiter().GetResult();
var b = r.Content.ReadAsStringAsync().GetAwaiter().GetResult();
sb.AppendLine($"GET {p} => {r.StatusCode} len={b.Length} ct={r.Content.Headers.ContentType}");
if (b.Length > 0 && b.Length < 500)
sb.AppendLine($" body: {b.Substring(0, Math.Min(300, b.Length))}");
}
Assert.Fail(sb.ToString());
}
}
}

View file

@ -0,0 +1,71 @@
using MailKit.Security;
using MimeKit;
using Yavsc.Interfaces;
namespace Yavsc.Org.Tests.Fakes
{
/// <summary>
/// In-memory test double for <see cref="ISmtpClient"/>. Records
/// every call for assertion and short-circuits the SMTP
/// roundtrip.
/// </summary>
public sealed class RecordingSmtpClient : ISmtpClient
{
public List<RecordingSmtpCall> Calls { get; } = new();
public int Timeout { get; set; }
public string? LastConnectedHost { get; private set; }
public int LastConnectedPort { get; private set; }
public SecureSocketOptions LastConnectedOptions { get; private set; }
public string? LastAuthenticatedUser { get; private set; }
public MimeMessage? LastSentMessage { get; private set; }
public bool WasDisconnected { get; private set; }
public bool Disposed { get; private set; }
public void Connect(string host, int port, SecureSocketOptions options)
{
LastConnectedHost = host;
LastConnectedPort = port;
LastConnectedOptions = options;
Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Connect, host, port, options));
}
public void Authenticate(string userName, string password)
{
LastAuthenticatedUser = userName;
Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Authenticate, userName, null, null));
}
public Task SendAsync(MimeMessage message, CancellationToken cancellationToken = default)
{
LastSentMessage = message;
Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Send, message.Subject, null, null));
return Task.CompletedTask;
}
public void Disconnect(bool quit)
{
WasDisconnected = quit;
Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Disconnect, null, null, null));
}
public void Dispose()
{
Disposed = true;
}
}
public enum RecordingSmtpCallKind
{
Connect,
Authenticate,
Send,
Disconnect,
}
public readonly record struct RecordingSmtpCall(
RecordingSmtpCallKind Kind,
string? HostOrSubjectOrUser,
int? Port,
SecureSocketOptions? Options);
}

View file

@ -0,0 +1,21 @@
using Yavsc.Interfaces;
namespace Yavsc.Org.Tests.Fakes
{
/// <summary>
/// Test double for <see cref="ISmtpClientFactory"/>. Hands out
/// <see cref="RecordingSmtpClient"/> instances and tracks them so
/// tests can assert on every SMTP interaction.
/// </summary>
public sealed class RecordingSmtpClientFactory : ISmtpClientFactory
{
public List<RecordingSmtpClient> Created { get; } = new();
public ISmtpClient CreateClient()
{
var client = new RecordingSmtpClient();
Created.Add(client);
return client;
}
}
}

View file

@ -0,0 +1,105 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Server.Models.IT.SourceCode;
using Microsoft.EntityFrameworkCore;
using Xunit;
using Yavsc.Server.Models.IT;
namespace Yavsc.Org.Tests
{
[Collection("Yavsc Server")]
[Trait("regression", "oui")]
public abstract class BaseTestContext : IClassFixture<WebServerFixture>, IDisposable
{
protected readonly WebServerFixture _serverFixture;
protected readonly ITestOutputHelper _output;
public BaseTestContext(ITestOutputHelper output, WebServerFixture fixture)
{
this._serverFixture = fixture;
this._output = output;
}
public HttpClient CreateHttpClient()
{
return new HttpClient(new BypassSslValidationHandler())
{
BaseAddress = new Uri(this._serverFixture.HttpsAuthority ?? throw new InvalidOperationException("Missing HttpsAuthority"))
};
}
/// <summary>
/// Issue a GET against <paramref name="relativePath"/> on the
/// in-memory test server. Returns the raw HttpResponseMessage
/// without following redirects — the test asserts on the first
/// hop, not the eventual page.
/// </summary>
protected static async Task<HttpResponseMessage> GetRaw(
HttpClient client, string relativePath)
{
Assert.NotNull(client);
var request = new HttpRequestMessage(HttpMethod.Get, relativePath);
return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
}
/// <summary>
/// Smoke assertion: a GET on <paramref name="relativePath"/>
/// returns 2xx (page served) or 3xx (redirect to login) or
/// 401/403 (anonymous rejected by [Authorize]). Anything else
/// — 404 (route missing), 5xx (server crash), connection
/// refused (host not started) — fails the test.
/// </summary>
protected static async Task AssertResponds(
HttpClient client, string relativePath)
{
var response = await GetRaw(client, relativePath);
var status = (int)response.StatusCode;
Assert.True(
status >= 200 && status < 400 || status == 401 || status == 403,
$"GET {relativePath} returned {status} {response.StatusCode}, " +
"expected 2xx/3xx (page or redirect) or 401/403 (auth required).");
}
// FIXME write a scenario from an empty database [Fact]
public void GitClone()
{
using var scope = _serverFixture.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.NotNull(dbContext.Project);
Project yavsc = new Project
{
Name = "Yavsc"
};
dbContext.Project.Add(yavsc);
dbContext.SaveChanges();
var firstProject = dbContext.Project.Include(p => p.Repository).FirstOrDefault(
p => p.Name == "Yavsc"
);
Assert.NotNull(firstProject);
var di = new DirectoryInfo(_serverFixture.SiteSettings.GitRepository);
if (!di.Exists) di.Create();
var clone = new GitClone(_serverFixture.SiteSettings.GitRepository);
clone.Launch(firstProject);
gitRepo = di.FullName;
}
string gitRepo = null;
private IConfigurationRoot configurationRoot;
[Fact]
public void HaveConfigurationRoot()
{
var builder = new ConfigurationBuilder();
configurationRoot = builder.Build();
}
public void Dispose()
{
if (gitRepo != null)
{
Directory.Delete(Path.Combine(gitRepo, "yavsc"), true);
}
}
}
}

View file

@ -0,0 +1,223 @@
using System.Security.Cryptography.X509Certificates;
using System.Net.Security;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using IdentityModel.Client;
using Xunit;
using Yavsc.Server.Helpers;
namespace Yavsc.Org.Tests
{
[Collection("Yavsc Server")]
[Trait("regression", "oui")]
public class Remoting : BaseTestContext, IClassFixture<WebServerFixture>
{
private readonly ITestOutputHelper _output;
public Remoting(WebServerFixture serverFixture, ITestOutputHelper output)
: base(output, serverFixture)
{
_output = output;
}
[Fact]
public async Task ObtainServiceToken()
{
var serverUrl = GetServerUrl();
var cancellationToken = TestContext.Current.CancellationToken;
HttpClient client = NewHttpClient();
var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken);
var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
Address = tokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)),
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)),
Scope = "test",
GrantType = "client_credentials"
}, cancellationToken);
if (response.IsError) throw new Exception(response.Error);
}
private static HttpClient NewHttpClient()
{
return new HttpClient(new BypassSslValidationHandler());
}
[Fact]
public async Task ObtainResourceOwnerPasswordToken()
{
var serverUrl = GetServerUrl();
var cancellationToken = TestContext.Current.CancellationToken;
var client = NewHttpClient();
var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken);
var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest
{
Address = tokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)),
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)),
UserName = RequireNonEmpty(_serverFixture.TestingUserName, nameof(_serverFixture.TestingUserName)),
Password = RequireNonEmpty(_serverFixture.TestingUserPassword, nameof(_serverFixture.TestingUserPassword)),
Scope = "test",
Parameters =
{
{ "acr_values", "tenant:custom_account_store1 foo bar quux" }
}
}, cancellationToken);
if (response.IsError) throw new Exception(response.Error);
}
/// <summary>
/// Regression for the MyFiles upload 500 / null
/// <c>User.Identity.Name</c>: the access token minted by the
/// real IdentityServer (resource-owner password grant) must
/// carry a <c>name</c> claim whose value is the user's login
/// (<c>UserName</c>), and <see cref="UserHelpers.GetUserName"/>
/// — which reads <c>FindFirstValue("name")</c> — must resolve
/// to it on a principal built the way
/// <c>AddYavscJwtBearer</c> builds one
/// (<c>MapInboundClaims=false</c>, <c>NameClaimType="name"</c>).
///
/// <para>Prerequisites wired by <see cref="WebServerFixture"/>:
/// the test client's <c>AllowedScopes</c> include
/// <c>openid</c>+<c>profile</c>, and the standard identity
/// resources are seeded by the production pipeline. Requesting
/// <c>openid profile</c> makes <c>ProfileService</c> emit
/// <c>name</c> = <c>user.UserName</c>.</para>
/// </summary>
[Fact]
public async Task PasswordToken_carries_name_claim_equal_to_login()
{
var serverUrl = GetServerUrl();
var cancellationToken = TestContext.Current.CancellationToken;
var client = NewHttpClient();
var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken);
var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest
{
Address = tokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)),
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)),
UserName = RequireNonEmpty(_serverFixture.TestingUserName, nameof(_serverFixture.TestingUserName)),
Password = RequireNonEmpty(_serverFixture.TestingUserPassword, nameof(_serverFixture.TestingUserPassword)),
// "openid profile" triggers ProfileService to emit the
// "name" claim (user.UserName). Without profile, the
// token carries only sub + scopes and GetUserName()
// resolves to null — the exact regression behind the
// MyFiles upload 500. "test" is the API scope, so a real
// JWT access token (not just an id_token) is issued.
Scope = "openid profile test"
}, cancellationToken);
Assert.False(response.IsError,
$"Token request failed: {response.Error} ({response.ErrorDescription})");
// Decode the access token straight from the payload so short
// claim names ("name", "sub") stay as-emitted — mirrors
// AddYavscJwtBearer with MapInboundClaims=false.
var jwt = new JwtSecurityToken(response.AccessToken);
var nameClaim = jwt.Claims.FirstOrDefault(c => c.Type == "name");
Assert.True(nameClaim is not null,
"access_token has no 'name' claim; ProfileService did not emit it " +
"(is the 'profile' scope in the client's AllowedScopes and requested?).");
Assert.Equal(_serverFixture.TestingUserName, nameClaim!.Value);
// Rebuild the principal the way a bearer consumer host does
// and exercise the real GetUserName() helper used by the
// file-system / avatar paths.
var identity = new ClaimsIdentity(
jwt.Claims, authenticationType: "Bearer",
nameType: "name", roleType: null);
var principal = new ClaimsPrincipal(identity);
Assert.Equal(_serverFixture.TestingUserName, principal.GetUserName());
}
[Fact]
public async Task GetSignin_returns_a_page()
{
using var client = new HttpClient(new BypassSslValidationHandler())
{
BaseAddress = new Uri(this._serverFixture.HttpsAuthority ?? throw new InvalidOperationException("Missing HttpsAuthority"))
};
await AssertResponds(client, "/signin");
}
[Fact]
public async Task GetOpenIdConfiguration_returns_ok()
{
using var client = CreateHttpClient();
var response = await GetRaw(client, "/.well-known/openid-configuration");
var payload = await response.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
);
Assert.True(
response.IsSuccessStatusCode,
$"GET /.well-known/openid-configuration returned {(int)response.StatusCode} {response.StatusCode}. Body: {payload}");
}
public static IEnumerable<object[]> GetLoginIntentData()
{
return new object[][] { new object[] { "testuser", "test" } };
}
private async Task<string> ResolveTokenEndpointAsync(HttpClient client, string serverUrl, CancellationToken cancellationToken)
{
var disco = await client.GetDiscoveryDocumentAsync(serverUrl, cancellationToken);
if (!disco.IsError && !string.IsNullOrWhiteSpace(disco.TokenEndpoint))
{
return disco.TokenEndpoint;
}
// Some full-suite runs intermittently return 500 on the OIDC
// discovery document while /connect/token remains available.
var fallback = new Uri(new Uri(serverUrl), "/connect/token").ToString();
_output.WriteLine($"WARNING: OIDC discovery failed ({disco.Error}). Fallback token endpoint: {fallback}");
return fallback;
}
private string GetServerUrl()
{
return RequireNonEmpty(_serverFixture.SiteSettings?.Authority, "SiteSettings.Authority");
}
private static string RequireNonEmpty(string? value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException($"Missing required test setting: {name}");
}
return value;
}
}
internal class BypassSslValidationHandler : HttpClientHandler
{
public BypassSslValidationHandler()
{
// Override validation for this handler only
ServerCertificateCustomValidationCallback = ValidateCertificate;
}
private bool ValidateCertificate(
HttpRequestMessage request,
X509Certificate2? certificate,
X509Chain? chain,
SslPolicyErrors errors)
{
// Accept all certificates (bypass validation)
return true;
}
}
}

View file

@ -0,0 +1,22 @@
namespace Yavsc {
public class ResxResources {
const string resPath = "Resources/Test.TestResources.resx";
public void HaveAResxLoader()
{
System.Resources.ResourceReader loader = new System.Resources.ResourceReader(resPath);
// IDictionary
var etor = loader.GetEnumerator();
while (etor.Current !=null)
{
byte[] data;
string stringdata;
string resName = etor.Key.ToString();
loader.GetResourceData(resName, out stringdata, out data);
}
}
}
}

View file

@ -0,0 +1,12 @@
namespace Yavsc.Org.Tests.Mandatory;
using Xunit;
[Collection("Yavsc Server")]
[Trait("regression", "oui")]
public class Services : BaseTestContext, IClassFixture<WebServerFixture>
{
public Services(ITestOutputHelper output, WebServerFixture fixture) : base(output, fixture)
{
}
}

View file

@ -0,0 +1,28 @@
using Yavsc.Server.Helpers;
using Xunit;
namespace Yavsc
{
[Collection("Yavsc Abstract tests")]
[Trait("regression", "II")]
public class AbstractTests
{
readonly ITestOutputHelper output;
public AbstractTests(ITestOutputHelper output)
{
this.output = output;
}
[Fact]
public void UniqueFilenameAfterCleaning()
{
var name1 = "content:///scanned_files/2020-06-02/00.11.02.JPG";
var name2 = "content:///scanned_files/2020-06-02/00.11.03.JPG";
var cleanName1 = AbstractFileSystemHelpers.FilterFileName(name1);
var cleanName2 = AbstractFileSystemHelpers.FilterFileName(name2);
output.WriteLine($"{name1} => {cleanName1}");
output.WriteLine($"{name2} => {cleanName2}");
Assert.True(cleanName1 != cleanName2);
}
}
}

View file

@ -0,0 +1,67 @@
namespace Yavsc.Org.Tests.NonRegression;
using Xunit;
/// <summary>
/// Régression du 500 sur <c>GET /BlogSpot/Details/{id}</c> (auteur
/// sans <c>UserName</c>) : le display template
/// <c>ApplicationUser.cshtml</c> ne doit plus accéder à
/// <c>Model.UserName</c> directement. Toute lecture passe par
/// <c>UserDisplayHelpers.AvatarSrc</c>, qui défend contre
/// <c>null</c> et contre les chaînes vides/whitespace.
///
/// On ne compile pas la vue Razor ici (coût de mise en place
/// disproportionné pour un seul display template) ; on asserte
/// statiquement que le cshtml ne porte plus l'accès fautif. Si
/// quelqu'un revert la ligne, ce test casse.
/// </summary>
public class ApplicationUserDisplayTemplateTests
{
[Fact]
public void ApplicationUser_cshtml_does_not_construct_avatar_path_from_Model_UserName()
{
// L'invariant qu'on protège : l'URL d'avatar ne doit plus
// être construite à partir de Model.UserName direct (le
// commit 2 du fix). Cette construction était la cause du
// 500 sur GET /BlogSpot/Details/{id} : avec
// <Nullable>enable</Nullable>, Razor émet un null-check
// implicite sur l'expression, et lève NPE si UserName est
// null. Le helper AvatarSrc défend contre ce cas.
//
// On n'interdit pas les autres usages de Model.UserName
// (alt, title, asp-route-id) : Razor les rend en chaîne
// vide si null, sans NPE. C'est laid, pas cassé.
var path = ResolveTemplatePath();
var content = File.ReadAllText(path);
// L'ancien code fautif concaténait directement
// "/Avatars/" + Model.UserName + ".s.png".
Assert.DoesNotContain("Model.UserName + ", content);
Assert.DoesNotContain("Model.UserName+", content);
}
[Fact]
public void ApplicationUser_cshtml_uses_the_null_safe_helper_for_avatar()
{
var path = ResolveTemplatePath();
var content = File.ReadAllText(path);
Assert.Contains("UserDisplayHelpers.AvatarSrc", content);
}
private static string ResolveTemplatePath()
{
// Le test s'exécute depuis src/Yavsc.Org.Tests/bin/...,
// on remonte pour trouver la vue source.
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 8 && dir is not null; i++)
{
var candidate = Path.Combine(dir,
"src", "Yavsc.Org", "Views", "Shared",
"DisplayTemplates", "ApplicationUser.cshtml");
if (File.Exists(candidate)) return candidate;
dir = Path.GetDirectoryName(dir);
}
throw new FileNotFoundException(
"Could not locate ApplicationUser.cshtml from " + AppContext.BaseDirectory);
}
}

View file

@ -0,0 +1,35 @@
namespace Yavsc.Org.Tests.NonRegression;
using Xunit;
/// <summary>
/// Non-regression: avatar requests under /avatars must never return 404
/// for missing files. The pipeline falls back to static defaults under
/// /images/Users/icon_user*.png.
/// </summary>
public class AvatarFallbackTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public AvatarFallbackTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Theory]
[InlineData("/avatars/user-does-not-exist.png")]
[InlineData("/avatars/user-does-not-exist.s.png")]
[InlineData("/avatars/user-does-not-exist.xs.png")]
public async Task Missing_avatar_file_returns_default_image_instead_of_404(string path)
{
using var client = _factory.CreateClient();
var ct = TestContext.Current.CancellationToken;
var response = await client.GetAsync(path, ct);
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
Assert.Equal("image/png", response.Content.Headers.ContentType?.MediaType);
var payload = await response.Content.ReadAsByteArrayAsync(ct);
Assert.True(payload.Length > 0, $"Expected a non-empty fallback image for {path}.");
}
}

View file

@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Workflow;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Haircut;
using Yavsc.Services;
using Xunit;
namespace Yavsc
{
// BillingService and Config expose static mutable registries that
// WorkflowHelpers.ConfigureBillingService() also mutates from the
// WebServerFixture host build (HostingExtensions.cs). Without a
// collection, xUnit runs this class in parallel with the
// "Yavsc Server" collection, and the host build's ConfigureBilling
// call can land between this test's two ConfigureBilling calls,
// breaking the idempotency count assertion (a flaky failure that
// surfaces whenever the fixture's timing shifts). Pinning the class
// to the same collection serializes it with the host build.
[Collection("Yavsc Server")]
[Trait("regression", "II")]
public class BillingServiceTests
{
[Fact]
public void ConfigureBillingService_CanBeCalledTwiceWithoutThrowing()
{
// First initialization should populate the billing registry.
WorkflowHelpers.ConfigureBillingService();
int firstBillingCount = BillingService.Billing.Count;
int firstSettingsCount = BillingService.UserSettings.Count;
int firstProfileTypesCount = Config.ProfileTypes.Count;
// Second call should be idempotent and not throw.
WorkflowHelpers.ConfigureBillingService();
Assert.Equal(firstBillingCount, BillingService.Billing.Count);
Assert.Equal(firstSettingsCount, BillingService.UserSettings.Count);
Assert.Equal(firstProfileTypesCount, Config.ProfileTypes.Count);
}
[Fact]
public void RegisterBilling_DuplicateRegistrationThrowsInvalidOperationException()
{
WorkflowHelpers.ConfigureBillingService();
var firstRegistrar = new Func<ApplicationDbContext, long, IQuery>((db, id) =>
db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularization).Single(q => q.Id == id));
const string testCode = "Brush";
Assert.Throws<InvalidOperationException>(() =>
WorkflowHelpers.RegisterBilling<HairCutQuery>(testCode, firstRegistrar));
}
}
}

View file

@ -0,0 +1,32 @@
namespace Yavsc.Org.Tests.Mandatory
{
using Xunit;
[Collection("Database")]
[Trait("regression", "II")]
[Trait("dev", "wip")]
public class Database : IClassFixture<WebServerFixture>
{
readonly ITestOutputHelper output;
readonly WebServerFixture _serverFixture;
public Database(ITestOutputHelper output, WebServerFixture _serverFixture)
{
this.output = output;
this._serverFixture = _serverFixture;
}
/// <summary>
/// Assuming we're using an account that may create databases,
/// Install all our migrations in a fresh new database.
/// </summary>
[Fact]
public void TestDatabaseMigration()
{
// Test logic goes here
_serverFixture.ResetAndMigrateDatabase();
}
}
}

View file

@ -0,0 +1,164 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using MailKit.Net.Smtp;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MimeKit;
using Yavsc.Interface;
using Yavsc.Interfaces;
using Yavsc.Models.Relationship;
using Yavsc.Org.Tests.Fakes;
using Yavsc.Services;
using Yavsc.Settings;
using Yavsc.ViewModels.Account;
using Xunit;
namespace Yavsc.Org.Tests
{
[Collection("EMaillingTeststCollection")]
[Trait("regression", "II")]
public class EMaillingTests : IClassFixture<WebServerFixture>
{
readonly WebServerFixture _serverFixture;
readonly ITestOutputHelper output;
readonly ILogger _logger;
public EMaillingTests(WebServerFixture serverFixture, ITestOutputHelper output)
{
this.output = output;
_serverFixture = serverFixture;
_logger = serverFixture.Logger!;
}
[Fact]
public async Task SendEMailSynchrone()
{
using IServiceScope scope = _serverFixture.Services.CreateScope();
ITrueEmailSender mailSender = scope.ServiceProvider.GetRequiredService<ITrueEmailSender>();
var factory = Assert.IsType<RecordingSmtpClientFactory>(
scope.ServiceProvider.GetRequiredService<ISmtpClientFactory>());
output.WriteLine("SendEMailSynchrone ...");
await mailSender.SendEmailAsync
(
_serverFixture.SiteSettings!.Owner.Name,
_serverFixture.SiteSettings!.Owner.EMail,
$"monthly email",
"test boby monthly email");
// Assert the SMTP roundtrip was short-circuited by the
// recording fake installed in WebServerFixture: exactly
// one client was created and it saw the expected sequence
// of Connect → Authenticate → Send → Disconnect.
var client = Assert.Single(factory.Created);
Assert.Equal(
new[]
{
RecordingSmtpCallKind.Connect,
RecordingSmtpCallKind.Authenticate,
RecordingSmtpCallKind.Send,
RecordingSmtpCallKind.Disconnect,
},
client.Calls.Select(c => c.Kind).ToArray());
Assert.Equal(_serverFixture.SiteSettings.Owner.EMail, client.LastSentMessage?.To.Mailboxes.First().Address);
}
[Fact]
public void RegisterModel_rejects_invalid_email_format()
{
var model = new RegisterModel
{
UserName = "alice",
Email = "this is not an email",
Password = "Password123!",
ConfirmPassword = "Password123!"
};
var results = new List<ValidationResult>();
var valid = Validator.TryValidateObject(
model,
new ValidationContext(model),
results,
validateAllProperties: true);
Assert.False(valid);
Assert.Contains(results, r => r.MemberNames.Contains(nameof(RegisterModel.Email)));
}
[Fact]
public async Task SendEmailAsync_ignores_smtp_recipient_rejection()
{
var sender = new MailSender(
Options.Create(new SiteSettings
{
Title = "Test",
Authority = "example.com",
Owner = new StaticContact { Name = "Test Owner", EMail = "owner@example.com" }
}),
Options.Create(new SmtpSettings
{
Host = "smtp.test.local",
Port = 465,
UserName = "test-user",
Password = "secret"
}),
NullLoggerFactory.Instance,
new TestStringLocalizer(),
new RejectingSmtpClientFactory());
var result = await sender.SendEmailAsync(
"Alice",
"contact@pschneider.fr",
"Welcome",
"hello");
Assert.Equal(string.Empty, result);
}
private sealed class RejectingSmtpClientFactory : ISmtpClientFactory
{
public Yavsc.Interfaces.ISmtpClient CreateClient() => new RejectingSmtpClient();
}
private sealed class RejectingSmtpClient : Yavsc.Interfaces.ISmtpClient
{
public int Timeout { get; set; }
public void Connect(string host, int port, MailKit.Security.SecureSocketOptions options) { }
public void Authenticate(string userName, string password) { }
public Task SendAsync(MimeMessage message, CancellationToken cancellationToken = default)
{
throw new SmtpCommandException(
SmtpErrorCode.RecipientNotAccepted,
SmtpStatusCode.MailboxUnavailable,
"Recipient address rejected: User unknown in local recipient table");
}
public void Disconnect(bool quit) { }
public void Dispose() { }
}
private sealed class TestStringLocalizer : IStringLocalizer<MailSender>
{
public LocalizedString this[string name] => new(name, name);
public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(CultureInfo.InvariantCulture, name, arguments));
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
=> Enumerable.Empty<LocalizedString>();
public LocalizedString GetString(string name)
=> new(name, name);
public LocalizedString GetString(string name, params object[] arguments)
=> new(name, string.Format(CultureInfo.InvariantCulture, name, arguments));
public IStringLocalizer WithCulture(CultureInfo culture)
=> this;
}
}
}

View file

@ -0,0 +1,42 @@
using Yavsc.Abstract.Files;
namespace Yavsc.Org.Tests.NonRegression;
using Xunit;
public class FileServerUrlHelpersTests
{
[Fact]
public void GetUserFilesBaseUri_appends_the_user_files_path_to_the_authority_root()
{
var baseUri = FileServerUrlHelpers.GetUserFilesBaseUri("https://oidc.example.org");
Assert.Equal("https://oidc.example.org/files/", baseUri.ToString());
}
[Fact]
public void GetUserFilesBaseUri_preserves_the_authority_and_discards_any_existing_path()
{
var baseUri = FileServerUrlHelpers.GetUserFilesBaseUri("https://oidc.example.org/signin");
Assert.Equal("https://oidc.example.org/files/", baseUri.ToString());
}
[Fact]
public void GetUserFilesUri_builds_an_absolute_file_url_from_a_relative_path()
{
var fileUri = FileServerUrlHelpers.GetUserFilesUri(
"https://oidc.example.org",
"/alice/inbox/report.pdf");
Assert.Equal("https://oidc.example.org/files/alice/inbox/report.pdf", fileUri.ToString());
}
[Fact]
public void GetUserFilesUri_rejects_blank_relative_path()
{
Assert.Throws<ArgumentException>(
() => FileServerUrlHelpers.GetUserFilesUri(
"https://oidc.example.org",
" "));
}
}

View file

@ -0,0 +1,101 @@
using Xunit;
namespace Yavsc
{
/// <summary>
/// Non-regression tests for the IdentityServer8 configuration seed.
/// Today (2026-06-25) we hit a runtime crash on prod because the
/// seed attempted to insert 'openid' / 'profile' both as
/// IdentityResources and as ApiScopes:
///
/// Found identity scopes and API scopes that use the same names.
/// This is an invalid configuration. Use different names for
/// identity scopes and API scopes. Scopes found: openid, profile
///
/// These tests pin the invariants that prevent that mistake from
/// regressing: the application-defined scope list must never
/// contain names that collide with identity scopes, every entry
/// must reference an ApiResource, and the seed's translation to
/// SQL must stay valid.
/// </summary>
[Trait("regression", "OIDC seed")]
public class OidcSeedTests
{
// Identity scopes that IdentityServer8 manages on its own. They
// must never appear in Constants.BuildInApiScopes nor in
// Constants.ApiResourcesScopes — an ApiScope that shares its
// Name with an IdentityResource prevents the host from starting.
private static readonly string[] ReservedIdentityScopeNames =
{
"openid",
"profile",
"offline_access"
};
[Fact]
public void BuildInApiScopes_DoesNotContainIdentityScopes()
{
foreach (var reserved in ReservedIdentityScopeNames)
{
Assert.DoesNotContain(reserved, Org.Constants.BuildInApiScopes);
}
}
[Fact]
public void ApiResourcesScopes_DoesNotContainIdentityScopes()
{
foreach (var reserved in ReservedIdentityScopeNames)
{
foreach (var spec in Org.Constants.ApiResourcesScopes)
{
Assert.NotEqual(reserved, spec.ScopeName);
}
}
}
[Fact]
public void ApiResourcesScopes_EveryEntryReferencesAUniqueResource()
{
// Same ScopeName appearing twice would mean two ApiScopes
// (and two ApiResourceScopes rows) trying to claim the same
// name — IdentityServer8 would reject that on startup.
var scopeNames = Org.Constants.ApiResourcesScopes
.Select(s => s.ScopeName)
.ToList();
Assert.Equal(scopeNames.Count, scopeNames.Distinct().Count());
// Same ResourceName appearing twice would mean two
// ApiResources rows with the same Name — the EF seed would
// crash on the unique index.
var resourceNames = Org.Constants.ApiResourcesScopes
.Select(s => s.ResourceName)
.ToList();
Assert.Equal(resourceNames.Count, resourceNames.Distinct().Count());
}
[Fact]
public void ApiResourcesScopes_EveryEntryHasNonEmptyStrings()
{
// An empty ScopeName or ResourceName silently produces an
// ApiScope / ApiResource row with no key, which breaks the
// scope validator's string-based lookup later on.
foreach (var spec in Org.Constants.ApiResourcesScopes)
{
Assert.False(string.IsNullOrWhiteSpace(spec.ScopeName),
$"ScopeName must be set (got '{spec.ScopeName}')");
Assert.False(string.IsNullOrWhiteSpace(spec.ResourceName),
$"ResourceName must be set (got '{spec.ResourceName}')");
Assert.False(string.IsNullOrWhiteSpace(spec.ResourceDisplayName),
$"ResourceDisplayName must be set (got '{spec.ResourceDisplayName}')");
}
}
[Fact]
public void ApiResourcesScopes_IsNotEmpty()
{
// Belt and braces: if someone ever wipes the list thinking
// it's stale, this test forces them to be explicit about it.
Assert.NotEmpty(Org.Constants.ApiResourcesScopes);
}
}
}

View file

@ -0,0 +1,77 @@
using System.ComponentModel.DataAnnotations;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Xunit;
namespace Yavsc.Tests.NonRegression;
public class PerformerCodeInputValidationTests
{
[Fact]
public void Validate_rejects_unknown_country_code()
{
var profile = CreateBaseProfile();
profile.ExerciseCountryCode = "de";
profile.SIREN = "123456789";
var results = Validate(profile);
Assert.Contains(results, r => r.MemberNames.Contains(nameof(PerformerProfile.ExerciseCountryCode)));
}
[Fact]
public void Validate_rejects_code_not_matching_country_rule()
{
var profile = CreateBaseProfile();
profile.ExerciseCountryCode = "pt";
profile.SIREN = "ABC123";
var results = Validate(profile);
Assert.Contains(results, r => r.MemberNames.Contains(nameof(PerformerProfile.SIREN)));
}
[Fact]
public void Validate_accepts_country_specific_valid_codes()
{
var fr = CreateBaseProfile();
fr.ExerciseCountryCode = "fr";
fr.SIREN = "123456789";
var en = CreateBaseProfile();
en.ExerciseCountryCode = "en";
en.SIREN = "AB12CD34";
var pt = CreateBaseProfile();
pt.ExerciseCountryCode = "pt";
pt.SIREN = "501964843";
Assert.Empty(Validate(fr));
Assert.Empty(Validate(en));
Assert.Empty(Validate(pt));
}
private static PerformerProfile CreateBaseProfile()
{
return new PerformerProfile
{
PerformerId = "perf-1",
SIREN = "123456789",
ExerciseCountryCode = "fr",
OrganizationAddress = new Location
{
Address = "1 rue du Test",
Latitude = 48.8566,
Longitude = 2.3522,
},
};
}
private static List<ValidationResult> Validate(PerformerProfile profile)
{
var ctx = new ValidationContext(profile);
var results = new List<ValidationResult>();
Validator.TryValidateObject(profile, ctx, results, validateAllProperties: true);
return results;
}
}

View file

@ -0,0 +1,71 @@
using Xunit;
namespace Yavsc.Org.Tests.NonRegression;
/// <summary>
/// Guard rails for the SetActivity performer settings page:
/// - countries are provided to the ComboBox via ViewBag.Countries
/// - client-side SIREN validation is wired to country-specific regex rules
/// - controller exposes the validation catalog to the view
/// </summary>
public class SetActivityCountryValidationViewTests
{
[Fact]
public void SetActivity_cshtml_binds_country_combo_to_ViewBag_Countries()
{
var content = File.ReadAllText(ResolveSetActivityViewPath());
Assert.Contains("asp-for=\"ExerciseCountryCode\"", content);
Assert.Contains("asp-items=\"ViewBag.Countries\"", content);
}
[Fact]
public void SetActivity_cshtml_contains_country_aware_siren_javascript_validation()
{
var content = File.ReadAllText(ResolveSetActivityViewPath());
Assert.Contains("$.validator.addMethod(\"sirenByCountry\"", content);
Assert.Contains("new RegExp(selectedRule.regex)", content);
Assert.Contains("const countryInput = $(\"#ExerciseCountryCode\")", content);
Assert.Contains("const sirenInput = $(\"#SIREN\")", content);
}
[Fact]
public void ManageController_exposes_countries_and_country_validation_rules_to_view()
{
var content = File.ReadAllText(ResolveManageControllerPath());
Assert.Contains("ViewBag.Countries = countries;", content);
Assert.Contains("ViewBag.CountryCodeValidationRules = PerformerCodeInputValidationCatalog.Rules;", content);
Assert.Contains("ModelState.Remove(nameof(PerformerProfile.ExerciseCountryCode));", content);
}
private static string ResolveSetActivityViewPath()
{
return ResolveFromWorkspaceRoot(
"src", "Yavsc.Org", "Views", "Manage", "SetActivity.cshtml");
}
private static string ResolveManageControllerPath()
{
return ResolveFromWorkspaceRoot(
"src", "Yavsc.Org", "Controllers", "Accounting", "ManageController.cs");
}
private static string ResolveFromWorkspaceRoot(params string[] relative)
{
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 10 && dir is not null; i++)
{
var candidate = Path.Combine(new[] { dir }.Concat(relative).ToArray());
if (File.Exists(candidate))
{
return candidate;
}
dir = Path.GetDirectoryName(dir);
}
throw new FileNotFoundException(
"Could not locate test target from " + AppContext.BaseDirectory);
}
}

View file

@ -0,0 +1,62 @@
using Yavsc.Abstract.Identity;
using Xunit;
namespace Yavsc.Org.Tests.NonRegression;
/// <summary>
/// Régression sur le 500 <c>GET /BlogSpot/Details/{id}</c> : un
/// billet dont l'auteur a un <c>UserName</c> null ou absent faisait
/// lever <c>NullReferenceException</c> dans le display template
/// <c>ApplicationUser.cshtml</c> à l'évaluation de
/// <c>Model.UserName</c>. ASP.NET transforme en 500, et la page
/// d'erreur elle-même crash (ErrorViewModel manquant), donc on
/// ne voit rien — juste un 500 muet.
///
/// Le fix passe par <see cref="UserDisplayHelpers.AvatarSrc"/> qui
/// retourne <see cref="Yavsc.Constants.DefaultAvatar"/> pour toute
/// donnée partielle. Ces tests couvrent les trois formes de
/// "donnée absente" : user null, UserName vide, UserName whitespace.
/// </summary>
public class UserDisplayHelpersTests
{
[Fact]
public void AvatarSrc_null_user_returns_default_avatar()
{
Assert.Equal(Yavsc.Constants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(null));
}
[Fact]
public void AvatarSrc_user_with_empty_UserName_returns_default_avatar()
{
var user = new FakeUser { UserName = "" };
Assert.Equal(Yavsc.Constants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user));
}
[Fact]
public void AvatarSrc_user_with_whitespace_UserName_returns_default_avatar()
{
var user = new FakeUser { UserName = " " };
Assert.Equal(Yavsc.Constants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user));
}
[Fact]
public void AvatarSrc_valid_user_returns_canonical_avatars_path()
{
var user = new FakeUser { UserName = "alice" };
// Le path doit matcher YavscConstants.AvatarsPath (minuscule),
// pas un /Avatars/ avec S majuscule qui ne résout pas
// dans le middleware de fichiers statiques.
var expected = $"{Yavsc.Constants.AvatarsPath}/alice.s.png";
Assert.Equal(expected, UserDisplayHelpers.AvatarSrc(user));
}
private sealed class FakeUser : IApplicationUser
{
public string Id { get; set; } = "";
public string? UserName { get; set; }
public string? Avatar { get; set; }
public IAccountBalance? AccountBalance => null;
public string? DedicatedGoogleCalendar => null;
public ILocation? PostalAddress => null;
}
}

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<!--
route name for the api controller used to tag the 'BlogPost' entity
-->
<data name="ErrMessageTooLong"><value>Too Long ({MaxLen} at maximum, {ecart} in excess))</value></data>
<data name="ErrMessageTooShort"><value>Too Short ({MinLen} at minus, {ecart} missing)</value></data>
</root>

View file

@ -0,0 +1,129 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Yavsc.Models;
using Yavsc.Models.Relationship;
using Yavsc.Services;
using Xunit;
namespace Yavsc.Org.Tests.Services;
public class FileSystemAuthManagerTests
{
[Fact]
public void SetAccess_creates_acl_row_with_owner_path_and_flags()
{
using var scope = CreateScope();
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.Read | FileAccessRight.Write);
var row = scope.Db.CircleAuthorizationToFile.Single();
Assert.Equal(scope.Circle.Id, row.CircleId);
Assert.Equal("alice/documents/report.txt", row.Path);
Assert.Equal("alice", row.OwnerId);
Assert.Equal(FileAccessRight.Read | FileAccessRight.Write, row.Access);
}
[Fact]
public void SetAccess_updates_existing_acl_row_without_duplicates()
{
using var scope = CreateScope();
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.Read);
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.Write);
var rows = scope.Db.CircleAuthorizationToFile.ToList();
Assert.Single(rows);
Assert.Equal(FileAccessRight.Write, rows[0].Access);
}
[Fact]
public void SetAccess_none_removes_existing_acl_row()
{
using var scope = CreateScope();
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.Read);
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.None);
Assert.Empty(scope.Db.CircleAuthorizationToFile);
}
[Fact]
public void SetAccess_ignores_unknown_owner_prefix()
{
using var scope = CreateScope();
scope.Service.SetAccess(scope.Circle.Id, "unknown/documents/report.txt", FileAccessRight.Read);
Assert.Empty(scope.Db.CircleAuthorizationToFile);
}
[Fact]
public void Deleting_circle_cascades_file_acl_rows()
{
using var scope = CreateScope();
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.Read);
scope.Db.Circle.Remove(scope.Circle);
scope.Db.SaveChanges();
Assert.Empty(scope.Db.CircleAuthorizationToFile);
}
private static TestScope CreateScope()
{
var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
var db = new ApplicationDbContext(options);
db.Database.EnsureCreated();
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.test"
});
db.SaveChanges();
var circle = new Circle
{
OwnerId = "alice",
Name = "shared",
Public = false
};
db.Circle.Add(circle);
db.SaveChanges();
var service = new FileSystemAuthManager(db, Options.Create(new SiteSettings()));
return new TestScope(connection, db, service, circle);
}
private sealed class TestScope : IDisposable
{
public TestScope(SqliteConnection connection, ApplicationDbContext db, FileSystemAuthManager service, Circle circle)
{
Connection = connection;
Db = db;
Service = service;
Circle = circle;
}
public SqliteConnection Connection { get; }
public ApplicationDbContext Db { get; }
public FileSystemAuthManager Service { get; }
public Circle Circle { get; }
public void Dispose()
{
Db.Dispose();
Connection.Dispose();
}
}
}

View file

@ -0,0 +1,91 @@
using System.Net;
using System.Net.Http;
using IdentityServer8.Stores;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Tests.Shared;
using Xunit;
namespace Yavsc.Org.Tests.Smoke;
/// <summary>
/// Smoke for the Account BC (login / registration / OIDC discovery).
/// Hits the in-memory test server through
/// <see cref="TestWebApplicationFactory"/>: no sockets, no
/// self-signed certificates, no real DB (the
/// <c>AddInMemoryCollection</c> in <c>WebServerFixture.SetupHost</c>
/// points the EF context at the in-memory provider).
///
/// <c>GET /signin</c> is the public login endpoint served by
/// <c>Yavsc.Org/Controllers/Accounting/AccountController</c>
/// (decorated <c>[HttpGet(YavscConstants.SigninPath)]</c> with
/// <c>YavscConstants.SigninPath = "~/signin"</c>). A 200 means the
/// entire pipeline (routing + Razor + IdentityServer + EF + DI)
/// is wired correctly end-to-end.
/// </summary>
public class AccountSmokeTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public AccountSmokeTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task ResourceStore_get_all_resources_does_not_throw()
{
using var scope = _factory.Services.CreateScope();
var resourceStore = scope.ServiceProvider.GetRequiredService<IResourceStore>();
var exception = await Record.ExceptionAsync(resourceStore.GetAllResourcesAsync);
Assert.Null(exception);
}
// Regression: anonymous access to a restricted page used to redirect to
// the ASP.NET Identity default LoginPath "/Account/Login", which has no
// controller here. The application cookie must redirect to the real
// sign-in endpoint at /signin instead.
[Fact]
public async Task Anonymous_access_to_protected_page_redirects_to_signin()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
// EstimateController is [Authorize]; GET /Estimate hits Index().
var response = await client.GetAsync("/Estimate", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var location = response.Headers.Location;
Assert.NotNull(location);
Assert.Equal("/signin", location.AbsolutePath);
Assert.Contains("ReturnUrl=%2FEstimate", location.Query);
}
// Regression: the POST Delete action existed, but neither the GET
// Delete view nor a rendered confirmation page was reachable —
// Views/Account/Delete.cshtml was missing. An authenticated GET
// must now return the confirmation page (200).
[Fact]
public async Task Authenticated_get_account_delete_returns_confirmation_page()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
client.DefaultRequestHeaders.Add(
TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole);
var response = await client.GetAsync("/Account/Delete", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken);
Assert.Contains("Supprimer mon compte", body);
}
}

View file

@ -0,0 +1,36 @@
using Xunit;
namespace Yavsc.Org.Tests.Smoke;
/// <summary>
/// Smoke for the Blog BC. Hits <c>Yavsc.Org</c> on
/// <c>GET /BlogSpot/Index</c> — the front-end page that lists blog
/// posts. The controller is <c>BlogSpotController</c> (note the
/// capital S) under <c>Yavsc.Org/Controllers/Communicating/</c>,
/// without a class-level <c>[Route]</c>, so the conventional
/// <c>/{controller}/{action}</c> route applies (controller name
/// preserved case).
///
/// The blog backend API itself lives in <c>Yavsc.Blogs</c> on a
/// separate host in production; cf.
/// <c>doc/architecture/decoupage-organisation.md</c>. The smoke
/// here asserts the front-end side of the BC.
/// </summary>
public class BlogSmokeTests : BaseTestContext, IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public BlogSmokeTests(TestWebApplicationFactory factory, ITestOutputHelper output,
WebServerFixture webServerFixture)
: base(output, webServerFixture)
{
_factory = factory;
}
[Fact]
public async Task GetBlogSpotIndex_returns_a_page()
{
using var client = _factory.CreateClient();
await AssertResponds(client, "/BlogSpot/Index");
}
}

View file

@ -0,0 +1,60 @@
namespace Yavsc.Org.Tests;
using Xunit;
/// <summary>
/// Diagnostic-only test that confirms the static-assets manifests
/// the WebServerFixture relies on are present in the test bin.
///
/// The MSBuild target <c>CopyYavscOrgStaticAssets</c> in
/// Yavsc.Org.Tests.csproj mirrors the Yavsc.Org static-assets
/// manifests (<c>Yavsc.Org.staticwebassets.endpoints.json</c> and
/// <c>Yavsc.Org.staticwebassets.runtime.json</c>) from
/// <c>../Yavsc.Org/bin/$(Configuration)/$(TargetFramework)/</c> into
/// the test output directory. The fixture then calls
/// <c>_app.MapStaticAssets(testRuntimeManifest)</c> with the
/// <c>runtime.json</c> path so the test host resolves the manifest
/// explicitly by file location, bypassing the default
/// {AssemblyName}.staticwebassets.* lookup (which would look for
/// <c>Yavsc.Org.Tests.staticwebassets.*</c>, files we don't produce).
///
/// This test is a guard for that build-time copy: if it ever fails
/// or is removed, the WebServerFixture will throw
/// "The static resources manifest file ... was not found" at boot.
/// Once the WebServerFixture is stable in CI, this test can be
/// deleted.
/// </summary>
public class StaticAssetsPathsTests
{
private readonly ITestOutputHelper _output;
public StaticAssetsPathsTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void YavscOrg_staticwebassets_manifests_are_mirrored_into_test_bin()
{
var testBin = AppContext.BaseDirectory;
_output.WriteLine($"Test bin (AppContext.BaseDirectory) = {testBin}");
var runtimeManifest = Path.Combine(testBin,
"Yavsc.Org.staticwebassets.runtime.json");
var endpointsManifest = Path.Combine(testBin,
"Yavsc.Org.staticwebassets.endpoints.json");
_output.WriteLine($"runtime manifest: exists = {File.Exists(runtimeManifest)}, path = {runtimeManifest}");
_output.WriteLine($"endpoints manifest: exists = {File.Exists(endpointsManifest)}, path = {endpointsManifest}");
Assert.True(File.Exists(runtimeManifest),
$"Expected {runtimeManifest} to be copied by CopyYavscOrgStaticAssets, but it is missing. " +
"Check that the Yavsc.Org project builds before Yavsc.Org.Tests so the source manifest exists " +
"and that the <Target> in Yavsc.Org.Tests.csproj is wired up correctly.");
Assert.True(File.Exists(endpointsManifest),
$"Expected {endpointsManifest} to be copied by CopyYavscOrgStaticAssets, but it is missing. " +
"The runtime manifest alone is not enough — MapStaticAssets() may also read the endpoints " +
"manifest depending on the SDK version.");
}
}

View file

@ -0,0 +1,45 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Yavsc.Tests.Shared;
namespace Yavsc.Org.Tests;
/// <summary>
/// Middleware that promotes the <c>X-Test-Role</c> request header to
/// an authenticated <see cref="ClaimsPrincipal"/> on
/// <see cref="HttpContext.User"/>. Lets downstream code (controllers,
/// services) call <c>User.GetUserId()</c> and other claim-based
/// helpers as if a real login had happened, while
/// <see cref="TestAuthPolicyProvider"/> independently short-circuits
/// <c>[Authorize(...)]</c> policy checks.
///
/// This middleware is added to the pipeline by
/// <see cref="TestUserStartupFilter"/> so it runs after the
/// production authentication middleware; setting User before
/// <c>UseAuthentication</c> would have it overwritten on the next
/// middleware.
/// </summary>
public class TestUserMiddleware : IMiddleware
{
public const string UserId = "test-user";
public Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var role = context.Request.Headers[TestAuthPolicyProvider.HeaderName].ToString();
if (!string.IsNullOrEmpty(role) &&
(context.User.Identity is null || !context.User.Identity.IsAuthenticated))
{
var identity = new ClaimsIdentity(
new[]
{
new Claim(
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
role),
new Claim(ClaimTypes.NameIdentifier, UserId),
},
authenticationType: "TestAuth");
context.User = new ClaimsPrincipal(identity);
}
return next(context);
}
}

View file

@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
namespace Yavsc.Org.Tests;
/// <summary>
/// Startup filter that injects <see cref="TestUserMiddleware"/> after
/// the authentication and authorization middleware. The
/// <see cref="IStartupFilter.Configure"/> contract wraps the existing
/// pipeline: the <c>next</c> delegate is the rest of the app's
/// pipeline, so we run our middleware <em>before</em> it but
/// <em>after</em> anything that was registered as a startup filter
/// earlier in the chain.
///
/// In practice this puts <c>TestUserMiddleware</c> ahead of
/// <c>UseAuthentication</c> (registered inside <c>ConfigurePipeline</c>)
/// because the production code path runs <c>UseAuthentication</c>
/// synchronously inside <c>Configure</c>, after all startup filters
/// have wrapped it. We want the opposite: the test identity must be
/// visible to authorization and the controller, so we register
/// <c>TestUserMiddleware</c> via <see cref="IApplicationBuilder.Use"/>
/// inside the filter such that it runs late in the chain. The
/// simplest way to achieve that is to register the middleware
/// after the production authorization pipeline: we wrap with our
/// middleware inside the filter, so our delegate sits between the
/// framework middleware (set up by Configure) and the rest of the
/// pipeline — meaning requests flow:
/// framework authN/authZ → TestUserMiddleware → next pipeline.
/// </summary>
public class TestUserStartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
app.UseMiddleware<TestUserMiddleware>();
// Replay the production pipeline after the test middleware,
// so downstream auth and controllers can see the injected
// principal when no real login flow is used.
next(app);
};
}
}

View file

@ -0,0 +1,112 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Tests.Shared;
namespace Yavsc.Org.Tests;
/// <summary>
/// WebApplicationFactory-based fixture for integration tests that need
/// to override services registered by the production <c>Program</c>.
/// Uses the in-memory <see cref="TestServer"/> so tests can hit real
/// HTTP endpoints without sockets or self-signed certificates.
///
/// Currently overrides <see cref="TestAuthPolicyProvider"/> so that
/// <c>[Authorize("AdministratorOnly")]</c> (and any other policy
/// requiring a role) is satisfied by sending an
/// <c>X-Test-Role: Administrator</c> header, without a real login.
/// Also adds <see cref="TestUserStartupFilter"/> which installs
/// <see cref="TestUserMiddleware"/> so that <c>User.GetUserId()</c>
/// in user code sees a logged-in identity derived from the same
/// header.
///
/// Each instance gets its own in-memory database, identified by a
/// GUID generated in the constructor. The connection string
/// (<c>ConnectionStrings:YavscConnection</c>) is set as an
/// environment variable (<c>ConnectionStrings__YavscConnection</c>)
/// in the constructor and unset in <see cref="Dispose"/>, so the
/// production <c>AddIdentityDBAndStores</c> registers <c>DbContext</c>
/// instances against this fixture's own store. Without this, the
/// <c>"InMemory"</c> connection string from
/// <c>appsettings-org.Testing.json</c> would route every
/// <see cref="TestWebApplicationFactory"/> instance — and any
/// <see cref="WebServerFixture"/> running in the same process — to
/// the same backing store, leaking state between fixtures.
///
/// Env vars are used (rather than <c>ConfigureAppConfiguration</c> or
/// <c>UseSetting</c>) because <c>WebApplicationFactory</c> applies
/// those too late: <c>Program.Main</c> has already captured the
/// connection string in <c>AddIdentityDBAndStores</c> by the time
/// the test host's overrides take effect. Env vars are the last
/// provider added in <c>AddConfiguration</c> (see
/// <c>Yavsc.Server/Helpers/ConfigHelpers.cs</c>), so they win.
/// </summary>
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _fixtureId = Guid.NewGuid().ToString("N");
// ASP.NET Core's environment-variable configuration provider uses
// the key ConnectionStrings__YavscConnection (double underscore
// for the section separator). Set it before the host starts so
// the per-fixture connection string wins over
// appsettings-org.Testing.json. We do NOT touch the appsettings
// file; env vars take precedence in the configuration pipeline
// (see AddConfiguration in Yavsc.Server/Helpers/ConfigHelpers.cs,
// which adds AddEnvironmentVariables last).
private static readonly object _envLock = new();
private bool _envSet;
public TestWebApplicationFactory()
{
lock (_envLock)
{
Environment.SetEnvironmentVariable(
"ConnectionStrings__YavscConnection",
InMemoryDatabaseName.For(_fixtureId));
_envSet = true;
}
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// UseEnvironment("Testing") puts the host in a dedicated
// configuration environment so AddConfiguration("org") in
// Program.Main loads the optional appsettings-org.Testing.json
// file (which overrides the connection string and SMTP section
// for the test host). See that file for the values.
// We don't use "Development" because that environment is also
// used by the dev launcher and would change the signing
// credential path in IdentityServer; "Testing" is unambiguous.
builder.UseEnvironment("Testing");
builder.ConfigureTestServices(services =>
{
// Replace the production IAuthorizationPolicyProvider with
// the test one. The default registered by AddAuthorization
// becomes irrelevant: any GetPolicyAsync call is routed here.
services.AddSingleton<IAuthorizationPolicyProvider, TestAuthPolicyProvider>();
// Register the test middleware and its startup filter.
// The startup filter wraps the production pipeline so
// TestUserMiddleware runs after UseAuthentication/Authorization.
services.AddTransient<TestUserMiddleware>();
services.AddTransient<IStartupFilter, TestUserStartupFilter>();
});
}
protected override void Dispose(bool disposing)
{
if (disposing && _envSet)
{
lock (_envLock)
{
Environment.SetEnvironmentVariable(
"ConnectionStrings__YavscConnection", null);
_envSet = false;
}
}
base.Dispose(disposing);
}
}

View file

@ -0,0 +1,413 @@
using IdentityServer8.EntityFramework.Entities;
using IdentityServer8.EntityFramework.Mappers;
using IdentityServer8.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net;
using System.Net.Sockets;
using Yavsc.Extensions;
using Yavsc.Interfaces;
using Yavsc.Models;
using Yavsc.Server.Helpers;
using Yavsc.Tests.Shared;
using Client = IdentityServer8.EntityFramework.Entities.Client;
using Yavsc.Org.Tests.Fakes;
using Xunit;
namespace Yavsc.Org.Tests;
/// <summary>
/// Specialisation of <see cref="WebHostFixture"/> for the Yavsc.Org
/// host. Adds:
/// <list type="bullet">
/// <item><description>In-memory configuration (<c>ConnectionStrings</c>,
/// <c>Smtp</c>) before <c>ConfigureWebAppServices</c> runs.</description></item>
/// <item><description>Test-only <see cref="TestAuthPolicyProvider"/>
/// (from <c>Yavsc.Tests.Shared</c>) swapped in for
/// <see cref="IAuthorizationPolicyProvider"/>.</description></item>
/// <item><description>Recording <see cref="RecordingSmtpClientFactory"/>
/// fake for <see cref="ISmtpClientFactory"/>.</description></item>
/// <item><description>IdentityServer8 client + API scope + test user
/// seeded into the in-memory database.</description></item>
/// </list>
/// All cross-cutting Kestrel / cert / address plumbing is inherited
/// from <see cref="WebHostFixture"/>.
/// </summary>
[CollectionDefinition("Yavsc Server")]
public sealed class WebServerFixture : WebHostFixture
{
private static readonly int _httpsPort = GetAvailableLoopbackPort();
// One in-memory database name for the whole process: WebHostFixture
// is a per-process singleton (see _app, _isInitialized, _sharedServices
// in the base class), so every WebServerFixture instance shares the
// same backing store. That is intentional — the "Yavsc Server" test
// collection groups tests that should see the same seeded state, and
// re-initialising the store per fixture would just regress the
// order-dependence we are trying to eliminate. The GUID still matters
// because TestWebApplicationFactory and WebServerFixture must not
// collide in the in-memory store; see InMemoryDatabaseName.
private static readonly string _fixtureId = Guid.NewGuid().ToString("N");
protected override int HttpsPort => _httpsPort;
private static IConfiguration? _sharedConfiguration;
private static SiteSettings? _sharedSiteSettings;
private static ILogger? _sharedLogger;
private static string? _sharedTestClientId;
private static string? _sharedTestClientSecret;
private static string? _sharedTestingUserName;
private static string? _sharedTestingUserPassword;
private static string? _sharedTestingUserEmail;
private static string? _sharedHttpsAuthority;
private static RecordingSmtpClientFactory? _sharedSmtpClientFactory;
public IConfiguration? Configuration { get; private set; }
public string? TestClientId { get; private set; }
public string? TestClientSecret { get; set; }
public string? TestingUserName { get; private set; }
public string? TestingUserPassword { get; private set; }
public string? TestingUserEmail { get; set; }
public string? ProtectedTestingApiKey { get; internal set; }
public ApplicationUser? TestingUser { get; private set; }
public bool DbCreated { get; internal set; }
public SiteSettings? SiteSettings { get; set; }
public RecordingSmtpClientFactory? SmtpClientFactory { get; private set; }
public ILogger? Logger { get; internal set; }
public string? HttpsAuthority { get; private set; }
protected override WebApplicationOptions CreateBuilderOptions()
{
return new WebApplicationOptions
{
ApplicationName = typeof(Yavsc.Program).Assembly.GetName().Name
};
}
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
HttpsAuthority = $"https://localhost:{HttpsPort}";
// WebApplication.CreateBuilder defaults WebRootPath to
// {ContentRoot}/wwwroot. The test assembly runs from
// src/Yavsc.Org.Tests/bin/.../, which has no wwwroot of
// its own — so point the host at the Yavsc.Org project's
// wwwroot so that ConfigurePipeline's static-assets middleware
// can resolve it. The AddConfiguration extension takes care of
// that plus the in-memory overrides below.
builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary<string, string?>
{
[$"ConnectionStrings:{Yavsc.Constants.YavscConnectionStringName}"] = InMemoryDatabaseName.For(_fixtureId),
// SMTP test config: UserName non-null so MailSender
// exercises the Authenticate branch — the
// RecordingSmtpClient captures it.
["Smtp:Host"] = "smtp.test.local",
["Smtp:Port"] = "465",
["Smtp:UserName"] = "test-user",
["Smtp:Password"] = "test-pass",
["Site:Authority"] = HttpsAuthority
});
Configuration = builder.Configuration;
// Swap the production authorization policy provider for
// TestAuthPolicyProvider BEFORE ConfigureWebAppServices
// runs. ConfigureWebAppServices calls builder.Build() at
// the end, which freezes the service collection. Tests
// can satisfy [Authorize("AdministratorOnly")] (and any
// other policy that requires a role) by sending an
// X-Test-Role header; the production policy is replaced
// by the test one via the last-write-wins semantics of
// IServiceCollection.AddSingleton.
builder.Services.AddSingleton<IAuthorizationPolicyProvider, TestAuthPolicyProvider>();
// Replace the production ISmtpClientFactory (added later
// by ConfigureWebAppServices via TryAddSingleton) with a
// recording fake. By pre-registering here, the prod
// TryAdd becomes a no-op and tests get a single shared
// fake they can assert against.
var smtpFactory = new RecordingSmtpClientFactory();
builder.Services.AddSingleton<ISmtpClientFactory>(smtpFactory);
_sharedSmtpClientFactory = smtpFactory;
var app = builder.ConfigureWebAppServices();
SiteSettings = app.Services.GetRequiredService<IOptions<SiteSettings>>().Value;
using (var migrationScope = app.Services.CreateScope())
{
var db = migrationScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
TestingUserName = "Tester";
TestingUserPassword = "Test123!";
TestClientId = "testClientId";
TestingUserEmail = "test@no-reply.com";
TestingUser = null;
TestClientSecret = Guid.CreateVersion7().ToString();
EnsureUser(TestingUserName, TestingUserPassword, TestingUserEmail, migrationScope);
AddAuthorizedClient(migrationScope, TestClientId, TestClientSecret);
TestingUser = db.Users.FirstOrDefaultAsync(u => u.UserName == TestingUserName).Result;
// Add test API scope if it doesn't exist
var testScope = db.ApiScopes.FirstOrDefault(s => s.Name == "test");
if (testScope == null)
{
db.ApiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope
{
Name = "test",
Enabled = true,
DisplayName = "Test API Scope",
Description = "Scope for testing purposes",
UserClaims = new List<IdentityServer8.EntityFramework.Entities.ApiScopeClaim>
{
new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "role" },
new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "email" }
}
});
// Add a basic API resource for the test scope
var apiResource = new IdentityServer8.EntityFramework.Entities.ApiResource
{
Name = "testapi",
DisplayName = "Test API",
Enabled = true,
Scopes = new List<IdentityServer8.EntityFramework.Entities.ApiResourceScope>
{
new IdentityServer8.EntityFramework.Entities.ApiResourceScope { Scope = "test" }
}
};
db.ApiResources.Add(apiResource);
db.SaveChanges();
}
}
_sharedConfiguration = Configuration;
_sharedSiteSettings = SiteSettings;
_sharedTestClientId = TestClientId;
_sharedTestClientSecret = TestClientSecret;
_sharedTestingUserName = TestingUserName;
_sharedTestingUserPassword = TestingUserPassword;
_sharedTestingUserEmail = TestingUserEmail;
_sharedHttpsAuthority = HttpsAuthority;
_sharedLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger<WebServerFixture>();
Logger = _sharedLogger;
SmtpClientFactory = smtpFactory;
return app;
}
public void ResetAndMigrateDatabase()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
if (db.Database.IsRelational())
{
db.Database.Migrate();
ReseedAuthTestData(scope);
return;
}
ReseedAuthTestData(scope);
}
private void ReseedAuthTestData(IServiceScope scope)
{
TestingUserName ??= "Tester";
TestingUserPassword ??= "Test123!";
TestingUserEmail ??= "test@no-reply.com";
TestClientId ??= "testClientId";
TestClientSecret ??= Guid.CreateVersion7().ToString();
TestingUser = null;
EnsureUser(TestingUserName, TestingUserPassword, TestingUserEmail, scope);
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
TestingUser = db.Users.FirstOrDefault(u => u.UserName == TestingUserName);
var configDb = scope.ServiceProvider.GetRequiredService<IdentityServer8.EntityFramework.DbContexts.ConfigurationDbContext>();
var hasClient = configDb.Set<Client>().Any(c => c.ClientId == TestClientId);
if (!hasClient)
{
AddAuthorizedClient(scope, TestClientId, TestClientSecret);
}
// EnsureDefaultApplicationScopes seeds the standard identity
// resources (openid, profile) once at host startup, but a
// ResetAndMigrateDatabase call does EnsureDeleted on the shared
// in-memory store, which wipes them — and ReseedAuthTestData
// previously only re-added the client. Without openid/profile
// present, any token request asking for those scopes returns
// invalid_scope, so a password-grant token carrying the "name"
// claim (scope "openid profile") could not be minted after a
// reset. Re-seed them here to mirror the startup seed.
var identityResources = configDb.Set<IdentityServer8.EntityFramework.Entities.IdentityResource>();
if (!identityResources.Any(r => r.Name == "openid"))
{
identityResources.Add(new IdentityResources.OpenId().ToEntity());
}
if (!identityResources.Any(r => r.Name == "profile"))
{
identityResources.Add(new IdentityResources.Profile().ToEntity());
}
configDb.SaveChanges();
_sharedTestClientId = TestClientId;
_sharedTestClientSecret = TestClientSecret;
_sharedTestingUserName = TestingUserName;
_sharedTestingUserPassword = TestingUserPassword;
_sharedTestingUserEmail = TestingUserEmail;
}
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
{
// The MSBuild target CopyYavscOrgStaticAssets in
// Yavsc.Org.Tests.csproj mirrors the Yavsc.Org static
// assets manifest into the test bin directory. Call
// MapStaticAssets() with the explicit path so the test
// host resolves the manifest by file location rather
// than by {AssemblyName}.staticwebassets.* convention
// (which would look for Yavsc.Org.Tests.staticwebassets.*,
// a file we don't produce).
var testRuntimeManifest = Path.Combine(AppContext.BaseDirectory,
"Yavsc.Org.staticwebassets.runtime.json");
return await app.ConfigurePipeline(testRuntimeManifest);
}
protected override void CopySpecialisedSharedState()
{
TestClientId = _sharedTestClientId;
TestClientSecret = _sharedTestClientSecret;
TestingUserName = _sharedTestingUserName;
TestingUserPassword = _sharedTestingUserPassword;
TestingUserEmail = _sharedTestingUserEmail;
HttpsAuthority = _sharedHttpsAuthority;
SmtpClientFactory = _sharedSmtpClientFactory;
Configuration = _sharedConfiguration;
SiteSettings = _sharedSiteSettings;
Logger = _sharedLogger;
}
private void AddAuthorizedClient(IServiceScope scope, string testClientId, string testClientSecret)
{
var configDb = scope.ServiceProvider.GetRequiredService<IdentityServer8.EntityFramework.DbContexts.ConfigurationDbContext>();
if (configDb == null)
throw new InvalidOperationException("ConfigurationDbContext is not available for IdentityServer client seeding.");
Client testingClient = new Client
{
ClientId = testClientId,
AccessTokenLifetime = 3600000,
// AccessTokenType = 0 → Jwt in IdentityServer8 (1 = Reference,
// the inverse of Duende's enum). The real PostIt client issues
// JWT access tokens so the Blogs/Api bearer hosts can validate
// them with JwtBearer; mirror that here so the token can be
// decoded and its "name" claim asserted.
AccessTokenType = 0,
ClientName = "Testing client",
Enabled = true,
RequireClientSecret = true
};
configDb.Set<Client>().Add(testingClient);
configDb.SaveChanges();
var apiScope = new IdentityServer8.EntityFramework.Entities.ApiScope
{
Name = "test",
DisplayName = "Test Scope",
Description = "Scope for testing",
Enabled = true,
Required = false,
ShowInDiscoveryDocument = true,
Emphasize = false
};
configDb.Set<IdentityServer8.EntityFramework.Entities.ApiScope>().Add(apiScope);
ClientSecret secret = new ClientSecret
{
Value = testClientSecret.Sha256(),
Type = IdentityServer8.IdentityServerConstants.SecretTypes.SharedSecret,
ClientId = testingClient.Id
};
configDb.Set<ClientSecret>().Add(secret);
configDb.Set<ClientGrantType>().Add(new ClientGrantType
{
ClientId = testingClient.Id,
GrantType = "client_credentials"
});
configDb.Set<ClientGrantType>().Add(new ClientGrantType
{
ClientId = testingClient.Id,
GrantType = "password"
});
configDb.Set<ClientScope>().Add(new ClientScope
{
ClientId = testingClient.Id,
Scope = "test"
});
// Allow the identity scopes so a resource-owner-password token
// can request "openid profile" and trigger ProfileService to
// emit the "name" claim (user.UserName). Without profile in the
// client's AllowedScopes, IdentityServer strips the scope from
// the request and the access_token carries no "name" — the
// regression that left GetUserName() / the MyFiles upload root
// null. Additive only: existing tests request "test" alone.
configDb.Set<ClientScope>().Add(new ClientScope
{
ClientId = testingClient.Id,
Scope = "openid"
});
configDb.Set<ClientScope>().Add(new ClientScope
{
ClientId = testingClient.Id,
Scope = "profile"
});
configDb.SaveChanges();
}
public void EnsureUser(string testingUserName, string password, string email, IServiceScope scope)
{
if (TestingUser == null)
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
TestingUser = new ApplicationUser
{
UserName = testingUserName,
Email = testingUserName + "@example.com",
EmailConfirmed = true
};
var result = userManager.CreateAsync(TestingUser, password).Result;
Assert.True(result.Succeeded);
ApplicationDbContext dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
TestingUser = dbContext.Users.FirstOrDefault(u => u.UserName == testingUserName);
}
}
private static int GetAvailableLoopbackPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
finally
{
listener.Stop();
}
}
}

View file

@ -0,0 +1,91 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Tests</RootNamespace>
<UserSecretsId>78a4efec-68dc-4745-ba06-d8545ef9ee91</UserSecretsId>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<OutputType>exe</OutputType>
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
<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.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.AspNetCore.Hosting" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
</ItemGroup>
<ItemGroup>
<Content Update="xunit.runner.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<!--
MapStaticAssets() in the production pipeline resolves
{AssemblyName}.staticwebassets.endpoints.json from ContentRoot.
The referenced Yavsc.Org project produces
Yavsc.Org.staticwebassets.endpoints.json (and a runtime sibling).
Mirror them as Yavsc.Org.Tests.staticwebassets.* in the test bin
directory so the test WebApplicationFactory finds them at boot.
-->
<Target Name="CopyYavscOrgStaticAssets" AfterTargets="Build">
<PropertyGroup>
<_YavscOrgStaticAssetsDir>$(MSBuildProjectDirectory)\..\Yavsc.Org\bin\$(Configuration)\$(TargetFramework)</_YavscOrgStaticAssetsDir>
</PropertyGroup>
<ItemGroup>
<!--
Match the SDK-generated manifest files. Each file is then
copied into the test bin with the test assembly's name as
the prefix so that the default MapStaticAssets() lookup at
{AssemblyName}.staticwebassets.endpoints.json finds them.
Example:
src : Yavsc.Org.staticwebassets.endpoints.json
dst : Yavsc.Org.Tests.staticwebassets.endpoints.json
The transform is a string replace of the source filename's
"Yavsc.Org." prefix with "Yavsc.Org.Tests." — done by the
-> '$(OutDir)Yavsc.Org.Tests.%(Filename)' transformation
with a RegexReplace on the source path.
-->
<_YavscOrgStaticAssetsFiles Include="$(_YavscOrgStaticAssetsDir)\Yavsc.Org.staticwebassets.*.json" />
</ItemGroup>
<Copy SourceFiles="@(_YavscOrgStaticAssetsFiles)" DestinationFolder="$(OutDir)" />
</Target>
</Project>

View file

@ -0,0 +1,82 @@
{
"Site": {
"Authority": "https://localhost:5101",
"Audience": ["blogs"],
"Title": "Yavsc dev",
"Slogan": "Yavsc : WIP.",
"Banner": "/images/yavsc.png",
"HomeViewName": "Home",
"FavIcon": "/favicon.ico",
"Icon": "/images/yavsc.png",
"GitRepository": "testingrepo",
"Blog": "blog",
"Bills": "bills",
"TempDir": "temp",
"Avatars": "avatars",
"Owner": {
"Name": "Site Owner Name",
"EMail": "your@email",
"PostalAddress": {
"Street1": "Your Address",
"Street2": "your street",
"PostalCode": "543 21~3",
"City": "",
"State": "",
"Province": null
}
},
"Admin": {
"Name": "Administrator name",
"EMail": "daAdmin@e.mail"
}
},
"Smtp": {
"Server": "localhost",
"Port": 465
},
"Logging": {
"IncludeScopes": {},
"LogLevel": {
"Default": "Debug",
"System": "Warning",
"Microsoft": "Warning"
}
},
"ConnectionStrings": {
"YavscConnection": "InMemory"
},
"DataProtection": {
"Keys": {
"Dir": "DataProtection-Keys"
},
"RSAParamFile": "RSA-Params.json",
"ExpiresInHours": 168
},
"ApiKey": "lame-key",
"Testing": {
"ConnectionStrings": {
"Default": "lame-default-connection-string",
"DatabaseCtor": "lame-database-ctor-connection-string"
},
"YavscWebPath": "../../src/Yavsc",
"ValidCreds": {
"UserName": "lame-user",
"Password": "lame-password"
},
"InvalidCreds": {
"UserName": "fakeuser",
"Password": "f/\\kePassw0rd"
}
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:5100"
},
"Https": {
"Url": "https://localhost:5101"
}
}
}
}

View file

@ -0,0 +1,42 @@
<RunSettings>
<RunConfiguration>
</RunConfiguration>
<LoggerRunSettings>
<Loggers>
<Logger friendlyName="blame" enabled="True" />
<Logger friendlyName="console" enabled="True">
<Configuration>
<Verbosity>quiet</Verbosity>
</Configuration>
</Logger>
<Logger friendlyName="trx" enabled="True">
<Configuration>
<LogFileName>Yavsc.Org.tests.trx</LogFileName>
</Configuration>
</Logger>
<Logger friendlyName="html" enabled="True">
<Configuration>
<LogFileName>Yavsc.Org.tests.html</LogFileName>
</Configuration>
</Logger>
</Loggers>
</LoggerRunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<!-- Enables blame -->
<DataCollector friendlyName="blame" enabled="True">
<Configuration>
<!-- Enables crash dump, with dump type "Full" or "Mini".
Requires ProcDump in PATH for .NET Framework. -->
<CollectDump DumpType="Full" />
<!-- Enables hang dump or testhost and its child processes
when a test hangs for more than 10 minutes.
Dump type "Full", "Mini" or "None" (just kill the processes). -->
<CollectDumpOnTestSessionHang TestTimeout="10min" HangDumpType="Full" />
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>

View file

@ -0,0 +1,5 @@
{
"diagnosticMessages": false,
"methodDisplay": "classAndMethod",
"parallelizeTestCollections": true
}

View file

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

View file

@ -0,0 +1,452 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Npgsql;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Services;
namespace Yavsc.Server.Tests.Fixtures;
/// <summary>
/// Service-level test host for <see cref="BlogSpotService"/> and the
/// other Yavsc.Server services that take an
/// <see cref="ApplicationDbContext"/> + <see cref="IAuthorizationService"/>.
///
/// <para>Unlike <c>BlogsWebServerFixture</c> (in Yavsc.Blogs.Tests),
/// this fixture does <b>not</b> spin up Kestrel, JWT bearer, or MVC
/// controllers. <see cref="BlogSpotService.Details"/> takes the
/// <see cref="System.Security.Claims.ClaimsPrincipal"/> as a parameter,
/// so the caller controls identity directly — there is no HTTP/auth
/// middleware in the path under test. Building only the DI graph the
/// service actually resolves keeps these tests fast and isolates the
/// service layer from the blog API's transport concerns, which
/// Yavsc.Blogs.Tests already covers end-to-end.</para>
///
/// <para><b>Database provider.</b> Mirrors
/// <c>ApiWebServerFixture</c>: SQLite <c>:memory:</c> by default, or
/// real PostgreSQL when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c> is
/// set. Production runs on Npgsql, so the Npgsql path — which applies
/// the real <c>Yavsc.Org</c> migrations against a dedicated test
/// database — is the one that catches provider-specific bugs (FK
/// enforcement, type mappings, migration drift). SQLite stays the
/// default so the suite runs out-of-the-box with no external server.</para>
///
/// <list type="bullet">
/// <item><description>SQLite: a named <c>:memory:</c> database
/// (Cache=Shared) backed by a single connection held open for the
/// fixture's lifetime, schema via <c>EnsureCreated</c>. Reset is
/// <c>EnsureDeleted</c> + <c>EnsureCreated</c>.</description></item>
/// <item><description>Npgsql: a dedicated database
/// <c>yavscServerTestDb</c> (created from the admin connection if it
/// does not exist), schema via <c>Migrate</c> using the
/// <c>Yavsc.Org</c> migrations assembly. Reset purges the blog-graph
/// tables in FK order — children before parents — so no DELETE
/// violates a constraint, instead of dropping the database (which
/// would force a full re-migration per test).</description></item>
/// <item><description>The real <see cref="BlogSpotService"/> and the
/// real <see cref="PermissionHandler"/>: <c>Details</c> calls
/// <c>IAuthorizationService.AuthorizeAsync(user, blog, new
/// ReadPermission())</c>, and <see cref="PermissionHandler"/> answers
/// that requirement with the public/owner/sponsor/Administrator
/// disjunction that the tests below pin. No mocking.</description></item>
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
/// stub: <c>Details</c> never touches the file system, but the DI
/// container needs an instance to construct the service.</description></item>
/// </list>
///
/// <para>Marked <see cref="CollectionDefinitionAttribute"/> so a single
/// fixture instance (one store, one DI container) is shared across
/// every <c>[Collection("Yavsc Server")]</c> test class. Each test
/// resets the store via <see cref="ResetDatabase"/>.</para>
/// </summary>
[CollectionDefinition("Yavsc Server")]
public sealed class ServerServicesFixture : IDisposable
{
private const string DbProviderEnvVar = "YAVSC_SERVER_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_SERVER_TEST_NPGSQL_ADMIN_CONNECTION";
private const string DedicatedNpgsqlDatabaseName = "yavscServerTestDb";
private static readonly object _npgsqlLock = new();
private static string? _sharedNpgsqlConnectionString;
private readonly SqliteConnection? _sqliteConnection;
private readonly ServiceProvider _services;
public IServiceProvider Services => _services;
public ServerServicesFixture()
{
var services = new ServiceCollection();
if (UseNpgsqlProvider())
{
var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated();
services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseNpgsql(npgsqlConnectionString,
x => x.MigrationsAssembly("Yavsc.Org")));
}
else
{
// Mode=Memory + Cache=Shared: a named in-memory database
// every connection string referencing "YavscServerTests"
// resolves to the same backing store, as long as at least
// one connection stays open against it. _sqliteConnection
// is held for the fixture's lifetime (disposed in Dispose),
// so the store survives across scoped DbContexts per test.
_sqliteConnection = new SqliteConnection(
"Data Source=YavscServerTests;Mode=Memory;Cache=Shared");
_sqliteConnection.Open();
services.AddDbContext<ApplicationDbContext>(opt =>
// UseSqlite(DbConnection) keeps _sqliteConnection 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
// store.
opt.UseSqlite(_sqliteConnection));
}
// BlogSpotService.AttachFiles writes under siteSettings.Blog.
// Details itself doesn't read it, but the service constructor
// throws if SiteSettings.Blog is null, so configure a temp
// value to keep construction alive.
services.Configure<SiteSettings>(s => s.Blog = "server-tests-files-root");
// Trivial file-system auth: Details never calls into it, but
// the DI container needs an instance to construct the service.
services.AddSingleton<IFileSystemAuthManager>(
new NoopFileSystemAuthManager());
// The real BlogSpotService — same shape the production host
// builds (ApplicationDbContext, IAuthorizationService,
// IFileSystemAuthManager, IOptions<SiteSettings>,
// ILoggerFactory).
services.AddScoped<BlogSpotService>();
// The real PermissionHandler: Details authorizes with
// ReadPermission, and PermissionHandler resolves it via the
// IsPublic / IsOwner / IsSponsor / Administrator disjunction.
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
// Default authorization services register
// IAuthorizationService (DefaultAuthorizationService), which
// collects the IAuthorizationHandler above and runs it against
// the requirement passed to AuthorizeAsync.
services.AddAuthorization();
services.AddLogging();
_services = services.BuildServiceProvider();
// Create the schema once. Npgsql uses Migrate (the Yavsc.Org
// migrations) so the test schema matches production; SQLite uses
// EnsureCreated (idempotent, model-derived). ResetDatabase
// re-creates / purges per test.
using (var scope = _services.CreateScope())
{
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
if (UseNpgsqlProvider())
db.Database.Migrate();
else
db.Database.EnsureCreated();
}
}
/// <summary>True when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c>
/// is set — the fixture then runs against a real PostgreSQL server
/// instead of the SQLite in-memory default.</summary>
public static bool UseNpgsqlProvider()
=> string.Equals(
Environment.GetEnvironmentVariable(DbProviderEnvVar),
"npgsql",
StringComparison.OrdinalIgnoreCase);
/// <summary>Reset the store to a known empty state for the next
/// test. SQLite: <c>EnsureDeleted</c> + <c>EnsureCreated</c> (the
/// named in-memory store survives because _sqliteConnection is held
/// open). Npgsql: purge the blog-graph tables in FK order —
/// children before parents — so no DELETE trips a foreign key,
/// avoiding a full re-migration per test.</summary>
public void ResetDatabase()
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
if (UseNpgsqlProvider())
{
// Children before parents. BlogAttachedFile, Comment and
// BlogSpotPublication reference BlogSpot; CircleAuthorization
// ToBlogPost references BlogSpot + Circle; BlogTag references
// BlogSpot + Tag; BlogSpot references ApplicationUser;
// CircleMember references Circle + ApplicationUser.
db.BlogAttachedFiles.RemoveRange(db.BlogAttachedFiles);
db.Comment.RemoveRange(db.Comment);
db.blogSpotPublications.RemoveRange(db.blogSpotPublications);
db.CircleAuthorizationToBlogPost
.RemoveRange(db.CircleAuthorizationToBlogPost);
db.BlogTag.RemoveRange(db.BlogTag);
db.UploadedFiles.RemoveRange(db.UploadedFiles);
db.BlogSpot.RemoveRange(db.BlogSpot);
db.CircleMembers.RemoveRange(db.CircleMembers);
db.Circle.RemoveRange(db.Circle);
db.Users.RemoveRange(db.Users);
db.SaveChanges();
return;
}
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
/// <summary>Resolve a fresh service scope and the
/// <see cref="ApplicationDbContext"/> from it, run
/// <paramref name="body"/>, and dispose the scope (and its
/// DbContext). Use this for seeding writes that should not share
/// a DbContext with the service call under test.</summary>
public T WithDb<T>(Func<ApplicationDbContext, T> body)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
return body(db);
}
public void SeedUser(string userName,
Action<ApplicationUser>? configure = null)
{
WithDb(db =>
{
if (db.Users.Any(u => u.Id == userName)) return false;
var user = new ApplicationUser
{
Id = userName,
UserName = userName,
Email = $"{userName}@example.test",
};
configure?.Invoke(user);
db.Users.Add(user);
db.SaveChanges();
return true;
});
}
public long SeedBlogPost(string authorId, string title,
Action<BlogPost>? configure = null)
{
return WithDb(db =>
{
var post = new BlogPost
{
AuthorId = authorId,
Title = title,
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
configure?.Invoke(post);
db.BlogSpot.Add(post);
db.SaveChanges();
return post.Id;
});
}
/// <summary>Insert a <see cref="BlogSpotPublication"/> row for
/// <paramref name="postId"/> — the existence of that row is what
/// makes a post "public" (readable by anyone) in
/// <see cref="PermissionHandler"/>. This is the same write
/// <c>SetPublishAsync</c> performs in production.</summary>
public void Publish(long postId)
{
WithDb(db =>
{
db.blogSpotPublications.Add(
new BlogSpotPublication { PostId = postId });
db.SaveChanges();
return true;
});
}
/// <summary>Seed a circle owned by <paramref name="ownerId"/> and
/// return its server-assigned id. Sponsorship in
/// <see cref="PermissionHandler"/> is "the viewer is a member of a
/// circle the post's author owns", so a circle + a
/// <see cref="SeedCircleMember"/> entry is what grants a non-owner
/// read access to a draft.</summary>
public long SeedCircle(string ownerId, string name)
{
return WithDb(db =>
{
var circle = new Circle
{
OwnerId = ownerId,
Name = name,
Public = false,
};
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
});
}
public void SeedCircleMember(long circleId, string memberId)
{
WithDb(db =>
{
db.CircleMembers.Add(
new CircleMember { CircleId = circleId, MemberId = memberId });
db.SaveChanges();
return true;
});
}
/// <summary>Attach a <see cref="CircleAuthorizationToBlogPost"/> ACL
/// entry to <paramref name="postId"/> for <paramref name="circleId"/>.
/// The owner always sees the ACL; non-owners have it scrubbed by
/// <see cref="BlogSpotService"/>'s ScrubAclForViewer.</summary>
public void SeedAcl(long postId, long circleId)
{
WithDb(db =>
{
db.CircleAuthorizationToBlogPost.Add(
new CircleAuthorizationToBlogPost
{
BlogPostId = postId,
CircleId = circleId,
});
db.SaveChanges();
return true;
});
}
/// <summary>Seed a comment on <paramref name="postId"/> authored by
/// <paramref name="authorId"/>. <see cref="BlogSpotService.Details"/>
/// loads each comment's <c>Author</c> navigation from
/// <c>_context.Users</c>; seeding the user first keeps the FK
/// valid (SQLite and PostgreSQL both enforce it).</summary>
public void SeedComment(long postId, string authorId, string article)
{
WithDb(db =>
{
db.Comment.Add(new Comment
{
ReceiverId = postId,
AuthorId = authorId,
Article = article,
Visible = true,
UserModified = authorId,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
db.SaveChanges();
return true;
});
}
/// <summary>Resolve the real <see cref="BlogSpotService"/> from the
/// fixture's DI container, run <paramref name="body"/> against it,
/// and dispose the request scope (and its scoped DbContext). The
/// service is scoped, so it must be resolved from a scope — and the
/// scope is what we dispose to avoid leaking a DbContext against
/// the shared connection across tests.</summary>
public async Task<T> WithServiceAsync<T>(
Func<BlogSpotService, Task<T>> body)
{
using var scope = _services.CreateScope();
var service = scope.ServiceProvider
.GetRequiredService<BlogSpotService>();
return await body(service);
}
private static string EnsureNpgsqlDatabaseCreated()
{
lock (_npgsqlLock)
{
if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString))
return _sharedNpgsqlConnectionString;
var adminConnectionString = BuildAdminConnectionString();
var databaseName = DedicatedNpgsqlDatabaseName;
using (var adminConnection = new NpgsqlConnection(adminConnectionString))
{
adminConnection.Open();
using var existsCommand = adminConnection.CreateCommand();
existsCommand.CommandText =
"SELECT 1 FROM pg_database WHERE datname = @databaseName";
existsCommand.Parameters.AddWithValue("databaseName", databaseName);
if (existsCommand.ExecuteScalar() is null)
{
using var createCommand = adminConnection.CreateCommand();
createCommand.CommandText =
$"CREATE DATABASE \"{databaseName}\"";
createCommand.ExecuteNonQuery();
}
}
var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
{
Database = databaseName,
Pooling = false,
IncludeErrorDetail = true
};
_sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
return _sharedNpgsqlConnectionString;
}
}
private static string BuildAdminConnectionString()
{
// The admin connection string (host + credentials) is supplied
// via environment, never embedded in source — committing a
// real dev password would leak it into git history. The Npgsql
// path is opt-in, so requiring the env var is fine: a missing
// value produces a clear failure rather than a silent fallback
// onto someone's local dev database.
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
if (string.IsNullOrWhiteSpace(configured))
throw new InvalidOperationException(
$"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test "
+ "provider requires an admin PostgreSQL connection string "
+ "in that environment variable (e.g. "
+ "'Server=localhost;Port=5432;Database=postgres;"
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
var builder = new NpgsqlConnectionStringBuilder(configured)
{
Pooling = false,
IncludeErrorDetail = true
};
// The admin connection targets the maintenance database
// (postgres) so we can CREATE/DROP the dedicated test database;
// whatever database the env var names is overridden here.
builder.Database = "postgres";
return builder.ToString();
}
public void Dispose()
{
_services.Dispose();
_sqliteConnection?.Dispose();
}
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
{
public FileAccessRight GetFilePathAccess(
System.Security.Claims.ClaimsPrincipal user,
string fileRelativePath)
=> FileAccessRight.None;
public void SetAccess(long circleId, string normalizedFullPath,
FileAccessRight access) { }
}
}

View file

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Server.Tests</RootNamespace>
<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.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.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,31 @@
namespace Yavsc.Blogs.Tests.Fixtures;
using static Yavsc.Constants;
public static class BlogHelpers
{
public static string ApiUrl(this IBackendFixture fixture, string apiSubPath)
{
var secured = fixture.Addresses.FirstOrDefault(a => a.StartsWith("https://"));
if (secured is null)
{
var unsecured = fixture.Addresses.FirstOrDefault(a => a.StartsWith("http://"));
if (unsecured is null)
{
throw new InvalidOperationException("No backend address found");
}
return $"{unsecured}/{APIPrefix}/{apiSubPath}";
}
return $"{secured}/{APIPrefix}/{apiSubPath}";
}
public static string BlogAclUrl(this IBackendFixture fixture)
=> fixture.ApiUrl(BlogAclPath);
public static string BlogSpotUrl(this IBackendFixture fixture)
=> fixture.ApiUrl(BlogSpotPath);
public static string PublishUrl(this IBackendFixture fixture, long id)
=> fixture.ApiUrl(BlogSpotPath) +"/" + id + "/publish";
}

View file

@ -0,0 +1,13 @@
public interface IBackendFixture : IDisposable
{
/// <summary>
/// The addresses the fixture bound to.
/// </summary>
IReadOnlyList<string> Addresses { get; }
/// <summary>
/// The service provider for the fixture host.
/// </summary>
IServiceProvider Services { get; }
}

View file

@ -0,0 +1,30 @@
namespace Yavsc.Tests.Shared;
/// <summary>
/// Helpers for the in-memory connection string used by test fixtures.
///
/// EF Core's <c>UseInMemoryDatabase(name)</c> returns the same backing
/// store to every <c>DbContext</c> that asks for it under the same
/// <paramref name="name"/>, in the same process. That means every
/// fixture that uses the bare <c>"InMemory"</c> connection string
/// shares the same in-memory database — which leaks state between
/// fixtures that are supposed to be independent, and silently makes
/// tests order-dependent.
///
/// The fix is to give each fixture its own suffix. <see cref="For"/>
/// returns a stable, fixture-scoped connection string. The fixture
/// stores the suffix in an instance field so successive calls within
/// the same fixture always resolve to the same database.
/// </summary>
public static class InMemoryDatabaseName
{
/// <summary>Base connection string for the in-memory provider,
/// as it appears in <c>appsettings-org.Testing.json</c>.</summary>
public const string Base = "InMemory";
/// <summary>Builds a per-fixture connection string. Two calls
/// with the same <paramref name="fixtureId"/> return the same
/// string; two calls with different ids return different
/// strings, isolating the underlying in-memory stores.</summary>
public static string For(string fixtureId) => $"{Base}-{fixtureId}";
}

View file

@ -0,0 +1,149 @@
# Yavsc.Tests.Shared
Scaffold partagé pour les tests d'intégration ASP.NET Core de
Yavsc. Ce projet **n'est pas lui-même un projet de tests** — il
n'a pas xUnit ni de test runner. Il expose des fixtures
réutilisables que les projets de tests consommateurs
(`Yavsc.Org.Tests`, `Yavsc.Blogs.Tests`, etc.) héritent ou
instancient.
## Contenu
| Fichier | Rôle |
|---|---|
| `WebHostFixture.cs` | Base abstraite : Kestrel HTTPS, certificat auto-signé, port dynamique, host partagé inter-fixtures |
| `TestAuthPolicyProvider.cs` | `IAuthorizationPolicyProvider` de test, lit `X-Test-Role` au lieu d'interroger la DB |
| `TestTokenIssuer.cs` | Émet des JWT HS256 signés avec une clé statique, pour les tests d'API qui montent un `AddJwtBearer` réel |
## WebHostFixture
`WebHostFixture` est la base de toute fixture d'intégration.
Une seule instance de `WebApplication` tourne par process ; les
fixtures qui héritent partagent le host. Kestrel est bindé sur
`127.0.0.1:0` (port dynamique) avec un certificat auto-signé
généré lazily.
### Cycle de vie
- **Premier ctor** d'une fixture concrète → `InitializeAsync()`
lance `BuildApp(builder)` puis `ConfigurePipelineAsync(app)`
puis `app.StartAsync()`. L'`IServerAddressesFeature` est lu
pour peupler `Addresses`.
- **Ctors suivants** (xUnit instancie une fixture par
`IClassFixture<T>`) → reprise de l'état partagé via
`CopySpecialisedSharedState()` (vide par défaut, surchargeable).
- **Dernier `Dispose`** → `app.StopAsync()`, reset des slots
statiques.
### Hooks à surcharger
| Hook | Quand | Quoi y mettre |
|---|---|---|
| `BuildApp(builder)` | Toujours | Enregistrement des services, configuration in-memory, seeding éventuel |
| `ConfigurePipelineAsync(app)` | Optionnel | Pipeline middleware spécifique (sinon : pas de pipeline custom) |
| `CopySpecialisedSharedState()` | Optionnel | Recopie des slots statiques de la spécialisation sur les propriétés d'instance |
### Exemple : fixture de portée minimale
```csharp
public sealed class MyFixture : WebHostFixture
{
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
// In-memory config, services, etc.
return builder.Build();
}
}
```
`MyFixture` n'a pas de test runner propre ; c'est l'assembly
consommateur (par exemple `Yavsc.MyModule.Tests`) qui déclare
les `[Fact]` et utilise `IClassFixture<MyFixture>`.
## Spécifications : fixtures concrètes
Deux fixtures héritent de `WebHostFixture` dans le repo :
### Yavsc.Org.Tests.WebServerFixture
Pour le host principal de Yavsc.Org. Caractéristiques :
- Configure `InMemory` pour la `ConnectionStrings` Yavsc
- Remplace `IAuthorizationPolicyProvider` par
`TestAuthPolicyProvider` **avant** `ConfigureWebAppServices`
(qui freeze la collection de services)
- Stub `ISmtpClientFactory` par `RecordingSmtpClientFactory`
pour capturer les envois sans SMTP réel
- Seed IdentityServer8 : un `Client` + une `ApiScope` "test" +
un `ApplicationUser` "Tester"
- Configure le pipeline via `app.ConfigurePipeline(...)` avec
un manifeste de static assets explicite (le MSBuild target
`CopyYavscOrgStaticAssets` du csproj miroir les manifests
Yavsc.Org sous le nom Yavsc.Org.Tests.* dans le bin de test)
Cf. `src/Yavsc.Org.Tests/WebServerFixture.cs`.
### Yavsc.Blogs.Tests.BlogsWebServerFixture
Pour le host API de Yavsc.Blogs. Caractéristiques :
- `UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot)` —
un `InMemoryDatabaseRoot` partagé pour que POST + GET voient
le même store
- `BlogSpotService` réel (pas de mock)
- `PermissionHandler` réel (le handler d'authorization qui
résout `IsOwner(user, blog)`)
- `AddJwtBearer` réel avec HS256, validation contre
`TestTokenIssuer.SigningKey` — pas d'OIDC discovery, pas
d'IdP
- Politique `BlogScope` verbatim (`RequireAuthenticatedUser` +
`RequireClaim("scope", "blogs")`)
Cf. `src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs`.
## TestAuthPolicyProvider
`IAuthorizationPolicyProvider` de test qui lit le rôle dans
l'en-tête HTTP `X-Test-Role` au lieu d'interroger la
`UserManager`. Permet aux smoke tests d'exercer `[Authorize
("AdministratorOnly")]` sans seed de rôle réel.
Activation : enregistré par les fixtures spécialisées **avant**
`ConfigureWebAppServices` (qui call `builder.Build()` et
fige la collection). La sémantique last-write-wins du
`AddSingleton` fait que le test provider prend le pas.
## TestTokenIssuer
Émet un JWT HS256 avec une `SigningKey` statique, exposé en
`TestTokenIssuer.SigningKey` (et `Issuer`). Les fixtures qui
montent un `AddJwtBearer` réutilisent cette clé pour valider
les tokens localement, sans OIDC discovery.
Helpers :
- `TestTokenIssuer.Issue(subject, scope, lifetime)` →
chaîne `"Bearer <jwt>"` prête pour un header HTTP
- `TestTokenIssuer.SigningKey` — `SymmetricSecurityKey` à
passer au `TokenValidationParameters` du `AddJwtBearer`
## Tests statiques sur du code compilé
Pour tester un display template Razor sans monter un host
ASP.NET, on peut s'appuyer sur la lecture du fichier source
et asserter des invariants syntaxiques. Cf.
`Yavsc.Org.Tests/NonRegression/ApplicationUserDisplayTemplateTests`
pour un exemple : on asserte que le cshtml ne porte plus
`Model.UserName` directement, ce qui aurait rouvert la
non-régression du 500 sur `/BlogSpot/Details/{id}`.
C'est pragmatique : la mise en place d'un `RazorProjectEngine`
pour compiler et rendre une vue hors host coûte plus cher que
ce qu'elle protège pour un seul template.
## Pour aller plus loin
- `doc/testing.md` à la racine : vue d'ensemble de la
stratégie de test
- `src/Yavsc.Org.Tests/NonRegression/` et
`src/Yavsc.Blogs.Tests/` : exemples d'utilisation

View file

@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using System.Security.Claims;
namespace Yavsc.Tests.Shared;
/// <summary>
/// Authorization policy provider used by integration tests. Replaces the
/// production provider in the test host so that any policy-protected
/// controller can be exercised by sending a <c>X-Test-Role: …</c>
/// header — no login roundtrip, no cookie, no database user.
///
/// Any policy that requires a role short-circuits to success when the
/// matching header is present; otherwise the production policy is
/// preserved. The test does not perform a real login, so we attach an
/// in-memory <see cref="ClaimsPrincipal"/> carrying the role claim to
/// the request <see cref="HttpContext.User"/> before the assertion
/// fires, so claim-based requirements (e.g. <c>RequireRole("Admin")</c>)
/// also pass.
/// </summary>
public sealed class TestAuthPolicyProvider : IAuthorizationPolicyProvider
{
/// <summary>HTTP header read by the test bypass to learn the role.</summary>
public const string HeaderName = "X-Test-Role";
/// <summary>Conventional admin role name; the production default.</summary>
public const string AdminRole = "Administrator";
private readonly DefaultAuthorizationPolicyProvider _fallback;
public TestAuthPolicyProvider(IOptions<AuthorizationOptions> options)
{
_fallback = new DefaultAuthorizationPolicyProvider(options);
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public async Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
var policy = await _fallback.GetPolicyAsync(policyName);
if (policy is null) return null;
return new AuthorizationPolicyBuilder()
.RequireAssertion(ctx =>
{
// ASP.NET Core sets ctx.Resource to the HttpContext when
// the authorization middleware invokes the policy. Use
// the request headers directly to honour X-Test-Role.
var http = ctx.Resource as HttpContext;
if (http is null) return false;
var role = http.Request.Headers[HeaderName].ToString();
if (string.IsNullOrEmpty(role)) return false;
if (http.User.Identity is null || !http.User.Identity.IsAuthenticated)
{
var identity = new ClaimsIdentity(
new[]
{
new Claim(
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
role),
},
authenticationType: "TestAuth");
http.User = new ClaimsPrincipal(identity);
}
return true;
})
.Build();
}
}

View file

@ -0,0 +1,122 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Yavsc.Tests.Shared;
/// <summary>
/// Mints HS256-signed JWTs for integration tests. The signing key is
/// held in a static field shared with the test host's
/// <c>AddJwtBearer</c> registration: whatever the host validates
/// against, this issuer signs with.
///
/// <para>
/// HS256 (symmetric) is the right choice for a unit-test issuer:
/// no key generation ceremony, no PEM round-trip, no asymmetric
/// crypto on the hot path. The key never leaves the test process.
/// Production continues to validate against the OIDC authority via
/// <c>AddYavscJwtBearer</c> — this issuer is *only* for the
/// in-process test host.
/// </para>
/// </summary>
public static class TestTokenIssuer
{
/// <summary>
/// Symmetric signing key shared with the test host's
/// <c>TokenValidationParameters.IssuerSigningKey</c>.
/// 32 bytes of zeros is enough entropy for HS256 *within the test
/// process*; the assertion we care about is "does the policy
/// evaluate a properly-signed token", not "is the key unguessable
/// by an attacker" (there is no attacker here).
/// </summary>
public static readonly SymmetricSecurityKey SigningKey =
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('k', 32)));
/// <summary>
/// Issuer stamped into the <c>iss</c> claim and checked by the
/// test host. Must match
/// <c>TokenValidationParameters.ValidIssuer</c>.
/// </summary>
public const string Issuer = "yavsc-test-issuer";
/// <summary>
/// Audience stamped into the <c>aud</c> claim. The test host
/// does not validate audience (production may), so this is here
/// for shape only.
/// </summary>
public const string Audience = "yavsc-test";
private static bool _inboundClaimTypeMapCleared;
/// <summary>
/// Mint a JWT carrying the given <paramref name="subject"/> as
/// the <c>sub</c> claim, a single <c>scope</c> claim with value
/// <paramref name="scope"/>, and any additional
/// <paramref name="extraClaims"/>. Token is valid for one hour
/// from now.
/// </summary>
/// <param name="subject">Value of the <c>sub</c> claim. Read
/// back by <c>UserHelpers.GetUserId</c>, which is how
/// <c>PermissionHandler.IsOwner</c> identifies the author of a
/// <c>BlogPost</c> on PUT.</param>
/// <param name="scope">Value of the <c>scope</c> claim. The
/// production <c>BlogScope</c> policy requires
/// <c>RequireClaim("scope", "blogs")</c>.</param>
/// <param name="name">Value of the <c>name</c> claim. Defaults to
/// <paramref name="subject"/> (the test convention where the login
/// equals the subject). Pass a distinct login to mirror production,
/// where <c>sub</c> is a GUID and <c>name</c> is the user's
/// <c>UserName</c> — the file-system list/upload paths key the
/// personal-storage tree off the login, not the subject.</param>
/// <param name="extraClaims">Optional additional claims
/// (e.g. a role for an admin-bypass test).</param>
public static string Issue(
string subject,
string scope = "blogs",
string? name = null,
IEnumerable<Claim>? extraClaims = null)
{
var now = DateTime.UtcNow;
var claims = new List<Claim>
{
new("sub", subject),
// Mirror production: IdentityServer's ProfileService emits the
// "name" claim as the user's login (UserName) when the profile
// scope is granted, and the file-system helpers resolve the
// per-user storage root via GetUserName() (FindFirstValue("name"))
// — not via User.Identity.Name. A test token without "name" would
// make ReceiveUserFile / EnsureDestinationDirectory compute a
// null owner root and 500 on upload, so stamp it here.
new("name", name ?? subject),
new("scope", scope),
};
if (extraClaims is not null) claims.AddRange(extraClaims);
// JwtSecurityTokenHandler ships with a static
// DefaultInboundClaimTypeMap that rewrites short JWT claim
// names to their long Microsoft URIs at deserialisation
// time. The most relevant rewrite for us is
// "sub" → ClaimTypes.NameIdentifier. Without clearing the
// map, UserHelpers.GetUserId() — which reads the literal
// "sub" claim — would not find the value, PermissionHandler
// .IsOwner would compare against null, and the controller
// would return 401 on every PUT. Clearing is the standard
// way to opt out of the legacy mapping.
if (!_inboundClaimTypeMapCleared)
{
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
_inboundClaimTypeMapCleared = true;
}
var creds = new SigningCredentials(SigningKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
notBefore: now,
expires: now.AddHours(1),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View file

@ -0,0 +1,226 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Runtime.Loader;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace Yavsc.Tests.Shared;
/// <summary>
/// Base class for ASP.NET Core integration test hosts. Provides the
/// cross-cutting plumbing shared by every test fixture in the
/// repository:
///
/// <list type="bullet">
/// <item><description>Kestrel with a self-signed HTTPS certificate
/// on a fixture-defined fixed port for deterministic integration
/// test endpoints.</description></item>
/// <item><description>A per-process single-instance host initialised
/// on first construction and torn down when the last fixture is
/// disposed — same lazy + lock + count pattern as the original Org
/// fixture, lifted out of the specialisation.</description></item>
/// <item><description>Address discovery via
/// <see cref="IServerAddressesFeature"/>.</description></item>
/// </list>
///
/// The actual service registration, middleware pipeline and route
/// mapping are the responsibility of the subclass, through
/// <see cref="BuildApp"/>.
/// </summary>
public abstract class WebHostFixture : IBackendFixture
{
private static readonly Lazy<X509Certificate2> _selfSignedCertificate =
new Lazy<X509Certificate2>(CreateSelfSignedCertificate);
private static readonly object _sync = new object();
private static WebApplication? _app;
private static bool _isInitialized;
private static bool _shutdownHooksRegistered;
private static int _instanceCount;
private static readonly List<string> _sharedAddresses = new();
private static IServiceProvider? _sharedServices;
/// <summary>HTTPS listen URLs the host bound to.</summary>
public IReadOnlyList<string> Addresses { get; private set; } = Array.Empty<string>();
/// <summary>The DI service provider of the running host. Read from
/// the shared static slot so every fixture instance (xUnit creates
/// one per <c>IClassFixture</c>) sees the same provider after the
/// first initialisation. Throws if <see cref="IsInitialized"/> is
/// false.</summary>
public IServiceProvider Services => _sharedServices
?? throw new InvalidOperationException(
"WebHostFixture has not been initialised. Call InitializeAsync first.");
/// <summary>True once <see cref="InitializeAsync"/> has completed
/// successfully and the host is running.</summary>
public bool IsInitialized { get; private set; }
#pragma warning disable CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez d’ajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null.
protected WebHostFixture()
#pragma warning restore CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez d’ajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null.
{
lock (_sync)
{
RegisterShutdownHooks();
if (!_isInitialized)
{
InitializeAsync().GetAwaiter().GetResult();
_isInitialized = true;
}
_instanceCount++;
CopySharedState();
CopySpecialisedSharedState();
}
}
private void CopySharedState()
{
Addresses = _sharedAddresses.ToArray();
IsInitialized = _isInitialized;
App = _app ?? App;
}
/// <summary>Hook for specialisations to copy any other shared
/// state (test client credentials, user names, factories, etc.)
/// from the static slots exposed by the base class onto instance
/// properties. Called once per fixture construction, after
/// <see cref="InitializeAsync"/> has populated the shared state
/// the first time.</summary>
protected virtual void CopySpecialisedSharedState() { }
/// <summary>Specialisations register their services and middleware
/// here. The base class has already configured Kestrel HTTPS on
/// the fixture-defined test port — do not bind additional
/// listeners.</summary>
/// <param name="builder">The <see cref="WebApplicationBuilder"/>
/// configured with Kestrel HTTPS on the fixture-defined test port
/// and the shared self-signed certificate.</param>
/// <returns>The fully built <see cref="WebApplication"/>, ready
/// for <c>ConfigurePipeline</c> + <c>StartAsync</c>.</returns>
protected abstract WebApplication BuildApp(WebApplicationBuilder builder);
/// <summary>Apply the production pipeline to <paramref name="app"/>.
/// Defaults to identity + routing + auth + MapStaticAssets; override
/// only if your host needs a different shape.</summary>
protected virtual async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
{
await Task.CompletedTask;
return app;
}
/// <summary>HTTPS port used by this fixture's Kestrel host.
/// Override in derived fixtures when they must not share the same
/// listen port.</summary>
protected virtual int HttpsPort => 5101;
/// <summary>Options used to create the WebApplicationBuilder.
/// Derived fixtures can override (for example, to set
/// ApplicationName for MVC controller discovery).</summary>
protected virtual WebApplicationOptions CreateBuilderOptions()
{
return new WebApplicationOptions();
}
public WebApplication App { get; private set; }
private async Task InitializeAsync()
{
var builder = WebApplication.CreateBuilder(CreateBuilderOptions());
builder.WebHost.ConfigureKestrel(options =>
{
options.Listen(IPAddress.Loopback, HttpsPort, listenOptions =>
{
listenOptions.UseHttps(_selfSignedCertificate.Value);
});
});
this.App = BuildApp(builder);
this.App = await ConfigurePipelineAsync(this.App);
await this.App.StartAsync();
_app = this.App;
_sharedServices = this.App.Services;
var server = this.App.Services.GetRequiredService<IServer>();
var addressFeatures = server.Features.Get<IServerAddressesFeature>();
_sharedAddresses.Clear();
if (addressFeatures?.Addresses is not null)
{
foreach (var address in addressFeatures.Addresses)
{
_sharedAddresses.Add(address);
}
}
Addresses = _sharedAddresses.ToArray();
IsInitialized = true;
}
public virtual void Dispose()
{
lock (_sync)
{
if (_instanceCount > 0)
{
_instanceCount--;
}
IsInitialized = _isInitialized;
// Keep the shared host alive for the whole test process.
// Disposing per class/collection can race with other test
// classes and intermittently drop the listener mid-run.
}
}
private static void RegisterShutdownHooks()
{
if (_shutdownHooksRegistered)
{
return;
}
AppDomain.CurrentDomain.ProcessExit += (_, __) => ShutdownSharedHost();
AssemblyLoadContext.Default.Unloading += _ => ShutdownSharedHost();
_shutdownHooksRegistered = true;
}
private static void ShutdownSharedHost()
{
lock (_sync)
{
if (!_isInitialized || _app is null)
{
return;
}
_app.StopAsync().GetAwaiter().GetResult();
_app = null;
_isInitialized = false;
_sharedServices = null;
_sharedAddresses.Clear();
}
}
private static X509Certificate2 CreateSelfSignedCertificate()
{
var rsa = RSA.Create(2048);
var certRequest = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
certRequest.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DataEncipherment | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, false));
certRequest.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
return certRequest.CreateSelfSigned(
new DateTimeOffset(DateTime.UtcNow.AddDays(-1)),
new DateTimeOffset(DateTime.UtcNow.AddDays(3650)));
}
}

View file

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Tests.Shared</RootNamespace>
<!--
Shared library of test fixtures (WebHostFixture, auth bypass,
etc.) consumed by both Yavsc.Org.Tests and Yavsc.Blogs.Tests.
Not a test project itself: no xUnit, no test runner, no
TestSdk. The consumer projects own the test execution and
inherit from the shared base classes.
-->
<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="Microsoft.AspNetCore.Hosting" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup>
</Project>