forked from gongxuegit/tiku-backend.net
feat(security): add distributed authorization foundation
This commit is contained in:
@@ -34,8 +34,17 @@ public sealed class ApiTestFactory(
|
||||
{
|
||||
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
|
||||
|
||||
public string DatabaseConnectionString => database.ConnectionString;
|
||||
|
||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||
{
|
||||
if (configurationOverrides is not null)
|
||||
{
|
||||
foreach (var pair in configurationOverrides.Where(pair => pair.Value is not null))
|
||||
{
|
||||
builder.UseSetting(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
@@ -61,7 +70,7 @@ public sealed class ApiTestFactory(
|
||||
descriptor.ServiceType == typeof(NpgsqlDataSource) ||
|
||||
descriptor.ServiceType == typeof(TenantIsolationSaveChangesInterceptor) ||
|
||||
descriptor.ServiceType == typeof(DbContextOptions<TikuDbContext>) ||
|
||||
descriptor.ServiceType.FullName?.Contains(nameof(TikuDbContext), StringComparison.Ordinal) == true)
|
||||
descriptor.ServiceType == typeof(TikuDbContext))
|
||||
.ToArray())
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
|
||||
@@ -305,6 +305,123 @@ public sealed class AuthEndpointTests
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wechat_first_login_is_rejected_without_self_registration_and_leaves_no_identity()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(new FakeWechatOAuthClient());
|
||||
var tenantId = Guid.NewGuid();
|
||||
await SeedWechatProviderAsync(factory, tenantId,
|
||||
new TenantAuthPolicy
|
||||
{
|
||||
TenantId = tenantId,
|
||||
AllowExternalStudentSelfRegistration = false
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/oauth/wechat-miniapp",
|
||||
new OAuthCodeDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = tenantId.ToString("N"),
|
||||
Code = "wx-code"
|
||||
});
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.DoesNotContain(dbContext.UserIdentities, item => item.Provider == "wechat_miniapp");
|
||||
Assert.DoesNotContain(dbContext.TenantMemberships, item => item.TenantId == tenantId);
|
||||
Assert.DoesNotContain(dbContext.AuthSessions, item => item.TenantId == tenantId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Invited)]
|
||||
[InlineData(MembershipStatus.Disabled)]
|
||||
public async Task Wechat_login_does_not_reactivate_non_active_membership(MembershipStatus status)
|
||||
{
|
||||
await using var factory = new ApiTestFactory(new FakeWechatOAuthClient());
|
||||
var tenantId = Guid.NewGuid();
|
||||
var user = new User { Id = Guid.NewGuid(), Name = "Existing Wechat User" };
|
||||
await SeedWechatProviderAsync(factory, tenantId,
|
||||
user,
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = user.Id,
|
||||
Provider = "wechat_miniapp",
|
||||
ProviderSubject = "wx-app-id:mini-open-id",
|
||||
OpenId = "mini-open-id",
|
||||
UnionId = "union-id"
|
||||
},
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = user.Id,
|
||||
Role = TenantRole.Student,
|
||||
Status = status
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/oauth/wechat-miniapp",
|
||||
new OAuthCodeDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = tenantId.ToString("N"),
|
||||
Code = "wx-code"
|
||||
});
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal(status, dbContext.TenantMemberships.Single(item =>
|
||||
item.TenantId == tenantId && item.UserId == user.Id).Status);
|
||||
Assert.DoesNotContain(dbContext.AuthSessions, item =>
|
||||
item.TenantId == tenantId && item.UserId == user.Id);
|
||||
}
|
||||
|
||||
private static async Task SeedWechatProviderAsync(
|
||||
ApiTestFactory factory,
|
||||
Guid tenantId,
|
||||
params object[] additionalEntities)
|
||||
{
|
||||
const string secretRef = "tenant_secrets:identity:wechat_miniapp:default";
|
||||
var protectedSecret = ProtectTenantSecret(
|
||||
tenantId,
|
||||
secretRef,
|
||||
JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" }));
|
||||
var entities = new List<object>
|
||||
{
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Wechat Tenant" },
|
||||
new TenantExternalProvider
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Provider = "wechat_miniapp",
|
||||
Capability = TenantExternalProviderCapability.Identity,
|
||||
Status = TenantExternalProviderStatus.Active,
|
||||
SecretRef = secretRef,
|
||||
ConfigPublic = JsonSerializer.SerializeToElement(new { appId = "wx-app-id" })
|
||||
},
|
||||
new TenantSecret
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Purpose = "identity",
|
||||
Provider = "wechat_miniapp",
|
||||
SecretKey = "default",
|
||||
SecretRef = secretRef,
|
||||
Status = TenantSecretStatus.Active,
|
||||
EncryptionKeyId = protectedSecret.KeyId,
|
||||
EncryptedPayload = protectedSecret.Ciphertext,
|
||||
EncryptionNonce = protectedSecret.Nonce,
|
||||
EncryptionTag = protectedSecret.Tag
|
||||
}
|
||||
};
|
||||
entities.AddRange(additionalEntities);
|
||||
await factory.SeedAsync(entities.ToArray());
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync(
|
||||
ApiTestFactory factory)
|
||||
{
|
||||
|
||||
78
Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs
Normal file
78
Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Security;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthorizationManifestTests
|
||||
{
|
||||
private const int ExpectedActionCount = 330;
|
||||
private const string ExpectedSha256 = "ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5";
|
||||
|
||||
[Fact]
|
||||
public void Controller_authorization_surface_matches_reviewed_manifest()
|
||||
{
|
||||
var descriptors = typeof(Tiku.Api.ApiProgramMarker).Assembly.GetTypes()
|
||||
.Where(type => !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type))
|
||||
.SelectMany(type => type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
|
||||
.Where(method => method.GetCustomAttributes<HttpMethodAttribute>().Any())
|
||||
.Select(method => Describe(type, method)))
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', descriptors))))
|
||||
.ToLowerInvariant();
|
||||
|
||||
Assert.True(
|
||||
descriptors.Length == ExpectedActionCount && hash == ExpectedSha256,
|
||||
$"Authorization manifest changed. count={descriptors.Length}, sha256={hash}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runtime_controller_endpoints_have_authorization_and_audit_metadata()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/api/health");
|
||||
var endpoints = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints
|
||||
.Where(endpoint => endpoint.Metadata.GetMetadata<ControllerActionDescriptor>() is not null)
|
||||
.ToArray();
|
||||
|
||||
Assert.NotEmpty(endpoints);
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
var anonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null;
|
||||
var metadata = endpoint.Metadata.GetMetadata<EndpointAuthorizationMetadata>();
|
||||
if (anonymous)
|
||||
{
|
||||
Assert.Null(metadata);
|
||||
continue;
|
||||
}
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.AuditAction));
|
||||
Assert.Contains(metadata.Realm, new[] { "authenticated", "tenant", "platform" });
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(Type controller, MethodInfo action)
|
||||
{
|
||||
var controllerRoute = controller.GetCustomAttribute<RouteAttribute>()?.Template ?? string.Empty;
|
||||
var http = action.GetCustomAttributes<HttpMethodAttribute>().ToArray();
|
||||
var methods = string.Join(',', http.SelectMany(attribute => attribute.HttpMethods).Distinct().Order(StringComparer.Ordinal));
|
||||
var templates = string.Join(',', http.Select(attribute => attribute.Template ?? string.Empty).Distinct().Order(StringComparer.Ordinal));
|
||||
var policies = controller.GetCustomAttributes<AuthorizeAttribute>()
|
||||
.Concat(action.GetCustomAttributes<AuthorizeAttribute>())
|
||||
.Select(attribute => attribute.Policy ?? "authenticated")
|
||||
.Order(StringComparer.Ordinal);
|
||||
var anonymous = controller.IsDefined(typeof(AllowAnonymousAttribute)) ||
|
||||
action.IsDefined(typeof(AllowAnonymousAttribute));
|
||||
return $"{methods}|{controllerRoute}/{templates}|{controller.Name}.{action.Name}|anonymous={anonymous}|policies={string.Join(',', policies)}";
|
||||
}
|
||||
}
|
||||
183
Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs
Normal file
183
Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class BrowserAuthenticationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Password_login_uses_secure_cookies_and_does_not_return_tokens()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
const string phone = "13890000000";
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Browser User" }.WithTestPassword();
|
||||
await factory.SeedAsync(
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Browser Tenant" },
|
||||
user,
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = user.Id,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
||||
{
|
||||
HandleCookies = false
|
||||
});
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
client.DefaultRequestHeaders.Add("Origin", "http://localhost");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/browser-auth/login/password", new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = tenantId.ToString("N"),
|
||||
Identifier = phone,
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
});
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var cookies = response.Headers.GetValues("Set-Cookie").ToArray();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.DoesNotContain("accessToken", body, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("refreshToken", body, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("Authenticated", json.RootElement.GetProperty("status").GetString());
|
||||
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.AccessCookie) &&
|
||||
value.Contains("secure", StringComparison.OrdinalIgnoreCase) &&
|
||||
value.Contains("httponly", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.RefreshCookie) &&
|
||||
value.Contains("samesite=strict", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.CsrfCookie));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browser_refresh_requires_same_origin_and_matching_csrf_token()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
const string phone = "13890000001";
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "CSRF User" }.WithTestPassword();
|
||||
await factory.SeedAsync(
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CSRF Tenant" },
|
||||
user,
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = user.Id,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
||||
{
|
||||
HandleCookies = false
|
||||
});
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/login/password")
|
||||
{
|
||||
Content = JsonContent.Create(new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = tenantId.ToString("N"),
|
||||
Identifier = phone,
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
})
|
||||
};
|
||||
login.Headers.Add("Origin", "http://localhost");
|
||||
var loginResponse = await client.SendAsync(login);
|
||||
var setCookies = loginResponse.Headers.GetValues("Set-Cookie").ToArray();
|
||||
var refresh = ReadCookie(setCookies, BrowserAuthOptions.RefreshCookie);
|
||||
var csrf = ReadCookie(setCookies, BrowserAuthOptions.CsrfCookie);
|
||||
var cookieHeader = $"{BrowserAuthOptions.RefreshCookie}={refresh}; {BrowserAuthOptions.CsrfCookie}={csrf}";
|
||||
|
||||
var missingCsrf = await SendRefreshAsync(client, cookieHeader, "http://localhost", null);
|
||||
var wrongOrigin = await SendRefreshAsync(client, cookieHeader, "https://attacker.example", csrf);
|
||||
var accepted = await SendRefreshAsync(client, cookieHeader, "http://localhost", csrf);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, missingCsrf.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, wrongOrigin.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
||||
Assert.Contains(accepted.Headers.GetValues("Set-Cookie"), value =>
|
||||
value.StartsWith(BrowserAuthOptions.RefreshCookie, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Access_cookie_is_ignored_without_same_origin_evidence()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
const string phone = "13890000002";
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Cookie User" }.WithTestPassword();
|
||||
await factory.SeedAsync(
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Cookie Tenant" },
|
||||
user,
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = user.Id,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
||||
{
|
||||
HandleCookies = false
|
||||
});
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/login/password")
|
||||
{
|
||||
Content = JsonContent.Create(new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = tenantId.ToString("N"),
|
||||
Identifier = phone,
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
})
|
||||
};
|
||||
login.Headers.Add("Origin", "http://localhost");
|
||||
var loginResponse = await client.SendAsync(login);
|
||||
var access = ReadCookie(loginResponse.Headers.GetValues("Set-Cookie").ToArray(), BrowserAuthOptions.AccessCookie);
|
||||
|
||||
using var crossSite = new HttpRequestMessage(HttpMethod.Get, "/api/me");
|
||||
crossSite.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}");
|
||||
crossSite.Headers.Add("Sec-Fetch-Site", "cross-site");
|
||||
var rejected = await client.SendAsync(crossSite);
|
||||
using var sameOrigin = new HttpRequestMessage(HttpMethod.Get, "/api/me");
|
||||
sameOrigin.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}");
|
||||
sameOrigin.Headers.Add("Origin", "http://localhost");
|
||||
var accepted = await client.SendAsync(sameOrigin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> SendRefreshAsync(
|
||||
HttpClient client,
|
||||
string cookieHeader,
|
||||
string origin,
|
||||
string? csrf)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/refresh");
|
||||
request.Headers.Add("Cookie", cookieHeader);
|
||||
request.Headers.Add("Origin", origin);
|
||||
if (csrf is not null)
|
||||
{
|
||||
request.Headers.Add(BrowserAuthOptions.CsrfHeader, csrf);
|
||||
}
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static string ReadCookie(IEnumerable<string> setCookies, string name)
|
||||
{
|
||||
var prefix = name + "=";
|
||||
var header = setCookies.Single(value => value.StartsWith(prefix, StringComparison.Ordinal));
|
||||
return header[prefix.Length..header.IndexOf(';')];
|
||||
}
|
||||
}
|
||||
49
Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs
Normal file
49
Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class CapabilityAuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Entitlement_is_database_backed_and_past_due_is_read_only()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Capability Tenant" },
|
||||
new PlatformSaasPlan { Code = "capability-test", Name = "Capability Test" },
|
||||
new ProductModule { Code = "content", Name = "Content" },
|
||||
new TenantSubscription
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PlanCode = "capability-test",
|
||||
Status = TenantSubscriptionStatus.Active,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
|
||||
});
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify capability authorization");
|
||||
var evaluator = scope.ServiceProvider.GetRequiredService<ICapabilityAccessEvaluator>();
|
||||
Assert.False(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Read));
|
||||
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
dbContext.PlanModuleEntitlements.Add(new PlanModuleEntitlement
|
||||
{
|
||||
PlanCode = "capability-test",
|
||||
ModuleCode = "content"
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
Assert.True(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Write));
|
||||
|
||||
var subscription = dbContext.TenantSubscriptions.Single(item => item.TenantId == tenantId);
|
||||
subscription.Status = TenantSubscriptionStatus.PastDue;
|
||||
await dbContext.SaveChangesAsync();
|
||||
Assert.True(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Read));
|
||||
Assert.False(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Write));
|
||||
}
|
||||
}
|
||||
@@ -85,4 +85,24 @@ public sealed class ProductionConfigurationTests
|
||||
MasterKey = Convert.ToBase64String(new byte[32])
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_network_configuration_requires_formal_hosts_allowed_hosts_and_proxy()
|
||||
{
|
||||
var developmentDefaults = new TenantResolutionOptions();
|
||||
var wildcard = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["AllowedHosts"] = "*" })
|
||||
.Build();
|
||||
Assert.False(OptionsValidation.BeValidTenantResolutionOptions(developmentDefaults, wildcard, true));
|
||||
|
||||
var production = new TenantResolutionOptions
|
||||
{
|
||||
PlatformHosts = ["admin.example.com"],
|
||||
TrustedProxyAddresses = ["10.0.0.10"]
|
||||
};
|
||||
var explicitHosts = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["AllowedHosts"] = "admin.example.com;api.example.com" })
|
||||
.Build();
|
||||
Assert.True(OptionsValidation.BeValidTenantResolutionOptions(production, explicitHosts, true));
|
||||
}
|
||||
}
|
||||
|
||||
217
Tiku.IntegrationTests/MassTransitOutboxTests.cs
Normal file
217
Tiku.IntegrationTests/MassTransitOutboxTests.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using MassTransit;
|
||||
using MassTransit.EntityFrameworkCoreIntegration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StackExchange.Redis;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application;
|
||||
using Tiku.Contracts;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests;
|
||||
|
||||
public sealed class MassTransitOutboxTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Worker_consumer_uses_inbox_and_updates_non_authoritative_redis_version()
|
||||
{
|
||||
var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ");
|
||||
var redisConnection = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
|
||||
if (string.IsNullOrWhiteSpace(rabbitMqHost) || string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var factory = CreateRabbitFactory(rabbitMqHost);
|
||||
using var client = factory.CreateClient();
|
||||
Assert.True(await WaitForReadyAsync(client), "API dependencies did not become ready within 10 seconds.");
|
||||
|
||||
var redisEnvironment = $"consumer-{Guid.NewGuid():N}";
|
||||
var workerBuilder = Host.CreateApplicationBuilder();
|
||||
workerBuilder.Services.AddApplication();
|
||||
workerBuilder.Services.AddInfrastructure(factory.DatabaseConnectionString);
|
||||
workerBuilder.Services.AddRedisSecurity(redisConnection, redisEnvironment);
|
||||
workerBuilder.Services.AddReliableMessaging(CreateRabbitOptions(rabbitMqHost, configureConsumers: true));
|
||||
using var worker = workerBuilder.Build();
|
||||
await worker.StartAsync();
|
||||
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var messageId = Guid.NewGuid();
|
||||
const long version = 123456789;
|
||||
using (var scope = factory.CreateSystemScope("Publish duplicate inbox test message"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var publishEndpoint = scope.ServiceProvider.GetRequiredService<IPublishEndpoint>();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||
var message = new AuthorizationStateChangedV1(
|
||||
Guid.NewGuid(), tenantId, userId, "consumer_test", version,
|
||||
DateTimeOffset.UtcNow, Guid.NewGuid().ToString("N"));
|
||||
await publishEndpoint.Publish(message, context => context.MessageId = messageId);
|
||||
await publishEndpoint.Publish(message, context => context.MessageId = messageId);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
var redisKey = $"tiku:{redisEnvironment}:auth-inv:authorization:{tenantId:N}:{userId:N}";
|
||||
var consumed = false;
|
||||
for (var attempt = 0; attempt < 60; attempt++)
|
||||
{
|
||||
var multiplexer = worker.Services.GetRequiredService<IConnectionMultiplexer>();
|
||||
if (await multiplexer.GetDatabase().StringGetAsync(redisKey) == version)
|
||||
{
|
||||
consumed = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(250);
|
||||
}
|
||||
Assert.True(consumed, "Worker did not consume the security event within 15 seconds.");
|
||||
|
||||
using (var verification = factory.CreateSystemScope("Verify duplicate consumer inbox"))
|
||||
{
|
||||
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Single(await dbContext.Set<InboxState>()
|
||||
.Where(item => item.MessageId == messageId)
|
||||
.ToArrayAsync());
|
||||
}
|
||||
|
||||
var managementEndpoint = ResolveRabbitManagementEndpoint(rabbitMqHost);
|
||||
if (managementEndpoint is not null)
|
||||
{
|
||||
Assert.Equal(0, await GetQueueMessageCountAsync(
|
||||
managementEndpoint, "security-state-changed_error"));
|
||||
}
|
||||
|
||||
var redis = worker.Services.GetRequiredService<IConnectionMultiplexer>();
|
||||
await redis.GetDatabase().KeyDeleteAsync(redisKey);
|
||||
await worker.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RabbitMq_health_and_bus_outbox_follow_database_transaction()
|
||||
{
|
||||
var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ");
|
||||
if (string.IsNullOrWhiteSpace(rabbitMqHost))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var factory = CreateRabbitFactory(rabbitMqHost);
|
||||
using var client = factory.CreateClient();
|
||||
Assert.True(await WaitForReadyAsync(client), "API dependencies did not become ready within 10 seconds.");
|
||||
var ready = await client.GetAsync("/api/health/ready");
|
||||
using var readyJson = JsonDocument.Parse(await ready.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, ready.StatusCode);
|
||||
Assert.True(readyJson.RootElement.GetProperty("rabbitMq").GetProperty("configured").GetBoolean());
|
||||
Assert.True(readyJson.RootElement.GetProperty("rabbitMq").GetProperty("ready").GetBoolean());
|
||||
|
||||
using (var rollbackScope = factory.CreateSystemScope("Verify rolled back bus outbox"))
|
||||
{
|
||||
var dbContext = rollbackScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var publisher = rollbackScope.ServiceProvider.GetRequiredService<ISecurityEventPublisher>();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||
await publisher.AuthorizationChangedAsync(
|
||||
null, null, "rollback_test", 1, Guid.NewGuid().ToString("N"));
|
||||
await dbContext.SaveChangesAsync();
|
||||
await transaction.RollbackAsync();
|
||||
}
|
||||
|
||||
using (var verification = factory.CreateSystemScope("Verify rolled back outbox is empty"))
|
||||
{
|
||||
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Empty(await dbContext.Set<OutboxMessage>().ToArrayAsync());
|
||||
}
|
||||
|
||||
using (var commitScope = factory.CreateSystemScope("Verify committed bus outbox"))
|
||||
{
|
||||
var dbContext = commitScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var publisher = commitScope.ServiceProvider.GetRequiredService<ISecurityEventPublisher>();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||
await publisher.AuthorizationChangedAsync(
|
||||
null, null, "commit_test", 2, Guid.NewGuid().ToString("N"));
|
||||
await dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
var drained = false;
|
||||
for (var attempt = 0; attempt < 40; attempt++)
|
||||
{
|
||||
using var verification = factory.CreateSystemScope("Wait for committed outbox delivery");
|
||||
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
if (!await dbContext.Set<OutboxMessage>().AnyAsync())
|
||||
{
|
||||
drained = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(250);
|
||||
}
|
||||
if (!drained)
|
||||
{
|
||||
using var diagnostics = factory.CreateSystemScope("Inspect undelivered outbox");
|
||||
var dbContext = diagnostics.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var messages = await dbContext.Set<OutboxMessage>().CountAsync();
|
||||
var states = await dbContext.Set<OutboxState>().CountAsync();
|
||||
Assert.Fail($"Committed MassTransit outbox was not delivered within 10 seconds. messages={messages}, states={states}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Api.ApiTestFactory CreateRabbitFactory(string rabbitMqHost) =>
|
||||
new(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["RabbitMq:Host"] = rabbitMqHost,
|
||||
["RabbitMq:Username"] = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest",
|
||||
["RabbitMq:Password"] = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest"
|
||||
});
|
||||
|
||||
private static MessagingOptions CreateRabbitOptions(string rabbitMqHost, bool configureConsumers) => new()
|
||||
{
|
||||
Host = rabbitMqHost,
|
||||
Username = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest",
|
||||
Password = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest",
|
||||
ConfigureConsumers = configureConsumers
|
||||
};
|
||||
|
||||
private static async Task<bool> WaitForReadyAsync(HttpClient client)
|
||||
{
|
||||
for (var attempt = 0; attempt < 40; attempt++)
|
||||
{
|
||||
if ((await client.GetAsync("/api/health/ready")).StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
await Task.Delay(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Uri? ResolveRabbitManagementEndpoint(string rabbitMqHost)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_MANAGEMENT");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return new Uri(configured);
|
||||
}
|
||||
|
||||
var broker = new Uri(rabbitMqHost);
|
||||
return broker.IsLoopback ? new Uri($"http://{broker.Host}:15672") : null;
|
||||
}
|
||||
|
||||
private static async Task<int> GetQueueMessageCountAsync(Uri managementEndpoint, string queueName)
|
||||
{
|
||||
using var client = new HttpClient { BaseAddress = managementEndpoint };
|
||||
var username = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest";
|
||||
var password = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest";
|
||||
var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
using var response = await client.GetAsync($"/api/queues/%2F/{Uri.EscapeDataString(queueName)}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return document.RootElement.GetProperty("messages").GetInt32();
|
||||
}
|
||||
}
|
||||
81
Tiku.IntegrationTests/RedisSecurityStoreTests.cs
Normal file
81
Tiku.IntegrationTests/RedisSecurityStoreTests.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.IntegrationTests;
|
||||
|
||||
public sealed class RedisSecurityStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Unavailable_redis_fails_closed_for_security_operations()
|
||||
{
|
||||
await using var provider = BuildProvider(
|
||||
"localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false",
|
||||
$"integration-unavailable-{Guid.NewGuid():N}");
|
||||
var store = provider.GetRequiredService<IRedisSecurityStore>();
|
||||
|
||||
await Assert.ThrowsAsync<RedisSecurityUnavailableException>(() => store.ConsumeAsync(
|
||||
[
|
||||
new DistributedRateLimitBucket("password:ip:test", 1, TimeSpan.FromSeconds(1))
|
||||
]));
|
||||
Assert.False(await store.PingAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_instances_share_atomic_limit_and_keys_contain_no_plaintext_identifier()
|
||||
{
|
||||
var connectionString = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var environment = $"integration-{Guid.NewGuid():N}";
|
||||
await using var first = BuildProvider(connectionString, environment);
|
||||
await using var second = BuildProvider(connectionString, environment);
|
||||
var store1 = first.GetRequiredService<IRedisSecurityStore>();
|
||||
var store2 = second.GetRequiredService<IRedisSecurityStore>();
|
||||
const string phone = "13812345678";
|
||||
var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone))).ToLowerInvariant();
|
||||
var bucket = new DistributedRateLimitBucket(
|
||||
$"sms-verify:tenant-id:login:{phoneHash}", 1, TimeSpan.FromMinutes(1));
|
||||
|
||||
try
|
||||
{
|
||||
var attempts = await Task.WhenAll(Enumerable.Range(0, 12).Select(index =>
|
||||
(index & 1) == 0
|
||||
? store1.ConsumeAsync([bucket])
|
||||
: store2.ConsumeAsync([bucket])));
|
||||
|
||||
Assert.Single(attempts, result => result.Allowed);
|
||||
Assert.All(attempts.Where(result => !result.Allowed), result => Assert.NotNull(result.RetryAfter));
|
||||
var multiplexer = first.GetRequiredService<IConnectionMultiplexer>();
|
||||
var server = multiplexer.GetServer(multiplexer.GetEndPoints().Single());
|
||||
var keys = server.Keys(pattern: $"tiku:{environment}:*").Select(key => key.ToString()).ToArray();
|
||||
Assert.NotEmpty(keys);
|
||||
Assert.DoesNotContain(keys, key => key.Contains(phone, StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
var multiplexer = first.GetRequiredService<IConnectionMultiplexer>();
|
||||
var server = multiplexer.GetServer(multiplexer.GetEndPoints().Single());
|
||||
var keys = server.Keys(pattern: $"tiku:{environment}:*").ToArray();
|
||||
if (keys.Length > 0)
|
||||
{
|
||||
await multiplexer.GetDatabase().KeyDeleteAsync(keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildProvider(string connectionString, string environment)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddRedisSecurity(connectionString, environment);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
75
Tiku.IntegrationTests/SystemScopeAuditTests.cs
Normal file
75
Tiku.IntegrationTests/SystemScopeAuditTests.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests;
|
||||
|
||||
public sealed class SystemScopeAuditTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Descriptor_is_required_and_success_is_audited()
|
||||
{
|
||||
await using var factory = new Api.ApiTestFactory();
|
||||
using var outer = factory.CreateSystemScope("Resolve execution scope");
|
||||
var executionScope = outer.ServiceProvider.GetRequiredService<ITenantExecutionScope>();
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => executionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "worker", "", "job-1"),
|
||||
(_, _) => Task.CompletedTask));
|
||||
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
await executionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "background-worker", "test audit", correlationId),
|
||||
(_, _) => Task.CompletedTask);
|
||||
|
||||
using var verification = factory.CreateSystemScope("Verify execution scope audit");
|
||||
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.AuditLogs, item => item.Action == "system_scope.entered" && item.TargetId == correlationId);
|
||||
Assert.Contains(dbContext.AuditLogs, item => item.Action == "system_scope.completed" && item.TargetId == correlationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_system_scope_rolls_back_business_write_and_persists_failure_audit()
|
||||
{
|
||||
await using var factory = new Api.ApiTestFactory();
|
||||
using var outer = factory.CreateSystemScope("Resolve execution scope");
|
||||
var executionScope = outer.ServiceProvider.GetRequiredService<ITenantExecutionScope>();
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Existing Tenant"
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => executionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(
|
||||
tenantId,
|
||||
SystemScopeCallerType.Worker,
|
||||
"background-worker",
|
||||
"verify transactional rollback",
|
||||
correlationId),
|
||||
async (provider, cancellationToken) =>
|
||||
{
|
||||
var dbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
dbContext.TenantBrandings.Add(new TenantBranding
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BrandName = "Must Roll Back"
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new InvalidOperationException("expected failure");
|
||||
}));
|
||||
|
||||
using var verification = factory.CreateSystemScope("Verify failed execution scope audit");
|
||||
var verificationDb = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.DoesNotContain(verificationDb.TenantBrandings, item => item.TenantId == tenantId);
|
||||
Assert.Contains(verificationDb.AuditLogs, item =>
|
||||
item.Action == "system_scope.entered" && item.TargetId == correlationId);
|
||||
Assert.Contains(verificationDb.AuditLogs, item =>
|
||||
item.Action == "system_scope.failed" && item.TargetId == correlationId);
|
||||
Assert.DoesNotContain(verificationDb.AuditLogs, item =>
|
||||
item.Action == "system_scope.completed" && item.TargetId == correlationId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user