From 9de05b44b865871b21e6080ce7bf9fa68dbfa786 Mon Sep 17 00:00:00 2001
From: fuenmao <980740792@qq.com>
Date: Thu, 17 Sep 2026 11:37:39 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=9B=B8=E5=85=B3bug?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Entity/Onboarding/ObCompany.cs | 14 +++---
.../Entity/Onboarding/ObDepartment.cs | 4 +-
.../Entity/Onboarding/ObPosition.cs | 2 +-
.../Common/ContractTemplateVersionValidity.cs | 21 +++++++++
.../Company/Dto/OnboardingCompanyInput.cs | 45 +++++++++++++++++++
.../Company/OnboardingCompanyService.cs | 8 +++-
.../OnboardingContractDocumentService.cs | 5 ++-
.../Dto/OnboardingContractVersionInput.cs | 4 +-
.../OnboardingContractTemplateService.cs | 18 ++++++++
.../Dto/OnboardingDepartmentInput.cs | 10 ++++-
.../OnboardingElectronicSignService.cs | 8 +++-
.../OnboardingTemplateService.cs | 5 ++-
.../Position/Dto/OnboardingPositionInput.cs | 10 ++++-
.../Onboarding/Task/OnboardingTaskService.cs | 24 +++++-----
tests/SecurityRegression/Program.cs | 21 +++++++++
.../SecurityRegression.csproj | 1 +
tests/SecurityRegression/Stubs.cs | 1 +
17 files changed, 171 insertions(+), 30 deletions(-)
create mode 100644 SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionValidity.cs
diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs b/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs
index 008e992..2a576c9 100644
--- a/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs
+++ b/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs
@@ -35,35 +35,35 @@ public class ObCompany : BaseEntity
///
/// 统一社会信用代码。
///
- public string UnifiedSocialCreditCode { get; set; }
+ public virtual string UnifiedSocialCreditCode { get; set; }
///
/// 法定代表人姓名。
///
- public string LegalRepresentative { get; set; }
+ public virtual string LegalRepresentative { get; set; }
///
/// 工商注册地址。
///
- public string RegisteredAddress { get; set; }
+ public virtual string RegisteredAddress { get; set; }
///
/// 公司联系电话。
///
- public string ContactPhone { get; set; }
+ public virtual string ContactPhone { get; set; }
///
/// 契约锁默认业务分类 Id;每个签约公司独立配置。
///
- public string QiyuesuoCategoryId { get; set; }
+ public virtual string QiyuesuoCategoryId { get; set; }
///
/// 契约锁企业印章 Id;每个签约公司独立配置。
///
- public string QiyuesuoSealId { get; set; }
+ public virtual string QiyuesuoSealId { get; set; }
///
/// 业务备注。
///
- public string Remark { get; set; }
+ public virtual string Remark { get; set; }
}
diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObDepartment.cs b/SimpleAdmin.Application/Entity/Onboarding/ObDepartment.cs
index fbd3e23..0f6a60e 100644
--- a/SimpleAdmin.Application/Entity/Onboarding/ObDepartment.cs
+++ b/SimpleAdmin.Application/Entity/Onboarding/ObDepartment.cs
@@ -1,4 +1,4 @@
-namespace SimpleAdmin.Application;
+namespace SimpleAdmin.Application;
///
/// 入职业务部门档案,属于某家入职公司。
@@ -30,7 +30,7 @@ public class ObDepartment : BaseEntity
///
/// 业务备注。
///
- public string Remark { get; set; }
+ public virtual string Remark { get; set; }
///
/// 所属公司名称 (展示用)
diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs b/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs
index 6adaa46..298c63c 100644
--- a/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs
+++ b/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs
@@ -56,7 +56,7 @@ public class ObPosition : BaseEntity
///
/// 业务备注。
///
- public string Remark { get; set; }
+ public virtual string Remark { get; set; }
///
/// 所属公司名称 (展示用)
diff --git a/SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionValidity.cs b/SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionValidity.cs
new file mode 100644
index 0000000..f65a5f9
--- /dev/null
+++ b/SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionValidity.cs
@@ -0,0 +1,21 @@
+namespace SimpleAdmin.Application;
+
+///
+/// 签署材料模板版本的时间有效性规则。
+///
+internal static class ContractTemplateVersionValidity
+{
+ internal static void ValidateRange(DateTime? effectiveAt, DateTime? expiresAt)
+ {
+ if (effectiveAt.HasValue && expiresAt.HasValue && expiresAt.Value <= effectiveAt.Value)
+ throw Oops.Bah("失效时间必须晚于生效时间");
+ }
+
+ internal static bool IsAvailable(ObContractTemplateVersion version, DateTime now)
+ {
+ return version != null
+ && version.VersionStatus == ContractTemplateVersionStatus.Enabled
+ && (!version.EffectiveDate.HasValue || version.EffectiveDate.Value <= now)
+ && (!version.ExpiryDate.HasValue || version.ExpiryDate.Value > now);
+ }
+}
diff --git a/SimpleAdmin.Application/Services/Onboarding/Company/Dto/OnboardingCompanyInput.cs b/SimpleAdmin.Application/Services/Onboarding/Company/Dto/OnboardingCompanyInput.cs
index 1f12c77..9921a46 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Company/Dto/OnboardingCompanyInput.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Company/Dto/OnboardingCompanyInput.cs
@@ -30,19 +30,64 @@ public class OnboardingCompanyAddInput : ObCompany
/// 公司业务编码。
///
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司编码不能为空")]
+ [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司编码不能超过50个字符")]
public override string CompanyCode { get; set; }
///
/// 公司名称。
///
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司名称不能为空")]
+ [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司名称不能超过50个字符")]
public override string CompanyName { get; set; }
///
/// 合同甲方完整法定名称。
///
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "合同甲方全称不能为空")]
+ [global::System.ComponentModel.DataAnnotations.MaxLength(100, ErrorMessage = "合同甲方全称不能超过100个字符")]
public override string LegalName { get; set; }
+
+ ///
+ /// 统一社会信用代码。
+ ///
+ [global::System.ComponentModel.DataAnnotations.MaxLength(18, ErrorMessage = "统一社会信用代码不能超过18位")]
+ public override string UnifiedSocialCreditCode { get; set; }
+
+ ///
+ /// 法定代表人姓名。
+ ///
+ [global::System.ComponentModel.DataAnnotations.MaxLength(20, ErrorMessage = "法定代表人不能超过20个字符")]
+ public override string LegalRepresentative { get; set; }
+
+ ///
+ /// 工商注册地址。
+ ///
+ [global::System.ComponentModel.DataAnnotations.MaxLength(150, ErrorMessage = "注册地址不能超过150个字符")]
+ public override string RegisteredAddress { get; set; }
+
+ ///
+ /// 公司联系电话。
+ ///
+ [global::System.ComponentModel.DataAnnotations.MaxLength(20, ErrorMessage = "联系电话不能超过20个字符")]
+ public override string ContactPhone { get; set; }
+
+ ///
+ /// 契约锁默认业务分类 Id;每个签约公司独立配置。
+ ///
+ [global::System.ComponentModel.DataAnnotations.MaxLength(19, ErrorMessage = "契约锁业务分类Id不能超过19位数字")]
+ public override string QiyuesuoCategoryId { get; set; }
+
+ ///
+ /// 契约锁企业印章 Id;每个签约公司独立配置。
+ ///
+ [global::System.ComponentModel.DataAnnotations.MaxLength(19, ErrorMessage = "契约锁印章Id不能超过19位数字")]
+ public override string QiyuesuoSealId { get; set; }
+
+ ///
+ /// 业务备注。
+ ///
+ [global::System.ComponentModel.DataAnnotations.MaxLength(300, ErrorMessage = "备注不能超过300个字符")]
+ public override string Remark { get; set; }
}
///
diff --git a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs
index 518835e..87cbe5e 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs
@@ -138,10 +138,16 @@ public class OnboardingCompanyService : DbRepository, IOnboardingComp
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写");
if (!hasCategoryId)
return;
+ if (!company.QiyuesuoCategoryId.All(char.IsDigit)
+ || !company.QiyuesuoSealId.All(char.IsDigit))
+ {
+ throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写纯数字");
+ }
+
if (!long.TryParse(company.QiyuesuoCategoryId, out _)
|| !long.TryParse(company.QiyuesuoSealId, out _))
{
- throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写数字");
+ throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 超出有效数字范围(最大支持19位长整数)");
}
}
diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs
index 3ef15f3..a59d9b3 100644
--- a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs
@@ -153,13 +153,14 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
var versions = await _db.Queryable()
.Where(item => versionIds.Contains(item.Id))
.ToListAsync();
+ var now = DateTime.Now;
var versionsValid = versions.Count == versionIds.Count
&& contractConfigs.All(config => versions.Any(version =>
version.Id == config.ContractTemplateVersionId
&& version.TemplateId == config.ContractTemplateId
- && version.VersionStatus == ContractTemplateVersionStatus.Enabled));
+ && ContractTemplateVersionValidity.IsAvailable(version, now)));
if (!versionsValid)
- throw Oops.Bah("入职模板包含不存在、未启用或归属错误的合同版本");
+ throw Oops.Bah("入职模板包含不存在、未生效、已失效、未启用或归属错误的签署材料版本");
var inputs = input.ContractVariables
?? new List();
diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs
index 2bf12f9..d768607 100644
--- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs
@@ -46,12 +46,12 @@ public class OnboardingContractVersionAddInput
public string FileHash { get; set; }
///
- /// 生效日期。
+ /// 生效时间。
///
public DateTime? EffectiveDate { get; set; }
///
- /// 失效日期。
+ /// 失效时间。
///
public DateTime? ExpiryDate { get; set; }
diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs
index a73e131..f82129e 100644
--- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs
@@ -79,7 +79,21 @@ public class OnboardingContractTemplateService : DbRepository item.Id).ToList();
+ var availableVersionTemplateIds = (await Context.Queryable()
+ .Where(item => candidateIds.Contains(item.TemplateId))
+ .ToListAsync())
+ .Where(item => ContractTemplateVersionValidity.IsAvailable(item, now))
+ .Select(item => item.TemplateId)
+ .ToHashSet();
+ candidates = candidates
+ .Where(item => availableVersionTemplateIds.Contains(item.Id))
+ .ToList();
+ if (candidates.Count == 0)
+ return candidates;
+
+ candidateIds = candidates.Select(item => item.Id).ToList();
var relations = await Context.Queryable()
.Where(item => candidateIds.Contains(item.ContractTemplateId))
.ToListAsync();
@@ -270,6 +284,7 @@ public class OnboardingContractTemplateService : DbRepository
public async Task AddVersion(OnboardingContractVersionAddInput input)
{
+ ContractTemplateVersionValidity.ValidateRange(input.EffectiveDate, input.ExpiryDate);
var template = await Detail(new BaseIdInput { Id = input.TemplateId });
await _dataPermission.EnsureCanManage(
template.CreateUserId,
@@ -357,6 +372,9 @@ public class OnboardingContractTemplateService : DbRepository()
.AnyAsync(item => item.TemplateVersionId == input.Id
&& item.VariableType == ContractVariableType.Unresolved);
diff --git a/SimpleAdmin.Application/Services/Onboarding/Department/Dto/OnboardingDepartmentInput.cs b/SimpleAdmin.Application/Services/Onboarding/Department/Dto/OnboardingDepartmentInput.cs
index 378d0bf..1b931f8 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Department/Dto/OnboardingDepartmentInput.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Department/Dto/OnboardingDepartmentInput.cs
@@ -1,4 +1,4 @@
-namespace SimpleAdmin.Application;
+namespace SimpleAdmin.Application;
///
/// 入职业务部门分页查询参数。
@@ -41,13 +41,21 @@ public class OnboardingDepartmentAddInput : ObDepartment
/// 部门业务编码。
///
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门编码不能为空")]
+ [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "部门编码不能超过50个字符")]
public override string DepartmentCode { get; set; }
///
/// 部门名称。
///
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门名称不能为空")]
+ [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "部门名称不能超过50个字符")]
public override string DepartmentName { get; set; }
+
+ ///
+ /// 业务备注。
+ ///
+ [global::System.ComponentModel.DataAnnotations.MaxLength(200, ErrorMessage = "备注不能超过200个字符")]
+ public override string Remark { get; set; }
}
///
diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs
index cfa408c..2fa8c3f 100644
--- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs
@@ -177,8 +177,14 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
throw Oops.Bah($"公司“{companyName}”未配置契约锁默认业务分类 Id,请先到公司与岗位中编辑公司资料");
if (string.IsNullOrWhiteSpace(sealId))
throw Oops.Bah($"公司“{companyName}”未配置契约锁企业印章 Id,请先到公司与岗位中编辑公司资料");
+ if (!categoryId.All(char.IsDigit)
+ || !sealId.All(char.IsDigit))
+ {
+ throw Oops.Bah($"公司“{companyName}”的契约锁业务分类或企业印章 Id 只能填写纯数字");
+ }
+
if (!long.TryParse(categoryId, out _) || !long.TryParse(sealId, out _))
- throw Oops.Bah($"公司“{companyName}”的契约锁业务分类或印章 Id 格式不正确");
+ throw Oops.Bah($"公司“{companyName}”的契约锁业务分类或企业印章 Id 超出有效范围(最大支持19位长整数)");
var taskContracts = await _db.Queryable()
.Where(item => item.TaskId == task.Id)
diff --git a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs
index 40b15f9..6a94678 100644
--- a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs
@@ -311,6 +311,9 @@ public class OnboardingTemplateService : IOnboardingTemplateService
{
throw Oops.Bah("合同模板与版本不匹配");
}
+ var now = DateTime.Now;
+ if (versions.Any(version => !ContractTemplateVersionValidity.IsAvailable(version, now)))
+ throw Oops.Bah("入职模板只能选择当前已生效且尚未失效的启用签署材料版本");
var contractTemplateIds = input.Contracts
.Select(item => item.ContractTemplateId)
@@ -352,8 +355,6 @@ public class OnboardingTemplateService : IOnboardingTemplateService
"enable",
global::System.StringComparison.OrdinalIgnoreCase)))
throw Oops.Bah("启用入职模板前,请先启用全部合同模板");
- if (versions.Any(item => item.VersionStatus != ContractTemplateVersionStatus.Enabled))
- throw Oops.Bah("启用入职模板前,请先启用全部合同版本");
}
var materialIds = input.Materials.Select(item => item.MaterialTypeId).ToList();
diff --git a/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs b/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs
index 5de8f08..ffd683f 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs
@@ -1,4 +1,4 @@
-namespace SimpleAdmin.Application;
+namespace SimpleAdmin.Application;
///
/// 入职业务岗位分页查询参数。
@@ -52,13 +52,21 @@ public class OnboardingPositionAddInput : ObPosition
/// 岗位业务编码。
///
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位编码不能为空")]
+ [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位编码不能超过50个字符")]
public override string PositionCode { get; set; }
///
/// 岗位名称。
///
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位名称不能为空")]
+ [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位名称不能超过50个字符")]
public override string PositionName { get; set; }
+
+ ///
+ /// 业务备注。
+ ///
+ [global::System.ComponentModel.DataAnnotations.MaxLength(200, ErrorMessage = "备注不能超过200个字符")]
+ public override string Remark { get; set; }
}
///
diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs
index 2b6cc26..88c85cb 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs
@@ -95,11 +95,13 @@ public class OnboardingTaskService : IOnboardingTaskService
throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板");
var versionIds = contractConfigs.Select(item => item.ContractTemplateVersionId).Distinct().ToList();
- var enabledVersionCount = await _db.Queryable()
- .CountAsync(item => versionIds.Contains(item.Id)
- && item.VersionStatus == ContractTemplateVersionStatus.Enabled);
- if (enabledVersionCount != versionIds.Count)
- throw Oops.Bah("入职模板包含未启用的合同版本,请先调整模板配置");
+ var now = DateTime.Now;
+ var taskVersions = await _db.Queryable()
+ .Where(item => versionIds.Contains(item.Id))
+ .ToListAsync();
+ if (taskVersions.Count != versionIds.Count
+ || taskVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now)))
+ throw Oops.Bah("入职模板包含未生效、已失效或未启用的签署材料版本,请先调整模板配置");
var materialIds = materialConfigs.Select(item => item.MaterialTypeId).Distinct().ToList();
var materialTypes = materialIds.Count == 0
@@ -290,11 +292,13 @@ public class OnboardingTaskService : IOnboardingTaskService
.Select(item => item.TemplateVersionId)
.Distinct()
.ToList();
- var enabledVersionCount = await _db.Queryable()
- .CountAsync(item => versionIds.Contains(item.Id)
- && item.VersionStatus == ContractTemplateVersionStatus.Enabled);
- if (enabledVersionCount != versionIds.Count)
- throw Oops.Bah("原任务包含已停用的合同版本,不能继续复制");
+ var now = DateTime.Now;
+ var copyVersions = await _db.Queryable()
+ .Where(item => versionIds.Contains(item.Id))
+ .ToListAsync();
+ if (copyVersions.Count != versionIds.Count
+ || copyVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now)))
+ throw Oops.Bah("原任务包含未生效、已失效或未启用的签署材料版本,不能继续复制");
var sourceMaterialRequirements = await _db.Queryable()
.Where(item => item.TaskId == sourceTask.Id)
diff --git a/tests/SecurityRegression/Program.cs b/tests/SecurityRegression/Program.cs
index d47f56c..25ffb87 100644
--- a/tests/SecurityRegression/Program.cs
+++ b/tests/SecurityRegression/Program.cs
@@ -47,6 +47,27 @@ foreach (var terminal in new[] { ElectronicSignStatus.Completed, ElectronicSignS
foreach (var backward in new[] { ElectronicSignStatus.PendingEmployeeSign, ElectronicSignStatus.Draft, ElectronicSignStatus.Creating, ElectronicSignStatus.Failed })
Reject(() => OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingCompanySeal, backward), "signed progress cannot regress: " + backward);
+
+// Uses the real signing-material validity rule. End time is exclusive.
+var validityNow = new DateTime(2026, 9, 17, 12, 0, 0);
+var availableVersion = new ObContractTemplateVersion { VersionStatus = ContractTemplateVersionStatus.Enabled };
+Check(ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "enabled version without time bounds is available");
+availableVersion.EffectiveDate = validityNow;
+availableVersion.ExpiryDate = validityNow.AddSeconds(1);
+Check(ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "effective boundary is inclusive");
+availableVersion.ExpiryDate = validityNow;
+Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "expiry boundary is exclusive");
+availableVersion.EffectiveDate = validityNow.AddSeconds(1);
+availableVersion.ExpiryDate = validityNow.AddMinutes(1);
+Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "future version is unavailable");
+availableVersion.EffectiveDate = validityNow.AddMinutes(-1);
+availableVersion.ExpiryDate = validityNow.AddMinutes(1);
+availableVersion.VersionStatus = ContractTemplateVersionStatus.Disabled;
+Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "disabled version is unavailable");
+ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow.AddSeconds(1));
+Reject(() => ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow), "equal start/end rejected");
+Reject(() => ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow.AddSeconds(-1)), "end before start rejected");
+
// Exercises the real async gate with a fake external lease. Does NOT claim MySQL GET_LOCK integration.
var leases = new FakeLeases();
Task Gate(string key, Func> action) => OnboardingOperationGate.Run(key, () => leases.Acquire(key), action);
diff --git a/tests/SecurityRegression/SecurityRegression.csproj b/tests/SecurityRegression/SecurityRegression.csproj
index 70324da..f763a52 100644
--- a/tests/SecurityRegression/SecurityRegression.csproj
+++ b/tests/SecurityRegression/SecurityRegression.csproj
@@ -10,6 +10,7 @@
+
\ No newline at end of file
diff --git a/tests/SecurityRegression/Stubs.cs b/tests/SecurityRegression/Stubs.cs
index c0ffd50..8aea31f 100644
--- a/tests/SecurityRegression/Stubs.cs
+++ b/tests/SecurityRegression/Stubs.cs
@@ -7,4 +7,5 @@ namespace SimpleAdmin.Core {
}
namespace SimpleAdmin.Application {
public static class Oops { public static Exception Bah(string text) => new InvalidOperationException(text); }
+ public sealed class ObContractTemplateVersion { public string VersionStatus { get; set; } public DateTime? EffectiveDate { get; set; } public DateTime? ExpiryDate { get; set; } }
}