Files
tiku-backend.net/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs

573 lines
26 KiB
C#

using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Tiku.Api.Controllers;
namespace Tiku.IntegrationTests;
public sealed class ArchitectureBoundaryTests
{
[Fact]
public void Api_controllers_depend_on_application_contracts_not_infrastructure()
{
var root = FindRepositoryRoot();
var violations = Directory.EnumerateFiles(
Path.Combine(root, "Tiku.Api", "Controllers"),
"*.cs",
SearchOption.AllDirectories)
.SelectMany(path => File.ReadLines(path)
.Select((line, index) => new { path, line, lineNumber = index + 1 }))
.Where(candidate =>
candidate.line.Contains("using Tiku.Infrastructure", StringComparison.Ordinal) ||
candidate.line.Contains("TikuDbContext", StringComparison.Ordinal))
.Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}")
.ToArray();
Assert.True(
violations.Length == 0,
$"API controllers crossed the Application boundary:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
[Fact]
public void Service_classes_do_not_exceed_the_hard_size_limit_or_grow_legacy_debt()
{
var root = FindRepositoryRoot();
var legacyLineBudgets = new Dictionary<string, int>(StringComparer.Ordinal)
{
["ContentManagementService"] = 1096,
["PlatformQuestionBankService"] = 1033,
["CommerceService"] = 983,
["AssetManagementService"] = 948,
["AuthService"] = 876,
["ReferralService"] = 849,
["PlatformTenantCapabilitiesService"] = 805
};
var serviceGroups = Directory.EnumerateFiles(
Path.Combine(root, "Tiku.Infrastructure"),
"*Service*.cs",
SearchOption.AllDirectories)
.Where(path => !path.Contains(
$"{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}",
StringComparison.Ordinal))
.GroupBy(path => Path.GetFileNameWithoutExtension(path).Split('.')[0], StringComparer.Ordinal)
.Select(group => new
{
Service = group.Key,
Lines = group.Sum(path => File.ReadLines(path).Count()),
Files = group.Count()
})
.ToArray();
var violations = serviceGroups
.Where(item => legacyLineBudgets.TryGetValue(item.Service, out var budget)
? item.Lines > budget
: item.Lines > 800)
.Select(item => legacyLineBudgets.TryGetValue(item.Service, out var budget)
? $"{item.Service} ({item.Lines} lines across {item.Files} files; legacy budget {budget})"
: $"{item.Service} ({item.Lines} lines across {item.Files} files; hard limit 800)")
.ToArray();
foreach (var warning in serviceGroups.Where(item =>
!legacyLineBudgets.ContainsKey(item.Service) && item.Lines is > 500 and <= 800))
Console.WriteLine(
$"Architecture warning: {warning.Service} has {warning.Lines} lines across {warning.Files} files.");
Assert.True(
violations.Length == 0,
$"Service classes exceeded the hard limit or grew legacy debt:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
[Fact]
public void Capability_contracts_and_services_remain_focused()
{
var contracts = CapabilityContracts();
var contractViolations = contracts
.Where(contract => contract.GetMethods().Length > 10)
.Select(contract => $"{contract.FullName} ({contract.GetMethods().Length} public methods)")
.ToArray();
var implementations = typeof(Tiku.Infrastructure.Persistence.TikuDbContext).Assembly
.GetTypes()
.Where(type => !type.IsAbstract && contracts.Any(contract => contract.IsAssignableFrom(type)))
.ToArray();
var methodViolations = implementations
.Select(type => new
{
Type = type,
Count = type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
.Count(method => !method.IsSpecialName)
})
.Where(item => item.Count > 10)
.Select(item => $"{item.Type.FullName} ({item.Count} public methods)")
.ToArray();
var dependencyCounts = implementations
.Select(type => new
{
Type = type,
Count = type.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Select(constructor => constructor.GetParameters().Length)
.DefaultIfEmpty(0)
.Max()
})
.ToArray();
foreach (var warning in dependencyCounts.Where(item => item.Count is > 6 and <= 8))
Console.WriteLine($"Architecture warning: {warning.Type.FullName} has {warning.Count} constructor dependencies.");
var dependencyViolations = dependencyCounts
.Where(item => item.Count > 8)
.Select(item => $"{item.Type.FullName} ({item.Count} constructor dependencies)")
.ToArray();
Assert.True(contractViolations.Length == 0,
$"Capability interfaces exceeded 10 public methods:{Environment.NewLine}{string.Join(Environment.NewLine, contractViolations)}");
Assert.True(methodViolations.Length == 0,
$"Capability services exceeded 10 public methods:{Environment.NewLine}{string.Join(Environment.NewLine, methodViolations)}");
Assert.True(dependencyViolations.Length == 0,
$"Capability services exceeded 8 constructor dependencies:{Environment.NewLine}{string.Join(Environment.NewLine, dependencyViolations)}");
}
[Fact]
public void Controllers_remain_focused_and_legacy_facades_do_not_return()
{
var controllerViolations = typeof(AuthController).Assembly.GetTypes()
.Where(type => !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type))
.Select(type => new
{
Type = type,
Count = type.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Select(constructor => constructor.GetParameters().Length)
.DefaultIfEmpty(0)
.Max()
})
.Where(item => item.Count > 8)
.Select(item => $"{item.Type.FullName} ({item.Count} constructor dependencies)")
.ToArray();
var legacyNames = new[]
{
"ITenantAdminDirectService",
"TenantAdminDirectService",
"IDirectContentService",
"DirectContentService",
"ICommerceAdminService",
"CommerceAdminService",
"ILearningActivityService",
"LearningActivityService",
"IPlatformAdminService",
"PlatformAdminService"
};
var applicationTypes = typeof(Tiku.Application.TenantAdmin.ITenantAdminDashboardService).Assembly.GetTypes();
var infrastructureTypes = typeof(Tiku.Infrastructure.Persistence.TikuDbContext).Assembly.GetTypes();
var legacyViolations = applicationTypes.Concat(infrastructureTypes)
.Where(type => legacyNames.Contains(type.Name, StringComparer.Ordinal))
.Select(type => type.FullName)
.ToArray();
Assert.True(controllerViolations.Length == 0,
$"Controllers exceeded 8 constructor dependencies:{Environment.NewLine}{string.Join(Environment.NewLine, controllerViolations)}");
Assert.True(legacyViolations.Length == 0,
$"Legacy aggregate services returned:{Environment.NewLine}{string.Join(Environment.NewLine, legacyViolations)}");
}
[Fact]
public void Background_jobs_are_dispatched_by_module_registered_handlers()
{
var root = FindRepositoryRoot();
var infrastructureRoot = Path.Combine(root, "Tiku.Infrastructure");
var sourceFiles = Directory.EnumerateFiles(infrastructureRoot, "*.cs", SearchOption.AllDirectories)
.Where(path => !path.Contains(
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}",
StringComparison.Ordinal))
.ToArray();
var handlerFiles = sourceFiles
.Where(path => File.ReadAllText(path).Contains(": IBackgroundJobHandler", StringComparison.Ordinal))
.ToArray();
var expectedJobTypes = new[]
{
"content_export",
"content_import",
"asset_security_scan",
"tenant_export",
"statistics_aggregation",
"commerce_reconciliation",
"tenant_domain_recheck"
};
Assert.Equal(expectedJobTypes.Length, handlerFiles.Length);
foreach (var jobType in expectedJobTypes)
Assert.Single(handlerFiles, path =>
File.ReadAllText(path).Contains($"JobType => \"{jobType}\"", StringComparison.Ordinal));
var moduleRegistrations = Directory
.EnumerateFiles(Path.Combine(infrastructureRoot, "Modules"), "*.cs", SearchOption.AllDirectories)
.Sum(path => File.ReadLines(path).Count(line =>
line.Contains("AddScoped<IBackgroundJobHandler", StringComparison.Ordinal)));
Assert.Equal(expectedJobTypes.Length, moduleRegistrations);
var jobsSource = string.Join(Environment.NewLine, Directory
.EnumerateFiles(Path.Combine(infrastructureRoot, "Jobs"), "*.cs", SearchOption.AllDirectories)
.Select(File.ReadAllText));
Assert.DoesNotContain("Dictionary<string, Func<Task<JsonElement>>>", jobsSource, StringComparison.Ordinal);
}
[Fact]
public void Production_authorization_does_not_depend_on_legacy_role_claims()
{
var root = FindRepositoryRoot();
var authorizationFiles = Directory
.EnumerateFiles(Path.Combine(root, "Tiku.Api", "Security"), "*.cs", SearchOption.AllDirectories)
.Append(Path.Combine(root, "Tiku.Api", "Configuration", "AuthenticationExtensions.cs"));
var forbidden = new[]
{
"TikuClaimTypes.TenantRole",
"TenantRoleAuthorization",
"PrimaryRole"
};
AssertNoForbiddenSymbols(
root,
authorizationFiles,
forbidden,
"Production authorization still depends on a legacy role claim or primary role");
}
[Fact]
public void Backoffice_controllers_do_not_construct_platform_access_flags()
{
var root = FindRepositoryRoot();
var controllerFiles = Directory.EnumerateFiles(
Path.Combine(root, "Tiku.Api", "Controllers"),
"*Backoffice*Controller.cs",
SearchOption.AllDirectories);
var forbidden = new[]
{
"new BackofficeActor(",
"IsPlatformAdmin(",
"IsPlatform ="
};
AssertNoForbiddenSymbols(
root,
controllerFiles,
forbidden,
"Backoffice controllers must use the resolved access context instead of constructing platform access flags");
}
[Fact]
public void BackendAuthorizationDoesNotUseMembershipBusinessRoles()
{
var root = FindRepositoryRoot();
var files = new[]
{
Path.Combine(root, "Tiku.Infrastructure", "Growth", "CommissionService.cs"),
Path.Combine(root, "Tiku.Infrastructure", "Growth", "ReferralService.cs")
}
.Concat(Directory.EnumerateFiles(
Path.Combine(root, "Tiku.Api", "Controllers"),
"Tenant*Controller.cs",
SearchOption.AllDirectories));
AssertNoForbiddenSymbols(
root,
files,
new[]
{
"item.Role == TenantRole.TenantOwner",
"item.Role == TenantRole.TenantAdmin",
"TenantRole Role = TenantRole.TenantAdmin",
"new TenantAdminActor("
},
"Backend authorization must use database role permissions rather than membership business roles or fabricated admin actors");
}
[Fact]
public void Auth_sessions_are_accessed_only_through_the_session_store()
{
var root = FindRepositoryRoot();
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Infrastructure" };
var allowedFiles = new[]
{
"TikuDbContext.cs",
"AuthSessionStore.cs",
"SessionStore.cs"
};
var files = sourceRoots
.SelectMany(directory => Directory.EnumerateFiles(
Path.Combine(root, directory),
"*.cs",
SearchOption.AllDirectories))
.Where(path => !path.Contains(
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}",
StringComparison.Ordinal))
.Where(path => !path.Contains(
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Configurations{Path.DirectorySeparatorChar}",
StringComparison.Ordinal))
.Where(path =>
!allowedFiles.Contains(Path.GetFileName(path), StringComparer.Ordinal) &&
!Path.GetFileName(path).StartsWith("TikuDbContext.", StringComparison.Ordinal));
AssertNoForbiddenSymbols(
root,
files,
new[] { ".AuthSessions", "Set<AuthSession>" },
"AuthSession DbSet access must be encapsulated by IAuthSessionStore");
}
[Fact]
public void Api_uses_an_authenticated_fallback_policy()
{
var root = FindRepositoryRoot();
var authenticationRegistration = File.ReadAllText(
Path.Combine(root, "Tiku.Api", "Configuration", "AuthenticationExtensions.cs"));
Assert.Contains("FallbackPolicy", authenticationRegistration, StringComparison.Ordinal);
Assert.Contains("RequireAuthenticatedUser()", authenticationRegistration, StringComparison.Ordinal);
}
[Fact]
public void Every_controller_action_declares_authorization_or_anonymous_access()
{
var controllerAssembly = typeof(AuthController).Assembly;
var violations = controllerAssembly
.GetTypes()
.Where(type => !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type))
.SelectMany(type => type
.GetMethods(BindingFlags.Instance |
BindingFlags.Public |
BindingFlags.DeclaredOnly)
.Where(method => method
.GetCustomAttributes(true)
.OfType<HttpMethodAttribute>()
.Any())
.Select(method => new
{
Controller = type,
Action = method,
Metadata = type.GetCustomAttributes(true)
.Concat(method.GetCustomAttributes(true))
}))
.Where(candidate => !candidate.Metadata.Any(attribute =>
attribute is IAuthorizeData or
IAllowAnonymous))
.Select(candidate => $"{candidate.Controller.FullName}.{candidate.Action.Name}")
.Order(StringComparer.Ordinal)
.ToArray();
Assert.True(
violations.Length == 0,
$"Controller actions without explicit authorization metadata were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
[Fact]
public void Business_code_does_not_bypass_tenant_query_boundaries()
{
var root = FindRepositoryRoot();
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Infrastructure" };
var forbidden = new[]
{
"IgnoreQueryFilters(",
"FromSql(",
"FromSqlRaw(",
"FromSqlInterpolated(",
"ExecuteSql(",
"ExecuteSqlRaw(",
"ExecuteSqlInterpolated(",
"new NpgsqlCommand("
};
var violations = sourceRoots
.SelectMany(directory => Directory.EnumerateFiles(
Path.Combine(root, directory),
"*.cs",
SearchOption.AllDirectories))
.Where(path => !path.Contains(
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}",
StringComparison.Ordinal))
.SelectMany(path => File.ReadLines(path)
.Select((line, index) => new { path, line, lineNumber = index + 1 }))
.Where(candidate => forbidden.Any(symbol =>
candidate.line.Contains(symbol, StringComparison.Ordinal)))
.Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}")
.ToArray();
Assert.True(
violations.Length == 0,
$"Forbidden tenant-boundary bypasses were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
[Fact]
public void Business_code_does_not_depend_on_supabase_or_removed_provider_models()
{
var root = FindRepositoryRoot();
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Domain", "Tiku.Infrastructure" };
var forbidden = new[]
{
"Supabase",
"supabase_storage",
"SUPABASE_",
"TenantAuthProvider",
"TenantPaymentAccount",
"TenantPaymentMode",
"TenantPaymentAccountStatus",
"TenantAuthProviderStatus"
};
var allowedPathFragments = new[]
{
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}"
};
var violations = sourceRoots
.SelectMany(directory => Directory.EnumerateFiles(
Path.Combine(root, directory),
"*.cs",
SearchOption.AllDirectories))
.Where(path => !allowedPathFragments.Any(fragment => path.Contains(fragment, StringComparison.Ordinal)))
.SelectMany(path => File.ReadLines(path)
.Select((line, index) => new { path, line, lineNumber = index + 1 }))
.Where(candidate => forbidden.Any(symbol =>
candidate.line.Contains(symbol, StringComparison.Ordinal)))
.Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}")
.ToArray();
Assert.True(
violations.Length == 0,
$"Forbidden external-provider coupling was found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
[Fact]
public void Production_code_does_not_reference_removed_wechat_payment_sdk()
{
var root = FindRepositoryRoot();
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Domain", "Tiku.Infrastructure" };
var forbidden = new[]
{
"SKIT.FlurlHttpClient.Wechat"
};
var violations = sourceRoots
.SelectMany(directory => Directory.EnumerateFiles(
Path.Combine(root, directory),
"*.cs",
SearchOption.AllDirectories))
.Where(path => !path.Contains(
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}",
StringComparison.Ordinal))
.SelectMany(path => File.ReadLines(path)
.Select((line, index) => new { path, line, lineNumber = index + 1 }))
.Where(candidate => forbidden.Any(symbol =>
candidate.line.Contains(symbol, StringComparison.Ordinal)))
.Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}")
.ToArray();
Assert.True(
violations.Length == 0,
$"Removed WeChat payment SDK references were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
[Fact]
public void Business_layers_do_not_reference_third_party_provider_sdks()
{
var root = FindRepositoryRoot();
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Domain" };
var forbidden = new[]
{
"AlibabaCloud.",
"Aliyun.OSS",
"Senparc.Weixin",
"AlipaySDKNet",
"Aop.Api",
"Microsoft.SemanticKernel"
};
var violations = sourceRoots
.SelectMany(directory => Directory.EnumerateFiles(
Path.Combine(root, directory),
"*.cs",
SearchOption.AllDirectories))
.SelectMany(path => File.ReadLines(path)
.Select((line, index) => new { path, line, lineNumber = index + 1 }))
.Where(candidate => forbidden.Any(symbol =>
candidate.line.Contains(symbol, StringComparison.Ordinal)))
.Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}")
.ToArray();
Assert.True(
violations.Length == 0,
$"Business-layer third-party provider SDK references were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
private static Type[] CapabilityContracts()
{
return
[
typeof(Tiku.Application.TenantAdmin.ITenantAdminDashboardService),
typeof(Tiku.Application.TenantAdmin.ITenantClassService),
typeof(Tiku.Application.TenantAdmin.ITenantStudentService),
typeof(Tiku.Application.TenantAdmin.ITenantSupervisionService),
typeof(Tiku.Application.TenantAdmin.ITenantStudentEngagementService),
typeof(Tiku.Application.TenantAdmin.ITenantMemberAccessService),
typeof(Tiku.Application.TenantAdmin.ITenantSiteSettingsService),
typeof(Tiku.Application.TenantAdmin.ITenantDomainProviderService),
typeof(Tiku.Application.TenantAdmin.ITenantEngagementService),
typeof(Tiku.Application.Content.IQuestionManagementService),
typeof(Tiku.Application.Content.IVocabularyManagementService),
typeof(Tiku.Application.Content.IHandbookManagementService),
typeof(Tiku.Application.Content.IEducationCatalogManagementService),
typeof(Tiku.Application.Content.IScorelineManagementService),
typeof(Tiku.Application.Content.IVideoManagementService),
typeof(Tiku.Application.Content.IOperationContentManagementService),
typeof(Tiku.Application.Content.IContentImportService),
typeof(Tiku.Application.Commerce.IPaymentConfigurationService),
typeof(Tiku.Application.Commerce.ICommerceOrderAdministrationService),
typeof(Tiku.Application.Commerce.IActivationCodeAdministrationService),
typeof(Tiku.Application.Commerce.ICouponAdministrationService),
typeof(Tiku.Application.Commerce.IRefundAdministrationService),
typeof(Tiku.Application.Commerce.IReconciliationAdministrationService),
typeof(Tiku.Application.Commerce.ICommerceAdjustmentService),
typeof(Tiku.Application.Points.IPointAdministrationService),
typeof(Tiku.Application.Learning.ILearningAnalyticsService),
typeof(Tiku.Application.Learning.IAnsweringService),
typeof(Tiku.Application.Learning.IQuestionReviewService),
typeof(Tiku.Application.Learning.IWordLearningService),
typeof(Tiku.Application.Learning.IPracticeSessionService),
typeof(Tiku.Application.Learning.IPracticeReportService),
typeof(Tiku.Application.PlatformAdmin.IPlatformDashboardService),
typeof(Tiku.Application.PlatformAdmin.ITenantProvisioningAdministrationService),
typeof(Tiku.Application.PlatformAdmin.IPlatformTenantDomainService),
typeof(Tiku.Application.PlatformAdmin.IPlatformStaffAccessService),
typeof(Tiku.Application.PlatformAdmin.IPlatformAuditAlertService),
typeof(Tiku.Application.PlatformAdmin.IPlatformDunningService)
];
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "TIKU-BACKEND.slnx")))
directory = directory.Parent;
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root was not found.");
}
private static void AssertNoForbiddenSymbols(
string root,
IEnumerable<string> files,
IReadOnlyCollection<string> forbidden,
string failureMessage)
{
var violations = files
.SelectMany(path => File.ReadLines(path)
.Select((line, index) => new { path, line, lineNumber = index + 1 }))
.Where(candidate => forbidden.Any(symbol =>
candidate.line.Contains(symbol, StringComparison.Ordinal)))
.Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}")
.ToArray();
Assert.True(
violations.Length == 0,
$"{failureMessage}:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
}