已完成管理后台第一批只读功能的 ASP.NET Core 10 迁移:

原生接口:context、dashboard、schools、school-organization、admins、exams
保持超级、校级、班级管理员的数据范围及权限隔离
其余管理写入、审批流和考务编排仍由 Node 转发
新增 ADMIN_NATIVE_READS_ENABLED=true 开关,并强制要求原生认证和共享 Redis
This commit is contained in:
2026-07-22 20:34:13 +08:00 Unverified
parent 4b0dae6d71
commit 712bd1a3a2
12 changed files with 894 additions and 5 deletions
+2
View File
@@ -25,6 +25,8 @@ PORT=4173
AUTH_NATIVE_ENABLED=false AUTH_NATIVE_ENABLED=false
# 第一批考生只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 同时使用。 # 第一批考生只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 同时使用。
CANDIDATE_NATIVE_ENABLED=false CANDIDATE_NATIVE_ENABLED=false
# 第一批管理端只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 及共享 Redis 同时使用。
ADMIN_NATIVE_READS_ENABLED=false
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。 # 仅在首次创建空数据库时使用。部署前务必修改初始密码。
INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_USERNAME=admin
+12 -3
View File
@@ -11,9 +11,9 @@
- [x] 公开首页与已发布通知(原生 SQLite / MySQL 读取) - [x] 公开首页与已发布通知(原生 SQLite / MySQL 读取)
- [x] 招生公示与 HMAC 文书验真公开接口 - [x] 招生公示与 HMAC 文书验真公开接口
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
- [ ] 考生业务 - [x] 考生业务
- [ ] 管理后台、审批流和考务编排 - [ ] 管理后台、审批流和考务编排(第一批管理端只读接口已原生化)
- [ ] 招生录取 - [x] 考生志愿填报与招生录取查询
- [ ] Excel、文书和缓存 - [ ] Excel、文书和缓存
- [ ] 容器入口切换及 Node.js 后端移除 - [ ] 容器入口切换及 Node.js 后端移除
@@ -54,6 +54,15 @@ $env:AUTH_NATIVE_ENABLED = 'true'
$env:CANDIDATE_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true'
``` ```
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。其余管理端写入、审批流和考务编排接口仍转发给 Node,因此该开关同样要求原生认证和共享 Redis:
```powershell
$env:AUTH_NATIVE_ENABLED = 'true'
$env:ADMIN_NATIVE_READS_ENABLED = 'true'
```
`GET /health/migration``administration.nativeReadsEnabled``administration.routes` 会报告这一批端点是否已切换。
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
```powershell ```powershell
+45
View File
@@ -422,6 +422,8 @@ try {
AUTH_NATIVE_ENABLED = 'true' AUTH_NATIVE_ENABLED = 'true'
CANDIDATE_NATIVE_ENABLED = 'true' CANDIDATE_NATIVE_ENABLED = 'true'
CANDIDATE_NATIVE_ALLOW_MEMORY = 'true' CANDIDATE_NATIVE_ALLOW_MEMORY = 'true'
ADMIN_NATIVE_READS_ENABLED = 'true'
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
LegacyNode__Enabled = 'true' LegacyNode__Enabled = 'true'
LegacyNode__BaseUrl = $legacyBaseUrl LegacyNode__BaseUrl = $legacyBaseUrl
DATABASE_CLIENT = 'sqlite' DATABASE_CLIENT = 'sqlite'
@@ -692,6 +694,48 @@ try {
if ($nativeLogin.ok -ne $true -or $nativeLogin.user.username -ne 'admin') { if ($nativeLogin.ok -ne $true -or $nativeLogin.user.username -ne 'admin') {
throw 'Native authentication could not verify the existing Node PBKDF2 account' throw 'Native authentication could not verify the existing Node PBKDF2 account'
} }
foreach ($adminReadRoute in @('context', 'dashboard', 'schools', 'admins', 'exams')) {
$legacyAdminRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$adminReadRoute" -WebSession $session
$nativeAdminRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$adminReadRoute" -WebSession $nativeSession
if ($nativeAdminRead.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw "Admin read route '$adminReadRoute' did not use the native ASP.NET Core endpoint"
}
Assert-JsonEquivalent -Expected $legacyAdminRead.Content -Actual $nativeAdminRead.Content -Label "Admin read route '$adminReadRoute'"
}
$superSchoolOrganization = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/school-organization" -WebSession $nativeSession -SkipHttpErrorCheck
if ($superSchoolOrganization.StatusCode -ne 403 -or $superSchoolOrganization.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native school-organization route did not preserve the school-level authorization boundary'
}
$schoolLoginBody = @{ username = 'school_admin'; password = '12345678' } | ConvertTo-Json -Compress
$legacySchoolSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$nativeSchoolSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $schoolLoginBody -WebSession $legacySchoolSession | Out-Null
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $schoolLoginBody -WebSession $nativeSchoolSession | Out-Null
foreach ($schoolAdminRoute in @('context', 'dashboard', 'school-organization', 'admins')) {
$legacySchoolRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$schoolAdminRoute" -WebSession $legacySchoolSession
$nativeSchoolRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$schoolAdminRoute" -WebSession $nativeSchoolSession
Assert-JsonEquivalent -Expected $legacySchoolRead.Content -Actual $nativeSchoolRead.Content -Label "School admin read route '$schoolAdminRoute'"
}
foreach ($superOnlyRoute in @('schools', 'exams')) {
$schoolForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$superOnlyRoute" -WebSession $nativeSchoolSession -SkipHttpErrorCheck
if ($schoolForbidden.StatusCode -ne 403 -or $schoolForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw "Native admin route '$superOnlyRoute' did not preserve the super-admin boundary"
}
}
$classLoginBody = @{ username = 'class_admin'; password = '12345678' } | ConvertTo-Json -Compress
$legacyClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$nativeClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $classLoginBody -WebSession $legacyClassSession | Out-Null
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $classLoginBody -WebSession $nativeClassSession | Out-Null
foreach ($classAdminRoute in @('context', 'dashboard')) {
$legacyClassRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$classAdminRoute" -WebSession $legacyClassSession
$nativeClassRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$classAdminRoute" -WebSession $nativeClassSession
Assert-JsonEquivalent -Expected $legacyClassRead.Content -Actual $nativeClassRead.Content -Label "Class admin read route '$classAdminRoute'"
}
$classAdminsForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admins" -WebSession $nativeClassSession -SkipHttpErrorCheck
if ($classAdminsForbidden.StatusCode -ne 403) {
throw 'Native admins route did not preserve the class-admin boundary'
}
$adminCandidateRoute = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $nativeSession -SkipHttpErrorCheck $adminCandidateRoute = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $nativeSession -SkipHttpErrorCheck
if ($adminCandidateRoute.StatusCode -ne 403) { if ($adminCandidateRoute.StatusCode -ne 403) {
throw 'Native candidate API did not enforce the candidate role boundary' throw 'Native candidate API did not enforce the candidate role boundary'
@@ -774,6 +818,7 @@ try {
NativeCandidateWrites = 'passed' NativeCandidateWrites = 'passed'
NativeCandidateDocuments = 'passed' NativeCandidateDocuments = 'passed'
NativeCandidateAdmissions = 'passed' NativeCandidateAdmissions = 'passed'
NativeAdminReads = 'passed'
} | Format-List } | Format-List
} }
finally { finally {
@@ -0,0 +1,20 @@
using System.Text.Json.Nodes;
namespace Eis.Application.Administration;
public sealed record AdminEndpointResult(int StatusCode, JsonObject Body);
public interface IAdminReadService
{
Task<AdminEndpointResult> GetContextAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetSchoolsAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetSchoolOrganizationAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdminsAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetExamsAsync(string sessionToken, CancellationToken cancellationToken);
}
@@ -0,0 +1,37 @@
namespace Eis.Infrastructure.Administration;
public sealed record AdminMigrationOptions(bool NativeReadsEnabled)
{
public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled,
bool authenticationNativeEnabled,
bool sharesLegacySessions)
{
var enabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
configuredNativeReadsEnabled);
if (enabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
"启用原生管理端读取接口前必须同时设置 AUTH_NATIVE_ENABLED=true");
}
var allowMemoryForIsolatedTesting = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"),
fallback: false);
if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting)
{
throw new InvalidOperationException(
"管理端仍有写入接口需要转发给 Node;启用原生管理端读取接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(enabled);
}
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
{
"1" or "true" or "yes" or "on" => true,
"0" or "false" or "no" or "off" => false,
_ => fallback
};
}
@@ -0,0 +1,374 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminReadService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
AdminReadSnapshotLoader snapshotLoader) : IAdminReadService
{
private static readonly IReadOnlyDictionary<string, string> LevelNames = new Dictionary<string, string>(StringComparer.Ordinal)
{
["super"] = "超级管理员",
["school"] = "校级管理员",
["class"] = "班级管理员"
};
private static readonly IReadOnlyDictionary<string, string[]> Permissions = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["super"] = ["*"],
["school"] =
[
"dashboard.read", "candidates.read", "candidates.write", "candidates.review", "registrations.read",
"registrations.review", "payments.read", "payments.write", "results.read", "centers.read", "centers.write",
"workflows.inbox"
],
["class"] =
[
"dashboard.read", "candidates.read", "candidates.review", "registrations.read", "registrations.review",
"payments.read", "payments.write", "results.read", "workflows.inbox"
]
};
public async Task<AdminEndpointResult> GetContextAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var level = Level(context.User!);
return Success(new JsonObject
{
["ok"] = true,
["admin"] = SafeUser(context.User!),
["adminLevelName"] = LevelNames[level],
["permissions"] = StringArray(Permissions[level]),
["scopeLabel"] = ScopeLabel(snapshot, context.User!),
["schools"] = new JsonArray(snapshot.Schools.Select(SchoolJson).ToArray()),
["classes"] = new JsonArray(snapshot.Classes.Select(ClassJson).ToArray())
});
}
public async Task<AdminEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var profiles = snapshot.Profiles.Where(item => InScope(user, item.SchoolId, item.ClassId)).ToArray();
var userIds = profiles.Select(item => item.UserId).ToHashSet(StringComparer.Ordinal);
var registrations = snapshot.Registrations.Where(item => userIds.Contains(item.UserId)).ToArray();
var visibleFlows = snapshot.WorkflowInstances.Where(instance =>
{
if (Level(user) == "super") return true;
var scope = WorkflowScope(snapshot, instance);
return scope is not null && InScope(user, scope.Value.SchoolId, scope.Value.ClassId) &&
(instance.AssigneeId == user.Id || instance.Status != "pending");
}).ToArray();
var logs = snapshot.AuditLogs
.Where(item => Level(user) == "super" || item.ActorId == user.Id)
.Take(8)
.Select(LogJson)
.ToArray();
return Success(new JsonObject
{
["ok"] = true,
["admin"] = SafeUser(user),
["scopeLabel"] = ScopeLabel(snapshot, user),
["permissions"] = StringArray(Permissions[Level(user)]),
["metrics"] = new JsonObject
{
["candidates"] = profiles.Length,
["pendingCandidates"] = profiles.Count(item => item.Status == "pending"),
["registrations"] = registrations.Length,
["pendingRegistrations"] = registrations.Count(item => item.Status == "pending"),
["pendingPayments"] = registrations.Count(item => item.Status == "approved" && item.PaymentStatus == "unpaid"),
["pendingFlows"] = visibleFlows.Count(item => item.Status == "pending"),
["publishedExams"] = snapshot.Exams.Count(item => item.Status == "published"),
["notices"] = snapshot.PublishedNoticeCount
},
["logs"] = new JsonArray(logs)
});
}
public async Task<AdminEndpointResult> GetSchoolsAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
if (Level(context.User!) != "super") return Error(403, "只有超级管理员可以管理学校");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var schools = snapshot.Schools.Select(school =>
{
var item = SchoolJson(school);
item["classCount"] = snapshot.Classes.Count(entry => entry.SchoolId == school.Id);
item["adminCount"] = snapshot.Users.Count(entry => entry.Role == "admin" && entry.SchoolId == school.Id);
item["candidateCount"] = snapshot.Profiles.Count(entry => entry.SchoolId == school.Id);
item["centerCount"] = snapshot.CenterSchoolIds.Count(id => id == school.Id);
return item;
}).ToArray();
return Success(new JsonObject { ["ok"] = true, ["schools"] = new JsonArray(schools) });
}
public async Task<AdminEndpointResult> GetSchoolOrganizationAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (Level(user) != "school") return Error(403, "只有校级管理员可以维护本校组织");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var school = snapshot.Schools.FirstOrDefault(item => item.Id == user.SchoolId);
var classes = snapshot.Classes.Where(item => item.SchoolId == user.SchoolId).Select(item =>
{
var output = ClassJson(item);
output["candidateCount"] = snapshot.Profiles.Count(profile => profile.ClassId == item.Id);
output["admins"] = new JsonArray(snapshot.Users
.Where(admin => admin.Role == "admin" && admin.AdminLevel == "class" && admin.ClassId == item.Id)
.Select(admin =>
{
var json = SafeUser(admin);
json["active"] = admin.Active;
return json;
}).ToArray());
return output;
}).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["school"] = school is null ? null : SchoolJson(school),
["classes"] = new JsonArray(classes)
});
}
public async Task<AdminEndpointResult> GetAdminsAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (Level(user) is not ("super" or "school")) return Error(403, "当前账号不能管理管理员");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var admins = snapshot.Users.Where(item => item.Role == "admin" &&
(Level(user) == "super" || item.AdminLevel == "class" && item.SchoolId == user.SchoolId)).Select(item =>
{
var output = SafeUser(item);
output["active"] = item.Active;
output["levelName"] = LevelNames[item.AdminLevel ?? "super"];
output["schoolName"] = snapshot.Schools.FirstOrDefault(school => school.Id == item.SchoolId)?.Name ?? string.Empty;
output["className"] = snapshot.Classes.FirstOrDefault(schoolClass => schoolClass.Id == item.ClassId)?.Name ?? string.Empty;
return output;
}).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["admins"] = new JsonArray(admins),
["schools"] = new JsonArray(snapshot.Schools.Where(item => item.IsSourceSchool).Select(SchoolJson).ToArray()),
["classes"] = new JsonArray(snapshot.Classes.Select(ClassJson).ToArray()),
["selfRegistrationEnabled"] = snapshot.SelfRegistrationEnabled
});
}
public async Task<AdminEndpointResult> GetExamsAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
if (Level(context.User!) != "super") return Error(403, "当前管理员层级无权执行此操作");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var exams = snapshot.Exams.Select(exam =>
{
var output = PublicExamJson(exam);
output["registrationCount"] = snapshot.Registrations.Count(item => item.ExamId == exam.Id);
return output;
}).ToArray();
return Success(new JsonObject { ["ok"] = true, ["exams"] = new JsonArray(exams) });
}
private async Task<ResolvedAdmin> ResolveAsync(string sessionToken, CancellationToken cancellationToken)
{
if (sessionToken.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
var userId = await authenticationState.GetSessionUserIdAsync(sessionToken);
if (userId is null) return ResolvedAdmin.Failed(Error(401, "请先登录"));
var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken);
if (user is not { Active: true, ArchivedAt: null }) return ResolvedAdmin.Failed(Error(401, "请先登录"));
return user.Role == "admin"
? new ResolvedAdmin(user, null)
: ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作"));
}
private static (string? SchoolId, string? ClassId)? WorkflowScope(AdminReadSnapshot snapshot, AdminWorkflowInstance instance)
{
if (instance.BusinessType == "profile_change")
{
var profile = snapshot.Profiles.FirstOrDefault(item => item.Id == instance.BusinessId);
return profile is null ? null : (profile.SchoolId, profile.ClassId);
}
if (instance.BusinessType == "registration_review")
{
var registration = snapshot.Registrations.FirstOrDefault(item => item.Id == instance.BusinessId);
var profile = snapshot.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
return profile is null ? null : (profile.SchoolId, profile.ClassId);
}
if (instance.BusinessType == "center_change")
{
var change = snapshot.CenterChanges.FirstOrDefault(item => item.Id == instance.BusinessId);
return change is null ? null : (change.SchoolId, null);
}
if (instance.BusinessType == "candidate_account_batch")
{
var batch = snapshot.AccountBatches.FirstOrDefault(item => item.Id == instance.BusinessId);
return batch is null ? null : (batch.SchoolId, null);
}
if (instance.BusinessType == "score_appeal")
{
var result = snapshot.Results.FirstOrDefault(item => item.Id == instance.BusinessId);
var registration = snapshot.Registrations.FirstOrDefault(item => item.Id == result?.RegistrationId);
var profile = snapshot.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
return profile is null ? null : (profile.SchoolId, profile.ClassId);
}
return null;
}
private static bool InScope(AuthenticationUser user, string? schoolId, string? classId) => Level(user) switch
{
"super" => true,
"school" => user.SchoolId is not null && schoolId == user.SchoolId,
_ => user.ClassId is not null && classId == user.ClassId
};
private static string ScopeLabel(AdminReadSnapshot snapshot, AuthenticationUser user)
{
if (Level(user) == "super") return "全部学校与班级";
var school = snapshot.Schools.FirstOrDefault(item => item.Id == user.SchoolId)?.Name ?? "未绑定学校";
if (Level(user) == "school") return school;
var schoolClass = snapshot.Classes.FirstOrDefault(item => item.Id == user.ClassId)?.Name ?? "未绑定班级";
return $"{school} · {schoolClass}";
}
private static JsonObject PublicExamJson(AdminExam item)
{
var json = ExamJson(item);
json["totalScore"] = item.Subjects.Sum(subject => subject.FullScore);
json["registrationState"] = item.ArchivedAt is not null
? "archived"
: DateTimeOffset.UtcNow < ParseDate(item.RegistrationStart) ? "upcoming"
: DateTimeOffset.UtcNow > ParseDate(item.RegistrationEnd) ? "closed"
: "open";
return json;
}
private static JsonObject ExamJson(AdminExam item) => new()
{
["id"] = item.Id,
["code"] = item.Code,
["name"] = item.Name,
["description"] = item.Description,
["registrationStart"] = item.RegistrationStart,
["registrationEnd"] = item.RegistrationEnd,
["examStart"] = item.ExamStart,
["examEnd"] = item.ExamEnd,
["admitDownloadStart"] = item.AdmitDownloadStart,
["admitDownloadEnd"] = item.AdmitDownloadEnd,
["location"] = item.Location,
["passPolicy"] = item.PassPolicy,
["passValue"] = item.PassValue,
["status"] = item.Status,
["archivedAt"] = JsonValue.Create(item.ArchivedAt),
["archivedBy"] = JsonValue.Create(item.ArchivedBy),
["createdAt"] = item.CreatedAt,
["subjects"] = new JsonArray(item.Subjects.Select(SubjectJson).ToArray())
};
private static JsonObject SubjectJson(AdminSubject item) => new()
{
["id"] = item.Id,
["name"] = item.Name,
["date"] = item.Date,
["start"] = item.Start,
["end"] = item.End,
["fee"] = item.Fee,
["fullScore"] = item.FullScore,
["passRule"] = item.PassRule,
["passValue"] = item.PassValue,
["passScore"] = JsonValue.Create(item.PassScore),
["order"] = item.Order
};
private static JsonObject SchoolJson(AdminSchool item) => new()
{
["id"] = item.Id,
["name"] = item.Name,
["code"] = item.Code,
["address"] = item.Address,
["isSourceSchool"] = item.IsSourceSchool,
["isAdmissionSchool"] = item.IsAdmissionSchool,
["active"] = item.Active
};
private static JsonObject ClassJson(AdminClass item) => new()
{
["id"] = item.Id,
["schoolId"] = item.SchoolId,
["name"] = item.Name,
["grade"] = item.Grade,
["active"] = item.Active
};
private static JsonObject SafeUser(AuthenticationUser item) => new()
{
["id"] = item.Id,
["username"] = item.Username,
["role"] = item.Role,
["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null,
["schoolId"] = JsonValue.Create(item.SchoolId),
["classId"] = JsonValue.Create(item.ClassId),
["displayName"] = item.DisplayName,
["candidateNumber"] = JsonValue.Create(item.CandidateNumber),
["mustChangePassword"] = item.MustChangePassword,
["totpEnabled"] = item.TotpEnabled,
["archived"] = item.ArchivedAt is not null
};
private static JsonObject SafeUser(AdminUser item) => new()
{
["id"] = item.Id,
["username"] = item.Username,
["role"] = item.Role,
["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null,
["schoolId"] = JsonValue.Create(item.SchoolId),
["classId"] = JsonValue.Create(item.ClassId),
["displayName"] = item.DisplayName,
["candidateNumber"] = JsonValue.Create(item.CandidateNumber),
["mustChangePassword"] = item.MustChangePassword,
["totpEnabled"] = item.TotpEnabled,
["archived"] = item.ArchivedAt is not null
};
private static JsonObject LogJson(AdminAuditLog item) => new()
{
["id"] = item.Id,
["actorId"] = JsonValue.Create(item.ActorId),
["action"] = item.Action,
["detail"] = item.Detail,
["createdAt"] = item.CreatedAt
};
private static JsonArray StringArray(IEnumerable<string> values) =>
new(values.Select(value => JsonValue.Create(value)).ToArray());
private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
private static DateTimeOffset ParseDate(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed
: DateTimeOffset.MinValue;
private static AdminEndpointResult Success(JsonObject body) => new(200, body);
private static AdminEndpointResult Error(int status, string message) =>
new(status, new JsonObject { ["ok"] = false, ["message"] = message });
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
{
public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error);
}
}
@@ -0,0 +1,255 @@
using System.Data.Common;
using System.Globalization;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed record AdminReadSnapshot(
IReadOnlyList<AdminSchool> Schools,
IReadOnlyList<AdminClass> Classes,
IReadOnlyList<AdminUser> Users,
IReadOnlyList<AdminProfile> Profiles,
IReadOnlyList<AdminRegistration> Registrations,
IReadOnlyList<AdminExam> Exams,
IReadOnlyList<AdminWorkflowInstance> WorkflowInstances,
IReadOnlyList<AdminScopeBusiness> CenterChanges,
IReadOnlyList<AdminScopeBusiness> AccountBatches,
IReadOnlyList<AdminResultScope> Results,
IReadOnlyList<AdminAuditLog> AuditLogs,
IReadOnlyList<string> CenterSchoolIds,
int PublishedNoticeCount,
bool SelfRegistrationEnabled);
internal sealed record AdminSchool(
string Id,
string Name,
string Code,
string Address,
bool IsSourceSchool,
bool IsAdmissionSchool,
bool Active);
internal sealed record AdminClass(string Id, string SchoolId, string Name, string Grade, bool Active);
internal sealed record AdminUser(
string Id,
string Username,
string Role,
string? AdminLevel,
string? SchoolId,
string? ClassId,
string DisplayName,
string? CandidateNumber,
bool Active,
bool MustChangePassword,
bool TotpEnabled,
string? ArchivedAt,
string CreatedAt);
internal sealed record AdminProfile(string Id, string UserId, string? SchoolId, string? ClassId, string Status);
internal sealed record AdminRegistration(
string Id,
string UserId,
string ExamId,
string Status,
string PaymentStatus);
internal sealed record AdminExam(
string Id,
string Code,
string Name,
string Description,
string RegistrationStart,
string RegistrationEnd,
string ExamStart,
string ExamEnd,
string AdmitDownloadStart,
string AdmitDownloadEnd,
string Location,
string PassPolicy,
double PassValue,
string Status,
string? ArchivedAt,
string? ArchivedBy,
string CreatedAt,
IReadOnlyList<AdminSubject> Subjects);
internal sealed record AdminSubject(
string Id,
string Name,
string Date,
string Start,
string End,
double Fee,
double FullScore,
string PassRule,
double PassValue,
double? PassScore,
int Order);
internal sealed record AdminWorkflowInstance(
string Id,
string BusinessType,
string BusinessId,
string Status,
string? AssigneeId);
internal sealed record AdminScopeBusiness(string Id, string SchoolId);
internal sealed record AdminResultScope(string Id, string RegistrationId);
internal sealed record AdminAuditLog(string Id, string? ActorId, string Action, string Detail, string CreatedAt);
internal sealed class AdminReadSnapshotLoader(IRelationalConnectionFactory connectionFactory)
{
public async Task<AdminReadSnapshot> LoadAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var schools = await QueryAsync(connection,
"SELECT id, name, code, address, is_source_school, is_admission_school, active FROM schools ORDER BY name, id",
reader => new AdminSchool(
Text(reader, "id"), Text(reader, "name"), Text(reader, "code"), Optional(reader, "address") ?? string.Empty,
Boolean(reader, "is_source_school"), Boolean(reader, "is_admission_school"), Boolean(reader, "active")),
cancellationToken);
var classes = await QueryAsync(connection,
"SELECT id, school_id, name, grade, active FROM school_classes ORDER BY school_id, grade, name, id",
reader => new AdminClass(Text(reader, "id"), Text(reader, "school_id"), Text(reader, "name"), Text(reader, "grade"), Boolean(reader, "active")),
cancellationToken);
var users = await QueryAsync(connection,
"SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, active, must_change_password, totp_enabled, archived_at, created_at FROM users ORDER BY created_at, id",
reader => new AdminUser(
Text(reader, "id"), Text(reader, "username"), Text(reader, "role"), Optional(reader, "admin_level"),
Optional(reader, "school_id"), Optional(reader, "class_id"), Text(reader, "display_name"), Optional(reader, "candidate_number"),
Boolean(reader, "active"), Boolean(reader, "must_change_password"), Boolean(reader, "totp_enabled"),
Optional(reader, "archived_at"), Text(reader, "created_at")),
cancellationToken);
var profiles = await QueryAsync(connection,
"SELECT id, user_id, school_id, class_id, status FROM candidate_profiles ORDER BY updated_at, id",
reader => new AdminProfile(Text(reader, "id"), Text(reader, "user_id"), Optional(reader, "school_id"), Optional(reader, "class_id"), Text(reader, "status")),
cancellationToken);
var registrations = await QueryAsync(connection,
"SELECT id, user_id, exam_id, status, payment_status FROM registrations ORDER BY created_at, id",
reader => new AdminRegistration(Text(reader, "id"), Text(reader, "user_id"), Text(reader, "exam_id"), Text(reader, "status"), Text(reader, "payment_status")),
cancellationToken);
var subjects = await QueryAsync(connection,
"SELECT id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position FROM exam_subjects ORDER BY exam_id, position, id",
ReadSubject,
cancellationToken);
var subjectsByExam = subjects.GroupBy(item => item.ExamId)
.ToDictionary(group => group.Key, group => (IReadOnlyList<AdminSubject>)group.Select(item => item.Subject).ToArray(), StringComparer.Ordinal);
var exams = await QueryAsync(connection,
"SELECT id, code, name, description, registration_start, registration_end, exam_start, exam_end, admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at FROM exams ORDER BY created_at, id",
reader => ReadExam(reader, subjectsByExam),
cancellationToken);
var instances = await QueryAsync(connection,
"SELECT id, business_type, business_id, status, assignee_id FROM workflow_instances ORDER BY created_at DESC, id",
reader => new AdminWorkflowInstance(
Text(reader, "id"), Text(reader, "business_type"), Text(reader, "business_id"), Text(reader, "status"), Optional(reader, "assignee_id")),
cancellationToken);
var centerChanges = await QueryAsync(connection,
"SELECT id, school_id FROM center_change_requests ORDER BY created_at DESC, id",
reader => new AdminScopeBusiness(Text(reader, "id"), Text(reader, "school_id")),
cancellationToken);
var batches = await QueryAsync(connection,
"SELECT id, school_id FROM candidate_account_batches ORDER BY created_at DESC, id",
reader => new AdminScopeBusiness(Text(reader, "id"), Text(reader, "school_id")),
cancellationToken);
var results = await QueryAsync(connection,
"SELECT id, registration_id FROM results ORDER BY id",
reader => new AdminResultScope(Text(reader, "id"), Text(reader, "registration_id")),
cancellationToken);
var logs = await QueryAsync(connection,
"SELECT id, actor_id, action, detail, created_at FROM audit_logs ORDER BY created_at DESC, id DESC",
reader => new AdminAuditLog(Text(reader, "id"), Optional(reader, "actor_id"), Text(reader, "action"), Text(reader, "detail"), Text(reader, "created_at")),
cancellationToken);
var centerSchoolIds = await QueryAsync(connection,
"SELECT school_id FROM test_centers ORDER BY school_id, name, id",
reader => Text(reader, "school_id"),
cancellationToken);
var noticeCount = await ScalarIntAsync(connection, "SELECT COUNT(*) FROM notices WHERE status = 'published'", cancellationToken);
var selfRegistration = await ScalarIntAsync(connection, "SELECT self_registration_enabled FROM schema_metadata WHERE id = 1", cancellationToken) != 0;
return new AdminReadSnapshot(
schools, classes, users, profiles, registrations, exams, instances, centerChanges, batches, results, logs,
centerSchoolIds, noticeCount, selfRegistration);
}
private static SubjectRow ReadSubject(DbDataReader reader)
{
var fullScore = Number(reader, "full_score", 150);
var rawRule = Optional(reader, "pass_rule") ?? "fixed_score";
var passRule = rawRule == "score_ratio" ? "rank_percent" : rawRule;
var passValue = NullableNumber(reader, "pass_value") ?? NullableNumber(reader, "pass_score") ?? fullScore * 0.6;
return new SubjectRow(
Text(reader, "exam_id"),
new AdminSubject(
Text(reader, "id"), Text(reader, "name"), Text(reader, "subject_date"), Text(reader, "start_time"), Text(reader, "end_time"),
Number(reader, "fee"), fullScore, passRule, passValue,
passRule == "fixed_score" ? Math.Round(passValue, 2, MidpointRounding.AwayFromZero) : null,
Integer(reader, "position")));
}
private static AdminExam ReadExam(DbDataReader reader, IReadOnlyDictionary<string, IReadOnlyList<AdminSubject>> subjects)
{
var id = Text(reader, "id");
var rawPolicy = Optional(reader, "pass_policy") ?? "rank_percent";
return new AdminExam(
id, Text(reader, "code"), Text(reader, "name"), Text(reader, "description"),
Text(reader, "registration_start"), Text(reader, "registration_end"), Text(reader, "exam_start"), Text(reader, "exam_end"),
Text(reader, "admit_download_start"), Text(reader, "admit_download_end"), Text(reader, "location"),
rawPolicy == "score_ratio" ? "rank_percent" : rawPolicy, Number(reader, "pass_value", 60), Text(reader, "status"),
Optional(reader, "archived_at"), Optional(reader, "archived_by"), Text(reader, "created_at"),
subjects.GetValueOrDefault(id) ?? []);
}
private static async Task<IReadOnlyList<T>> QueryAsync<T>(
DbConnection connection,
string sql,
Func<DbDataReader, T> map,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var output = new List<T>();
while (await reader.ReadAsync(cancellationToken)) output.Add(map(reader));
return output;
}
private static async Task<int> ScalarIntAsync(DbConnection connection, string sql, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
}
private static string Text(DbDataReader reader, string name) =>
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
private static string? Optional(DbDataReader reader, string name)
{
var ordinal = reader.GetOrdinal(name);
return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
}
private static bool Boolean(DbDataReader reader, string name) =>
Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
private static int Integer(DbDataReader reader, string name) =>
Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
private static double Number(DbDataReader reader, string name, double fallback = 0)
{
var ordinal = reader.GetOrdinal(name);
return reader.IsDBNull(ordinal) ? fallback : Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
}
private static double? NullableNumber(DbDataReader reader, string name)
{
var ordinal = reader.GetOrdinal(name);
return reader.IsDBNull(ordinal) ? null : Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
}
private sealed record SubjectRow(string ExamId, AdminSubject Subject);
}
@@ -1,7 +1,9 @@
using Eis.Application.Authentication; using Eis.Application.Authentication;
using Eis.Application.Administration;
using Eis.Application.Candidate; using Eis.Application.Candidate;
using Eis.Application.Public; using Eis.Application.Public;
using Eis.Infrastructure.Authentication; using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Administration;
using Eis.Infrastructure.Candidate; using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Data; using Eis.Infrastructure.Data;
using Eis.Infrastructure.Public; using Eis.Infrastructure.Public;
@@ -27,7 +29,8 @@ public static class DependencyInjection
DatabaseOptions databaseOptions, DatabaseOptions databaseOptions,
DocumentVerificationOptions documentVerificationOptions, DocumentVerificationOptions documentVerificationOptions,
AuthenticationOptions authenticationOptions, AuthenticationOptions authenticationOptions,
CandidateMigrationOptions candidateMigrationOptions) CandidateMigrationOptions candidateMigrationOptions,
AdminMigrationOptions adminMigrationOptions)
{ {
services.AddSingleton(databaseOptions); services.AddSingleton(databaseOptions);
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>(); services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
@@ -48,6 +51,9 @@ public static class DependencyInjection
services.AddScoped<CandidateWriteRepository>(); services.AddScoped<CandidateWriteRepository>();
services.AddScoped<CandidateAdmissionRepository>(); services.AddScoped<CandidateAdmissionRepository>();
services.AddScoped<ICandidateService, CandidateService>(); services.AddScoped<ICandidateService, CandidateService>();
services.AddSingleton(adminMigrationOptions);
services.AddScoped<AdminReadSnapshotLoader>();
services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IPublicQueryService, PublicQueryService>(); services.AddScoped<IPublicQueryService, PublicQueryService>();
return services; return services;
} }
@@ -0,0 +1,39 @@
using Eis.Application.Administration;
using Eis.Infrastructure.Administration;
namespace Eis.Web.Administration;
public static class NativeAdminReadEndpoints
{
public static IEndpointRouteBuilder MapNativeAdminReadEndpoints(
this IEndpointRouteBuilder endpoints,
AdminMigrationOptions options)
{
if (!options.NativeReadsEnabled) return endpoints;
endpoints.MapGet("/api/admin/context", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetContextAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/dashboard", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetDashboardAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/schools", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetSchoolsAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/school-organization", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetSchoolOrganizationAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/admins", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdminsAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/exams", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetExamsAsync(Token(context), cancellationToken)));
return endpoints;
}
private static async Task<IResult> Execute(HttpContext context, Task<AdminEndpointResult> operation)
{
var result = await operation;
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
return Results.Json(result.Body, statusCode: result.StatusCode);
}
private static string Token(HttpContext context) =>
context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty;
}
+16 -1
View File
@@ -1,5 +1,6 @@
using System.Net; using System.Net;
using Eis.Infrastructure.Authentication; using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Administration;
using Eis.Infrastructure.Candidate; using Eis.Infrastructure.Candidate;
using Eis.Application.Public; using Eis.Application.Public;
using Eis.Infrastructure; using Eis.Infrastructure;
@@ -8,6 +9,7 @@ using Eis.Infrastructure.Migration;
using Eis.Infrastructure.Security; using Eis.Infrastructure.Security;
using Eis.Web.Configuration; using Eis.Web.Configuration;
using Eis.Web.Authentication; using Eis.Web.Authentication;
using Eis.Web.Administration;
using Eis.Web.Candidate; using Eis.Web.Candidate;
using Eis.Web.Frontend; using Eis.Web.Frontend;
using Eis.Web.Legacy; using Eis.Web.Legacy;
@@ -40,11 +42,16 @@ var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("CandidateMigration:NativeEnabled"), builder.Configuration.GetValue<bool>("CandidateMigration:NativeEnabled"),
authenticationOptions.NativeEnabled, authenticationOptions.NativeEnabled,
authenticationOptions.SharesLegacySessions); authenticationOptions.SharesLegacySessions);
var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("AdminMigration:NativeReadsEnabled"),
authenticationOptions.NativeEnabled,
authenticationOptions.SharesLegacySessions);
builder.Services.AddEisInfrastructure( builder.Services.AddEisInfrastructure(
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
authenticationOptions, authenticationOptions,
candidateMigrationOptions); candidateMigrationOptions,
adminMigrationOptions);
var app = builder.Build(); var app = builder.Build();
app.Services.EnsureNativeAuthenticationReady(authenticationOptions); app.Services.EnsureNativeAuthenticationReady(authenticationOptions);
@@ -90,6 +97,13 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
} }
: [] : []
}, },
administration = new
{
nativeReadsEnabled = adminMigrationOptions.NativeReadsEnabled,
nativeRoutes = adminMigrationOptions.NativeReadsEnabled
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
: []
},
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
}, statusCode: statusCode); }, statusCode: statusCode);
}); });
@@ -97,6 +111,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
app.MapNativePublicEndpoints(); app.MapNativePublicEndpoints();
app.MapNativeAuthenticationEndpoints(authenticationOptions); app.MapNativeAuthenticationEndpoints(authenticationOptions);
app.MapNativeCandidateEndpoints(candidateMigrationOptions); app.MapNativeCandidateEndpoints(candidateMigrationOptions);
app.MapNativeAdminReadEndpoints(adminMigrationOptions);
string[] methods = string[] methods =
[ [
+3
View File
@@ -9,6 +9,9 @@
"CandidateMigration": { "CandidateMigration": {
"NativeEnabled": false "NativeEnabled": false
}, },
"AdminMigration": {
"NativeReadsEnabled": false
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
@@ -0,0 +1,84 @@
using Eis.Infrastructure.Administration;
namespace Eis.Infrastructure.Tests.Administration;
public sealed class AdminMigrationOptionsTests
{
[Fact]
public void DisabledByDefault()
{
WithEnvironment(null, null, () =>
{
var options = AdminMigrationOptions.FromEnvironment(
configuredNativeReadsEnabled: false,
authenticationNativeEnabled: false,
sharesLegacySessions: false);
Assert.False(options.NativeReadsEnabled);
});
}
[Fact]
public void RequiresNativeAuthentication()
{
WithEnvironment("true", "true", () =>
{
var exception = Assert.Throws<InvalidOperationException>(() =>
AdminMigrationOptions.FromEnvironment(
configuredNativeReadsEnabled: false,
authenticationNativeEnabled: false,
sharesLegacySessions: false));
Assert.Contains("AUTH_NATIVE_ENABLED=true", exception.Message);
});
}
[Fact]
public void RequiresSharedSessionsOutsideIsolatedTests()
{
WithEnvironment("true", null, () =>
{
var exception = Assert.Throws<InvalidOperationException>(() =>
AdminMigrationOptions.FromEnvironment(
configuredNativeReadsEnabled: false,
authenticationNativeEnabled: true,
sharesLegacySessions: false));
Assert.Contains("Redis", exception.Message);
});
}
[Fact]
public void AllowsSharedRedisSessions()
{
WithEnvironment("true", null, () =>
{
var options = AdminMigrationOptions.FromEnvironment(
configuredNativeReadsEnabled: false,
authenticationNativeEnabled: true,
sharesLegacySessions: true);
Assert.True(options.NativeReadsEnabled);
});
}
private static void WithEnvironment(
string? nativeReadsEnabled,
string? allowMemory,
Action test)
{
var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED");
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
try
{
Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", nativeReadsEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
test();
}
finally
{
Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", previousNativeReadsEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
}
}
}