REORG
This commit is contained in:
parent
f0792a844f
commit
8c8ab70d38
82 changed files with 113 additions and 94 deletions
161
src/test/Yavsc.Api.Tests/ActivityApiControllerTests.cs
Normal file
161
src/test/Yavsc.Api.Tests/ActivityApiControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
6
src/test/Yavsc.Api.Tests/ApiCollection.cs
Normal file
6
src/test/Yavsc.Api.Tests/ApiCollection.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Yavsc.Api.Test;
|
||||
|
||||
[CollectionDefinition("Yavsc Api")]
|
||||
public sealed class ApiCollection
|
||||
{
|
||||
}
|
||||
236
src/test/Yavsc.Api.Tests/BillingControllerTests.cs
Normal file
236
src/test/Yavsc.Api.Tests/BillingControllerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
342
src/test/Yavsc.Api.Tests/EstimateApiControllerTests.cs
Normal file
342
src/test/Yavsc.Api.Tests/EstimateApiControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
303
src/test/Yavsc.Api.Tests/EstimateAttachmentsApiTests.cs
Normal file
303
src/test/Yavsc.Api.Tests/EstimateAttachmentsApiTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
191
src/test/Yavsc.Api.Tests/EstimatePdfGenerationTests.cs
Normal file
191
src/test/Yavsc.Api.Tests/EstimatePdfGenerationTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
500
src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs
Normal file
500
src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
342
src/test/Yavsc.Api.Tests/FrontOfficeApiControllerTests.cs
Normal file
342
src/test/Yavsc.Api.Tests/FrontOfficeApiControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
256
src/test/Yavsc.Api.Tests/FrontOfficeQueryAttachmentsApiTests.cs
Normal file
256
src/test/Yavsc.Api.Tests/FrontOfficeQueryAttachmentsApiTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
120
src/test/Yavsc.Api.Tests/HairCutQueryApiControllerTests.cs
Normal file
120
src/test/Yavsc.Api.Tests/HairCutQueryApiControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
123
src/test/Yavsc.Api.Tests/HairMultiCutQueryApiControllerTests.cs
Normal file
123
src/test/Yavsc.Api.Tests/HairMultiCutQueryApiControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
69
src/test/Yavsc.Api.Tests/HomeControllerTests.cs
Normal file
69
src/test/Yavsc.Api.Tests/HomeControllerTests.cs
Normal 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!;
|
||||
}
|
||||
}
|
||||
229
src/test/Yavsc.Api.Tests/RdvQueryApiControllerTests.cs
Normal file
229
src/test/Yavsc.Api.Tests/RdvQueryApiControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
33
src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj
Normal file
33
src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj
Normal 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>
|
||||
390
src/test/Yavsc.Blogs.Tests/BlogAclApiTests.cs
Normal file
390
src/test/Yavsc.Blogs.Tests/BlogAclApiTests.cs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Abstract.BlogSpot;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Access;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Tests.Shared;
|
||||
using static Yavsc.Constants;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioral tests for <c>BlogAclApiController.PostCircleAuthorizationToBlogPost</c>:
|
||||
/// <c>POST /api/v1/blogacl</c> with a JSON body of
|
||||
/// <c>CircleAuthorizationToBlogPost</c> (CircleId + BlogPostId).
|
||||
///
|
||||
/// <para>Same fixture as <see cref="CircleMembersApiTests"/>:
|
||||
/// <see cref="BlogsWebServerFixture"/> provides a SQLite
|
||||
/// <c>:memory:</c> <c>ApplicationDbContext</c> (so FKs are
|
||||
/// enforced the way a real relational engine would) and JWT
|
||||
/// bearer auth via <c>TestTokenIssuer</c>. No mocks — the real
|
||||
/// DbContext receives the real INSERT attempt.</para>
|
||||
///
|
||||
/// <para>The bug being pinned by these tests: the POST endpoint
|
||||
/// calls <c>_context.CircleAuthorizationToBlogPost.Add(...)</c>
|
||||
/// then <c>SaveChangesAsync</c>. The entity has a composite
|
||||
/// key (CircleId + BlogPostId) and two FKs; EF Core refuses
|
||||
/// the INSERT with
|
||||
/// <c>System.InvalidOperationException: The value of
|
||||
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown when
|
||||
/// attempting to save changes</c> when the principal entities
|
||||
/// (the existing <c>BlogPost</c> and <c>Circle</c>) are not
|
||||
/// attached to the DbContext in the same change-tracker graph.</para>
|
||||
/// </summary>
|
||||
[Collection("Yavsc Blogs")]
|
||||
public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
||||
public BlogAclApiTests(BlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
|
||||
private string BlogUrl()
|
||||
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogSpotPath}";
|
||||
private string BlogAclUrl()
|
||||
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogAclPath}";
|
||||
|
||||
/// <summary>Delete any ACL rows tied to the specified
|
||||
/// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store
|
||||
/// persists across tests, so tests that POST a successful ACL
|
||||
/// row would otherwise conflict with whichever other test runs
|
||||
/// next against the same pair — xUnit does not guarantee
|
||||
/// execution order. Calling this at the start of each
|
||||
/// insert-bearing test guarantees a clean slate regardless of
|
||||
/// the previous test's outcome.</summary>
|
||||
private void CleanupAcl(long circleId, long blogPostId)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.CircleAuthorizationToBlogPost
|
||||
.Where(a => a.CircleId == circleId
|
||||
&& a.BlogPostId == blogPostId)
|
||||
.ExecuteDelete();
|
||||
}
|
||||
|
||||
private HttpClient NewClient(string subject)
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
var http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||
};
|
||||
// The Blogs fixture disables JwtSecurityTokenHandler's
|
||||
// inbound claim-type remap, so the JWT's "sub" stays "sub"
|
||||
// rather than being rewritten to ClaimTypes.NameIdentifier.
|
||||
// The controller, however, reads the user id via
|
||||
// User.FindFirstValue(ClaimTypes.NameIdentifier), so we add
|
||||
// an explicit NameIdentifier claim to keep the legacy lookup happy.
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
TestTokenIssuer.Issue(
|
||||
subject,
|
||||
extraClaims: new[]
|
||||
{
|
||||
new System.Security.Claims.Claim(
|
||||
System.Security.Claims.ClaimTypes.NameIdentifier,
|
||||
subject),
|
||||
}));
|
||||
return http;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduces the prod 500 logged on 2026-08-21 on mercure:
|
||||
/// <c>InvalidOperationException: The value of
|
||||
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown</c>
|
||||
/// when <see cref="PostAclDialogViewModel.AddAsync"/> POSTs the
|
||||
/// shape <c>{ "circleId": <id> }</c> — the exact body the
|
||||
/// PostIt client builds from <see cref="CircleAuthorization"/>
|
||||
/// (which only carries <c>CircleId</c>). The server deserializes
|
||||
/// it into <see cref="CircleAuthorizationToBlogPost"/>, leaves
|
||||
/// <c>BlogPostId</c> at its <c>default(long) = 0</c>, attaches
|
||||
/// no <c>Target</c> navigation, and EF Core refuses to INSERT
|
||||
/// during <c>PrepareToSave()</c>. The fix lives in PostIt
|
||||
/// (enrich the payload with <c>blogPostId</c> + <c>comment</c>)
|
||||
/// and on the wire DTO (<see cref="CircleAuthorization"/> must
|
||||
/// carry those fields); the server validates. Until that ships,
|
||||
/// this test stays red.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape_against_existing_circle_named_test()
|
||||
{
|
||||
// The prod circle already exists with Name="test", Public=true,
|
||||
// owned by the caller. We seed the same shape pre-POST so the
|
||||
// test reproduces the prod scenario end-to-end.
|
||||
_fixture.SeedUser(_fixture.DefaultUserLogin);
|
||||
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test");
|
||||
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-target");
|
||||
CleanupAcl(seededCircleId, seededBlogPostId);
|
||||
using var http = NewClient(_fixture.DefaultUserLogin);
|
||||
|
||||
var payload = new PostAccessControlRulePayload
|
||||
{
|
||||
CircleId = seededCircleId,
|
||||
BlogPostId = seededBlogPostId
|
||||
};
|
||||
|
||||
var response = await http.PostAsJsonAsync(
|
||||
BlogAclUrl(), payload,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload templates for <see cref="PostCircleAuthorization_never_returns_500"/>.
|
||||
/// Each row carries the shape we want to POST; <c>-1L</c> and
|
||||
/// <c>-2L</c> are negative sentinels that the test substitutes
|
||||
/// with the ids of freshly seeded <c>Circle</c> / <c>BlogPost</c>
|
||||
/// rows before sending, so every shape lands against a real
|
||||
/// principal entity and the seeded fixtures are not dead.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> BlogAclPayloadsForNever500()
|
||||
{
|
||||
|
||||
// circleId only (the historical bug shape, 2026-08-21 mercure):
|
||||
// must be rejected, never 500.
|
||||
return new object[][]
|
||||
{
|
||||
[
|
||||
new PostAccessControlRulePayload
|
||||
{
|
||||
BlogPostId = -2,
|
||||
CircleId = -1
|
||||
}
|
||||
],
|
||||
[new PostAccessControlRulePayload
|
||||
{
|
||||
BlogPostId = 1,
|
||||
CircleId = -1
|
||||
}
|
||||
],
|
||||
[new PostAccessControlRulePayload
|
||||
{
|
||||
BlogPostId = 1,
|
||||
CircleId = 1
|
||||
}
|
||||
]
|
||||
} ;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hard rule (Paul, 2026-08-21): a 500 is never acceptable
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(BlogAclPayloadsForNever500))]
|
||||
public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload)
|
||||
{
|
||||
using var http = NewClient(_fixture.DefaultUserLogin);
|
||||
|
||||
var response = await http.PostAsJsonAsync(
|
||||
BlogAclUrl(), payload,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotEqual(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
async Task PostCircleAuthorization_dosent_return_500 ()
|
||||
{
|
||||
_fixture.SeedUser(_fixture.DefaultUserLogin);
|
||||
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"));
|
||||
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-never-500");
|
||||
CleanupAcl(seededCircleId, seededBlogPostId);
|
||||
await PostCircleAuthorization_never_returns_500(
|
||||
|
||||
new PostAccessControlRulePayload
|
||||
{
|
||||
BlogPostId = -1,
|
||||
CircleId = seededCircleId
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
async Task PostCircleAuthorization_dosent_return_500_on_success ()
|
||||
{
|
||||
_fixture.SeedUser(_fixture.DefaultUserLogin);
|
||||
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"));
|
||||
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-never-500-success");
|
||||
CleanupAcl(seededCircleId, seededBlogPostId);
|
||||
await PostCircleAuthorization_never_returns_500(
|
||||
|
||||
new PostAccessControlRulePayload
|
||||
{
|
||||
BlogPostId = seededBlogPostId,
|
||||
CircleId = seededCircleId
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlog_with_ACL_creates_a_post_and_Get_returns_it_in_the_list()
|
||||
{
|
||||
_fixture.SeedUser(_fixture.DefaultUserLogin);
|
||||
_fixture.SeedUser("tester");
|
||||
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"),
|
||||
false,
|
||||
new String[]
|
||||
{
|
||||
_fixture.DefaultUserLogin,
|
||||
"tester"
|
||||
});
|
||||
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-seeded-target");
|
||||
CleanupAcl(seededCircleId, seededBlogPostId);
|
||||
using var http = NewClient(_fixture.DefaultUserLogin );
|
||||
|
||||
// Create a minimal BlogPost. The server assigns Id, so we
|
||||
// send 0 + an explicit AuthorId; the production
|
||||
// BlogSpotService.Create() tolerates that.
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Premier billet",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu de test.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
ACL = new List<CircleAuthorizationToBlogPost>(
|
||||
new CircleAuthorizationToBlogPost[]
|
||||
{
|
||||
new CircleAuthorizationToBlogPost
|
||||
{
|
||||
CircleId = seededCircleId,
|
||||
BlogPostId = seededBlogPostId
|
||||
}
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
var postResponse = await http.PostAsJsonAsync(
|
||||
BlogUrl(),
|
||||
draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||
|
||||
// The POST returns the server-issued post (with a real Id).
|
||||
var created = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
Assert.NotNull(created);
|
||||
Assert.NotEqual(0, created!.Id);
|
||||
Assert.Equal(draft.Title, created.Title);
|
||||
|
||||
// The list should now contain exactly one entry.
|
||||
var listResponse = await http.GetAsync(
|
||||
BlogUrl(),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken
|
||||
));
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.True(doc.RootElement.GetArrayLength() >= 1);
|
||||
Assert.Contains(doc.RootElement.EnumerateArray(), p => p.GetProperty("id").GetInt64() == created.Id);
|
||||
|
||||
// detail should return the same post, with ACL and tags.
|
||||
var detailResponse = await http.GetAsync(
|
||||
$"{BlogUrl()}/{created.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
||||
using var detailDoc = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken
|
||||
));
|
||||
Assert.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind);
|
||||
Assert.Equal(created.Id, detailDoc.RootElement.GetProperty("id").GetInt64());
|
||||
Assert.True(detailDoc.RootElement.TryGetProperty("acl", out var acl));
|
||||
Assert.False(detailDoc.RootElement.TryGetProperty("ACL", out _));
|
||||
Assert.Equal(JsonValueKind.Array, acl.ValueKind);
|
||||
Assert.Equal(1, acl.GetArrayLength());
|
||||
|
||||
var aclEntry = acl[0];
|
||||
Assert.Equal(JsonValueKind.Object, aclEntry.ValueKind);
|
||||
Assert.True(aclEntry.TryGetProperty("circleId", out var returnedCircleId));
|
||||
Assert.Equal(seededCircleId, returnedCircleId.GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_owner_can_read_restricted_post_but_receives_empty_acl_in_list_and_detail()
|
||||
{
|
||||
_fixture.SeedUser(_fixture.DefaultUserLogin);
|
||||
_fixture.SeedUser("tester");
|
||||
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"), false,
|
||||
new[] { _fixture.DefaultUserLogin, "tester" });
|
||||
|
||||
using var ownerHttp = NewClient(_fixture.DefaultUserLogin);
|
||||
using var readerHttp = NewClient("tester");
|
||||
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "ACL scrub test",
|
||||
Article = "Visible to circle member",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var postResponse = await ownerHttp.PostAsJsonAsync(
|
||||
BlogUrl(),
|
||||
draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||
|
||||
var created = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
Assert.NotEqual(0, created!.Id);
|
||||
|
||||
var grantResponse = await ownerHttp.PostAsJsonAsync(
|
||||
BlogAclUrl(),
|
||||
new PostAccessControlRulePayload
|
||||
{
|
||||
CircleId = seededCircleId,
|
||||
BlogPostId = created.Id
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, grantResponse.StatusCode);
|
||||
|
||||
var listResponse = await readerHttp.GetAsync(
|
||||
BlogUrl(),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
|
||||
using var listDoc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken));
|
||||
Assert.Equal(JsonValueKind.Array, listDoc.RootElement.ValueKind);
|
||||
foreach (var listed in listDoc.RootElement.EnumerateArray())
|
||||
{
|
||||
var authorId = listed.GetProperty("authorId").GetString();
|
||||
if (string.Equals(authorId, "tester", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
Assert.True(listed.TryGetProperty("acl", out var listedAcl));
|
||||
Assert.Equal(0, listedAcl.GetArrayLength());
|
||||
}
|
||||
|
||||
var detailResponse = await readerHttp.GetAsync(
|
||||
$"{BlogUrl()}/{created.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
||||
|
||||
using var detailDoc = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken));
|
||||
Assert.True(detailDoc.RootElement.TryGetProperty("acl", out var detailAcl));
|
||||
Assert.Equal(0, detailAcl.GetArrayLength());
|
||||
}
|
||||
|
||||
}
|
||||
161
src/test/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
Normal file
161
src/test/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Yavsc.Blogs.Tests.Fixtures;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
[Collection("JwtClaimMapping")]
|
||||
public sealed class BlogApiMappedClaimsTests :
|
||||
IClassFixture<MappedClaimsBlogsWebServerFixture>
|
||||
{
|
||||
private readonly MappedClaimsBlogsWebServerFixture _fixture;
|
||||
|
||||
public BlogApiMappedClaimsTests(MappedClaimsBlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private void ResetDatabase()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.Database.EnsureDeleted();
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
private HttpClient NewClient(string subject = "tester")
|
||||
{
|
||||
var http = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.Addresses.First())
|
||||
};
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
IssueMappedClaimsToken(subject));
|
||||
return http;
|
||||
}
|
||||
|
||||
private static string IssueMappedClaimsToken(string subject)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new("sub", subject),
|
||||
new("scope", "blogs"),
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: TestTokenIssuer.Issuer,
|
||||
audience: TestTokenIssuer.Audience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
expires: now.AddHours(1),
|
||||
signingCredentials: new SigningCredentials(
|
||||
TestTokenIssuer.SigningKey,
|
||||
SecurityAlgorithms.HmacSha256));
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlog_with_mapped_sub_claim_sets_AuthorId_from_authenticated_user()
|
||||
{
|
||||
ResetDatabase();
|
||||
using var http = NewClient(subject: "mapped-user");
|
||||
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Billet JWT re-mappé",
|
||||
AuthorId = "payload-attacker",
|
||||
Article = "Contenu de test.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var response = await http.PostAsJsonAsync(
|
||||
_fixture.BlogSpotUrl(),
|
||||
draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("mapped-user", created!.AuthorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutBlog_with_mapped_sub_claim_allows_owner_to_update()
|
||||
{
|
||||
ResetDatabase();
|
||||
using var http = NewClient(subject: "mapped-owner");
|
||||
|
||||
var createdResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Billet à modifier",
|
||||
AuthorId = "payload-attacker",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
|
||||
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
|
||||
var updateResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl() + $"/{created!.Id}", new BlogPost
|
||||
{
|
||||
Id = created.Id,
|
||||
Title = "Billet modifié",
|
||||
AuthorId = created.AuthorId,
|
||||
Article = "Contenu mis à jour.",
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutBlog_with_mapped_sub_claim_rejects_non_owner()
|
||||
{
|
||||
ResetDatabase();
|
||||
using var ownerHttp = NewClient(subject: "mapped-owner");
|
||||
|
||||
var createdResponse = await ownerHttp.PostAsJsonAsync(_fixture.BlogSpotUrl(), new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Billet protégé",
|
||||
AuthorId = "payload-attacker",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
|
||||
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
|
||||
using var otherHttp = NewClient(subject: "mapped-other");
|
||||
var updateResponse = await otherHttp.PutAsJsonAsync(_fixture.BlogSpotUrl() + $"/{created!.Id}", new BlogPost
|
||||
{
|
||||
Id = created.Id,
|
||||
Title = "Tentative de modification",
|
||||
AuthorId = created.AuthorId,
|
||||
Article = "Contenu non autorisé.",
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, updateResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
40
src/test/Yavsc.Blogs.Tests/BlogApiSmokeTests.cs
Normal file
40
src/test/Yavsc.Blogs.Tests/BlogApiSmokeTests.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Smoke tests for the Yavsc.Blogs API host. These tests only assert
|
||||
/// that the fixture boots and the test HTTP client reaches the
|
||||
/// controller pipeline — they do not yet exercise the controller
|
||||
/// surface. The first behavioural test (GET /api/v1/blog returns
|
||||
/// 200) lands in a follow-up commit.
|
||||
/// </summary>
|
||||
[Collection("Yavsc Blogs")]
|
||||
public sealed class BlogApiSmokeTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
||||
public BlogApiSmokeTests(BlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fixture_Binds_At_Least_One_Https_Address()
|
||||
{
|
||||
Assert.NotEmpty(_fixture.Addresses);
|
||||
Assert.Contains(_fixture.Addresses, a => a.StartsWith("https://"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fixture_Exposes_Resolving_ServiceProvider()
|
||||
{
|
||||
// If the host built correctly, the service provider should
|
||||
// be available and resolvable. We don't need to assert a
|
||||
// specific service here — the GET 200 test will exercise
|
||||
// the BlogSpotService indirectly.
|
||||
Assert.NotNull(_fixture.Services);
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
Assert.NotNull(scope.ServiceProvider);
|
||||
}
|
||||
}
|
||||
719
src/test/Yavsc.Blogs.Tests/BlogApiTests.cs
Normal file
719
src/test/Yavsc.Blogs.Tests/BlogApiTests.cs
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Yavsc.Blogs.Tests.Fixtures;
|
||||
using BlogPostDto = Yavsc.Blogspot.BlogPostDto;
|
||||
using NuGet.Protocol;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioural tests for <c>BlogApiController</c>. Built on the
|
||||
/// <see cref="BlogsWebServerFixture"/> scaffold: in-memory
|
||||
/// <c>ApplicationDbContext</c>, real <c>BlogSpotService</c>, and a
|
||||
/// real <c>AddJwtBearer</c> validating HS256 tokens signed by
|
||||
/// <see cref="TestTokenIssuer"/>. The production <c>BlogScope</c>
|
||||
/// policy runs unmodified — sending <c>Authorization: Bearer …</c>
|
||||
/// with a valid token is what gets a request through, omitting the
|
||||
/// header (or sending a token signed with the wrong key) gets a
|
||||
/// 401 back from the framework.
|
||||
/// </summary>
|
||||
[Collection("Yavsc Blogs")]
|
||||
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
||||
public BlogApiTests(BlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Reset the database and seed the
|
||||
/// <c>tester</c> <see cref="ApplicationUser"/> row. Required
|
||||
/// for any test that POST/PUT/DELETE a <c>BlogPost</c>:
|
||||
/// <c>BlogPost.AuthorId</c> is a FK to
|
||||
/// <c>AspNetUsers.Id</c>, and SQLite (unlike the EF Core
|
||||
/// InMemory provider) enforces it. Without the seed, the
|
||||
/// POST handler hits
|
||||
/// <c>SQLite Error 19: 'FOREIGN KEY constraint failed'</c>
|
||||
/// at <c>SaveChanges</c> and the controller returns 500.</summary>
|
||||
private void ResetAndSeedDefaultUser()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("tester");
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Build an authenticated client: a real
|
||||
/// <c>Authorization: Bearer <jwt></c> header where the JWT
|
||||
/// is signed by <see cref="TestTokenIssuer"/> and carries
|
||||
/// <c>sub = subject</c>. The production <c>BlogScope</c> policy
|
||||
/// reads <c>scope=blogs</c> off the same token, so
|
||||
/// <c>TestTokenIssuer.Issue</c>'s default scope is enough.</summary>
|
||||
private HttpClient NewClient(string subject = "tester")
|
||||
{
|
||||
// The fixture's self-signed certificate is not in the user's
|
||||
// trust store, so we accept anything (same pattern as
|
||||
// Yavsc.Org.Tests' BypassSslValidationHandler).
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
var http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||
};
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||
"Bearer", TestTokenIssuer.Issue(subject));
|
||||
return http;
|
||||
}
|
||||
|
||||
/// <summary>Build an unauthenticated client. Used to assert that
|
||||
/// the <c>BlogScope</c> policy fails closed when no bearer
|
||||
/// token is presented.</summary>
|
||||
private HttpClient NewAnonymousClient()
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
return new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||
};
|
||||
}
|
||||
|
||||
private int CountAttachmentsForPost(long postId)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
return db.BlogAttachedFiles.Count(a => a.PostId == postId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlogs_returns_200_with_empty_list_when_no_posts()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
using var http = NewClient();
|
||||
|
||||
var response = await http.GetAsync(
|
||||
_fixture.BlogSpotUrl(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
// Empty table → empty JSON array. We compare as a JsonDocument
|
||||
// so a future change in formatting (whitespace, indentation)
|
||||
// doesn't break the assertion.
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlog_creates_a_post_and_Get_returns_it_in_the_list()
|
||||
{
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient();
|
||||
|
||||
// Create a minimal BlogPost. The server assigns Id, so we
|
||||
// send 0 + an explicit AuthorId; the production
|
||||
// BlogSpotService.Create() tolerates that.
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Premier billet",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu de test.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||
|
||||
// The POST returns the server-issued post (with a real Id).
|
||||
var created = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
Assert.NotNull(created);
|
||||
Assert.NotEqual(0, created!.Id);
|
||||
Assert.Equal(draft.Title, created.Title);
|
||||
|
||||
// The list should now contain exactly one entry.
|
||||
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken
|
||||
));
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry()
|
||||
{
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Billet avec auteur",
|
||||
AuthorId = "payload-attacker",
|
||||
Article = "Contenu de test.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||
|
||||
var created = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("tester", created!.AuthorId);
|
||||
|
||||
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken
|
||||
));
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlogComment_returns_201_for_existing_post()
|
||||
{
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Billet commentable",
|
||||
AuthorId = "payload-attacker",
|
||||
Article = "Contenu de test.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||
|
||||
var createdPost = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
Assert.NotNull(createdPost);
|
||||
Thread.Sleep(100);
|
||||
var commentResponse = await http.PostAsJsonAsync("/api/v1/blogcomments", new
|
||||
{
|
||||
Article = "Premier commentaire",
|
||||
ReceiverId = createdPost!.Id
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, commentResponse.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(
|
||||
await commentResponse.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken
|
||||
));
|
||||
Assert.True(doc.RootElement.TryGetProperty("id", out var id));
|
||||
Assert.True(id.GetInt64() > 0);
|
||||
Assert.True(doc.RootElement.TryGetProperty("dateCreated", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUserId_reads_NameIdentifier_when_sub_was_mapped()
|
||||
{
|
||||
var principal = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.NameIdentifier, "tester")],
|
||||
authenticationType: "Bearer"));
|
||||
|
||||
Assert.Equal("tester", principal.GetUserId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlog_returns_401_when_no_token_is_provided()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
using var http = NewAnonymousClient();
|
||||
|
||||
// No Authorization header → the JwtBearer middleware
|
||||
// produces an unauthenticated principal, the BlogScope
|
||||
// policy's RequireAuthenticatedUser requirement fails, and
|
||||
// the framework returns 401. This is the proof that the
|
||||
// production policy is wired in the test host and not
|
||||
// short-circuited by a test-only auth bypass.
|
||||
var response = await http.GetAsync(_fixture.BlogSpotUrl(),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update()
|
||||
{
|
||||
ResetAndSeedDefaultUser();
|
||||
// The JWT's sub must match the post's AuthorId:
|
||||
// PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(),
|
||||
// and UserHelpers.GetUserId reads "sub" off the principal.
|
||||
// A mismatched sub → AuthorizationFailureException →
|
||||
// Challenge() (401) from the controller. The 204 in this
|
||||
// test is the proof that the real authorization chain
|
||||
// accepted the request, end-to-end.
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
// Seed a post we can update.
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Avant",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||
|
||||
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken
|
||||
))!;
|
||||
|
||||
// PUT with the server-issued Id; the controller rejects
|
||||
// mismatched id/blog.Id with 400, so we keep them aligned.
|
||||
var update = new BlogPost
|
||||
{
|
||||
Id = created.Id,
|
||||
Title = "Après",
|
||||
AuthorId = created.AuthorId,
|
||||
Article = created.Article,
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
var putResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl()+$"/{created.Id}",
|
||||
update,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
|
||||
|
||||
// The list should now reflect the new title.
|
||||
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
using var doc = JsonDocument.Parse(
|
||||
await listResponse.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken
|
||||
));
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
Assert.Equal("Après", doc.RootElement[0].GetProperty("title").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutBlog_multipart_with_blog_and_file_returns_204_and_persists_attachment()
|
||||
{
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
// AttachFiles writes under the fixture-configured
|
||||
// SiteSettings.Blog (BlogFilesRoot), not a local temp dir.
|
||||
var filesRoot = _fixture.BlogFilesRoot;
|
||||
|
||||
try
|
||||
{
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Initial",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var postResponse = await http.PostAsJsonAsync(
|
||||
_fixture.BlogSpotUrl(),
|
||||
draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||
|
||||
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken))!;
|
||||
|
||||
var attachmentCount = CountAttachmentsForPost(created.Id);
|
||||
Assert.True(attachmentCount == 0);
|
||||
|
||||
var update = new BlogPost
|
||||
{
|
||||
Id = created.Id,
|
||||
Title = "Mis a jour via multipart",
|
||||
AuthorId = created.AuthorId,
|
||||
Article = "Contenu mis a jour.",
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var form = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(JsonSerializer.Serialize(update)), "blog" }
|
||||
};
|
||||
|
||||
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
|
||||
var fileContent = new ByteArrayContent(fileBytes);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||
form.Add(fileContent, "file", "note.txt");
|
||||
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Put,
|
||||
_fixture.BlogSpotUrl() + $"/{created.Id}")
|
||||
{
|
||||
Content = form
|
||||
};
|
||||
|
||||
var putResponse = await http.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
|
||||
|
||||
var detailsResponse = await http.GetAsync(
|
||||
_fixture.BlogSpotUrl() + $"/{created.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, detailsResponse.StatusCode);
|
||||
|
||||
using var detailsDoc = JsonDocument.Parse(
|
||||
await detailsResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal("Mis a jour via multipart", detailsDoc.RootElement.GetProperty("title").GetString());
|
||||
attachmentCount = CountAttachmentsForPost(created.Id);
|
||||
Assert.True(attachmentCount >= 1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// BlogFilesRoot is the fixture-shared temp root (one per
|
||||
// test run); leave it in place for the other multipart
|
||||
// tests. The OS reclaims it.
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutBlog_multipart_without_blog_field_returns_400()
|
||||
{
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Initial",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var postResponse = await http.PostAsJsonAsync(
|
||||
_fixture.BlogSpotUrl(),
|
||||
draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||
|
||||
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken))!;
|
||||
|
||||
var form = new MultipartFormDataContent();
|
||||
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
|
||||
var fileContent = new ByteArrayContent(fileBytes);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||
form.Add(fileContent, "file", "note.txt");
|
||||
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Put,
|
||||
_fixture.BlogSpotUrl() + $"/{created.Id}")
|
||||
{
|
||||
Content = form
|
||||
};
|
||||
|
||||
var putResponse = await http.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, putResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutBlog_from_PostIt_client_with_attachment_returns_204_and_persists_attachment()
|
||||
{
|
||||
// Full PostIt "Save" flow for an existing post with a new
|
||||
// attachment, driven through the real BlogApiClient — the
|
||||
// same code PostIt runs — so any drift between the
|
||||
// client's wire shape and the controller's contract fails
|
||||
// here before it reaches a user:
|
||||
//
|
||||
// 1. BlogsViewModel.SaveAsync (update branch) calls
|
||||
// BlogApiClient.UpdatePostAsync(id, update, attachments)
|
||||
// with a non-empty attachments list →
|
||||
// multipart/form-data PUT built by
|
||||
// CreateMultipartContent: a "blog" field holding the
|
||||
// BlogPostDto serialised camelCase + WhenWritingNull,
|
||||
// plus one "file" part per attachment.
|
||||
// 2. TryAppendAttachmentLinks then appends a markdown
|
||||
// link per attachment to the article, and the VM
|
||||
// issues a second UpdatePostAsync without files →
|
||||
// plain JSON PUT.
|
||||
//
|
||||
// The test asserts the final state: the article carries
|
||||
// the markdown link, the attachment row is persisted, and
|
||||
// the file physically landed under the user-files root
|
||||
// the link points to.
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
// AttachFiles writes under the fixture-configured
|
||||
// SiteSettings.Blog (BlogFilesRoot), not a local temp dir.
|
||||
var filesRoot = _fixture.BlogFilesRoot;
|
||||
|
||||
try
|
||||
{
|
||||
// BlogApiClient takes the versioned base address
|
||||
// (…/api/v1/) and prepends its "blogspot" prefix —
|
||||
// exactly how PostIt wires it against production.
|
||||
var client = new BlogApiClient(
|
||||
new TestApiTransport(http),
|
||||
_fixture.ApiUrl(""));
|
||||
|
||||
// Seed the post through the same path PostIt uses for
|
||||
// a first Save on a new draft: CreatePostAsync without
|
||||
// files → plain JSON POST.
|
||||
var draft = new BlogPostDto
|
||||
{
|
||||
Title = "Avant PostIt",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
var created = await client.CreatePostAsync(draft,
|
||||
ct: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
Assert.NotEqual(0, created!.Id);
|
||||
Assert.Equal("tester", created.AuthorId);
|
||||
|
||||
// Step 1 — the update DTO the VM builds from the
|
||||
// editor buffer (SelectedPost is the read-only hint
|
||||
// for Id, AuthorId, Photo and DateCreated), sent with
|
||||
// one attachment → multipart PUT.
|
||||
var update = new BlogPostDto
|
||||
{
|
||||
Id = created.Id,
|
||||
AuthorId = created.AuthorId,
|
||||
Photo = created.Photo,
|
||||
Title = "Modifié depuis PostIt",
|
||||
Article = "Contenu modifié.",
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
var attachments = new[]
|
||||
{
|
||||
new BlogUploadFile(
|
||||
"note.txt",
|
||||
System.Text.Encoding.UTF8.GetBytes("payload test"),
|
||||
"text/plain")
|
||||
};
|
||||
|
||||
await client.UpdatePostAsync(created.Id, update, attachments,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Step 2 — TryAppendAttachmentLinks appended a
|
||||
// markdown link to the article (the payload does not
|
||||
// embed Author, so the owner segment falls back to
|
||||
// AuthorId), and the VM PUTs the article again,
|
||||
// without files → JSON path of UpdatePostAsync.
|
||||
var fileUrl = $"{Yavsc.Constants.UserFilesPath}/tester/blogs/{created.Id}/note.txt";
|
||||
var linkUpdate = new BlogPostDto
|
||||
{
|
||||
Id = created.Id,
|
||||
AuthorId = created.AuthorId,
|
||||
Photo = created.Photo,
|
||||
Title = update.Title,
|
||||
Article = update.Article + $"\n\n- [note.txt]({fileUrl})",
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await client.UpdatePostAsync(created.Id, linkUpdate,
|
||||
ct: TestContext.Current.CancellationToken);
|
||||
|
||||
// Final state, read back through the client.
|
||||
var details = await client.GetPostAsync(created.Id,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(details);
|
||||
Assert.Equal("Modifié depuis PostIt", details!.Title);
|
||||
Assert.Contains($"- [note.txt]({fileUrl})", details.Article);
|
||||
|
||||
// The attachment row is persisted…
|
||||
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
|
||||
|
||||
// …and the file physically landed where the appended
|
||||
// markdown link points:
|
||||
// {UserFilesDirName}/{user}/blogs/{postId}/{fileName}.
|
||||
var expectedFile = Path.Combine(
|
||||
filesRoot, "tester", "blogs", created.Id.ToString(), "note.txt");
|
||||
var written = Directory.Exists(filesRoot)
|
||||
? string.Join(", ", Directory.EnumerateFiles(filesRoot, "*", SearchOption.AllDirectories))
|
||||
: "<none>";
|
||||
Assert.True(File.Exists(expectedFile),
|
||||
$"Expected uploaded file at {expectedFile}. Actually written: {written}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// BlogFilesRoot is the fixture-shared temp root (one per
|
||||
// test run); leave it in place for the other multipart
|
||||
// tests. The OS reclaims it.
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
|
||||
{
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient();
|
||||
|
||||
// Seed a post we can delete.
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "À supprimer",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken
|
||||
))!;
|
||||
|
||||
var deleteResponse = await http.DeleteAsync(_fixture.BlogSpotUrl()+$"/{created.Id}",
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
|
||||
|
||||
// The list should now be empty.
|
||||
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
|
||||
TestContext.Current.CancellationToken);
|
||||
String response = await listResponse.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
using var doc = JsonDocument.Parse(response);
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlog_from_PostIt_shape_returns_201_not_400()
|
||||
{
|
||||
// Regression test for the "Save" button in PostIt: from the
|
||||
// user's point of view, they type a Title and an Article in
|
||||
// the editor pane and tap "Save". The VM serialises the
|
||||
// SelectedPost via JsonContent.Create (camelCase, System.Text.Json
|
||||
// defaults) and POSTs it to /api/v1/blog. This test sends
|
||||
// exactly that payload — same fields, same types, same
|
||||
// serialiser (PostAsJsonAsync is wired to the same
|
||||
// System.Net.Http.Json pipeline that YavscApiClient uses on
|
||||
// the PostIt side) — and asserts that the server accepts it
|
||||
// with 201 Created, not 400 BadRequest. If the controller's
|
||||
// ModelState validation starts rejecting the PostIt payload
|
||||
// (missing field, wrong casing, etc.), this test fails
|
||||
// before the regression reaches a user.
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
// Mirrors what MainPageViewModel.Save builds: a BlogPost with
|
||||
// Id=0 (so the controller treats it as a create), Title and
|
||||
// Article filled in by the user, and DateCreated/DateModified
|
||||
// stamped by the VM. AuthorId is what the OIDC sub resolves
|
||||
// to in the test fixture.
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Mon premier billet",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu du billet de test.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Dump the body on failure so the test name + the response
|
||||
// payload are enough to start a fix; the framework's
|
||||
// assertion message is otherwise opaque (just "Expected
|
||||
// Created, got BadRequest").
|
||||
if (response.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
Assert.Fail(string.Format("Expected 201 Created, got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlog_with_empty_title_returns_400()
|
||||
{
|
||||
// Mirrors the buggy branch in MainPageViewModel.Save: when
|
||||
// the user taps "Save" without a SelectedPost (e.g. they
|
||||
// typed into the editor without first clicking an item in
|
||||
// the list, so the {Binding SelectedPost.Title, Mode=TwoWay}
|
||||
// XAML binding had no target and the keystrokes were
|
||||
// silently dropped), the VM builds a BlogPost with
|
||||
// Title = string.Empty and POSTs it. BlogPost.Title carries
|
||||
// [Required] → ModelState.IsValid fails → 400 BadRequest.
|
||||
// This is the regression we are hunting. The 400 is
|
||||
// expected here: the test pins the *current* controller
|
||||
// behaviour so a future change that, say, makes Title
|
||||
// nullable in the model or drops [Required], triggers a
|
||||
// conscious update of the test (and probably of the VM).
|
||||
_fixture.ResetDatabase();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = string.Empty,
|
||||
AuthorId = "tester",
|
||||
Article = "Article non vide, mais titre vide.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.BadRequest)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
Assert.Fail(string.Format("Expected 400 BadRequest (empty Title is invalid), got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body));
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src/test/Yavsc.Blogs.Tests/BlogSpotServiceIndexTests.cs
Normal file
164
src/test/Yavsc.Blogs.Tests/BlogSpotServiceIndexTests.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Blogs.Tests.Fixtures;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Direct service-level tests for <see cref="BlogSpotService.Index"/>.
|
||||
///
|
||||
/// <para>The HTTP tests in <see cref="BlogApiTests"/> all drive the
|
||||
/// <c>[Authorize("BlogScope")]</c>-protected <c>BlogApiController</c>,
|
||||
/// so the request is always authenticated by the time it reaches the
|
||||
/// service. The MVC <c>BlogspotController.Index</c> action, by
|
||||
/// contrast, is <c>[AllowAnonymous]</c> — that is the one caller that
|
||||
/// actually exercises the unauthenticated branch of
|
||||
/// <see cref="BlogSpotService.Index"/>, the branch that reads from
|
||||
/// <c>blogSpotPublications</c> and projects to <c>p.Post</c>.</para>
|
||||
///
|
||||
/// <para>These tests pin that behaviour at the service layer: a
|
||||
/// <c>BlogPost</c> that has a <c>BlogSpotPublication</c> row is
|
||||
/// listed for an anonymous viewer, and one without is not. Seeding
|
||||
/// runs against the same shared SQLite store as the rest of the
|
||||
/// <c>Yavsc Blogs</c> collection, so the real
|
||||
/// <see cref="BlogSpotService"/> (registered by
|
||||
/// <see cref="BlogsWebServerFixture"/>) is resolved from DI and run
|
||||
/// end-to-end — no mocking.</para>
|
||||
/// </summary>
|
||||
[Collection("Yavsc Blogs")]
|
||||
public sealed class BlogSpotServiceIndexTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
||||
public BlogSpotServiceIndexTests(BlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>An unauthenticated principal: a <see cref="ClaimsIdentity"/>
|
||||
/// with no authentication type has
|
||||
/// <see cref="ClaimsIdentity.IsAuthenticated"/> == false, so
|
||||
/// <see cref="BlogSpotService.Index"/> takes its anonymous
|
||||
/// branch. This mirrors what the JwtBearer middleware hands the
|
||||
/// MVC <c>[AllowAnonymous]</c> action when no bearer token is
|
||||
/// presented.</summary>
|
||||
private static ClaimsPrincipal Anonymous()
|
||||
=> new(new ClaimsIdentity());
|
||||
|
||||
/// <summary>Resolve the real <see cref="BlogSpotService"/> from the
|
||||
/// fixture's DI container and run <paramref name="body"/> against it,
|
||||
/// disposing the request scope (and its scoped DbContext) afterwards.
|
||||
/// The service itself is not <see cref="IDisposable"/>; the scope is,
|
||||
/// so the scope is what we must dispose to avoid leaking a DbContext
|
||||
/// against the shared SQLite connection across tests.</summary>
|
||||
private async Task<T> WithServiceAsync<T>(
|
||||
Func<BlogSpotService, Task<T>> body)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var service = scope.ServiceProvider.GetRequiredService<BlogSpotService>();
|
||||
return await body(service);
|
||||
}
|
||||
|
||||
private void Publish(long postId)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
// The publication flag is the existence of a row in
|
||||
// blogSpotPublications — same write SetPublishAsync performs
|
||||
// in production. Inserting it directly is the most faithful
|
||||
// way to seed "this post is published" without routing
|
||||
// through the authorize-gated PUT/publish endpoint.
|
||||
db.blogSpotPublications.Add(new BlogSpotPublication { PostId = postId });
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Index_lists_a_published_post_for_anonymous_access()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
var publishedId = _fixture.SeedBlogPost("alice", "Published for all");
|
||||
|
||||
Publish(publishedId);
|
||||
|
||||
var posts = await WithServiceAsync(async svc =>
|
||||
{
|
||||
var result = await svc.Index(Anonymous(), id: "", skip: 0, take: 25);
|
||||
return result.OfType<BlogPost>().ToArray();
|
||||
});
|
||||
|
||||
var listed = Assert.Single(posts);
|
||||
Assert.Equal("Published for all", listed.Title);
|
||||
// The service serves IsPublished from the
|
||||
// blogSpotPublications table — the anonymous branch does
|
||||
// not Include the Publication navigation, so this proves
|
||||
// the value is hydrated by the service, not derived from
|
||||
// the EF graph.
|
||||
Assert.True(listed.IsPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Index_serves_IsPublished_false_for_a_draft_for_anonymous_access()
|
||||
{
|
||||
// Anonymous viewers never receive a draft (the branch
|
||||
// queries blogSpotPublications), so this is checked through
|
||||
// the authenticated branch: the service must still serve
|
||||
// IsPublished == false for a post with no publication row,
|
||||
// not the default of a freshly loaded entity.
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
var draftId = _fixture.SeedBlogPost("alice", "Draft, not published");
|
||||
|
||||
var viewer = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[new Claim("sub", "alice")], authenticationType: "Bearer"));
|
||||
|
||||
var posts = await WithServiceAsync(async svc =>
|
||||
{
|
||||
var result = await svc.Index(viewer, id: "", skip: 0, take: 25);
|
||||
return result.OfType<BlogPost>().ToArray();
|
||||
});
|
||||
|
||||
var draft = Assert.Single(posts);
|
||||
Assert.Equal(draftId, draft.Id);
|
||||
Assert.False(draft.IsPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Index_does_not_list_an_unpublished_post_for_anonymous_access()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
var publishedId = _fixture.SeedBlogPost("alice", "Published for all");
|
||||
_fixture.SeedBlogPost("alice", "Draft, not published");
|
||||
|
||||
// Only the first post is published; the draft has no
|
||||
// BlogSpotPublication row.
|
||||
Publish(publishedId);
|
||||
|
||||
var titles = await WithServiceAsync(async svc =>
|
||||
{
|
||||
var result = await svc.Index(Anonymous(), id: "", skip: 0, take: 25);
|
||||
return result.Select(p => p.Title).ToArray();
|
||||
});
|
||||
|
||||
Assert.Contains("Published for all", titles);
|
||||
Assert.DoesNotContain("Draft, not published", titles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Index_returns_an_empty_list_for_anonymous_access_when_nothing_is_published()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
// A draft with no publication row: invisible to anonymous.
|
||||
_fixture.SeedBlogPost("alice", "Draft, not published");
|
||||
|
||||
var result = await WithServiceAsync(svc =>
|
||||
svc.Index(Anonymous(), id: "", skip: 0, take: 25));
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
199
src/test/Yavsc.Blogs.Tests/CircleMembersApiTests.cs
Normal file
199
src/test/Yavsc.Blogs.Tests/CircleMembersApiTests.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Relationship;
|
||||
using Yavsc.Tests.Shared;
|
||||
using static Yavsc.Constants;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioural tests for the circle-members endpoints on
|
||||
/// <c>CircleApiController</c>:
|
||||
/// <c>GET /api/circle/{id}/members</c>,
|
||||
/// <c>POST /api/circle/{id}/members</c>,
|
||||
/// <c>DELETE /api/circle/{id}/members/{userId}</c>.
|
||||
///
|
||||
/// <para>Same fixture as <see cref="BlogApiTests"/>:
|
||||
/// <see cref="BlogsWebServerFixture"/> provides an in-memory
|
||||
/// <c>ApplicationDbContext</c>, JWT bearer auth with HS256,
|
||||
/// and the production <c>BlogScope</c> policy. Tests use
|
||||
/// <c>TestTokenIssuer</c> to mint tokens whose <c>sub</c>
|
||||
/// claim identifies the caller.</para>
|
||||
///
|
||||
/// <para>Test users (<c>alice</c>, <c>bob</c>) are seeded
|
||||
/// directly via <see cref="ApplicationDbContext.Users"/>:
|
||||
/// the Blogs fixture doesn't stand up
|
||||
/// <c>UserManager<ApplicationUser></c>, so we go
|
||||
/// through the DbContext the same way the production code
|
||||
/// would.</para>
|
||||
/// </summary>
|
||||
[Collection("JwtClaimMapping")]
|
||||
public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
||||
public CircleMembersApiTests(BlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>Reset the in-memory database and seed
|
||||
/// <c>alice</c> + <c>bob</c>. <c>UseInMemoryDatabase</c>
|
||||
/// shares its store across the fixture lifetime, so each
|
||||
/// test starts from a clean slate.</summary>
|
||||
private void ResetDatabaseWithUsers()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.Database.EnsureDeleted();
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
db.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "alice",
|
||||
UserName = "alice",
|
||||
Email = "alice@example.com",
|
||||
EmailConfirmed = true,
|
||||
FullName = "Alice Dupont",
|
||||
Avatar = "/avatars/alice.png",
|
||||
});
|
||||
db.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "bob",
|
||||
UserName = "bob",
|
||||
Email = "bob@example.com",
|
||||
EmailConfirmed = true,
|
||||
FullName = "Bob Martin",
|
||||
Avatar = "/avatars/bob.png",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>Create a circle owned by <paramref name="ownerId"/>
|
||||
/// directly in the in-memory store and return its server-assigned
|
||||
/// id. The tests below use this to bypass the controller's POST
|
||||
/// (which is already covered by other tests on the branch);
|
||||
/// the focus here is the members endpoints.</summary>
|
||||
private long SeedCircle(string ownerId, string name)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var circle = new Circle { OwnerId = ownerId, Name = name };
|
||||
db.Circle.Add(circle);
|
||||
db.SaveChanges();
|
||||
return circle.Id;
|
||||
}
|
||||
|
||||
private string MembersUrl(long circleId)
|
||||
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/circle/{circleId}/members";
|
||||
|
||||
private HttpClient NewClient(string subject)
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
var http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||
};
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||
"Bearer", TestTokenIssuer.Issue(subject));
|
||||
return http;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMembers_returns_200_with_empty_list_when_no_members()
|
||||
{
|
||||
ResetDatabaseWithUsers();
|
||||
var circleId = SeedCircle("alice", "Famille");
|
||||
using var http = NewClient("alice");
|
||||
|
||||
var response = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostMember_returns_201_then_Get_returns_the_member()
|
||||
{
|
||||
ResetDatabaseWithUsers();
|
||||
var circleId = SeedCircle("alice", "Famille");
|
||||
using var http = NewClient("alice");
|
||||
|
||||
var postResponse = await http.PostAsJsonAsync(
|
||||
MembersUrl(circleId),
|
||||
new { userId = "bob" }, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||
|
||||
var getResponse = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
var member = doc.RootElement[0];
|
||||
Assert.Equal("bob", member.GetProperty("id").GetString());
|
||||
Assert.Equal("bob", member.GetProperty("userName").GetString());
|
||||
Assert.Equal("Bob Martin", member.GetProperty("fullName").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostMember_returns_409_when_user_already_in_circle()
|
||||
{
|
||||
ResetDatabaseWithUsers();
|
||||
var circleId = SeedCircle("alice", "Famille");
|
||||
using var http = NewClient("alice");
|
||||
|
||||
var first = await http.PostAsJsonAsync(
|
||||
MembersUrl(circleId),
|
||||
new { userId = "bob" }, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
|
||||
var second = await http.PostAsJsonAsync(
|
||||
MembersUrl(circleId),
|
||||
new { userId = "bob" }, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteMember_returns_200_then_Get_does_not_include_member()
|
||||
{
|
||||
ResetDatabaseWithUsers();
|
||||
var circleId = SeedCircle("alice", "Famille");
|
||||
using var http = NewClient("alice");
|
||||
|
||||
await http.PostAsJsonAsync(MembersUrl(circleId), new { userId = "bob" }, TestContext.Current.CancellationToken);
|
||||
|
||||
var deleteResponse = await http.DeleteAsync(
|
||||
$"{MembersUrl(circleId)}/bob", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
|
||||
|
||||
var getResponse = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
|
||||
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMembers_returns_404_when_circle_not_owned_by_caller()
|
||||
{
|
||||
ResetDatabaseWithUsers();
|
||||
// Alice's circle, Bob tries to read its members.
|
||||
var circleId = SeedCircle("alice", "Famille");
|
||||
using var http = NewClient("bob");
|
||||
|
||||
var response = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
|
||||
|
||||
// 404, not 403 — the controller deliberately avoids leaking
|
||||
// the existence of someone else's circle.
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
255
src/test/Yavsc.Blogs.Tests/CreateMultipartContentTests.cs
Normal file
255
src/test/Yavsc.Blogs.Tests/CreateMultipartContentTests.cs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Yavsc.Blogs.Tests.Fixtures;
|
||||
using BlogPostDto = Yavsc.Blogspot.BlogPostDto;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Direct tests for <see cref="BlogApiClient.CreateMultipartContent"/>
|
||||
/// — the body builder PostIt runs when a post is saved with
|
||||
/// attachments. Two layers:
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item><description><b>Shape</b>: call the method with a sample
|
||||
/// <see cref="BlogPostDto"/> + a couple of
|
||||
/// <see cref="BlogUploadFile"/>s and assert the produced
|
||||
/// <see cref="MultipartFormDataContent"/> carries a <c>blog</c>
|
||||
/// part (camelCase JSON, <c>WhenWritingNull</c> so unset graph
|
||||
/// members are dropped) and one <c>file</c> part per attachment
|
||||
/// (right filename, right bytes, declared Content-Type, and the
|
||||
/// <c>application/octet-stream</c> fallback when the type is
|
||||
/// unset). This is the contract the controller's
|
||||
/// <c>ReadBlogRequestAsync</c> + <c>AttachFiles</c> rely on; if
|
||||
/// the wire shape drifts, the round-trip below fails first, but
|
||||
/// the shape assertions pin down <i>which</i> field broke.</description></item>
|
||||
/// <item><description><b>Round-trip</b>: POST the very content the
|
||||
/// method built to the live <see cref="BlogsWebServerFixture"/>
|
||||
/// host (authenticated as <c>tester</c>) and assert 201 Created
|
||||
/// + the attachment row persisted + the file physically landed
|
||||
/// under <c>{BlogFilesRoot}/tester/blogs/{id}/{fileName}</c>.
|
||||
/// This proves the shape the method produces is one the server
|
||||
/// actually accepts — not just well-formed multipart, but the
|
||||
/// contract <c>BlogApiController.PostBlog</c> binds.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// Built on <see cref="BlogsWebServerFixture"/> for the round-trip
|
||||
/// leg (real SQLite FK enforcement, real <c>BlogSpotService</c>,
|
||||
/// real <c>AddJwtBearer</c> validating <see cref="TestTokenIssuer"/>
|
||||
/// tokens). The shape leg needs no host, but the class is
|
||||
/// <c>[Collection("Yavsc Blogs")]</c> + <c>IClassFixture</c> so the
|
||||
/// one shared host serves both legs without spinning a second one.
|
||||
/// </summary>
|
||||
[Collection("Yavsc Blogs")]
|
||||
public sealed class CreateMultipartContentTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
||||
public CreateMultipartContentTests(BlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>Reset the database and seed the
|
||||
/// <c>tester</c> <see cref="ApplicationUser"/> row — required
|
||||
/// for the round-trip POST, whose <c>BlogPost.AuthorId</c> is
|
||||
/// a FK to <c>AspNetUsers.Id</c> that SQLite enforces. See
|
||||
/// <see cref="BlogApiTests.ResetAndSeedDefaultUser"/> for the
|
||||
/// same rationale.</summary>
|
||||
private void ResetAndSeedDefaultUser()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("tester");
|
||||
}
|
||||
|
||||
private HttpClient NewClient(string subject = "tester")
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
var http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||
};
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject));
|
||||
return http;
|
||||
}
|
||||
|
||||
private int CountAttachmentsForPost(long postId)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
return db.BlogAttachedFiles.Count(a => a.PostId == postId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_produces_blog_part_camelCase_with_WhenWritingNull_and_one_file_part_per_attachment()
|
||||
{
|
||||
// A DTO that exercises both branches of the serialiser:
|
||||
// filled scalar fields (title, article, id, dates, authorId)
|
||||
// must come through in camelCase; unset graph members
|
||||
// (Author, Tags, Photo) must be dropped by
|
||||
// WhenWritingNull — the controller's
|
||||
// ReadBlogRequestAsync would otherwise receive explicit
|
||||
// nulls that confuse the resource-based ownership check.
|
||||
var post = new BlogPostDto
|
||||
{
|
||||
Id = 42,
|
||||
AuthorId = "tester",
|
||||
Author = null,
|
||||
Title = "Billet multipart",
|
||||
Article = "Contenu du billet.",
|
||||
Photo = null,
|
||||
Tags = null,
|
||||
DateCreated = new DateTime(2026, 9, 18, 12, 0, 0, DateTimeKind.Utc),
|
||||
DateModified = new DateTime(2026, 9, 18, 12, 30, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
var noteBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
|
||||
var binBytes = new byte[] { 0x00, 0xFF, 0x42 };
|
||||
var files = new BlogUploadFile[]
|
||||
{
|
||||
new("note.txt", noteBytes, "text/plain"),
|
||||
new("bin.dat", binBytes, null) // no ContentType → octet-stream fallback
|
||||
};
|
||||
|
||||
var content = BlogApiClient.CreateMultipartContent(post, files);
|
||||
|
||||
// --- Top-level shape: a multipart/form-data body. ---
|
||||
var form = Assert.IsAssignableFrom<MultipartFormDataContent>(content);
|
||||
Assert.Equal("multipart/form-data", form.Headers.ContentType?.MediaType);
|
||||
|
||||
// Index parts by their form field name. The "blog" field
|
||||
// is unique; the "file" field repeats once per attachment
|
||||
// (distinguished by ContentDisposition.FileName), so a
|
||||
// flat dictionary would collide — group instead.
|
||||
var byName = form
|
||||
.GroupBy(p => p.Headers.ContentDisposition?.Name?.Trim('"'))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
Assert.Contains("blog", byName.Keys);
|
||||
Assert.Single(byName["blog"]);
|
||||
|
||||
// --- "blog" part: camelCase JSON + WhenWritingNull. ---
|
||||
var blogJson = await byName["blog"][0].ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken);
|
||||
using var doc = JsonDocument.Parse(blogJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// camelCase: the serialiser used JsonNamingPolicy.CamelCase,
|
||||
// so the properties land as title/article/id/dateCreated/….
|
||||
Assert.True(root.TryGetProperty("title", out var titleEl));
|
||||
Assert.Equal(post.Title, titleEl.GetString());
|
||||
Assert.True(root.TryGetProperty("article", out var articleEl));
|
||||
Assert.Equal(post.Article, articleEl.GetString());
|
||||
Assert.True(root.TryGetProperty("id", out var idEl));
|
||||
Assert.Equal(post.Id, idEl.GetInt64());
|
||||
Assert.True(root.TryGetProperty("authorId", out var authorIdEl));
|
||||
Assert.Equal(post.AuthorId, authorIdEl.GetString());
|
||||
Assert.True(root.TryGetProperty("dateCreated", out _));
|
||||
Assert.True(root.TryGetProperty("dateModified", out _));
|
||||
|
||||
// WhenWritingNull: the null graph members are omitted, not
|
||||
// emitted as explicit nulls.
|
||||
Assert.False(root.TryGetProperty("author", out _),
|
||||
$"Expected 'author' to be omitted (WhenWritingNull), got: {blogJson}");
|
||||
Assert.False(root.TryGetProperty("tags", out _),
|
||||
$"Expected 'tags' to be omitted (WhenWritingNull), got: {blogJson}");
|
||||
Assert.False(root.TryGetProperty("photo", out _),
|
||||
$"Expected 'photo' to be omitted (WhenWritingNull), got: {blogJson}");
|
||||
|
||||
// --- "file" parts: one per attachment, right name, type,
|
||||
// and bytes. The "file" form field carries several parts;
|
||||
// collect them by filename. ---
|
||||
var fileParts = form
|
||||
.Where(p => p.Headers.ContentDisposition?.Name?.Trim('"') == "file")
|
||||
.ToDictionary(p => p.Headers.ContentDisposition?.FileName?.Trim('"'));
|
||||
Assert.Equal(2, fileParts.Count);
|
||||
|
||||
var notePart = fileParts["note.txt"];
|
||||
Assert.Equal("text/plain", notePart.Headers.ContentType?.MediaType);
|
||||
Assert.Equal(noteBytes, await notePart.ReadAsByteArrayAsync(
|
||||
TestContext.Current.CancellationToken));
|
||||
|
||||
// The attachment with no ContentType falls back to
|
||||
// application/octet-stream — the branch in
|
||||
// CreateMultipartContent that guards
|
||||
// string.IsNullOrWhiteSpace(file.ContentType).
|
||||
var binPart = fileParts["bin.dat"];
|
||||
Assert.Equal("application/octet-stream", binPart.Headers.ContentType?.MediaType);
|
||||
Assert.Equal(binBytes, await binPart.ReadAsByteArrayAsync(
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_then_POST_round_trips_through_live_host_and_persists_attachment()
|
||||
{
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient(subject: "tester");
|
||||
var filesRoot = _fixture.BlogFilesRoot;
|
||||
|
||||
// Create-shape DTO (Id=0 → the controller treats it as a
|
||||
// create), mirroring what PostIt's MainPageViewModel.Save
|
||||
// builds for a first Save with an attachment.
|
||||
var draft = new BlogPostDto
|
||||
{
|
||||
Id = 0,
|
||||
AuthorId = "tester",
|
||||
Title = "Billet avec pièce jointe",
|
||||
Article = "Contenu du billet multipart.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
var attachments = new BlogUploadFile[]
|
||||
{
|
||||
new("note.txt", System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")
|
||||
};
|
||||
|
||||
// The exact body PostIt would send — built by the method
|
||||
// under test, not hand-rolled here.
|
||||
using var content = BlogApiClient.CreateMultipartContent(draft, attachments);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, _fixture.BlogSpotUrl())
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
var response = await http.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Dump the body on failure so the assertion message is
|
||||
// enough to start a fix (the framework default is opaque).
|
||||
if (response.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Fail(
|
||||
$"Expected 201 Created, got {(int)response.StatusCode} {response.StatusCode}. Body: {body}");
|
||||
}
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
Assert.NotEqual(0, created!.Id);
|
||||
Assert.Equal("tester", created.AuthorId);
|
||||
|
||||
// The attachment row is persisted…
|
||||
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
|
||||
|
||||
// …and the file physically landed where AttachFiles writes
|
||||
// it: {BlogFilesRoot}/{user}/blogs/{postId}/{fileName}.
|
||||
var expectedFile = Path.Combine(
|
||||
filesRoot, "tester", "blogs", created.Id.ToString(), "note.txt");
|
||||
var written = Directory.Exists(filesRoot)
|
||||
? string.Join(", ", Directory.EnumerateFiles(filesRoot, "*", SearchOption.AllDirectories))
|
||||
: "<none>";
|
||||
Assert.True(File.Exists(expectedFile),
|
||||
$"Expected uploaded file at {expectedFile}. Actually written: {written}");
|
||||
}
|
||||
}
|
||||
397
src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs
Normal file
397
src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Blogs.Tests.Fixtures;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Models.Relationship;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the personal-storage file endpoints
|
||||
/// (<c>api/v1/fs</c>) served by <c>FileSystemApiController</c> on the
|
||||
/// Blogs host.
|
||||
///
|
||||
/// <para>Two concerns:</para>
|
||||
/// <list type="number">
|
||||
/// <item><description><b>Upload records metadata</b>: a multipart
|
||||
/// <c>POST api/v1/fs</c> persists an <see cref="UploadedFile"/> row
|
||||
/// owned by the caller and returns its <c>fileId</c>, so the client
|
||||
/// can later attach that file to an estimate/query by
|
||||
/// reference.</description></item>
|
||||
/// <item><description><b>Download authorization</b>:
|
||||
/// <c>GET api/v1/fs/file/{fileId}</c> serves the bytes from the
|
||||
/// owner's personal space. The owner may always read; a party
|
||||
/// (client or provider) of an estimate/query the file is attached
|
||||
/// to may read; a third party is refused. The bytes live on the
|
||||
/// Blogs host's disk — this is the cross-container reason the
|
||||
/// download endpoint lives here and not on the API host.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Collection("Yavsc Blogs")]
|
||||
public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
||||
public FileSystemApiTests(BlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private HttpClient NewClient(string subject = "alice", string? name = null)
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
var http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||
};
|
||||
// Pass a distinct name to mirror production (sub = GUID, name =
|
||||
// login); defaults to subject for the existing tests where the
|
||||
// two are equal.
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, name: name));
|
||||
return http;
|
||||
}
|
||||
|
||||
private static string PhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName) =>
|
||||
Path.Combine(fixture.BlogFilesRoot, owner, fileName);
|
||||
|
||||
/// <summary>Write a physical file into the owner's personal space,
|
||||
/// so <c>GetFile</c> can stream it back. Creates the directory if
|
||||
/// needed.</summary>
|
||||
private static void WritePhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName, byte[] content)
|
||||
{
|
||||
var path = PhysicalFile(fixture, owner, fileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllBytes(path, content);
|
||||
}
|
||||
|
||||
private long SeedUploadedFile(string ownerId, string path, string contentType, long length)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var file = new UploadedFile
|
||||
{
|
||||
OwnerId = ownerId,
|
||||
Path = path,
|
||||
ContentType = contentType,
|
||||
Length = length,
|
||||
};
|
||||
db.UploadedFiles.Add(file);
|
||||
db.SaveChanges();
|
||||
return file.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
|
||||
// once usage >= DiskQuota, and the controller skips the metadata
|
||||
// row when QuotaOffense is set. A fresh user has quota 0.
|
||||
_fixture.SeedUser("alice", u => u.DiskQuota = 10_000_000);
|
||||
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes("hello personal storage");
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(payload);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||
form.Add(fileContent, "file", "note.txt");
|
||||
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var item = Assert.Single(doc.RootElement.EnumerateArray());
|
||||
var fileId = item.GetProperty("fileId").GetInt64();
|
||||
Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
|
||||
Assert.Equal("note.txt", item.GetProperty("fileName").GetString());
|
||||
Assert.False(item.GetProperty("quotaOffense").GetBoolean());
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
|
||||
Assert.Equal("note.txt", row.Path);
|
||||
Assert.Equal("text/plain", row.ContentType);
|
||||
Assert.Equal(payload.Length, row.Length);
|
||||
|
||||
// The file physically landed under {BlogFilesRoot}/alice/note.txt.
|
||||
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
|
||||
"Expected the uploaded file on disk under the owner's personal root");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for the "Mes fichiers" page listing nothing: the
|
||||
/// personal-storage tree is keyed by the user's <b>login</b>
|
||||
/// (<c>UserName</c>) — <c>EnsureDestinationDirectory</c> writes
|
||||
/// under <c>{Blog}/{name-claim}</c>, the download endpoint and
|
||||
/// avatar paths use <c>user.UserName</c> — but <c>GET api/v1/fs</c>
|
||||
/// passed <c>User.GetUserId()</c> (the <c>sub</c> claim, a GUID in
|
||||
/// production) to <c>GetUserFiles</c>, so the listing looked under
|
||||
/// <c>{Blog}/{sub-GUID}</c>, an empty tree. This test decouples
|
||||
/// <c>sub</c> from the login (mirroring production) and asserts the
|
||||
/// listing returns the file stored under the login.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Get_fs_lists_files_stored_under_the_user_login()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
// sub = "alice-sub" (GUID-like), login = "alice" — the shape
|
||||
// ProfileService mints in production. DiskQuota non-zero so the
|
||||
// upload persists the UploadedFile row (see Post_fs test).
|
||||
_fixture.SeedUser("alice", u =>
|
||||
{
|
||||
u.Id = "alice-sub";
|
||||
u.DiskQuota = 10_000_000;
|
||||
});
|
||||
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes("list me");
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(payload);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||
form.Add(fileContent, "file", "note.txt");
|
||||
|
||||
// Upload with a token whose sub is the GUID-like id and whose
|
||||
// name is the login — EnsureDestinationDirectory keys off "name".
|
||||
using (var http = NewClient("alice-sub", name: "alice"))
|
||||
{
|
||||
var post = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
|
||||
var postBody = await post.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(post.StatusCode == HttpStatusCode.OK,
|
||||
$"Upload failed: {(int)post.StatusCode} ({post.StatusCode}): {postBody}");
|
||||
}
|
||||
|
||||
// The file landed under the login, not the subject.
|
||||
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
|
||||
"Expected the uploaded file under {BlogFilesRoot}/alice/note.txt (the login)");
|
||||
Assert.False(Directory.Exists(Path.Combine(_fixture.BlogFilesRoot, "alice-sub")),
|
||||
"The subject-named directory must NOT exist — storage is keyed by the login");
|
||||
|
||||
// GET /api/v1/fs must list under the login and find the file.
|
||||
using var get = NewClient("alice-sub", name: "alice");
|
||||
var response = await get.GetAsync("/api/v1/fs", TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||
$"List failed: {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.True(doc.RootElement.TryGetProperty("files", out var filesEl),
|
||||
$"Response has no 'files' property. Body: {body}");
|
||||
var names = filesEl.EnumerateArray().Select(f => f.GetProperty("name").GetString()).ToArray();
|
||||
Assert.Contains("note.txt", names);
|
||||
|
||||
// The UploadedFile row (OwnerId = sub) is enriched onto the
|
||||
// entry, so the client can attach the file by reference.
|
||||
var noteEntry = filesEl.EnumerateArray()
|
||||
.First(f => f.GetProperty("name").GetString() == "note.txt");
|
||||
Assert.True(noteEntry.TryGetProperty("id", out var idEl) && idEl.GetInt64() > 0,
|
||||
"Expected the enriched UploadedFile id on the listed entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFile_owner_can_download_their_own_file()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes("owner payload");
|
||||
WritePhysicalFile(_fixture, "alice", "owner-note.txt", bytes);
|
||||
var fileId = SeedUploadedFile("alice", "owner-note.txt", "text/plain", bytes.Length);
|
||||
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.GetAsync(
|
||||
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(bytes, downloaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFile_refuses_a_third_party()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("carol");
|
||||
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes("private to alice");
|
||||
WritePhysicalFile(_fixture, "alice", "secret.txt", bytes);
|
||||
var fileId = SeedUploadedFile("alice", "secret.txt", "text/plain", bytes.Length);
|
||||
|
||||
// carol is neither the owner nor a party to any estimate/query
|
||||
// this file is attached to.
|
||||
using var http = NewClient("carol");
|
||||
var response = await http.GetAsync(
|
||||
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed the graph needed to prove a party (the client of an
|
||||
/// estimate the file is attached to) can download a file they do
|
||||
/// not own: alice owns the file and is the provider; bob is the
|
||||
/// client; the file is attached to the estimate by reference.
|
||||
/// </summary>
|
||||
private long SeedPartyDownloadGraph(out long estimateId, out string fileName)
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("bob");
|
||||
|
||||
fileName = "party-spec.pdf";
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes("party payload");
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
// Location + PerformerProfile(alice): Estimate.OwnerId is a FK
|
||||
// to PerformerProfile.PerformerId, so the provider profile must
|
||||
// exist before the estimate can be inserted.
|
||||
var location = new Location { Address = "1 rue du Test", Latitude = 48.8, Longitude = 2.3 };
|
||||
db.Locations.Add(location);
|
||||
db.SaveChanges();
|
||||
|
||||
db.Performers.Add(new PerformerProfile
|
||||
{
|
||||
PerformerId = "alice",
|
||||
SIREN = "12345678901234",
|
||||
OrganizationAddressId = location.Id,
|
||||
ExerciseCountryCode = "fr",
|
||||
AcceptNotifications = true,
|
||||
AcceptPublicContact = true,
|
||||
Active = true,
|
||||
Rate = 5,
|
||||
WebSite = "https://alice.dev",
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var estimate = new Estimate
|
||||
{
|
||||
OwnerId = "alice",
|
||||
ClientId = "bob",
|
||||
CommandType = "Rdv",
|
||||
Title = "Devis party test",
|
||||
Description = "Devis",
|
||||
AttachedFiles = new List<string>(),
|
||||
AttachedGraphics = new List<string>(),
|
||||
Bill = new List<CommandLine>
|
||||
{
|
||||
new() { Name = "Prestation", Description = "Prestation", Count = 1, UnitaryCost = 10m, Currency = "EUR" },
|
||||
},
|
||||
};
|
||||
db.Estimates.Add(estimate);
|
||||
db.SaveChanges();
|
||||
estimateId = estimate.Id;
|
||||
|
||||
var file = new UploadedFile
|
||||
{
|
||||
OwnerId = "alice",
|
||||
Path = fileName,
|
||||
ContentType = "application/pdf",
|
||||
Length = bytes.Length,
|
||||
};
|
||||
db.UploadedFiles.Add(file);
|
||||
db.SaveChanges();
|
||||
|
||||
db.EstimateAttachedFiles.Add(new EstimateAttachedFile
|
||||
{
|
||||
FileId = file.Id,
|
||||
EstimateId = estimate.Id,
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
// The bytes must exist on disk for GetFile to stream them.
|
||||
WritePhysicalFile(_fixture, "alice", fileName, bytes);
|
||||
return file.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFile_party_client_of_an_attached_estimate_can_download()
|
||||
{
|
||||
var fileId = SeedPartyDownloadGraph(out _, out _);
|
||||
|
||||
// bob is the client of the estimate the file is attached to:
|
||||
// a party, authorized to read even though alice owns the file.
|
||||
using var http = NewClient("bob");
|
||||
var response = await http.GetAsync(
|
||||
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(System.Text.Encoding.UTF8.GetBytes("party payload"), downloaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFile_party_provider_of_an_attached_estimate_can_download()
|
||||
{
|
||||
var fileId = SeedPartyDownloadGraph(out _, out _);
|
||||
|
||||
// alice is the owner too, so this also exercises the owner path;
|
||||
// the provider of an attached estimate is authorized regardless.
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.GetAsync(
|
||||
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFile_unknown_id_returns_404()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.GetAsync(
|
||||
"/api/v1/fs/file/999999", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for the "delete returns 40x yet the file is gone"
|
||||
/// report: <c>DeleteUserDirOrFile</c> read <c>FileInfo.Length</c>
|
||||
/// <b>after</b> <c>fi.Delete()</c>, and <c>FileInfo.Length</c>
|
||||
/// throws <c>FileNotFoundException</c> once the file no longer
|
||||
/// exists. The controller's <c>catch</c> turned that into a 400
|
||||
/// even though the file had already been removed — the user saw
|
||||
/// "le fichier n'existe pas sur le serveur" while the delete in
|
||||
/// fact succeeded. This test asserts a clean 200 and that the
|
||||
/// file is gone from disk.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Delete_fs_removes_the_file_and_returns_200()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes("delete me");
|
||||
WritePhysicalFile(_fixture, "alice", "doomed.txt", bytes);
|
||||
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.DeleteAsync(
|
||||
"/api/v1/fs/doomed.txt", TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
Assert.False(File.Exists(PhysicalFile(_fixture, "alice", "doomed.txt")),
|
||||
"The file must be gone from disk after a successful delete.");
|
||||
}
|
||||
}
|
||||
416
src/test/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs
Normal file
416
src/test/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Yavsc.Blogs.Controllers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Models.Relationship;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Tests.Shared;
|
||||
using static Yavsc.Constants;
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared integration-test host for the Yavsc.Blogs API surface.
|
||||
/// Specialisation of <see cref="WebHostFixture"/> that wires up
|
||||
/// only the bits the blog API actually depends on:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A SQLite <c>:memory:</c> database
|
||||
/// (<see cref="Microsoft.EntityFrameworkCore.Sqlite"/>) backed
|
||||
/// by a single shared <see cref="SqliteConnection"/> held open
|
||||
/// for the lifetime of the host. SQLite enforces real foreign
|
||||
/// keys and real transactional semantics, so the tests see the
|
||||
/// same INSERT-time FK validation a production Postgres host
|
||||
/// would — unlike the EF Core InMemory provider, which silently
|
||||
/// ignores FKs and masks bugs that surface only against a real
|
||||
/// relational engine.</description></item>
|
||||
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
|
||||
/// stub: the GET index path doesn't read the file system, so any
|
||||
/// implementation is fine.</description></item>
|
||||
/// <item><description>The real <c>BlogSpotService</c>, which calls
|
||||
/// <c>IAuthorizationService.AuthorizeAsync(user, blog, new EditPermission())</c>
|
||||
/// on PUT. The fixture registers the real
|
||||
/// <see cref="PermissionHandler"/> so the resource-based ownership
|
||||
/// check runs end-to-end; tests that want a 204 PUT must sign a
|
||||
/// JWT whose <c>sub</c> matches the post's <c>AuthorId</c>.</description></item>
|
||||
/// <item><description>A real <c>AddJwtBearer</c> with HS256,
|
||||
/// sharing its <see cref="TestTokenIssuer.SigningKey"/> with the
|
||||
/// token issuer. The production OIDC discovery path is bypassed:
|
||||
/// the test host validates tokens locally, against the static
|
||||
/// signing key, so no IdP is required to exercise auth.</description></item>
|
||||
/// <item><description>The production <c>BlogScope</c> policy
|
||||
/// (RequireAuthenticatedUser + RequireClaim("scope", "blogs"))
|
||||
/// registered verbatim. Tests that omit the bearer header exercise
|
||||
/// the unauthenticated path and get 401.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// No IdentityServer, no SMTP, no static assets — the Org fixture
|
||||
/// owns all of that and we don't need any of it for blog integration
|
||||
/// tests. Marked <see cref="CollectionDefinitionAttribute"/> so the
|
||||
/// host is shared across every <c>[Collection("Yavsc Blogs")]</c>
|
||||
/// test class: one host, one SQLite DB, one Kestrel port.
|
||||
/// </summary>
|
||||
[CollectionDefinition("Yavsc Blogs")]
|
||||
public sealed class BlogsWebServerFixture : WebHostFixture
|
||||
{
|
||||
protected override int HttpsPort => 5103;
|
||||
|
||||
public long CircleId { get; private set; }
|
||||
public long PostId { get; private set; }
|
||||
public string DefaultUserLogin { get => "alice"; }
|
||||
|
||||
/// <summary>Root directory under which
|
||||
/// <c>BlogSpotService.AttachFiles</c> writes uploaded files
|
||||
/// during tests (the configured <c>SiteSettings.Blog</c>).
|
||||
/// Attachments land at
|
||||
/// <c>{BlogFilesRoot}/{userName}/blogs/{postId}/{fileName}</c>.
|
||||
/// A fresh per-run temp directory, so tests can assert on the
|
||||
/// physical file without touching the developer's real
|
||||
/// user-files tree.</summary>
|
||||
public string BlogFilesRoot { get; private set; } = "files-root";
|
||||
|
||||
// A single SqliteConnection held open at the static level,
|
||||
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
|
||||
// shared configuration into static slots. Closing the
|
||||
// connection destroys the in-memory database — so we close
|
||||
// it only when the last fixture instance is disposed (see
|
||||
// Dispose below), exactly when WebHostFixture tears down the
|
||||
// host.
|
||||
private static SqliteConnection? _sharedSqliteConnection;
|
||||
private static readonly object _sqliteLock = new();
|
||||
|
||||
protected override WebApplication BuildApp(WebApplicationBuilder builder)
|
||||
{
|
||||
// Open the shared in-memory connection lazily on the first
|
||||
// fixture construction. Subsequent constructions (xUnit
|
||||
// creates one fixture instance per IClassFixture) reuse
|
||||
// the same connection so all DbContexts across all tests
|
||||
// see the same database.
|
||||
SqliteConnection sharedConnection;
|
||||
lock (_sqliteLock)
|
||||
{
|
||||
if (_sharedSqliteConnection is null)
|
||||
{
|
||||
// Mode=Memory + Cache=Shared gives us a named
|
||||
// in-memory database that every connection string
|
||||
// referencing "File:YavscBlogsTests?mode=memory&cache=shared"
|
||||
// will resolve to the same backing store, as long
|
||||
// as at least one SqliteConnection stays open
|
||||
// against it.
|
||||
_sharedSqliteConnection = new SqliteConnection(
|
||||
"Data Source=YavscBlogsTests;Mode=Memory;Cache=Shared");
|
||||
_sharedSqliteConnection.Open();
|
||||
}
|
||||
sharedConnection = _sharedSqliteConnection;
|
||||
}
|
||||
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
|
||||
// UseSqlite(DbConnection) keeps the connection we just
|
||||
// opened alive for the DbContext's lifetime, instead of
|
||||
// letting EF open and close its own. Without this,
|
||||
// each DbContext would get a fresh connection pointing
|
||||
// at an empty :memory: store and nothing would persist
|
||||
// across requests.
|
||||
opt.UseSqlite(sharedConnection));
|
||||
|
||||
// Trivial file-system auth: the GET index path never calls
|
||||
// into it, but the DI container needs an instance.
|
||||
builder.Services.AddSingleton<IFileSystemAuthManager>(
|
||||
new NoopFileSystemAuthManager());
|
||||
|
||||
// BlogSpotService.AttachFiles writes uploaded files under
|
||||
// siteSettings.Blog. Without a configured SiteSettings the
|
||||
// IOptions<SiteSettings> value has Blog == null and
|
||||
// Path.Combine(null, …) throws inside AttachFiles' catch-all,
|
||||
// silently persisting no attachment. Point Blog at a
|
||||
// per-run temp directory so the multipart tests can assert
|
||||
// on real files.
|
||||
builder.Services.Configure<SiteSettings>(s =>
|
||||
{
|
||||
s.Blog = BlogFilesRoot;
|
||||
});
|
||||
|
||||
// Real BlogSpotService — same instance the production host
|
||||
// builds (ApplicationDbContext, IAuthorizationService,
|
||||
// IFileSystemAuthManager). With PermissionHandler registered
|
||||
// below, Modify() now answers "is the caller the author of
|
||||
// the post?" for real, which is exactly what we want to
|
||||
// assert in the PUT tests.
|
||||
builder.Services.AddScoped<BlogSpotService>();
|
||||
|
||||
// The real PermissionHandler: BlogSpotService calls
|
||||
// IAuthorizationService.AuthorizeAsync(user, blog, new
|
||||
// EditPermission()) on Modify, and PermissionHandler
|
||||
// resolves it via IsOwner(user, blog) — i.e. blog.AuthorId
|
||||
// == user.GetUserId(). To PUT a post, the test JWT must
|
||||
// carry sub == post.AuthorId.
|
||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||
|
||||
// The BlogApiController is reached through MVC. AddControllers()
|
||||
// by default scans the test assembly only; we explicitly add the
|
||||
// Yavsc.Blogs application part so the controller is discovered
|
||||
// and routed.
|
||||
builder.Services.AddControllers()
|
||||
.AddApplicationPart(typeof(BlogApiController).Assembly);
|
||||
|
||||
// Production BlogScope policy, verbatim. Two requirements:
|
||||
// 1. RequireAuthenticatedUser: a request with no bearer
|
||||
// token (or an invalid one) will be rejected.
|
||||
// 2. RequireClaim("scope", "blogs"): the JWT must carry a
|
||||
// "scope" claim whose value is "blogs".
|
||||
// TestTokenIssuer.Issue() defaults to scope=blogs; the
|
||||
// GetBlog_returns_401_when_no_token test omits the token
|
||||
// entirely and asserts the policy fails closed.
|
||||
builder.Services.AddAuthorization(opt =>
|
||||
{
|
||||
opt.AddPolicy("BlogScope", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser()
|
||||
.RequireClaim("scope", "blogs");
|
||||
});
|
||||
});
|
||||
|
||||
// Real JWT Bearer authentication, sharing the signing key
|
||||
// with TestTokenIssuer. No Authority → no OIDC discovery,
|
||||
// no IdP roundtrip; the middleware validates the signature
|
||||
// and the standard claims against the static configuration
|
||||
// below. Production uses AddYavscJwtBearer with an IdP, but
|
||||
// for the unit-test host that path is unwanted coupling.
|
||||
builder.Services.AddAuthentication("Bearer")
|
||||
.AddJwtBearer("Bearer", options =>
|
||||
{
|
||||
options.IncludeErrorDetails = true;
|
||||
// MapInboundClaims = false here mirrors the
|
||||
// JwtSecurityTokenHandler.DefaultInboundClaimTypeMap
|
||||
// .Clear() in TestTokenIssuer: the validation
|
||||
// pipeline must not rewrite "sub" to
|
||||
// ClaimTypes.NameIdentifier, otherwise the
|
||||
// PermissionHandler ownership check sees a null
|
||||
// user id and rejects every PUT.
|
||||
options.MapInboundClaims = false;
|
||||
options.TokenValidationParameters
|
||||
= new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = TestTokenIssuer.Issuer,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = TestTokenIssuer.SigningKey,
|
||||
// "sub" stays "sub" (MapInboundClaims only
|
||||
// remaps long Microsoft claim URIs, not sub).
|
||||
// UserHelpers.GetUserId reads sub directly.
|
||||
NameClaimType = "sub",
|
||||
RoleClaimType = Yavsc.Constants.RoleClaimType,
|
||||
};
|
||||
});
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
|
||||
{
|
||||
// UseDeveloperExceptionPage gives full stack traces on
|
||||
// 500s during tests — much easier to debug than the
|
||||
// default empty InternalServerError body. Production
|
||||
// (Yavsc.Org) wires its own exception handler; this
|
||||
// fixture is test-only.
|
||||
app.UseDeveloperExceptionPage();
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
// EnsureCreated + seed alice, run once at host startup.
|
||||
// EnsureCreated is idempotent (creates only the tables that
|
||||
// don't exist yet) and runs against the shared
|
||||
// SqliteConnection (Cache=Shared), so every DbContext that
|
||||
// resolves through this fixture's host sees the same schema.
|
||||
// We do NOT call EnsureDeleted: the SqliteConnection is held
|
||||
// open at the static level and closing it destroys the
|
||||
// :memory: store for every other DbContext — the org
|
||||
// fixture can afford EnsureDeleted because its store is
|
||||
// built fresh per fixture, but the blogs fixture's static
|
||||
// connection outlives a single fixture instance.
|
||||
using (var seedScope = app.Services.CreateScope())
|
||||
{
|
||||
var db = seedScope.ServiceProvider
|
||||
.GetRequiredService<ApplicationDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
if (!db.Users.Any(u => u.Id == "alice"))
|
||||
{
|
||||
db.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "alice",
|
||||
UserName = "alice",
|
||||
Email = "alice@example.com",
|
||||
EmailConfirmed = true,
|
||||
FullName = "Alice Dupont",
|
||||
Avatar = "/avatars/alice.png",
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
// Inline the seed of the circle + post. We don't
|
||||
// call SeedCircle/SeedBlogPost (the instance helpers)
|
||||
// because those resolve through this.Services, which
|
||||
// is null until WebHostFixture.InitializeAsync has
|
||||
// finished wiring the shared slot — i.e. after this
|
||||
// method returns. Use app.Services directly.
|
||||
var circle = new Circle
|
||||
{
|
||||
OwnerId = "alice",
|
||||
Name = "test",
|
||||
Public = true,
|
||||
};
|
||||
db.Circle.Add(circle);
|
||||
db.SaveChanges();
|
||||
CircleId = circle.Id;
|
||||
|
||||
var post = new BlogPost
|
||||
{
|
||||
AuthorId = "alice",
|
||||
Title = "Billet ACL test",
|
||||
Article = "Test article body.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
db.BlogSpot.Add(post);
|
||||
db.SaveChanges();
|
||||
PostId = post.Id;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
return app;
|
||||
}
|
||||
/// <summary>Reset the in-memory database to a known empty state.
|
||||
/// <c>UseInMemoryDatabase</c> shares its store across the
|
||||
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance,
|
||||
/// so without a per-test reset the test order would leak
|
||||
/// state between tests.</summary>
|
||||
public void ResetDatabase()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.Database.EnsureDeleted();
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
public void CleanupAcl()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.CircleAuthorizationToBlogPost
|
||||
.Where(a => a.CircleId == CircleId
|
||||
&& a.BlogPostId == PostId)
|
||||
.ExecuteDelete();
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
// Keep the shared in-memory SQLite connection alive for the
|
||||
// whole test process. Closing it from one fixture instance can
|
||||
// destroy the database while other collections are still using
|
||||
// it, which surfaces as intermittent "no such table" failures.
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Seed an <see cref="ApplicationUser"/> in the shared
|
||||
/// SQLite store, so tests that POST/PUT/DELETE a
|
||||
/// <c>BlogPost</c> (whose <c>AuthorId</c> is a FK to
|
||||
/// <c>AspNetUsers.Id</c>) don't trip the FK constraint that
|
||||
/// SQLite enforces but the EF Core InMemory provider silently
|
||||
/// ignored. Idempotent on <paramref name="userName"/>: a
|
||||
/// second call for the same id is a no-op (the user already
|
||||
/// exists).</summary>
|
||||
/// <param name="userName">Both the PK id and the login name.
|
||||
/// The JWT subject in tests is this same string, so seeding
|
||||
/// this id is enough to make the FK from a
|
||||
/// <c>BlogPost.AuthorId</c> resolve.</param>
|
||||
/// <param name="configure">Optional hook to fill in fields
|
||||
/// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c>
|
||||
/// that downstream tests assert on.</param>
|
||||
public ApplicationUser SeedUser(string userName,
|
||||
Action<ApplicationUser>? configure = null)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var existing = db.Users.SingleOrDefault(u => u.Id == userName);
|
||||
if (existing != null) return existing;
|
||||
|
||||
// Email is an alternate key on ApplicationUser; seeding
|
||||
// it explicitly avoids the InMemory provider's null-claim
|
||||
// tracking quirk (cf. PublishEndpointTests.ResetDatabase)
|
||||
// and keeps the column shape realistic for prod.
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = userName,
|
||||
UserName = userName,
|
||||
Email = $"{userName}@example.test",
|
||||
};
|
||||
configure?.Invoke(user);
|
||||
db.Users.Add(user);
|
||||
db.SaveChanges();
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>Trivial <see cref="IFileSystemAuthManager"/> stub. The
|
||||
/// blog API endpoints exercised by the first tests don't read the
|
||||
/// file system, so the implementation can be a no-op.</summary>
|
||||
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
|
||||
{
|
||||
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)
|
||||
=> FileAccessRight.None;
|
||||
|
||||
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Create a circle owned by <paramref name="ownerId"/>
|
||||
/// directly in the SQLite store and return its server-assigned
|
||||
/// id.</summary>
|
||||
public long SeedCircle(string ownerId, string name, bool isPublic = false,
|
||||
ICollection<String>? members = null
|
||||
)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic };
|
||||
db.Circle.Add(circle);
|
||||
db.SaveChanges();
|
||||
if (members != null && members.Count > 0)
|
||||
{
|
||||
foreach (String memberId in members)
|
||||
{
|
||||
var member = new CircleMember { CircleId = circle.Id, MemberId = memberId };
|
||||
db.CircleMembers.Add(member);
|
||||
}
|
||||
db.SaveChanges();
|
||||
}
|
||||
return circle.Id;
|
||||
}
|
||||
|
||||
/// <summary>Create a blog post owned by <paramref name="authorId"/>
|
||||
/// directly in the SQLite store and return its server-assigned
|
||||
/// id.</summary>
|
||||
public long SeedBlogPost(string authorId, string title)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var post = new BlogPost
|
||||
{
|
||||
AuthorId = authorId,
|
||||
Title = title,
|
||||
Article = "Test article body.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
db.BlogSpot.Add(post);
|
||||
db.SaveChanges();
|
||||
return post.Id;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Yavsc.Blogs.Controllers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Dedicated integration-test host that mirrors the production JWT
|
||||
/// remapping behavior: MapInboundClaims remains enabled and the
|
||||
/// default inbound map rewrites "sub" to ClaimTypes.NameIdentifier.
|
||||
/// This is the closest in-process reproduction of the production
|
||||
/// authentication surface for the blog API.
|
||||
/// </summary>
|
||||
public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFixture
|
||||
{
|
||||
private readonly InMemoryDatabaseRoot _inMemoryRoot = new();
|
||||
private readonly Dictionary<string, string> _savedInboundMap;
|
||||
private WebApplication? _app = null;
|
||||
|
||||
public MappedClaimsBlogsWebServerFixture()
|
||||
{
|
||||
_savedInboundMap = new Dictionary<string, string>(JwtSecurityTokenHandler.DefaultInboundClaimTypeMap);
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier;
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:5105");
|
||||
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
|
||||
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot));
|
||||
|
||||
builder.Services.AddSingleton<IFileSystemAuthManager>(new NoopFileSystemAuthManager());
|
||||
|
||||
// BlogSpotService's constructor throws InvalidOperationException
|
||||
// ("SiteSettings.Blog is not configured.") when Blog is null, so
|
||||
// every request hitting BlogApiController would 500 resolving the
|
||||
// service. These tests never upload files (JSON-only POST/PUT), so
|
||||
// AttachFiles short-circuits — but the service still has to be
|
||||
// constructible. Point Blog at a per-run temp directory, mirroring
|
||||
// BlogsWebServerFixture.
|
||||
builder.Services.Configure<SiteSettings>(s =>
|
||||
{
|
||||
s.Blog = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
|
||||
});
|
||||
builder.Services.AddScoped<BlogSpotService>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||
builder.Services.AddControllers()
|
||||
.AddApplicationPart(typeof(BlogApiController).Assembly);
|
||||
builder.Services.AddAuthorization(opt =>
|
||||
{
|
||||
opt.AddPolicy("BlogScope", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser()
|
||||
.RequireClaim("scope", "blogs");
|
||||
});
|
||||
});
|
||||
builder.Services.AddAuthentication("Bearer")
|
||||
.AddJwtBearer("Bearer", options =>
|
||||
{
|
||||
options.IncludeErrorDetails = true;
|
||||
options.MapInboundClaims = true;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = TestTokenIssuer.Issuer,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = TestTokenIssuer.SigningKey,
|
||||
RoleClaimType = Yavsc.Constants.RoleClaimType,
|
||||
NameClaimType = Yavsc.Constants.NameClaimType,
|
||||
};
|
||||
});
|
||||
|
||||
_app = builder.Build();
|
||||
_app.UseRouting();
|
||||
_app.UseAuthentication();
|
||||
_app.UseAuthorization();
|
||||
_app.MapControllers();
|
||||
_app.StartAsync().GetAwaiter().GetResult();
|
||||
|
||||
Addresses = ["http://127.0.0.1:5105"];
|
||||
Services = _app.Services;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Addresses { get; }
|
||||
|
||||
public IServiceProvider Services { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_app is null) return;
|
||||
_app.StopAsync().GetAwaiter().GetResult();
|
||||
_app.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
||||
foreach (var kvp in _savedInboundMap)
|
||||
{
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
|
||||
{
|
||||
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)
|
||||
=> FileAccessRight.None;
|
||||
|
||||
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/test/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs
Normal file
93
src/test/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace Yavsc.Blogs.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IYavscApiClient"/> transport for integration
|
||||
/// tests: it forwards <see cref="BlogApiClient"/> calls over a
|
||||
/// test-owned <see cref="HttpClient"/> (already authenticated by
|
||||
/// the caller) and applies the contract documented on the
|
||||
/// interface — JSON bodies serialised with web defaults
|
||||
/// (camelCase, case-insensitive reads), and an
|
||||
/// <see cref="HttpRequestException"/> carrying the response body
|
||||
/// on non-2xx statuses so assertion failures stay actionable.
|
||||
///
|
||||
/// <para>Unlike PostIt's <c>YavscApiClient</c>, there is no
|
||||
/// silent-refresh-on-401: the test token never expires mid-test,
|
||||
/// and a 401 is a failure we want surfaced, not retried.</para>
|
||||
///
|
||||
/// The <see cref="HttpClient"/> is owned by the test, not by this
|
||||
/// transport, so <see cref="DisposeAsync"/> is a no-op.
|
||||
/// </summary>
|
||||
public sealed class TestApiTransport : IYavscApiClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public TestApiTransport(HttpClient http)
|
||||
{
|
||||
Http = http ?? throw new ArgumentNullException(nameof(http));
|
||||
}
|
||||
|
||||
public HttpClient Http { get; }
|
||||
|
||||
public Task<T> CallAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
object? body = null,
|
||||
CancellationToken ct = default)
|
||||
=> SendAsync<T>(method, path,
|
||||
body is null ? null : () => JsonContent.Create(body, options: JsonOptions),
|
||||
ct);
|
||||
|
||||
public Task<T> CallAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
Func<HttpContent> contentFactory,
|
||||
CancellationToken ct = default)
|
||||
=> SendAsync<T>(method, path, contentFactory, ct);
|
||||
|
||||
public async Task CallAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
object? body = null,
|
||||
CancellationToken ct = default)
|
||||
=> await SendAsync<object>(method, path,
|
||||
body is null ? null : () => JsonContent.Create(body, options: JsonOptions),
|
||||
ct);
|
||||
|
||||
public async Task CallAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
Func<HttpContent> contentFactory,
|
||||
CancellationToken ct = default)
|
||||
=> await SendAsync<object>(method, path, contentFactory, ct);
|
||||
|
||||
private async Task<T> SendAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
Func<HttpContent>? contentFactory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path);
|
||||
if (contentFactory is not null)
|
||||
request.Content = contentFactory();
|
||||
|
||||
using var response = await Http.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new HttpRequestException(
|
||||
$"HTTP {(int)response.StatusCode} {response.StatusCode} on {method} {path}. Body: {error}");
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
return default!; // 201/204 with an empty body (PUT returns 204).
|
||||
|
||||
return JsonSerializer.Deserialize<T>(payload, JsonOptions)!;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
87
src/test/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs
Normal file
87
src/test/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using System.Text.Json;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Models.Blog;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-shape tests pinning that <see cref="IBlogPost.IsPublished"/>
|
||||
/// is carried on the JSON the blog API list endpoint emits.
|
||||
///
|
||||
/// <para>The blog API host (<c>Yavsc.Blogs/Program.cs</c>) serialises
|
||||
/// with System.Text.Json (the <c>.AddControllers()</c> default — no
|
||||
/// <c>AddNewtonsoftJson</c>), and <c>BlogApiController.GetBlogspot</c>
|
||||
/// returns <c>IEnumerable<IBlogPost></c>. System.Text.Json emits
|
||||
/// only the members of the <b>declared</b> type, so a property that
|
||||
/// exists solely on the concrete <c>BlogPost</c> entity is dropped from
|
||||
/// the payload. <c>IsPublished</c> used to be one such property: it was
|
||||
/// on <c>BlogPost</c> and <c>BlogPostDto</c> but not on
|
||||
/// <see cref="IBlogPost"/>, so the list response omitted it and PostIt
|
||||
/// read <c>IsPublished == false</c> for every post — including
|
||||
/// published ones.</para>
|
||||
///
|
||||
/// <para>These tests reproduce that serialisation path directly
|
||||
/// (declared type <c>IEnumerable<IBlogPost></c> through
|
||||
/// <see cref="JsonSerializer.Serialize(object, Type)"/>) and assert
|
||||
/// <c>isPublished</c> survives the round trip. They live in
|
||||
/// <c>Yavsc.Blogs.Tests</c> — the assembly that owns the blog API
|
||||
/// contract — so a regression at the wire boundary is caught here,
|
||||
/// where it manifested in production (PostIt showing a published
|
||||
/// post's status as <c>false</c>).</para>
|
||||
/// </summary>
|
||||
public sealed class IBlogPostWireShapeTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions WebDefaults
|
||||
= new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public void IsPublished_is_emitted_when_list_is_serialised_as_IEnumerable_IBlogPost()
|
||||
{
|
||||
var post = new BlogPost
|
||||
{
|
||||
Id = 13,
|
||||
Title = "Published",
|
||||
AuthorId = "alice",
|
||||
Article = "x",
|
||||
};
|
||||
post.IsPublished = true;
|
||||
|
||||
// Declared type mirrors GetBlogspot's return: IEnumerable<IBlogPost>.
|
||||
IEnumerable<IBlogPost> list = new List<BlogPost> { post };
|
||||
|
||||
// Serialize with the DECLARED type, the way ASP.NET Core's
|
||||
// SystemTextJsonOutputFormatter hands context.ObjectType (the
|
||||
// action return type) to JsonSerializer.Serialize.
|
||||
var json = JsonSerializer.Serialize(list, typeof(IEnumerable<IBlogPost>),
|
||||
WebDefaults);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var element = doc.RootElement[0];
|
||||
// Web defaults use camelCase, so the property is "isPublished".
|
||||
Assert.True(element.TryGetProperty("isPublished", out var isPublished),
|
||||
$"isPublished was dropped from the list payload. JSON: {json}");
|
||||
Assert.True(isPublished.GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPublished_round_trips_through_BlogPostDto_on_the_client()
|
||||
{
|
||||
// The shape the client (PostIt) receives and deserialises with
|
||||
// PropertyNameCaseInsensitive = true (cf. YavscApiClient).
|
||||
var json = """
|
||||
[{
|
||||
"id": 13,
|
||||
"title": "Published",
|
||||
"article": "x",
|
||||
"authorId": "alice",
|
||||
"isPublished": true
|
||||
}]
|
||||
""";
|
||||
|
||||
var posts = JsonSerializer.Deserialize<List<BlogPostDto>>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var post = Assert.Single(posts);
|
||||
Assert.True(post.IsPublished);
|
||||
}
|
||||
}
|
||||
6
src/test/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs
Normal file
6
src/test/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
[CollectionDefinition("JwtClaimMapping", DisableParallelization = true)]
|
||||
public sealed class JwtClaimMappingCollection
|
||||
{
|
||||
}
|
||||
147
src/test/Yavsc.Blogs.Tests/PublishEndpointTests.cs
Normal file
147
src/test/Yavsc.Blogs.Tests/PublishEndpointTests.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Yavsc.Blogs.Tests.Fixtures;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioural tests for the publication toggle endpoint:
|
||||
/// <c>PUT /api/BlogApi/{id}/publish</c> with body
|
||||
/// <c>{ "publish": bool }</c>.
|
||||
///
|
||||
/// <para>The endpoint is the PostIt-facing way to toggle
|
||||
/// whether a post is publicly readable (via
|
||||
/// <c>BlogSpotPublication</c>). It does NOT change the
|
||||
/// ACL — a Public post with a non-empty ACL is still
|
||||
/// restricted to the ACL's circles for authenticated
|
||||
/// callers; only anonymous reads open up.</para>
|
||||
///
|
||||
/// <para>Same fixture as <see cref="BlogApiTests"/>:
|
||||
/// in-memory <c>ApplicationDbContext</c>, JWT bearer auth
|
||||
/// via <see cref="TestTokenIssuer"/>.</para>
|
||||
/// </summary>
|
||||
[Collection("Yavsc Blogs")]
|
||||
public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
||||
public PublishEndpointTests(BlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private void ResetDatabase()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.Database.EnsureDeleted();
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// ApplicationUser has an AlternateKey on Email; the
|
||||
// InMemory provider refuses to track entities whose
|
||||
// alternate key is null, so we set it explicitly.
|
||||
db.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "alice",
|
||||
UserName = "alice",
|
||||
Email = "alice@example.com",
|
||||
EmailConfirmed = true,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private long SeedPost(string authorId)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var post = new BlogPost
|
||||
{
|
||||
AuthorId = authorId,
|
||||
Title = $"post-by-{authorId}",
|
||||
Article = "test",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
db.BlogSpot.Add(post);
|
||||
db.SaveChanges();
|
||||
return post.Id;
|
||||
}
|
||||
|
||||
private HttpClient NewClient(string subject)
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
var http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||
};
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||
"Bearer", TestTokenIssuer.Issue(subject));
|
||||
return http;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutPublish_true_returns_204_and_sets_IsPublished_in_subsequent_GET()
|
||||
{
|
||||
ResetDatabase();
|
||||
var postId = SeedPost("alice");
|
||||
|
||||
using var http = NewClient("alice");
|
||||
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var get = await http.GetAsync(_fixture.BlogSpotUrl() + $"/{postId}", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal($"post-by-alice", doc.RootElement.GetProperty("title").GetString());
|
||||
Assert.True(doc.RootElement.GetProperty("isPublished").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutPublish_false_returns_204_and_clears_IsPublished()
|
||||
{
|
||||
ResetDatabase();
|
||||
var postId = SeedPost("alice");
|
||||
|
||||
using var http = NewClient("alice");
|
||||
await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
|
||||
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var get = await http.GetAsync(_fixture.BlogSpotUrl() + $"/{postId}", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutPublish_on_unknown_post_returns_404()
|
||||
{
|
||||
ResetDatabase();
|
||||
using var http = NewClient("alice");
|
||||
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(99999L), new { publish = true }, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, put.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutPublish_by_non_author_returns_challenge()
|
||||
{
|
||||
ResetDatabase();
|
||||
var postId = SeedPost("alice");
|
||||
|
||||
using var http = NewClient("bob");
|
||||
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
|
||||
// 401 Challenge (the controller returns Challenge()
|
||||
// for AuthorizationFailureException). The exact code
|
||||
// is framework-dependent; what matters is "not 204".
|
||||
Assert.NotEqual(HttpStatusCode.NoContent, put.StatusCode);
|
||||
}
|
||||
}
|
||||
35
src/test/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj
Normal file
35
src/test/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Yavsc.Blogs.Tests</RootNamespace>
|
||||
<UserSecretsId>b1a9d0d6-3f5e-4a07-9f0a-7e4d5b6c1a82</UserSecretsId>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.v3.common" />
|
||||
<PackageReference Include="xunit.v3.extensibility.core" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
|
||||
<ProjectReference Include="..\..\Yavsc.Api.Client\Yavsc.Api.Client.csproj" />
|
||||
<ProjectReference Include="..\..\Yavsc.Server\Yavsc.Server.csproj" />
|
||||
<ProjectReference Include="..\..\Yavsc.Blogs\Yavsc.Blogs.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
162
src/test/Yavsc.Org.Tests/ComputeKidTests.cs
Normal file
162
src/test/Yavsc.Org.Tests/ComputeKidTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
77
src/test/Yavsc.Org.Tests/DictionnaireMetierTests.cs
Normal file
77
src/test/Yavsc.Org.Tests/DictionnaireMetierTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
42
src/test/Yavsc.Org.Tests/DumpHtml.cs
Normal file
42
src/test/Yavsc.Org.Tests/DumpHtml.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/test/Yavsc.Org.Tests/Fakes/RecordingSmtpClient.cs
Normal file
71
src/test/Yavsc.Org.Tests/Fakes/RecordingSmtpClient.cs
Normal 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);
|
||||
}
|
||||
21
src/test/Yavsc.Org.Tests/Fakes/RecordingSmtpClientFactory.cs
Normal file
21
src/test/Yavsc.Org.Tests/Fakes/RecordingSmtpClientFactory.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
105
src/test/Yavsc.Org.Tests/Mandatory/BatchTests.cs
Normal file
105
src/test/Yavsc.Org.Tests/Mandatory/BatchTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
223
src/test/Yavsc.Org.Tests/Mandatory/Remoting.cs
Normal file
223
src/test/Yavsc.Org.Tests/Mandatory/Remoting.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
22
src/test/Yavsc.Org.Tests/Mandatory/Resources.cs
Normal file
22
src/test/Yavsc.Org.Tests/Mandatory/Resources.cs
Normal 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);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/test/Yavsc.Org.Tests/Mandatory/Services.cs
Normal file
12
src/test/Yavsc.Org.Tests/Mandatory/Services.cs
Normal 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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
28
src/test/Yavsc.Org.Tests/NonRegression/AbstractTests.cs
Normal file
28
src/test/Yavsc.Org.Tests/NonRegression/AbstractTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/test/Yavsc.Org.Tests/NonRegression/Database.cs
Normal file
32
src/test/Yavsc.Org.Tests/NonRegression/Database.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
164
src/test/Yavsc.Org.Tests/NonRegression/EMailling.cs
Normal file
164
src/test/Yavsc.Org.Tests/NonRegression/EMailling.cs
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
" "));
|
||||
}
|
||||
}
|
||||
101
src/test/Yavsc.Org.Tests/NonRegression/OidcSeedTests.cs
Normal file
101
src/test/Yavsc.Org.Tests/NonRegression/OidcSeedTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
66
src/test/Yavsc.Org.Tests/Resources/Test.TestResources.resx
Normal file
66
src/test/Yavsc.Org.Tests/Resources/Test.TestResources.resx
Normal 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>
|
||||
129
src/test/Yavsc.Org.Tests/Services/FileSystemAuthManagerTests.cs
Normal file
129
src/test/Yavsc.Org.Tests/Services/FileSystemAuthManagerTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/test/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs
Normal file
91
src/test/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
36
src/test/Yavsc.Org.Tests/Smoke/BlogSmokeTests.cs
Normal file
36
src/test/Yavsc.Org.Tests/Smoke/BlogSmokeTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
60
src/test/Yavsc.Org.Tests/StaticAssetsPathsTests.cs
Normal file
60
src/test/Yavsc.Org.Tests/StaticAssetsPathsTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
45
src/test/Yavsc.Org.Tests/TestUserMiddleware.cs
Normal file
45
src/test/Yavsc.Org.Tests/TestUserMiddleware.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
43
src/test/Yavsc.Org.Tests/TestUserStartupFilter.cs
Normal file
43
src/test/Yavsc.Org.Tests/TestUserStartupFilter.cs
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
112
src/test/Yavsc.Org.Tests/TestWebApplicationFactory.cs
Normal file
112
src/test/Yavsc.Org.Tests/TestWebApplicationFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
413
src/test/Yavsc.Org.Tests/WebServerFixture.cs
Normal file
413
src/test/Yavsc.Org.Tests/WebServerFixture.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/test/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj
Normal file
91
src/test/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj
Normal 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>
|
||||
82
src/test/Yavsc.Org.Tests/appsettings.json
Normal file
82
src/test/Yavsc.Org.Tests/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
42
src/test/Yavsc.Org.Tests/test.runsettings
Normal file
42
src/test/Yavsc.Org.Tests/test.runsettings
Normal 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>
|
||||
5
src/test/Yavsc.Org.Tests/xunit.runner.json
Normal file
5
src/test/Yavsc.Org.Tests/xunit.runner.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"diagnosticMessages": false,
|
||||
"methodDisplay": "classAndMethod",
|
||||
"parallelizeTestCollections": true
|
||||
}
|
||||
222
src/test/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs
Normal file
222
src/test/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
452
src/test/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs
Normal file
452
src/test/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs
Normal 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) { }
|
||||
}
|
||||
}
|
||||
32
src/test/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj
Normal file
32
src/test/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj
Normal 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>
|
||||
31
src/test/Yavsc.Tests.Shared/BlogHelpers.cs
Normal file
31
src/test/Yavsc.Tests.Shared/BlogHelpers.cs
Normal 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";
|
||||
|
||||
}
|
||||
13
src/test/Yavsc.Tests.Shared/IBackendFixture.cs
Normal file
13
src/test/Yavsc.Tests.Shared/IBackendFixture.cs
Normal 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; }
|
||||
|
||||
}
|
||||
30
src/test/Yavsc.Tests.Shared/InMemoryDatabaseName.cs
Normal file
30
src/test/Yavsc.Tests.Shared/InMemoryDatabaseName.cs
Normal 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}";
|
||||
}
|
||||
149
src/test/Yavsc.Tests.Shared/README.md
Normal file
149
src/test/Yavsc.Tests.Shared/README.md
Normal 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
|
||||
71
src/test/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs
Normal file
71
src/test/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
122
src/test/Yavsc.Tests.Shared/TestTokenIssuer.cs
Normal file
122
src/test/Yavsc.Tests.Shared/TestTokenIssuer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
226
src/test/Yavsc.Tests.Shared/WebHostFixture.cs
Normal file
226
src/test/Yavsc.Tests.Shared/WebHostFixture.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
29
src/test/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj
Normal file
29
src/test/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj
Normal 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>
|
||||
Loading…
Reference in a new issue