forked from xiongyuxing/tiku-backend.net
feat: add runtime logging cors and zlinq foundation
This commit is contained in:
@@ -29,11 +29,17 @@
|
||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.2" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageVersion>
|
||||
<PackageVersion Include="ZLinq" Version="1.5.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
39
README.md
39
README.md
@@ -27,6 +27,8 @@
|
||||
- Entity Framework Core
|
||||
- PostgreSQL
|
||||
- Npgsql
|
||||
- Serilog
|
||||
- ZLinq
|
||||
- xUnit
|
||||
|
||||
项目分层:
|
||||
@@ -42,6 +44,43 @@ Tiku.UnitTests # 单元测试
|
||||
Tiku.IntegrationTests # EF 模型/持久化约束测试
|
||||
```
|
||||
|
||||
## 运行时地基
|
||||
|
||||
这次迁移不只是把 Node/Nest/Supabase 换成 C#,而是把旧系统没有认真处理的运行时基础补起来。
|
||||
|
||||
### 数据库连接
|
||||
|
||||
- API 通过单例 `NpgsqlDataSource` 管理 PostgreSQL 连接池。
|
||||
- EF Core 使用 `AddDbContextPool<TikuDbContext>`,避免每个请求重复构造完整 DbContext 依赖图。
|
||||
- 连接池参数继续交给 PostgreSQL/Npgsql connection string 配置,例如 `Maximum Pool Size`、`Minimum Pool Size`、`Timeout`、`Command Timeout`。
|
||||
- 业务代码不直接 new 连接,不绕过统一的 EF / Npgsql 配置入口。
|
||||
|
||||
### 日志
|
||||
|
||||
- API 接入 Serilog 结构化日志。
|
||||
- 启动阶段使用 bootstrap logger,避免应用启动失败时完全没有日志。
|
||||
- 请求日志统一记录 HTTP method、path、status code、elapsed 等基础字段。
|
||||
- 请求日志会补充当前用户、租户、Session、租户角色、TraceId 等上下文,后续排查“某个机构某个用户某次请求”会比旧方案清楚很多。
|
||||
- EF SQL、Microsoft 框架日志默认降噪;开发环境可提高 EF command 日志等级。
|
||||
|
||||
### 跨域
|
||||
|
||||
- CORS 作为 API 安全边界配置,不在 Controller 里散写。
|
||||
- 生产默认不放行任何 Origin,避免开发便利配置意外带到线上。
|
||||
- 开发环境默认允许本地前端常用端口:
|
||||
- `http://localhost:5173`
|
||||
- `http://127.0.0.1:5173`
|
||||
- `http://localhost:3000`
|
||||
- `http://127.0.0.1:3000`
|
||||
- 不默认允许 credentials;如果后续需要 cookie 模式或管理后台单独域名,需要显式配置。
|
||||
- 多租户正式域名确定后,可以把 CORS 白名单从静态配置升级为“租户域名 + 平台管理域名”的集中策略。
|
||||
|
||||
### 热路径集合处理
|
||||
|
||||
- API 项目引入 ZLinq,作为低分配集合处理工具。
|
||||
- 当前先在小范围权限判断里落模板,后续题库筛选、权限集合、菜单/内容树投影等热路径再逐步使用。
|
||||
- 不是为了炫技替换所有 LINQ;只在明确高频、低收益分配明显的路径使用。
|
||||
|
||||
## 数据库策略
|
||||
|
||||
当前数据库以 PostgreSQL 为核心能力,而不是把 PostgreSQL 当成普通 KV 存储:
|
||||
|
||||
45
Tiku.Api/Logging/SerilogRequestLogging.cs
Normal file
45
Tiku.Api/Logging/SerilogRequestLogging.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using Serilog;
|
||||
using Serilog.AspNetCore;
|
||||
using Serilog.Events;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Logging;
|
||||
|
||||
public static class SerilogRequestLogging
|
||||
{
|
||||
public static void ConfigureRequestLogging(RequestLoggingOptions options)
|
||||
{
|
||||
options.GetLevel = (httpContext, elapsed, exception) =>
|
||||
exception is not null || httpContext.Response.StatusCode >= StatusCodes.Status500InternalServerError
|
||||
? LogEventLevel.Error
|
||||
: elapsed > 1000 || httpContext.Response.StatusCode >= StatusCodes.Status400BadRequest
|
||||
? LogEventLevel.Warning
|
||||
: LogEventLevel.Information;
|
||||
|
||||
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
|
||||
{
|
||||
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
|
||||
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
|
||||
diagnosticContext.Set("TraceId", httpContext.TraceIdentifier);
|
||||
|
||||
var user = httpContext.User;
|
||||
SetClaim(diagnosticContext, "UserId", user, TikuClaimTypes.UserId);
|
||||
SetClaim(diagnosticContext, "TenantId", user, TikuClaimTypes.TenantId);
|
||||
SetClaim(diagnosticContext, "SessionId", user, TikuClaimTypes.SessionId);
|
||||
SetClaim(diagnosticContext, "TenantRole", user, TikuClaimTypes.TenantRole);
|
||||
};
|
||||
}
|
||||
|
||||
private static void SetClaim(
|
||||
IDiagnosticContext diagnosticContext,
|
||||
string propertyName,
|
||||
System.Security.Claims.ClaimsPrincipal principal,
|
||||
string claimType)
|
||||
{
|
||||
var value = principal.FindFirst(claimType)?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
diagnosticContext.Set(propertyName, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Tiku.Api/Options/CorsOptions.cs
Normal file
12
Tiku.Api/Options/CorsOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Tiku.Api.Options;
|
||||
|
||||
public sealed class CorsOptions
|
||||
{
|
||||
public const string SectionName = "Cors";
|
||||
public const string PolicyName = "TikuCors";
|
||||
|
||||
public string[] AllowedOrigins { get; set; } = [];
|
||||
public string[] AllowedHeaders { get; set; } = ["Authorization", "Content-Type", "x-tenant-code"];
|
||||
public string[] AllowedMethods { get; set; } = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||
public bool AllowCredentials { get; set; }
|
||||
}
|
||||
@@ -2,125 +2,185 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Scalar.AspNetCore;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using Tiku.Api.Logging;
|
||||
using Tiku.Api.Middleware;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console()
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
builder.Services.AddOpenApi(options =>
|
||||
try
|
||||
{
|
||||
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
|
||||
});
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddApplication();
|
||||
Log.Information("Starting TIKU API");
|
||||
|
||||
var connectionString =
|
||||
builder.Configuration.GetConnectionString("Database") ??
|
||||
builder.Configuration["DATABASE_URL"] ??
|
||||
"Host=localhost;Database=tiku;Username=postgres";
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddSerilog((services, configuration) => configuration
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext(),
|
||||
preserveStaticLogger: true);
|
||||
|
||||
builder.Services.AddInfrastructure(connectionString);
|
||||
|
||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Security:Jwt"));
|
||||
var jwtOptions = builder.Configuration
|
||||
.GetSection("Security:Jwt")
|
||||
.Get<JwtOptions>() ?? new JwtOptions();
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
builder.Services.AddOpenApi(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
|
||||
});
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.Configure<CorsOptions>(builder.Configuration.GetSection(CorsOptions.SectionName));
|
||||
var corsOptions = builder.Configuration
|
||||
.GetSection(CorsOptions.SectionName)
|
||||
.Get<CorsOptions>() ?? new CorsOptions();
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy(CorsOptions.PolicyName, policy =>
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnTokenValidated = async context =>
|
||||
var origins = corsOptions.AllowedOrigins
|
||||
.Where(origin => !string.IsNullOrWhiteSpace(origin))
|
||||
.Select(origin => origin.Trim().TrimEnd('/'))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (origins.Length > 0)
|
||||
{
|
||||
if (!jwtOptions.ValidateSessions)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value;
|
||||
if (!Guid.TryParse(sessionIdValue, out var sessionId))
|
||||
{
|
||||
context.Fail("Missing session claim.");
|
||||
return;
|
||||
}
|
||||
|
||||
var dbContext = context.HttpContext.RequestServices.GetRequiredService<TikuDbContext>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var isSessionActive = await dbContext.AuthSessions.AnyAsync(
|
||||
session =>
|
||||
session.Id == sessionId &&
|
||||
session.RevokedAt == null &&
|
||||
session.ExpiresAt > now);
|
||||
|
||||
if (!isSessionActive)
|
||||
{
|
||||
context.Fail("Session has been revoked or expired.");
|
||||
}
|
||||
policy.WithOrigins(origins);
|
||||
}
|
||||
};
|
||||
|
||||
policy
|
||||
.WithHeaders(corsOptions.AllowedHeaders)
|
||||
.WithMethods(corsOptions.AllowedMethods);
|
||||
|
||||
if (corsOptions.AllowCredentials)
|
||||
{
|
||||
policy.AllowCredentials();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(
|
||||
TikuPolicies.AuthenticatedUser,
|
||||
policy => policy.RequireAuthenticatedUser());
|
||||
options.AddPolicy(
|
||||
TikuPolicies.CurrentTenantMember,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User)));
|
||||
options.AddPolicy(
|
||||
TikuPolicies.TenantAdmin,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User)));
|
||||
});
|
||||
var connectionString =
|
||||
builder.Configuration.GetConnectionString("Database") ??
|
||||
builder.Configuration["DATABASE_URL"] ??
|
||||
"Host=localhost;Database=tiku;Username=postgres";
|
||||
|
||||
var app = builder.Build();
|
||||
builder.Services.AddInfrastructure(connectionString);
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Security:Jwt"));
|
||||
var jwtOptions = builder.Configuration
|
||||
.GetSection("Security:Jwt")
|
||||
.Get<JwtOptions>() ?? new JwtOptions();
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnTokenValidated = async context =>
|
||||
{
|
||||
if (!jwtOptions.ValidateSessions)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value;
|
||||
if (!Guid.TryParse(sessionIdValue, out var sessionId))
|
||||
{
|
||||
context.Fail("Missing session claim.");
|
||||
return;
|
||||
}
|
||||
|
||||
var dbContext = context.HttpContext.RequestServices.GetRequiredService<TikuDbContext>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var isSessionActive = await dbContext.AuthSessions.AnyAsync(
|
||||
session =>
|
||||
session.Id == sessionId &&
|
||||
session.RevokedAt == null &&
|
||||
session.ExpiresAt > now);
|
||||
|
||||
if (!isSessionActive)
|
||||
{
|
||||
context.Fail("Session has been revoked or expired.");
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(
|
||||
TikuPolicies.AuthenticatedUser,
|
||||
policy => policy.RequireAuthenticatedUser());
|
||||
options.AddPolicy(
|
||||
TikuPolicies.CurrentTenantMember,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User)));
|
||||
options.AddPolicy(
|
||||
TikuPolicies.TenantAdmin,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User)));
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference(options => options
|
||||
.WithTitle("TIKU Backend API")
|
||||
.AddPreferredSecuritySchemes("BearerAuth")
|
||||
.EnablePersistentAuthentication());
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseCors(CorsOptions.PolicyName);
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference(options => options
|
||||
.WithTitle("TIKU Backend API")
|
||||
.AddPreferredSecuritySchemes("BearerAuth")
|
||||
.EnablePersistentAuthentication());
|
||||
Log.Fatal(exception, "TIKU API terminated unexpectedly");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using ZLinq;
|
||||
|
||||
namespace Tiku.Api.Security;
|
||||
|
||||
@@ -22,7 +23,7 @@ internal static class TenantRoleAuthorization
|
||||
public static bool IsTenantAdmin(ClaimsPrincipal principal)
|
||||
{
|
||||
return IsTenantMember(principal) &&
|
||||
principal.Claims
|
||||
principal.Claims.AsValueEnumerable()
|
||||
.Where(claim => claim.Type == TikuClaimTypes.TenantRole)
|
||||
.Select(claim => claim.Value)
|
||||
.Any(role => AdminRoles.Contains(role));
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.OpenApi" />
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
<PackageReference Include="ZLinq" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000"
|
||||
],
|
||||
"AllowCredentials": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,48 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
}
|
||||
},
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithThreadId"
|
||||
],
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Properties": {
|
||||
"Application": "Tiku.Api"
|
||||
}
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [],
|
||||
"AllowedHeaders": [
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"x-tenant-code"
|
||||
],
|
||||
"AllowedMethods": [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS"
|
||||
],
|
||||
"AllowCredentials": false
|
||||
},
|
||||
"Security": {
|
||||
"Jwt": {
|
||||
"Issuer": "tiku-backend",
|
||||
|
||||
@@ -95,6 +95,21 @@ public sealed class TenantPublicEndpointTests
|
||||
Assert.Equal("ok", body.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cors_preflight_allows_configured_development_origin()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Options, "/api/health");
|
||||
request.Headers.Add("Origin", "http://localhost:5173");
|
||||
request.Headers.Add("Access-Control-Request-Method", "GET");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Equal("http://localhost:5173", response.Headers.GetValues("Access-Control-Allow-Origin").Single());
|
||||
}
|
||||
|
||||
private static Task SeedTenantAsync(ApiTestFactory factory, Guid tenantId)
|
||||
{
|
||||
return factory.SeedAsync(
|
||||
|
||||
Reference in New Issue
Block a user