diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml
index eaa0d1f76..b5c19c16b 100644
--- a/.github/workflows/ci-build.yml
+++ b/.github/workflows/ci-build.yml
@@ -14,14 +14,42 @@ jobs:
dotnet-version: '8.0.x'
- run: dotnet restore WalkingTec.Mvvm.sln
- run: dotnet build WalkingTec.Mvvm.sln --no-restore -c Release
- - name: Test
- run: dotnet test WalkingTec.Mvvm.sln --no-build -c Release --verbosity normal --logger "trx;LogFileName=test-results.trx"
+
+ - name: Test with coverage
+ run: |
+ dotnet test WalkingTec.Mvvm.sln \
+ --no-build -c Release \
+ --verbosity normal \
+ --logger "trx;LogFileName=test-results.trx" \
+ --collect:"XPlat Code Coverage" \
+ --settings coverlet.runsettings \
+ --results-directory ./TestResults
+
+ - name: Install ReportGenerator
+ run: dotnet tool install -g dotnet-reportgenerator-globaltool
+
+ - name: Generate coverage report
+ run: |
+ reportgenerator \
+ -reports:"TestResults/**/coverage.cobertura.xml" \
+ -targetdir:"TestResults/CoverageReport" \
+ -reporttypes:"Html;lcov;Cobertura" \
+ -title:"WTM Coverage Report"
+
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
- path: "**/test-results.trx"
+ path: "TestResults/**/*.trx"
+
+ - name: Upload coverage report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: coverage-report
+ path: TestResults/CoverageReport/
+
security-scan:
runs-on: ubuntu-latest
needs: build-and-test
diff --git a/WalkingTec.Mvvm.sln b/WalkingTec.Mvvm.sln
index de29b2732..5b47b8765 100644
--- a/WalkingTec.Mvvm.sln
+++ b/WalkingTec.Mvvm.sln
@@ -61,6 +61,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WalkingTec.Mvvm.Vue3Demo",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WalkingTec.Mvvm.Core.Tests", "src\WalkingTec.Mvvm.Core.Tests\WalkingTec.Mvvm.Core.Tests.csproj", "{7338D82E-C258-4739-AE85-3B8B0D27AAED}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WalkingTec.Mvvm.Mvc.Tests", "src\WalkingTec.Mvvm.Mvc.Tests\WalkingTec.Mvvm.Mvc.Tests.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -139,6 +141,10 @@ Global
{7338D82E-C258-4739-AE85-3B8B0D27AAED}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7338D82E-C258-4739-AE85-3B8B0D27AAED}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7338D82E-C258-4739-AE85-3B8B0D27AAED}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -163,6 +169,7 @@ Global
{D4104589-85BE-446A-A123-1E0FD1538A7B} = {840C6DED-B5D5-4763-A6A7-6F615B72EC10}
{411690A4-0021-4509-BF24-900437B5DC6E} = {DE184A47-CF5F-41C0-AB7D-CAD0AF2DAE75}
{7338D82E-C258-4739-AE85-3B8B0D27AAED} = {46CFCDC6-4358-48EB-A901-5618B6530CEE}
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {46CFCDC6-4358-48EB-A901-5618B6530CEE}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8D25EAAD-E43A-466A-95DA-ECE1F3C462DC}
diff --git a/coverlet.runsettings b/coverlet.runsettings
new file mode 100644
index 000000000..73a10dc8a
--- /dev/null
+++ b/coverlet.runsettings
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+ cobertura
+
+
+
+ [*.Tests]*,
+ [*.Test]*,
+ [WalkingTec.Mvvm.Demo]*,
+ [WalkingTec.Mvvm.BlazorDemo*]*,
+ [WalkingTec.Mvvm.ReactDemo*]*,
+ [WalkingTec.Mvvm.Vue*]*
+
+
+
+
+ GeneratedCodeAttribute,
+ ExcludeFromCodeCoverageAttribute
+
+
+
+ /tmp/coverage/coverage.json
+ false
+ false
+
+
+
+
+
diff --git a/docs/plans/2026-03-04-test-coverage-jd.md b/docs/plans/2026-03-04-test-coverage-jd.md
new file mode 100644
index 000000000..d7b384fa2
--- /dev/null
+++ b/docs/plans/2026-03-04-test-coverage-jd.md
@@ -0,0 +1,168 @@
+# Job Description: WTM Test Coverage Improvement (v8.1.14+)
+
+> **目的**:此文件供架構師(Opus)進行 Design 細化,後續由實作工程師執行。
+>
+> **背景**:WTM 8.1.14 已完成 ASYNC-1(全面去除 sync-over-async)、DEPS-1(依賴升級)、QUALITY-1/2(editorconfig + Nullable)。
+> 現有測試已能通過 CI,但覆蓋範圍以「快樂路徑」為主,無法驗證安全關鍵路徑(Auth/Token)、async 路徑、錯誤路徑,且 CI 無 coverage gate。
+
+---
+
+## 1. 現狀盤點(Context)
+
+### 1.1 現有測試專案
+
+| 專案 | 框架 | 定位 | 問題 |
+|------|------|------|------|
+| `test/WalkingTec.Mvvm.Test.Mock` | MSTest/Moq | 共用 Mock 基礎設施 | ✅ 良好,是整個測試體系的基石 |
+| `test/WalkingTec.Mvvm.Core.Test` | MSTest | BaseCRUDVM / BaseBatchVM / BasePagedListVM | ✅ 主要路徑已覆蓋,但缺 async 變體與錯誤路徑 |
+| `test/WalkingTec.Mvvm.Admin.Test` | MSTest | Admin API Controller(FrameworkUser)| ⚠️ 僅含 FrameworkUser,缺 DataPrivilege / FrameworkGroup / FrameworkRole |
+| `src/WalkingTec.Mvvm.Core.Tests` | **xUnit** | PasswordHash / RefreshToken 實體 | ⚠️ **定位與 Core.Test 重疊**,框架不一致,需要架構師決定合併或分工 |
+
+### 1.2 現有 Mock 基礎設施(`MockWtmContext.CreateWtmContext`)
+
+已解決的核心依賴:
+- `IDistributedCache` → `MemoryDistributedCache`
+- `IHttpContextAccessor` → Moq + `HttpContextAccessor`
+- `IStringLocalizerFactory` → `ResourceManagerStringLocalizerFactory`
+- `WtmFileProvider` → 實際注入
+- `IDataContext` → EF Core InMemory(`DBTypeEnum.Memory`)
+- `LoginUserInfo` → 直接設定 ITCode
+
+### 1.3 未被測試的關鍵路徑
+
+#### AUTH-GAP-1:ITokenService(最高優先)
+- `DoLoginAsync` — 整個 Login 流程未測試(PR #7 的核心功能)
+- `IssueTokenAsync` — JWT 生成路徑
+- `RefreshTokenAsync` — Refresh Token rotation(含 race condition 場景)
+- `RevokeTokenAsync` — 撤銷路徑
+
+#### AUTH-GAP-2:密碼 Migration Flow
+- MD5 legacy hash 自動升級至 PBKDF2 的端對端流程(在 Login 路徑內)
+- 驗證 `PasswordVerifyResult.SuccessRehashNeeded` 觸發後,DB 確實被更新
+
+#### VM-GAP-1:Async ViewModel 方法
+- `BaseCRUDVM.DoAddAsync` / `DoEditAsync` / `DoDeleteAsync`(8.1.13 新增)
+- 現有 `Core.Test` 只測試同步版本,async 版本完全未測
+
+#### VM-GAP-2:ImportVM 管線
+- `BaseImportVM.BatchSaveData` 成功路徑
+- Import 錯誤累積與 `ErrorListVM` 回報機制
+
+#### ADMIN-GAP-1:Admin API 覆蓋空缺
+- `DataPrivilegeController`(API)— 完全未測
+- `FrameworkGroupController`(API)— 完全未測
+- `FrameworkRoleController`(API)— 完全未測(假設存在)
+
+#### CI-GAP-1:無 coverage enforcement
+- CI 目前只執行 test,不計算 coverage,沒有最低門檻
+- 沒有 coverage report artifact
+
+---
+
+## 2. 設計要求(Design Requirements)
+
+### 2.1 測試框架統一策略(待架構師決定)
+
+**選項 A:MSTest 統一**
+- 刪除 `src/WalkingTec.Mvvm.Core.Tests`(xUnit)
+- 將 PasswordHash/RefreshToken 測試移入 `test/WalkingTec.Mvvm.Core.Test`
+- 優點:框架一致;缺點:丟棄已寫的 FluentAssertions 語法
+
+**選項 B:xUnit 統一(長期遷移)**
+- `src/WalkingTec.Mvvm.Core.Tests` 保留並擴充為主要 Core 測試專案
+- `test/WalkingTec.Mvvm.Core.Test` 逐步棄用(加 `[Obsolete]` 標記或移除)
+- 優點:xUnit 更適合 async test、FluentAssertions 更可讀;缺點:需遷移工作量
+
+**選項 C:共存(分工明確)**
+- `src/WalkingTec.Mvvm.Core.Tests`(xUnit)→ 純粹 Unit Test(不依賴 EF InMemory)
+- `test/WalkingTec.Mvvm.Core.Test`(MSTest)→ Integration Test(使用 MockWtmContext + EF InMemory)
+- 優點:分工清晰;缺點:兩個框架並存,onboarding 複雜度高
+
+**架構師應選定一個選項並說明理由。**
+
+### 2.2 Auth/Token 測試 Fixture 設計
+
+`ITokenService` 的實作 (`TokenService`) 依賴:
+- `IDataContext` — 讀寫 RefreshTokenEntity
+- `WTMContext` — 取得 `ConfigInfo`、`LoginUserInfo`
+- `IConfiguration` — 讀取 JWT 設定(secret、issuer、audience、expiry)
+- `ILogger` — optional
+
+需要架構師設計一個 `TokenServiceFixture`,解決:
+1. **JWT 設定注入** — 如何在測試中提供 `IConfiguration`(直接 mock 還是 `ConfigurationBuilder` + in-memory provider)
+2. **時間控制** — RefreshToken 過期測試需要控制「現在」的時間(IClock / DateTimeOffset.UtcNow)
+3. **Race condition 測試** — Refresh Token rotation 的重用攻擊場景:同一 token 被使用兩次,第二次應返回 401
+
+### 2.3 CI Coverage Gate
+
+需要架構師決定:
+1. **Coverage 工具**:`coverlet.collector`(MSTest)還是 `coverlet.msbuild`?
+2. **最低門檻**:建議 Line Coverage ≥ 60%,Branch Coverage ≥ 40%(考慮到 168 個 `#nullable disable` 檔案降低了可測性)
+3. **Report 格式**:Cobertura(GitHub Actions 可直接顯示)還是 HTML?
+4. **Gate 位置**:`dotnet test` 加 `--threshold` 還是 Coverlet MSBuild target?
+5. **排除範圍**:生成器模板(`GeneratorFiles/*.txt`)、Views(`.cshtml`)、Migration files
+
+### 2.4 測試命名規格
+
+建議格式(但請架構師確認):
+```
+MethodUnderTest_Scenario_ExpectedBehavior
+// 例:
+DoAddAsync_ValidEntity_PersistsToDbWithAuditFields
+RefreshTokenAsync_UsedToken_ThrowsSecurityException
+```
+
+---
+
+## 3. 優先覆蓋目標(Priority List)
+
+| Priority | Gap ID | 描述 | 預估 test 數 |
+|----------|--------|------|------------|
+| P0 | AUTH-GAP-1 | ITokenService 完整流程 | ~15 |
+| P0 | AUTH-GAP-2 | MD5 → PBKDF2 migration 端對端 | ~5 |
+| P1 | VM-GAP-1 | BaseCRUDVM DoAddAsync/DoEditAsync/DoDeleteAsync | ~10 |
+| P1 | CI-GAP-1 | Coverage gate + report artifact | n/a |
+| P2 | ADMIN-GAP-1 | DataPrivilege/FrameworkGroup API tests | ~12 |
+| P3 | VM-GAP-2 | BaseImportVM BatchSaveData | ~6 |
+
+---
+
+## 4. 技術限制與邊界
+
+- **無本機 dotnet SDK**:所有驗證透過 CI(GitHub Actions)。程式碼必須可在首次 push 時通過。
+- **EF InMemory 限制**:不支援 raw SQL、transactions(`BeginTransaction`)、stored procedures。需要繞過或 mock。
+- **Elsa Workflow 依賴**:`BaseCRUDVM` 的 `DoAdd/DoEdit/DoDelete` 內部會嘗試啟動 workflow。在測試環境下 Elsa 服務未注入,需確認是否會 throw 或 silently skip。
+- **Multi-tenant**:`MockWtmContext` 目前 `LoginUserInfo.CurrentTenant == null`。多租戶路徑需要額外 fixture。
+- **RefreshToken race condition**:EF InMemory 不支援真正的並發鎖,需用 `Task.WhenAll` 模擬並驗證「至少一個成功,另一個收到拒絕」的語意。
+
+---
+
+## 5. 驗收條件(Acceptance Criteria)
+
+1. **CI Green**:所有測試通過,無 skip。
+2. **Coverage Gate**:`dotnet test` 加 coverage threshold,低於門檻時 CI fail。
+3. **Auth Path Tests**:`DoLoginAsync` → `IssueToken` → `RefreshToken` → `RevokeToken` 完整鏈有測試。
+4. **Async Tests**:`DoAddAsync`/`DoEditAsync`/`DoDeleteAsync` 各有至少一個 happy path + 一個 error path 測試。
+5. **Migration Flow**:MD5 hash 輸入 → VerifyPassword 回傳 `SuccessRehashNeeded` → DB 中 hash 被更新。
+6. **Coverage Report**:每次 CI run 產生 Cobertura XML,作為 artifact 上傳。
+7. **Framework Consolidation**:xUnit 和 MSTest 並存問題有明確解決方案並實作。
+
+---
+
+## 6. 實作工程師的工作邊界
+
+實作工程師(Sonnet)負責:
+- 根據架構師(Opus)的設計細節,撰寫具體的 `.cs` test 檔案
+- 更新 `.csproj`、`.sln`、`ci-build.yml`
+- **不負責** 架構決策(框架統一、coverage 門檻、Fixture 設計模式)
+
+請架構師(Opus)在設計文件中提供:
+1. 選定的框架統一策略(選項 A/B/C)
+2. `TokenServiceFixture` 的類別設計(欄位、建構子、setup 方式)
+3. 時間控制機制(是否引入 `TimeProvider` 抽象或直接 Mock)
+4. Coverage 工具與門檻的精確設定
+5. 測試命名格式確認
+
+---
+
+*文件版本:1.0 | 2026-03-04 | 由 Sonnet (8.1.14 實作工程師) 撰寫供 Opus 設計細化*
diff --git a/src/WalkingTec.Mvvm.Core.Tests/Fixtures/WtmTestHelper.cs b/src/WalkingTec.Mvvm.Core.Tests/Fixtures/WtmTestHelper.cs
new file mode 100644
index 000000000..6345e19d5
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Core.Tests/Fixtures/WtmTestHelper.cs
@@ -0,0 +1,170 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Localization;
+using Microsoft.Extensions.Options;
+using Moq;
+using WalkingTec.Mvvm.Core;
+using WalkingTec.Mvvm.Core.Auth;
+using WalkingTec.Mvvm.Core.Implement;
+using WalkingTec.Mvvm.Core.Support.Json;
+using WalkingTec.Mvvm.Core.Support.FileHandlers;
+using WalkingTec.Mvvm.Test.Mock;
+
+namespace WalkingTec.Mvvm.Core.Tests.Fixtures
+{
+ ///
+ /// Minimal concrete user for DoLoginAsync tests.
+ /// FrameworkUserBase is abstract; each application defines its own subclass.
+ /// Core.Tests defines this lightweight version to avoid depending on demo projects.
+ ///
+ public class TestLoginUser : FrameworkUserBase { }
+
+ ///
+ /// Common helpers for creating test data and configured WTM contexts.
+ ///
+ public static class WtmTestHelper
+ {
+ // ─── Test Data Factories ───────────────────────────────────────────────
+
+ /// Create a TestLoginUser with PBKDF2 password.
+ public static TestLoginUser CreateUser(
+ string itCode = "testuser",
+ string password = "000000",
+ string? tenantCode = null,
+ bool isValid = true)
+ {
+ return new TestLoginUser
+ {
+ ID = Guid.NewGuid(),
+ ITCode = itCode,
+ Password = PasswordHashHelper.HashPassword(password),
+ TenantCode = tenantCode,
+ IsValid = isValid,
+ Name = $"Test_{itCode}"
+ };
+ }
+
+ /// Create a TestLoginUser with legacy MD5 password hash (for migration tests).
+ public static TestLoginUser CreateLegacyMd5User(
+ string itCode = "legacy_user",
+ string password = "000000",
+ string? tenantCode = null)
+ {
+ return new TestLoginUser
+ {
+ ID = Guid.NewGuid(),
+ ITCode = itCode,
+ Password = PasswordHashHelper.ComputeMD5(password),
+ TenantCode = tenantCode,
+ IsValid = true,
+ Name = $"Legacy_{itCode}"
+ };
+ }
+
+ /// Create a RefreshTokenEntity with specified state.
+ public static RefreshTokenEntity CreateRefreshToken(
+ string itCode = "testuser",
+ string? tenantCode = null,
+ int expiresInDays = 7,
+ bool revoked = false)
+ {
+ var token = new RefreshTokenEntity
+ {
+ Token = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) +
+ Convert.ToBase64String(Guid.NewGuid().ToByteArray()),
+ ITCode = itCode,
+ TenantCode = tenantCode,
+ ExpiresUtc = DateTime.UtcNow.AddDays(expiresInDays),
+ CreatedByIp = "127.0.0.1"
+ };
+ if (revoked)
+ {
+ token.RevokedUtc = DateTime.UtcNow;
+ token.RevokeReason = "Test revocation";
+ }
+ return token;
+ }
+
+ // ─── Context Factories ─────────────────────────────────────────────────
+
+ ///
+ /// Create a WTMContext fully configured for DoLoginAsync testing.
+ /// Unlike MockWtmContext.CreateWtmContext(), this fixture:
+ /// - Sets GlobaInfo.CustomUserType = typeof(TestLoginUser)
+ /// - Sets GlobaInfo.TenantGetFunc to return empty list (prevents NPE)
+ /// - Registers ITokenService in HttpContext.RequestServices
+ ///
+ public static WTMContext CreateLoginTestContext(
+ IDataContext dc,
+ ITokenService? tokenService = null,
+ string userCode = "admin")
+ {
+ var mockTs = tokenService ?? CreateNoOpTokenService();
+
+ var gd = new GlobalData();
+ gd.AllAccessUrls = new List();
+ gd.AllAssembly = new List();
+ gd.AllModule = new List();
+ gd.CustomUserType = typeof(TestLoginUser);
+ // AllTenant calls TenantGetFunc?.Invoke() — must not return null to avoid NPE
+ gd.SetTenantGetFunc(() => new List());
+
+ var cache = new MemoryDistributedCache(
+ Options.Create(new MemoryDistributedCacheOptions()));
+ var res = new ResourceManagerStringLocalizerFactory(
+ Options.Create(new LocalizationOptions { ResourcesPath = "Resources" }),
+ new Microsoft.Extensions.Logging.LoggerFactory());
+
+ var mockService = new Mock();
+ mockService.Setup(x => x.GetService(typeof(IDistributedCache))).Returns(cache);
+ mockService.Setup(x => x.GetService(typeof(ITokenService))).Returns(mockTs);
+
+ var mockHttpRequest = new Mock();
+ mockHttpRequest.Setup(x => x.Cookies).Returns(new MockCookie());
+ mockHttpRequest.Setup(x => x.Query).Returns(new Mock().Object);
+
+ var mockHttpContext = new Mock();
+ mockHttpContext.Setup(x => x.Request).Returns(mockHttpRequest.Object);
+ mockHttpContext.Setup(x => x.RequestServices).Returns(mockService.Object);
+ mockHttpContext.Setup(x => x.User)
+ .Returns(new System.Security.Claims.ClaimsPrincipal(
+ new System.Security.Claims.ClaimsIdentity()));
+ var mockSession = new MockHttpSession();
+ mockHttpContext.Setup(x => x.Session).Returns(mockSession);
+
+ var httpa = new HttpContextAccessor { HttpContext = mockHttpContext.Object };
+ var wtm = new WTMContext(null, gd, httpa, new DefaultUIService(), null, dc, res, cache: cache);
+ wtm.MSD = new BasicMSD();
+ wtm.DC = dc;
+ wtm.LoginUserInfo = new LoginUserInfo { ITCode = userCode };
+
+ mockService.Setup(x => x.GetService(typeof(WtmFileProvider)))
+ .Returns(new WtmFileProvider(wtm));
+
+ return wtm;
+ }
+
+ private static ITokenService CreateNoOpTokenService()
+ {
+ var mock = new Mock();
+ mock.Setup(x => x.IssueTokenAsync(It.IsAny(), It.IsAny()))
+ .Returns(Task.FromResult(new Token
+ {
+ AccessToken = "test_access_token",
+ RefreshToken = "test_refresh_token",
+ ExpiresIn = 3600,
+ TokenType = "Bearer"
+ }));
+ mock.Setup(x => x.RefreshTokenAsync(It.IsAny(), It.IsAny()))
+ .Returns(Task.FromResult(null!));
+ mock.Setup(x => x.RevokeTokenAsync(
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(System.Threading.Tasks.Task.CompletedTask);
+ return mock.Object;
+ }
+ }
+}
diff --git a/src/WalkingTec.Mvvm.Core.Tests/Integration/BaseCRUDVMAsyncTests.cs b/src/WalkingTec.Mvvm.Core.Tests/Integration/BaseCRUDVMAsyncTests.cs
new file mode 100644
index 000000000..271c90111
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Core.Tests/Integration/BaseCRUDVMAsyncTests.cs
@@ -0,0 +1,237 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using WalkingTec.Mvvm.Core;
+using WalkingTec.Mvvm.Test.Mock;
+using Xunit;
+
+namespace WalkingTec.Mvvm.Core.Tests.Integration
+{
+ ///
+ /// Tests for async ViewModel methods added in v8.1.13/v8.1.14.
+ /// Mirrors sync tests in test/WalkingTec.Mvvm.Core.Test/VM/BaseCRUDVMTest.cs
+ /// but uses async variants (DoAddAsync / DoEditAsync / DoDeleteAsync).
+ ///
+ /// Uses a minimal TestNote entity to avoid complex relationship setup.
+ /// Elsa workflow is NOT triggered because TestNote does not implement IWorkflow.
+ ///
+ public class BaseCRUDVMAsyncTests
+ {
+ private readonly string _seed;
+
+ public BaseCRUDVMAsyncTests()
+ {
+ _seed = Guid.NewGuid().ToString();
+ }
+
+ private IDataContext CreateDb() =>
+ new AsyncVMTestDataContext(_seed, DBTypeEnum.Memory);
+
+ // ─── DoAddAsync ────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DoAddAsync_ValidEntity_PersistsToDbWithAuditFields()
+ {
+ var vm = new BaseCRUDVM
+ {
+ Wtm = MockWtmContext.CreateWtmContext(CreateDb(), "async_user")
+ };
+ vm.Entity = new TestNote { Title = "async_add_test", Content = "content1" };
+
+ await vm.DoAddAsync();
+
+ using var db = CreateDb();
+ var saved = ((DbContext)db).Set().FirstOrDefault();
+ saved.Should().NotBeNull();
+ saved!.Title.Should().Be("async_add_test");
+ saved.CreateBy.Should().Be("async_user");
+ saved.CreateTime.Should().NotBeNull();
+ saved.CreateTime!.Value.Should().BeCloseTo(DateTime.Now, TimeSpan.FromSeconds(10));
+ vm.MSD.Count.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task DoAddAsync_TwoEntities_BothPersist()
+ {
+ var vm1 = new BaseCRUDVM
+ {
+ Wtm = MockWtmContext.CreateWtmContext(CreateDb(), "user1")
+ };
+ var vm2 = new BaseCRUDVM
+ {
+ Wtm = MockWtmContext.CreateWtmContext(CreateDb(), "user2")
+ };
+
+ vm1.Entity = new TestNote { Title = "first", Content = "a" };
+ vm2.Entity = new TestNote { Title = "second", Content = "b" };
+
+ await vm1.DoAddAsync();
+ await vm2.DoAddAsync();
+
+ using var db = CreateDb();
+ ((DbContext)db).Set().Count().Should().Be(2);
+ }
+
+ // ─── DoEditAsync ───────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DoEditAsync_ExistingEntity_UpdatesFieldsAndAuditInfo()
+ {
+ // Arrange: add entity via sync first
+ TestNote original;
+ using (var db = CreateDb())
+ {
+ original = new TestNote { Title = "original", Content = "old_content" };
+ ((DbContext)db).Set().Add(original);
+ await ((DbContext)db).SaveChangesAsync();
+ }
+
+ // Act: edit via async
+ var vm = new BaseCRUDVM
+ {
+ Wtm = MockWtmContext.CreateWtmContext(CreateDb(), "editor_user")
+ };
+ using (var db = CreateDb())
+ {
+ vm.DC = db;
+ vm.Entity = new TestNote
+ {
+ ID = original.ID,
+ Title = "updated",
+ Content = "new_content"
+ };
+ await vm.DoEditAsync(updateAllFields: true);
+ }
+
+ // Assert
+ using var assertDb = CreateDb();
+ var updated = await ((DbContext)assertDb).Set().FindAsync(original.ID);
+ updated.Should().NotBeNull();
+ updated!.Title.Should().Be("updated");
+ updated.Content.Should().Be("new_content");
+ updated.UpdateBy.Should().Be("editor_user");
+ updated.UpdateTime.Should().NotBeNull();
+ updated.UpdateTime!.Value.Should().BeCloseTo(DateTime.Now, TimeSpan.FromSeconds(10));
+ }
+
+ [Fact]
+ public async Task DoEditAsync_FieldSubset_UpdatesOnlySpecifiedFields()
+ {
+ // Arrange
+ TestNote original;
+ using (var db = CreateDb())
+ {
+ original = new TestNote { Title = "keep_title", Content = "keep_content" };
+ ((DbContext)db).Set().Add(original);
+ await ((DbContext)db).SaveChangesAsync();
+ }
+
+ // Act: edit only Title, not Content
+ var vm = new BaseCRUDVM
+ {
+ Wtm = MockWtmContext.CreateWtmContext(CreateDb(), "partial_editor")
+ };
+ using (var db = CreateDb())
+ {
+ vm.DC = db;
+ vm.Entity = new TestNote
+ {
+ ID = original.ID,
+ Title = "changed_title",
+ Content = "should_be_ignored"
+ };
+ vm.FC = new System.Collections.Generic.Dictionary
+ {
+ ["Entity.Title"] = ""
+ };
+ await vm.DoEditAsync(updateAllFields: false);
+ }
+
+ // Assert: only Title changed
+ using var assertDb = CreateDb();
+ var result = await ((DbContext)assertDb).Set().FindAsync(original.ID);
+ result!.Title.Should().Be("changed_title");
+ result.Content.Should().Be("keep_content", "Content not in FC, must not change");
+ }
+
+ // ─── DoDeleteAsync ─────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DoDeleteAsync_ExistingEntity_RemovesFromDb()
+ {
+ // Arrange
+ TestNote entity;
+ using (var db = CreateDb())
+ {
+ entity = new TestNote { Title = "to_delete", Content = "bye" };
+ ((DbContext)db).Set().Add(entity);
+ await ((DbContext)db).SaveChangesAsync();
+ }
+
+ // Act
+ var vm = new BaseCRUDVM
+ {
+ Wtm = MockWtmContext.CreateWtmContext(CreateDb(), "deleter")
+ };
+ using (var db = CreateDb())
+ {
+ vm.DC = db;
+ vm.Entity = new TestNote { ID = entity.ID };
+ await vm.DoDeleteAsync();
+ }
+
+ // Assert
+ using var assertDb = CreateDb();
+ ((DbContext)assertDb).Set().Count().Should().Be(0);
+ }
+
+ [Fact]
+ public async Task DoDeleteAsync_MultipleEntities_DeletesOnlyTarget()
+ {
+ // Arrange: two entities
+ TestNote keep, remove;
+ using (var db = CreateDb())
+ {
+ keep = new TestNote { Title = "keep", Content = "here" };
+ remove = new TestNote { Title = "remove", Content = "gone" };
+ ((DbContext)db).Set().AddRange(keep, remove);
+ await ((DbContext)db).SaveChangesAsync();
+ }
+
+ // Act
+ var vm = new BaseCRUDVM
+ {
+ Wtm = MockWtmContext.CreateWtmContext(CreateDb(), "deleter")
+ };
+ using (var db = CreateDb())
+ {
+ vm.DC = db;
+ vm.Entity = new TestNote { ID = remove.ID };
+ await vm.DoDeleteAsync();
+ }
+
+ // Assert
+ using var assertDb = CreateDb();
+ var remaining = ((DbContext)assertDb).Set().ToList();
+ remaining.Should().HaveCount(1);
+ remaining[0].Title.Should().Be("keep");
+ }
+ }
+
+ /// Minimal test entity with audit fields.
+ public class TestNote : BasePoco
+ {
+ public string Title { get; set; } = null!;
+ public string Content { get; set; } = null!;
+ }
+
+ internal class AsyncVMTestDataContext : EmptyContext
+ {
+ public AsyncVMTestDataContext(string cs, DBTypeEnum dbtype)
+ : base(cs, dbtype) { }
+
+ public DbSet TestNotes { get; set; } = null!;
+ }
+}
diff --git a/src/WalkingTec.Mvvm.Core.Tests/Integration/DoLoginAsyncTests.cs b/src/WalkingTec.Mvvm.Core.Tests/Integration/DoLoginAsyncTests.cs
new file mode 100644
index 000000000..691034ab9
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Core.Tests/Integration/DoLoginAsyncTests.cs
@@ -0,0 +1,208 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using WalkingTec.Mvvm.Core;
+using WalkingTec.Mvvm.Core.Tests.Fixtures;
+using Xunit;
+
+namespace WalkingTec.Mvvm.Core.Tests.Integration
+{
+ ///
+ /// Integration tests for WTMContext.DoLoginAsync.
+ /// Uses TestLoginUser (a concrete FrameworkUserBase subclass defined in Core.Tests).
+ ///
+ /// Verifies:
+ /// - PBKDF2 auth happy path
+ /// - Wrong password rejection
+ /// - MD5 → PBKDF2 migration (SuccessRehashNeeded → DB updated)
+ /// - Disabled user rejection
+ /// - Non-existent user rejection
+ ///
+ /// Uses SQLite shared in-memory (not EF InMemory) because LoadBasicInfoAsync has
+ /// correlated collection subqueries that EF Core InMemory cannot translate.
+ /// A single SqliteConnection is kept open throughout the test lifetime to keep
+ /// the shared in-memory database alive across multiple context instances.
+ ///
+ public class DoLoginAsyncTests : IDisposable
+ {
+ private readonly string _seed;
+ private readonly SqliteConnection _keepAlive;
+
+ public DoLoginAsyncTests()
+ {
+ _seed = Guid.NewGuid().ToString();
+ // Shared named SQLite in-memory database — stays alive as long as this connection is open
+ _keepAlive = new SqliteConnection($"DataSource={_seed}?mode=memory&cache=shared");
+ _keepAlive.Open();
+ // Create all tables (FrameworkUsers, FrameworkUserRoles, etc.)
+ using var db = CreateDb();
+ ((DbContext)db).Database.EnsureCreated();
+ }
+
+ public void Dispose() => _keepAlive.Dispose();
+
+ private IDataContext CreateDb() => new LoginTestDataContext(_seed);
+
+ // ─── PBKDF2 Happy Path ─────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DoLoginAsync_ValidPBKDF2User_ReturnsLoginUserInfo()
+ {
+ using var db = CreateDb();
+ var user = WtmTestHelper.CreateUser("alice", "pass123");
+ ((DbContext)db).Set().Add(user);
+ await ((DbContext)db).SaveChangesAsync();
+
+ var wtm = WtmTestHelper.CreateLoginTestContext(CreateDb());
+ var result = await wtm.DoLoginAsync("alice", "pass123", null);
+
+ result.Should().NotBeNull();
+ result!.ITCode.Should().Be("alice");
+ }
+
+ [Fact]
+ public async Task DoLoginAsync_ValidPBKDF2User_WrongPassword_ReturnsNull()
+ {
+ using var db = CreateDb();
+ var user = WtmTestHelper.CreateUser("bob", "correct");
+ ((DbContext)db).Set().Add(user);
+ await ((DbContext)db).SaveChangesAsync();
+
+ var wtm = WtmTestHelper.CreateLoginTestContext(CreateDb());
+ var result = await wtm.DoLoginAsync("bob", "wrong", null);
+
+ result.Should().BeNull();
+ }
+
+ // ─── MD5 Migration ─────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DoLoginAsync_LegacyMD5User_CorrectPassword_ReturnsLoginUserInfo()
+ {
+ using var db = CreateDb();
+ var user = WtmTestHelper.CreateLegacyMd5User("legacy_user", "000000");
+ ((DbContext)db).Set().Add(user);
+ await ((DbContext)db).SaveChangesAsync();
+
+ var wtm = WtmTestHelper.CreateLoginTestContext(CreateDb());
+ var result = await wtm.DoLoginAsync("legacy_user", "000000", null);
+
+ result.Should().NotBeNull();
+ result!.ITCode.Should().Be("legacy_user");
+ }
+
+ [Fact]
+ public async Task DoLoginAsync_LegacyMD5User_WrongPassword_ReturnsNull()
+ {
+ using var db = CreateDb();
+ var user = WtmTestHelper.CreateLegacyMd5User("legacy2", "000000");
+ ((DbContext)db).Set().Add(user);
+ await ((DbContext)db).SaveChangesAsync();
+
+ var wtm = WtmTestHelper.CreateLoginTestContext(CreateDb());
+ var result = await wtm.DoLoginAsync("legacy2", "wrong", null);
+
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task DoLoginAsync_MD5Migration_PersistsNewHashToDb()
+ {
+ // Arrange: user has legacy MD5 password
+ var userId = Guid.NewGuid();
+ using (var db = CreateDb())
+ {
+ var user = WtmTestHelper.CreateLegacyMd5User("migrating", "000000");
+ user.ID = userId;
+ ((DbContext)db).Set().Add(user);
+ await ((DbContext)db).SaveChangesAsync();
+ }
+
+ // Verify starting state: MD5 hash (32 chars uppercase hex)
+ using (var db = CreateDb())
+ {
+ var before = await ((DbContext)db).Set().FindAsync(userId);
+ before!.Password.Should().HaveLength(32,
+ "Legacy MD5 hash is 32 hex characters");
+ PasswordHashHelper.IsLegacyMD5Hash(before.Password).Should().BeTrue();
+ }
+
+ // Act: login triggers migration
+ var wtm = WtmTestHelper.CreateLoginTestContext(CreateDb());
+ var result = await wtm.DoLoginAsync("migrating", "000000", null);
+ result.Should().NotBeNull("Login should succeed with correct MD5 password");
+
+ // Assert: password in DB is now PBKDF2 (longer than 32 chars)
+ using (var db = CreateDb())
+ {
+ var after = await ((DbContext)db).Set().FindAsync(userId);
+ after!.Password.Length.Should().BeGreaterThan(32,
+ "Password should be upgraded from MD5 to PBKDF2");
+ PasswordHashHelper.IsLegacyMD5Hash(after.Password)
+ .Should().BeFalse("Upgraded hash is no longer MD5");
+ PasswordHashHelper.VerifyPassword(after.Password, "000000")
+ .Should().Be(PasswordVerifyResult.Success,
+ "New PBKDF2 hash verifies without rehash needed");
+ }
+ }
+
+ // ─── User State Checks ─────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DoLoginAsync_NonexistentUser_ReturnsNull()
+ {
+ var wtm = WtmTestHelper.CreateLoginTestContext(CreateDb());
+ var result = await wtm.DoLoginAsync("nobody", "pass", null);
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task DoLoginAsync_DisabledUser_ReturnsNull()
+ {
+ using var db = CreateDb();
+ var user = WtmTestHelper.CreateUser("disabled_user", "pass", isValid: false);
+ ((DbContext)db).Set().Add(user);
+ await ((DbContext)db).SaveChangesAsync();
+
+ var wtm = WtmTestHelper.CreateLoginTestContext(CreateDb());
+ var result = await wtm.DoLoginAsync("disabled_user", "pass", null);
+
+ result.Should().BeNull("Disabled users should be rejected");
+ }
+
+ [Fact]
+ public async Task DoLoginAsync_TenantMismatch_ReturnsNull()
+ {
+ // User exists but belongs to "tenant_a"; login request targets "tenant_b"
+ using var db = CreateDb();
+ var user = WtmTestHelper.CreateUser("tenant_user", "pass", tenantCode: "tenant_a");
+ ((DbContext)db).Set().Add(user);
+ await ((DbContext)db).SaveChangesAsync();
+
+ var wtm = WtmTestHelper.CreateLoginTestContext(CreateDb());
+ var result = await wtm.DoLoginAsync("tenant_user", "pass", "tenant_b");
+
+ result.Should().BeNull("Tenant mismatch should reject login");
+ }
+ }
+
+ ///
+ /// Minimal DataContext for DoLoginAsync tests.
+ /// Inherits FrameworkContext so all related entity types (FrameworkUserRole, etc.)
+ /// are in the model — required by LoadBasicInfoAsync's correlated LINQ queries.
+ /// Uses SQLite (not EF InMemory) because the correlated subqueries in LoadBasicInfoAsync
+ /// cannot be translated by the EF Core InMemory provider.
+ /// Overrides OnConfiguring to prevent the base SqlServer/InMemory config from running
+ /// (the connection is configured by the DoLoginAsyncTests ctor via EnsureCreated).
+ ///
+ internal class LoginTestDataContext : FrameworkContext
+ {
+ public LoginTestDataContext(string seed)
+ : base($"DataSource={seed}?mode=memory&cache=shared", DBTypeEnum.SQLite) { }
+
+ public DbSet TestLoginUsers { get; set; } = null!;
+ }
+}
diff --git a/src/WalkingTec.Mvvm.Core.Tests/PasswordHashHelperTests.cs b/src/WalkingTec.Mvvm.Core.Tests/PasswordHashHelperTests.cs
deleted file mode 100644
index 468604f28..000000000
--- a/src/WalkingTec.Mvvm.Core.Tests/PasswordHashHelperTests.cs
+++ /dev/null
@@ -1,122 +0,0 @@
-using FluentAssertions;
-using WalkingTec.Mvvm.Core;
-using Xunit;
-
-namespace WalkingTec.Mvvm.Core.Tests
-{
- public class PasswordHashHelperTests
- {
- [Fact]
- public void HashPassword_ReturnsNonEmptyString()
- {
- var hash = PasswordHashHelper.HashPassword("test123");
- hash.Should().NotBeNullOrEmpty();
- hash.Should().NotBe("test123");
- }
-
- [Fact]
- public void HashPassword_EmptyInput_ReturnsEmpty()
- {
- PasswordHashHelper.HashPassword("").Should().BeEmpty();
- PasswordHashHelper.HashPassword(null).Should().BeEmpty();
- }
-
- [Fact]
- public void HashPassword_DifferentCalls_ProduceDifferentHashes()
- {
- var hash1 = PasswordHashHelper.HashPassword("test123");
- var hash2 = PasswordHashHelper.HashPassword("test123");
- hash1.Should().NotBe(hash2, "PBKDF2 uses random salt");
- }
-
- [Fact]
- public void VerifyPassword_CorrectPassword_ReturnsSuccess()
- {
- var hash = PasswordHashHelper.HashPassword("myPassword");
- var result = PasswordHashHelper.VerifyPassword(hash, "myPassword");
- result.Should().Be(PasswordVerifyResult.Success);
- }
-
- [Fact]
- public void VerifyPassword_WrongPassword_ReturnsFailed()
- {
- var hash = PasswordHashHelper.HashPassword("myPassword");
- var result = PasswordHashHelper.VerifyPassword(hash, "wrongPassword");
- result.Should().Be(PasswordVerifyResult.Failed);
- }
-
- [Fact]
- public void VerifyPassword_NullInputs_ReturnsFailed()
- {
- PasswordHashHelper.VerifyPassword(null, "pw")
- .Should().Be(PasswordVerifyResult.Failed);
- PasswordHashHelper.VerifyPassword("hash", null)
- .Should().Be(PasswordVerifyResult.Failed);
- PasswordHashHelper.VerifyPassword(null, null)
- .Should().Be(PasswordVerifyResult.Failed);
- }
-
- [Fact]
- public void VerifyPassword_LegacyMD5_ReturnsSuccessRehashNeeded()
- {
- var md5Hash = PasswordHashHelper.ComputeMD5("000000");
- var result = PasswordHashHelper.VerifyPassword(md5Hash, "000000");
- result.Should().Be(PasswordVerifyResult.SuccessRehashNeeded);
- }
-
- [Fact]
- public void VerifyPassword_LegacyMD5_WrongPassword_ReturnsFailed()
- {
- var md5Hash = PasswordHashHelper.ComputeMD5("000000");
- var result = PasswordHashHelper.VerifyPassword(md5Hash, "111111");
- result.Should().Be(PasswordVerifyResult.Failed);
- }
-
- [Fact]
- public void IsLegacyMD5Hash_ValidMD5_ReturnsTrue()
- {
- PasswordHashHelper.IsLegacyMD5Hash("670B14728AD9902AECBA32E22FA4F6BD")
- .Should().BeTrue();
- }
-
- [Fact]
- public void IsLegacyMD5Hash_PBKDF2Hash_ReturnsFalse()
- {
- var pbkdf2 = PasswordHashHelper.HashPassword("test");
- PasswordHashHelper.IsLegacyMD5Hash(pbkdf2).Should().BeFalse();
- }
-
- [Fact]
- public void IsLegacyMD5Hash_NullOrEmpty_ReturnsFalse()
- {
- PasswordHashHelper.IsLegacyMD5Hash(null).Should().BeFalse();
- PasswordHashHelper.IsLegacyMD5Hash("").Should().BeFalse();
- }
-
- [Fact]
- public void IsLegacyMD5Hash_WrongLength_ReturnsFalse()
- {
- PasswordHashHelper.IsLegacyMD5Hash("ABC123").Should().BeFalse();
- }
-
- [Fact]
- public void MigrationFlow_MD5ToNewHash_Verify_Success()
- {
- // Simulate: user had MD5 password, logs in, system upgrades
- var legacyMD5 = PasswordHashHelper.ComputeMD5("secretPassword");
-
- // Step 1: Verify against legacy hash
- var verifyResult = PasswordHashHelper.VerifyPassword(
- legacyMD5, "secretPassword");
- verifyResult.Should().Be(PasswordVerifyResult.SuccessRehashNeeded);
-
- // Step 2: Generate new PBKDF2 hash
- var newHash = PasswordHashHelper.HashPassword("secretPassword");
-
- // Step 3: Verify against new hash
- var verifyNew = PasswordHashHelper.VerifyPassword(
- newHash, "secretPassword");
- verifyNew.Should().Be(PasswordVerifyResult.Success);
- }
- }
-}
diff --git a/src/WalkingTec.Mvvm.Core.Tests/Security/BruteForceResistanceTests.cs b/src/WalkingTec.Mvvm.Core.Tests/Security/BruteForceResistanceTests.cs
new file mode 100644
index 000000000..f809a8b0e
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Core.Tests/Security/BruteForceResistanceTests.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using FluentAssertions;
+using WalkingTec.Mvvm.Core;
+using Xunit;
+
+namespace WalkingTec.Mvvm.Core.Tests.Security
+{
+ ///
+ /// Verifies PBKDF2 work-factor properties that make offline brute-force attacks expensive.
+ /// These tests assert measurable computational cost and salt diversity —
+ /// properties that MD5 lacks and which motivated the v8.1.13 security upgrade.
+ ///
+ public class BruteForceResistanceTests
+ {
+ // ─── Work-Factor Timing ─────────────────────────────────────────────
+
+ [Fact]
+ public void HashPassword_PBKDF2_TakesNonTrivialTime()
+ {
+ // Hash 5 passwords and assert total wall-clock time > 5ms (conservative).
+ // PBKDF2-SHA256 with 100 000 iterations typically takes 20–200ms per hash.
+ // This guards against accidentally switching to a fast (insecure) algorithm.
+ const int iterations = 5;
+ const int minExpectedMs = 5;
+
+ var sw = Stopwatch.StartNew();
+ for (int i = 0; i < iterations; i++)
+ PasswordHashHelper.HashPassword($"password{i}");
+ sw.Stop();
+
+ sw.ElapsedMilliseconds.Should().BeGreaterThan(minExpectedMs,
+ $"PBKDF2 hashing {iterations} passwords must take at least {minExpectedMs}ms total; " +
+ $"a trivially fast result indicates a weak algorithm");
+ }
+
+ [Fact]
+ public void VerifyPassword_CorrectPBKDF2_TakesNonTrivialTime()
+ {
+ var hash = PasswordHashHelper.HashPassword("benchmark_password");
+ const int iterations = 5;
+ const int minExpectedMs = 5;
+
+ var sw = Stopwatch.StartNew();
+ for (int i = 0; i < iterations; i++)
+ PasswordHashHelper.VerifyPassword(hash, "benchmark_password");
+ sw.Stop();
+
+ sw.ElapsedMilliseconds.Should().BeGreaterThan(minExpectedMs,
+ "verification must re-derive the key and is equally expensive to hashing");
+ }
+
+ // ─── Salt Diversity ─────────────────────────────────────────────────
+
+ [Fact]
+ public void HashPassword_100Calls_AllDistinct()
+ {
+ const int count = 100;
+ var hashes = new HashSet(StringComparer.Ordinal);
+
+ for (int i = 0; i < count; i++)
+ hashes.Add(PasswordHashHelper.HashPassword("same_password"));
+
+ hashes.Should().HaveCount(count,
+ "every hash call must use a unique random salt, producing 100 unique hashes");
+ }
+
+ [Fact]
+ public void HashPassword_SaltedOutput_MeansNoTwoHashesAreEqual()
+ {
+ var h1 = PasswordHashHelper.HashPassword("p@ssw0rd");
+ var h2 = PasswordHashHelper.HashPassword("p@ssw0rd");
+
+ h1.Should().NotBe(h2, "unique salt per hash prevents precomputed table attacks");
+ }
+
+ // ─── MD5 vs PBKDF2 Security Gap ─────────────────────────────────────
+
+ [Fact]
+ public void MD5Hash_IsDeterministic_DemonstrationOfWeakness()
+ {
+ // This test documents the security gap that PBKDF2 migration closes.
+ // A deterministic hash means identical passwords share identical hashes —
+ // enabling precomputed (rainbow-table) attacks against the full user table.
+ var hash1 = PasswordHashHelper.ComputeMD5("admin");
+ var hash2 = PasswordHashHelper.ComputeMD5("admin");
+
+ hash1.Should().Be(hash2,
+ "MD5 is deterministic — this is a known weakness that PBKDF2 migration addresses");
+ }
+
+ [Fact]
+ public void PBKDF2Hash_IsNonDeterministic_SaltPreventsRainbowTables()
+ {
+ var hash1 = PasswordHashHelper.HashPassword("admin");
+ var hash2 = PasswordHashHelper.HashPassword("admin");
+
+ hash1.Should().NotBe(hash2,
+ "PBKDF2 with random salt is non-deterministic — same password, different stored value");
+ }
+
+ // ─── Verification Still Works After Distinct Salts ──────────────────
+
+ [Fact]
+ public void VerifyPassword_AllHashes_FromSamePassword_VerifyCorrectly()
+ {
+ const string password = "consistent_pass";
+ var hashes = Enumerable.Range(0, 10)
+ .Select(_ => PasswordHashHelper.HashPassword(password))
+ .ToList();
+
+ foreach (var hash in hashes)
+ {
+ PasswordHashHelper.VerifyPassword(hash, password)
+ .Should().Be(PasswordVerifyResult.Success,
+ "every salted hash must still verify against the original password");
+ }
+ }
+ }
+}
diff --git a/src/WalkingTec.Mvvm.Core.Tests/Security/PasswordMigrationFlowTests.cs b/src/WalkingTec.Mvvm.Core.Tests/Security/PasswordMigrationFlowTests.cs
new file mode 100644
index 000000000..fa472e723
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Core.Tests/Security/PasswordMigrationFlowTests.cs
@@ -0,0 +1,104 @@
+using System;
+using FluentAssertions;
+using WalkingTec.Mvvm.Core;
+using Xunit;
+
+namespace WalkingTec.Mvvm.Core.Tests.Security
+{
+ ///
+ /// Security property tests for the MD5 → PBKDF2 migration path.
+ /// Verifies guarantees that prevent stored-hash downgrade and
+ /// rainbow-table/precomputation attacks.
+ ///
+ public class PasswordMigrationFlowTests
+ {
+ // ─── Post-Migration Hash Properties ────────────────────────────────────
+
+ [Fact]
+ public void Migrate_MD5ToNewHash_StoredHashIsNolongerMD5()
+ {
+ // Simulate migration: detect legacy hash, then replace with PBKDF2
+ var legacyHash = PasswordHashHelper.ComputeMD5("secret");
+ PasswordHashHelper.IsLegacyMD5Hash(legacyHash).Should().BeTrue();
+
+ var newHash = PasswordHashHelper.HashPassword("secret");
+ PasswordHashHelper.IsLegacyMD5Hash(newHash).Should().BeFalse(
+ "after migration the stored value is PBKDF2, not MD5");
+ }
+
+ [Fact]
+ public void Migrate_MD5ToNewHash_NewHashVerifiesSuccessWithoutRehash()
+ {
+ var newHash = PasswordHashHelper.HashPassword("pass@word1");
+ PasswordHashHelper.VerifyPassword(newHash, "pass@word1")
+ .Should().Be(PasswordVerifyResult.Success,
+ "migrated PBKDF2 hash must not require another rehash");
+ }
+
+ [Fact]
+ public void Migrate_MD5ToNewHash_LegacyHashIsReplacedNotAppended()
+ {
+ // The new hash must be longer than the 32-char MD5
+ var legacyHash = PasswordHashHelper.ComputeMD5("password");
+ var newHash = PasswordHashHelper.HashPassword("password");
+
+ newHash.Length.Should().BeGreaterThan(legacyHash.Length,
+ "PBKDF2 Base64 encoding is longer than 32-char MD5 hex");
+ newHash.Should().NotContain(legacyHash,
+ "new hash must not embed the old MD5 value");
+ }
+
+ // ─── Salt Uniqueness (Rainbow-Table Resistance) ─────────────────────
+
+ [Fact]
+ public void TwoUsers_SamePassword_GetDistinctHashes()
+ {
+ var hash1 = PasswordHashHelper.HashPassword("commonpass");
+ var hash2 = PasswordHashHelper.HashPassword("commonpass");
+
+ hash1.Should().NotBe(hash2,
+ "each hash has a unique salt, so identical passwords produce different stored values");
+ }
+
+ [Fact]
+ public void MD5_IsNotSalted_SamePasswordProducesSameHash()
+ {
+ // Demonstrates WHY migration is necessary: MD5 is deterministic
+ var h1 = PasswordHashHelper.ComputeMD5("000000");
+ var h2 = PasswordHashHelper.ComputeMD5("000000");
+ h1.Should().Be(h2,
+ "MD5 has no salt — same input always produces same hash (rainbow-table risk)");
+ }
+
+ [Fact]
+ public void VerifyPassword_MD5Hash_WrongPassword_NeverSucceeds()
+ {
+ var md5Hash = PasswordHashHelper.ComputeMD5("correct");
+ PasswordHashHelper.VerifyPassword(md5Hash, "incorrect")
+ .Should().Be(PasswordVerifyResult.Failed,
+ "migrating the hash of wrong credentials must never succeed");
+ }
+
+ // ─── Cross-User Hash Isolation ──────────────────────────────────────
+
+ [Fact]
+ public void UserA_Hash_CannotVerify_UserB_Password()
+ {
+ var hashA = PasswordHashHelper.HashPassword("passwordA");
+
+ PasswordHashHelper.VerifyPassword(hashA, "passwordB")
+ .Should().Be(PasswordVerifyResult.Failed,
+ "hash computed from one password must not verify a different password");
+ }
+
+ [Fact]
+ public void LegacyMD5_CannotVerify_AsDifferentUser()
+ {
+ // Even if attacker knows Alice's MD5 hash, it cannot be used to authenticate as Bob
+ var aliceMd5 = PasswordHashHelper.ComputeMD5("alice_password");
+
+ PasswordHashHelper.VerifyPassword(aliceMd5, "bob_password")
+ .Should().Be(PasswordVerifyResult.Failed);
+ }
+ }
+}
diff --git a/src/WalkingTec.Mvvm.Core.Tests/Unit/PasswordHashHelperTests.cs b/src/WalkingTec.Mvvm.Core.Tests/Unit/PasswordHashHelperTests.cs
new file mode 100644
index 000000000..bd32c6217
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Core.Tests/Unit/PasswordHashHelperTests.cs
@@ -0,0 +1,192 @@
+using FluentAssertions;
+using WalkingTec.Mvvm.Core;
+using Xunit;
+
+namespace WalkingTec.Mvvm.Core.Tests.Unit
+{
+ public class PasswordHashHelperTests
+ {
+ // ─── HashPassword ──────────────────────────────────────────────────────
+
+ [Fact]
+ public void HashPassword_ReturnsNonEmptyString()
+ {
+ var hash = PasswordHashHelper.HashPassword("test123");
+ hash.Should().NotBeNullOrEmpty();
+ hash.Should().NotBe("test123");
+ }
+
+ [Fact]
+ public void HashPassword_EmptyInput_ReturnsEmpty()
+ {
+ PasswordHashHelper.HashPassword("").Should().BeEmpty();
+ PasswordHashHelper.HashPassword(null).Should().BeEmpty();
+ }
+
+ [Fact]
+ public void HashPassword_DifferentCalls_ProduceDifferentHashes()
+ {
+ var hash1 = PasswordHashHelper.HashPassword("test123");
+ var hash2 = PasswordHashHelper.HashPassword("test123");
+ hash1.Should().NotBe(hash2, "PBKDF2 uses random salt");
+ }
+
+ [Fact]
+ public void HashPassword_VeryLongPassword_RoundTrips()
+ {
+ var longPw = new string('A', 10000);
+ var hash = PasswordHashHelper.HashPassword(longPw);
+ PasswordHashHelper.VerifyPassword(hash, longPw)
+ .Should().Be(PasswordVerifyResult.Success);
+ }
+
+ [Fact]
+ public void HashPassword_UnicodePassword_RoundTrips()
+ {
+ var pw = "密碼テスト🔐Ñoño";
+ var hash = PasswordHashHelper.HashPassword(pw);
+ PasswordHashHelper.VerifyPassword(hash, pw)
+ .Should().Be(PasswordVerifyResult.Success);
+ }
+
+ // ─── VerifyPassword ────────────────────────────────────────────────────
+
+ [Fact]
+ public void VerifyPassword_CorrectPassword_ReturnsSuccess()
+ {
+ var hash = PasswordHashHelper.HashPassword("myPassword");
+ PasswordHashHelper.VerifyPassword(hash, "myPassword")
+ .Should().Be(PasswordVerifyResult.Success);
+ }
+
+ [Fact]
+ public void VerifyPassword_WrongPassword_ReturnsFailed()
+ {
+ var hash = PasswordHashHelper.HashPassword("myPassword");
+ PasswordHashHelper.VerifyPassword(hash, "wrongPassword")
+ .Should().Be(PasswordVerifyResult.Failed);
+ }
+
+ [Fact]
+ public void VerifyPassword_NullInputs_ReturnsFailed()
+ {
+ PasswordHashHelper.VerifyPassword(null, "pw")
+ .Should().Be(PasswordVerifyResult.Failed);
+ PasswordHashHelper.VerifyPassword("hash", null)
+ .Should().Be(PasswordVerifyResult.Failed);
+ PasswordHashHelper.VerifyPassword(null, null)
+ .Should().Be(PasswordVerifyResult.Failed);
+ }
+
+ [Theory]
+ [InlineData("password123")]
+ [InlineData("P@$w0rd!")]
+ [InlineData("a")]
+ [InlineData(" ")]
+ public void VerifyPassword_VariousPasswords_RoundTrips(string password)
+ {
+ var hash = PasswordHashHelper.HashPassword(password);
+ PasswordHashHelper.VerifyPassword(hash, password)
+ .Should().Be(PasswordVerifyResult.Success);
+ }
+
+ [Fact]
+ public void VerifyPassword_CaseSensitive()
+ {
+ var hash = PasswordHashHelper.HashPassword("MyPassword");
+ PasswordHashHelper.VerifyPassword(hash, "mypassword")
+ .Should().Be(PasswordVerifyResult.Failed);
+ }
+
+ [Fact]
+ public void VerifyPassword_LegacyMD5_ReturnsSuccessRehashNeeded()
+ {
+ var md5Hash = PasswordHashHelper.ComputeMD5("000000");
+ PasswordHashHelper.VerifyPassword(md5Hash, "000000")
+ .Should().Be(PasswordVerifyResult.SuccessRehashNeeded);
+ }
+
+ [Fact]
+ public void VerifyPassword_LegacyMD5_WrongPassword_ReturnsFailed()
+ {
+ var md5Hash = PasswordHashHelper.ComputeMD5("000000");
+ PasswordHashHelper.VerifyPassword(md5Hash, "111111")
+ .Should().Be(PasswordVerifyResult.Failed);
+ }
+
+ // ─── IsLegacyMD5Hash ───────────────────────────────────────────────────
+
+ [Fact]
+ public void IsLegacyMD5Hash_ValidMD5_ReturnsTrue()
+ {
+ PasswordHashHelper.IsLegacyMD5Hash("670B14728AD9902AECBA32E22FA4F6BD")
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsLegacyMD5Hash_PBKDF2Hash_ReturnsFalse()
+ {
+ var pbkdf2 = PasswordHashHelper.HashPassword("test");
+ PasswordHashHelper.IsLegacyMD5Hash(pbkdf2).Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsLegacyMD5Hash_NullOrEmpty_ReturnsFalse()
+ {
+ PasswordHashHelper.IsLegacyMD5Hash(null).Should().BeFalse();
+ PasswordHashHelper.IsLegacyMD5Hash("").Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsLegacyMD5Hash_WrongLength_ReturnsFalse()
+ {
+ PasswordHashHelper.IsLegacyMD5Hash("ABC123").Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsLegacyMD5Hash_LowercaseHex_ReturnsFalse()
+ {
+ // WTM ComputeMD5 produces uppercase; lowercase should not match
+ PasswordHashHelper.IsLegacyMD5Hash("670b14728ad9902aecba32e22fa4f6bd")
+ .Should().BeFalse();
+ }
+
+ // ─── ComputeMD5 ────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ComputeMD5_KnownValue_MatchesExpected()
+ {
+ PasswordHashHelper.ComputeMD5("000000")
+ .Should().Be("670B14728AD9902AECBA32E22FA4F6BD");
+ }
+
+ [Fact]
+ public void ComputeMD5_SameInput_Deterministic()
+ {
+ PasswordHashHelper.ComputeMD5("abc")
+ .Should().Be(PasswordHashHelper.ComputeMD5("abc"));
+ }
+
+ // ─── Migration Flow ────────────────────────────────────────────────────
+
+ [Fact]
+ public void MigrationFlow_MD5ToNewHash_Verify_Success()
+ {
+ var legacyMD5 = PasswordHashHelper.ComputeMD5("secretPassword");
+ PasswordHashHelper.VerifyPassword(legacyMD5, "secretPassword")
+ .Should().Be(PasswordVerifyResult.SuccessRehashNeeded);
+
+ var newHash = PasswordHashHelper.HashPassword("secretPassword");
+ PasswordHashHelper.VerifyPassword(newHash, "secretPassword")
+ .Should().Be(PasswordVerifyResult.Success);
+ }
+
+ [Fact]
+ public void Migration_WrongPassword_NeverTriggered()
+ {
+ var md5Hash = PasswordHashHelper.ComputeMD5("correct");
+ PasswordHashHelper.VerifyPassword(md5Hash, "wrong")
+ .Should().Be(PasswordVerifyResult.Failed);
+ }
+ }
+}
diff --git a/src/WalkingTec.Mvvm.Core.Tests/RefreshTokenEntityTests.cs b/src/WalkingTec.Mvvm.Core.Tests/Unit/RefreshTokenEntityTests.cs
similarity index 61%
rename from src/WalkingTec.Mvvm.Core.Tests/RefreshTokenEntityTests.cs
rename to src/WalkingTec.Mvvm.Core.Tests/Unit/RefreshTokenEntityTests.cs
index c8bd98839..a5a39e896 100644
--- a/src/WalkingTec.Mvvm.Core.Tests/RefreshTokenEntityTests.cs
+++ b/src/WalkingTec.Mvvm.Core.Tests/Unit/RefreshTokenEntityTests.cs
@@ -3,7 +3,7 @@
using WalkingTec.Mvvm.Core;
using Xunit;
-namespace WalkingTec.Mvvm.Core.Tests
+namespace WalkingTec.Mvvm.Core.Tests.Unit
{
public class RefreshTokenEntityTests
{
@@ -48,6 +48,34 @@ public void RevokedToken_IsNotActive()
token.IsRevoked.Should().BeTrue();
}
+ [Fact]
+ public void Token_ExpiresExactlyNow_IsExpired()
+ {
+ var token = new RefreshTokenEntity
+ {
+ Token = "t",
+ ITCode = "u",
+ ExpiresUtc = DateTime.UtcNow.AddMilliseconds(-1)
+ };
+ token.IsExpired.Should().BeTrue();
+ token.IsActive.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Token_RevokedButNotExpired_IsInactive()
+ {
+ var token = new RefreshTokenEntity
+ {
+ Token = "t",
+ ITCode = "u",
+ ExpiresUtc = DateTime.UtcNow.AddDays(7),
+ RevokedUtc = DateTime.UtcNow
+ };
+ token.IsActive.Should().BeFalse();
+ token.IsRevoked.Should().BeTrue();
+ token.IsExpired.Should().BeFalse();
+ }
+
[Fact]
public void NewToken_HasGeneratedID()
{
@@ -55,6 +83,13 @@ public void NewToken_HasGeneratedID()
token.ID.Should().NotBe(Guid.Empty);
}
+ [Fact]
+ public void TwoTokens_HaveDistinctIDs()
+ {
+ new RefreshTokenEntity().ID.Should()
+ .NotBe(new RefreshTokenEntity().ID);
+ }
+
[Fact]
public void NewToken_HasCreatedUtcSet()
{
diff --git a/src/WalkingTec.Mvvm.Core.Tests/WalkingTec.Mvvm.Core.Tests.csproj b/src/WalkingTec.Mvvm.Core.Tests/WalkingTec.Mvvm.Core.Tests.csproj
index 6b58d228c..a0ad51ec2 100644
--- a/src/WalkingTec.Mvvm.Core.Tests/WalkingTec.Mvvm.Core.Tests.csproj
+++ b/src/WalkingTec.Mvvm.Core.Tests/WalkingTec.Mvvm.Core.Tests.csproj
@@ -17,10 +17,15 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
diff --git a/src/WalkingTec.Mvvm.Mvc.Tests/Fixtures/TokenTestFixture.cs b/src/WalkingTec.Mvvm.Mvc.Tests/Fixtures/TokenTestFixture.cs
new file mode 100644
index 000000000..85797f6e0
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Mvc.Tests/Fixtures/TokenTestFixture.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Moq;
+using WalkingTec.Mvvm.Core;
+using WalkingTec.Mvvm.Core.Auth;
+using WalkingTec.Mvvm.Mvc.Auth;
+
+namespace WalkingTec.Mvvm.Mvc.Tests.Fixtures
+{
+ ///
+ /// Provides an isolated TokenService instance backed by EF InMemory for integration tests.
+ /// Each test class should create a new fixture instance (don't share across tests).
+ ///
+ /// TokenService uses IServiceProvider.CreateScope() to resolve IDataContext.
+ /// We satisfy this by registering a scoped EmptyContext with InMemory EF in the DI container.
+ ///
+ public class TokenTestFixture : IDisposable
+ {
+ private readonly ServiceProvider _serviceProvider;
+ private bool _disposed;
+
+ public ITokenService TokenService { get; }
+
+ /// The InMemory DB name — unique per fixture to isolate tests.
+ public string DbName { get; } = $"TokenTest_{Guid.NewGuid():N}";
+
+ public TokenTestFixture()
+ {
+ var services = new ServiceCollection();
+
+ // Use FrameworkContext (not EmptyContext) so RefreshTokenEntity is in the EF model.
+ // Use the (string, DBTypeEnum) constructor so OnConfiguring picks Memory provider only.
+ // Avoid AddDbContext() — it would pass DbContextOptions that combines with
+ // OnConfiguring's SqlServer default, causing "two providers" InvalidOperationException.
+ services.AddScoped(_ => new FrameworkContext(DbName, DBTypeEnum.Memory));
+ services.AddScoped(sp => sp.GetRequiredService());
+
+ // Build minimal Configs with JWT options
+ var configs = new Configs();
+ configs.JwtOptions.SecurityKey = "WTM_Test_Key_AtLeast_32_Characters!!";
+ configs.JwtOptions.Issuer = "WTM_Test";
+ configs.JwtOptions.Audience = "WTM_Test";
+ configs.JwtOptions.Expires = 3600;
+
+ var mockOptions = new Mock>();
+ mockOptions.Setup(x => x.CurrentValue).Returns(configs);
+ services.AddSingleton(mockOptions.Object);
+ services.AddSingleton(sp =>
+ new TokenService(mockOptions.Object, sp));
+
+ _serviceProvider = services.BuildServiceProvider();
+ TokenService = _serviceProvider.GetRequiredService();
+ }
+
+ ///
+ /// Access the InMemory DbContext directly for seeding or assertion queries.
+ /// Caller is responsible for disposing the scope.
+ ///
+ public FrameworkContext CreateDbContext()
+ {
+ var scope = _serviceProvider.CreateScope();
+ return scope.ServiceProvider.GetRequiredService();
+ }
+
+ /// Seed a RefreshTokenEntity and save to InMemory DB.
+ public void SeedToken(RefreshTokenEntity token)
+ {
+ using var db = CreateDbContext();
+ db.Set().Add(token);
+ db.SaveChanges();
+ }
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _serviceProvider.Dispose();
+ _disposed = true;
+ }
+ }
+ }
+}
diff --git a/src/WalkingTec.Mvvm.Mvc.Tests/Integration/TokenServiceIntegrationTests.cs b/src/WalkingTec.Mvvm.Mvc.Tests/Integration/TokenServiceIntegrationTests.cs
new file mode 100644
index 000000000..a532bdab4
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Mvc.Tests/Integration/TokenServiceIntegrationTests.cs
@@ -0,0 +1,270 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using WalkingTec.Mvvm.Core;
+using WalkingTec.Mvvm.Core.Auth;
+using WalkingTec.Mvvm.Mvc.Tests.Fixtures;
+using Xunit;
+
+namespace WalkingTec.Mvvm.Mvc.Tests.Integration
+{
+ ///
+ /// Integration tests for TokenService.
+ /// Each test class instance gets its own isolated InMemory DB via TokenTestFixture.
+ ///
+ public class TokenServiceIntegrationTests : IDisposable
+ {
+ private readonly TokenTestFixture _fx;
+
+ public TokenServiceIntegrationTests()
+ {
+ _fx = new TokenTestFixture();
+ }
+
+ // ─── IssueTokenAsync ───────────────────────────────────────────────────
+
+ [Fact]
+ public async Task IssueTokenAsync_ValidUser_ReturnsAccessAndRefreshToken()
+ {
+ var user = new LoginUserInfo { ITCode = "alice", TenantCode = null };
+
+ var token = await _fx.TokenService.IssueTokenAsync(user, "127.0.0.1");
+
+ token.Should().NotBeNull();
+ token.AccessToken.Should().NotBeNullOrEmpty();
+ token.RefreshToken.Should().NotBeNullOrEmpty();
+ token.TokenType.Should().Be("Bearer");
+ token.ExpiresIn.Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public async Task IssueTokenAsync_PersistsRefreshTokenInDb()
+ {
+ var user = new LoginUserInfo { ITCode = "bob" };
+
+ var token = await _fx.TokenService.IssueTokenAsync(user);
+
+ using var db = _fx.CreateDbContext();
+ var stored = await db.Set()
+ .FirstOrDefaultAsync(x => x.Token == token.RefreshToken);
+ stored.Should().NotBeNull();
+ stored!.ITCode.Should().Be("bob");
+ stored.IsActive.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task IssueTokenAsync_NullUser_ThrowsArgumentNullException()
+ {
+ var act = () => _fx.TokenService.IssueTokenAsync(null!);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task IssueTokenAsync_TwoIssues_ProduceDistinctRefreshTokens()
+ {
+ var user = new LoginUserInfo { ITCode = "charlie" };
+
+ var t1 = await _fx.TokenService.IssueTokenAsync(user);
+ var t2 = await _fx.TokenService.IssueTokenAsync(user);
+
+ t1.RefreshToken.Should().NotBe(t2.RefreshToken,
+ "each issue produces a cryptographically unique token");
+ }
+
+ [Fact]
+ public async Task IssueTokenAsync_PreservesTenantCode()
+ {
+ var user = new LoginUserInfo { ITCode = "tenant_user", TenantCode = "tenant_a" };
+
+ var token = await _fx.TokenService.IssueTokenAsync(user);
+
+ using var db = _fx.CreateDbContext();
+ var stored = await db.Set()
+ .FirstOrDefaultAsync(x => x.Token == token.RefreshToken);
+ stored!.TenantCode.Should().Be("tenant_a");
+ }
+
+ // ─── RefreshTokenAsync ─────────────────────────────────────────────────
+
+ [Fact]
+ public async Task RefreshTokenAsync_ValidToken_ReturnsNewTokenPairAndRotatesOld()
+ {
+ var user = new LoginUserInfo { ITCode = "dave" };
+ var issued = await _fx.TokenService.IssueTokenAsync(user);
+
+ var refreshed = await _fx.TokenService.RefreshTokenAsync(
+ issued.RefreshToken, "127.0.0.1");
+
+ refreshed.Should().NotBeNull();
+ refreshed!.RefreshToken.Should().NotBe(issued.RefreshToken,
+ "old token must be rotated out");
+ refreshed.AccessToken.Should().NotBeNullOrEmpty();
+
+ // Old token is now revoked
+ using var db = _fx.CreateDbContext();
+ var old = await db.Set()
+ .FirstOrDefaultAsync(x => x.Token == issued.RefreshToken);
+ old!.IsRevoked.Should().BeTrue();
+ old.ReplacedByToken.Should().Be(refreshed.RefreshToken);
+ }
+
+ [Fact]
+ public async Task RefreshTokenAsync_ExpiredToken_ReturnsNull()
+ {
+ var expired = new RefreshTokenEntity
+ {
+ Token = "expired_token",
+ ITCode = "eve",
+ ExpiresUtc = DateTime.UtcNow.AddDays(-1),
+ CreatedByIp = "127.0.0.1"
+ };
+ _fx.SeedToken(expired);
+
+ var result = await _fx.TokenService.RefreshTokenAsync("expired_token");
+
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task RefreshTokenAsync_RevokedToken_ReturnsNull()
+ {
+ var revoked = new RefreshTokenEntity
+ {
+ Token = "revoked_token",
+ ITCode = "frank",
+ ExpiresUtc = DateTime.UtcNow.AddDays(7),
+ RevokedUtc = DateTime.UtcNow.AddMinutes(-5),
+ RevokeReason = "Test"
+ };
+ _fx.SeedToken(revoked);
+
+ var result = await _fx.TokenService.RefreshTokenAsync("revoked_token");
+
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task RefreshTokenAsync_NonexistentToken_ReturnsNull()
+ {
+ var result = await _fx.TokenService.RefreshTokenAsync("does_not_exist_token");
+ result.Should().BeNull();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public async Task RefreshTokenAsync_NullOrEmpty_ReturnsNull(string? token)
+ {
+ var result = await _fx.TokenService.RefreshTokenAsync(token!);
+ result.Should().BeNull();
+ }
+
+ // ─── Reuse Detection ───────────────────────────────────────────────────
+
+ [Fact]
+ public async Task RefreshTokenAsync_ReusedToken_RevokesDescendantChain()
+ {
+ // Issue T1
+ var user = new LoginUserInfo { ITCode = "grace" };
+ var issued = await _fx.TokenService.IssueTokenAsync(user);
+ var t1 = issued.RefreshToken;
+
+ // Refresh T1 → get T2
+ var secondIssue = await _fx.TokenService.RefreshTokenAsync(t1);
+ secondIssue.Should().NotBeNull();
+ var t2 = secondIssue!.RefreshToken;
+
+ // Attempt to reuse T1 (already revoked) → should revoke T2 as well
+ var reuseResult = await _fx.TokenService.RefreshTokenAsync(t1);
+ reuseResult.Should().BeNull("reused token must be rejected");
+
+ // Verify T2 (descendant) is now also revoked
+ using var db = _fx.CreateDbContext();
+ var t2Entity = await db.Set()
+ .FirstOrDefaultAsync(x => x.Token == t2);
+ t2Entity.Should().NotBeNull();
+ t2Entity!.IsRevoked.Should().BeTrue(
+ "descendant token must be revoked when ancestor is reused (token theft detection)");
+ }
+
+ // ─── RevokeTokenAsync ──────────────────────────────────────────────────
+
+ [Fact]
+ public async Task RevokeTokenAsync_ActiveToken_SetsRevokedUtcAndReason()
+ {
+ var user = new LoginUserInfo { ITCode = "helen" };
+ var issued = await _fx.TokenService.IssueTokenAsync(user);
+
+ await _fx.TokenService.RevokeTokenAsync(
+ issued.RefreshToken, "127.0.0.1", "Explicit logout");
+
+ using var db = _fx.CreateDbContext();
+ var entity = await db.Set()
+ .FirstOrDefaultAsync(x => x.Token == issued.RefreshToken);
+ entity!.IsRevoked.Should().BeTrue();
+ entity.RevokeReason.Should().Be("Explicit logout");
+ entity.RevokedByIp.Should().Be("127.0.0.1");
+ }
+
+ [Fact]
+ public async Task RevokeTokenAsync_AlreadyRevoked_IsNoOp()
+ {
+ var revoked = new RefreshTokenEntity
+ {
+ Token = "already_revoked",
+ ITCode = "ivan",
+ ExpiresUtc = DateTime.UtcNow.AddDays(7),
+ RevokedUtc = DateTime.UtcNow.AddHours(-1),
+ RevokeReason = "First revocation"
+ };
+ _fx.SeedToken(revoked);
+
+ // Should not throw and should not change the existing revocation reason
+ var act = () => _fx.TokenService.RevokeTokenAsync(
+ "already_revoked", "127.0.0.1", "Second attempt");
+ await act.Should().NotThrowAsync();
+
+ using var db = _fx.CreateDbContext();
+ var entity = await db.Set()
+ .FirstOrDefaultAsync(x => x.Token == "already_revoked");
+ entity!.RevokeReason.Should().Be("First revocation",
+ "already-revoked token is not modified");
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public async Task RevokeTokenAsync_NullOrEmpty_DoesNotThrow(string? token)
+ {
+ var act = () => _fx.TokenService.RevokeTokenAsync(token!);
+ await act.Should().NotThrowAsync();
+ }
+
+ // ─── Tenant Isolation ──────────────────────────────────────────────────
+
+ [Fact]
+ public async Task IssueTokenAsync_DifferentTenants_TokensHaveDifferentTenantCodes()
+ {
+ var userA = new LoginUserInfo { ITCode = "judy", TenantCode = "tenant_a" };
+ var userB = new LoginUserInfo { ITCode = "judy", TenantCode = "tenant_b" };
+
+ var tokenA = await _fx.TokenService.IssueTokenAsync(userA);
+ var tokenB = await _fx.TokenService.IssueTokenAsync(userB);
+
+ using var db = _fx.CreateDbContext();
+ var storedA = await db.Set()
+ .FirstOrDefaultAsync(x => x.Token == tokenA.RefreshToken);
+ var storedB = await db.Set()
+ .FirstOrDefaultAsync(x => x.Token == tokenB.RefreshToken);
+
+ storedA!.TenantCode.Should().Be("tenant_a");
+ storedB!.TenantCode.Should().Be("tenant_b");
+ tokenA.RefreshToken.Should().NotBe(tokenB.RefreshToken);
+ }
+
+ public void Dispose() => _fx.Dispose();
+ }
+}
diff --git a/src/WalkingTec.Mvvm.Mvc.Tests/Security/TokenChainSecurityTests.cs b/src/WalkingTec.Mvvm.Mvc.Tests/Security/TokenChainSecurityTests.cs
new file mode 100644
index 000000000..71c1d74af
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Mvc.Tests/Security/TokenChainSecurityTests.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using WalkingTec.Mvvm.Core;
+using WalkingTec.Mvvm.Core.Auth;
+using WalkingTec.Mvvm.Mvc.Tests.Fixtures;
+using Xunit;
+
+namespace WalkingTec.Mvvm.Mvc.Tests.Security
+{
+ ///
+ /// Security-focused tests for the JWT refresh token chain.
+ /// Verifies theft-detection, chain revocation, and token uniqueness properties
+ /// that go beyond basic happy-path coverage in TokenServiceIntegrationTests.
+ ///
+ public class TokenChainSecurityTests : IDisposable
+ {
+ private readonly TokenTestFixture _fx;
+
+ public TokenChainSecurityTests()
+ {
+ _fx = new TokenTestFixture();
+ }
+
+ // ─── Reuse Detection — Multi-Hop Chain ──────────────────────────────
+
+ [Fact]
+ public async Task RefreshChain_ThreeHops_EachPriorTokenRevoked()
+ {
+ // Issue T1, refresh → T2, refresh → T3
+ // Each step must invalidate the previous token
+ var user = new LoginUserInfo { ITCode = "chain_user" };
+ var issued = await _fx.TokenService.IssueTokenAsync(user);
+ var t1 = issued.RefreshToken;
+
+ var r2 = await _fx.TokenService.RefreshTokenAsync(t1);
+ r2.Should().NotBeNull();
+ var t2 = r2!.RefreshToken;
+
+ var r3 = await _fx.TokenService.RefreshTokenAsync(t2);
+ r3.Should().NotBeNull();
+ var t3 = r3!.RefreshToken;
+
+ using var db = _fx.CreateDbContext();
+
+ // T1 and T2 must be revoked; T3 is the live token
+ var e1 = await db.Set().FirstAsync(x => x.Token == t1);
+ var e2 = await db.Set().FirstAsync(x => x.Token == t2);
+ var e3 = await db.Set().FirstAsync(x => x.Token == t3);
+
+ e1.IsRevoked.Should().BeTrue("T1 was rotated out when T2 was issued");
+ e2.IsRevoked.Should().BeTrue("T2 was rotated out when T3 was issued");
+ e3.IsActive.Should().BeTrue("T3 is the live token at the end of the chain");
+ }
+
+ [Fact]
+ public async Task RefreshChain_ReuseAtMidpoint_RevokesAllDescendants()
+ {
+ // Issue T1 → T2 → T3
+ // Reusing T2 (already spent) should revoke T3 (the descendant) as theft detection
+ var user = new LoginUserInfo { ITCode = "midchain_user" };
+ var issued = await _fx.TokenService.IssueTokenAsync(user);
+ var t1 = issued.RefreshToken;
+
+ var r2 = await _fx.TokenService.RefreshTokenAsync(t1);
+ var t2 = r2!.RefreshToken;
+
+ var r3 = await _fx.TokenService.RefreshTokenAsync(t2);
+ var t3 = r3!.RefreshToken;
+
+ // Reuse T2 — already revoked when T3 was issued
+ var reuseResult = await _fx.TokenService.RefreshTokenAsync(t2);
+ reuseResult.Should().BeNull("reusing a spent token must be rejected");
+
+ using var db = _fx.CreateDbContext();
+ var e3 = await db.Set().FirstAsync(x => x.Token == t3);
+ e3.IsRevoked.Should().BeTrue(
+ "T3 (descendant of reused T2) must be revoked by theft detection");
+ }
+
+ // ─── Token Uniqueness ────────────────────────────────────────────────
+
+ [Fact]
+ public async Task IssueTokenAsync_RepeatedCalls_AlwaysProduceUniqueTokens()
+ {
+ const int count = 20;
+ var user = new LoginUserInfo { ITCode = "uniqueness_user" };
+ var tokens = new System.Collections.Generic.HashSet();
+
+ for (int i = 0; i < count; i++)
+ {
+ var issued = await _fx.TokenService.IssueTokenAsync(user);
+ tokens.Add(issued.RefreshToken);
+ }
+
+ tokens.Should().HaveCount(count,
+ $"each of {count} issue calls must produce a cryptographically unique refresh token");
+ }
+
+ [Fact]
+ public async Task RefreshTokenAsync_Consecutive_NewTokenDiffersFromOld()
+ {
+ var user = new LoginUserInfo { ITCode = "rotate_user" };
+ var issued = await _fx.TokenService.IssueTokenAsync(user);
+
+ var refreshed = await _fx.TokenService.RefreshTokenAsync(issued.RefreshToken);
+
+ refreshed.Should().NotBeNull();
+ refreshed!.RefreshToken.Should().NotBe(issued.RefreshToken,
+ "rotation must produce a different token, not reissue the same value");
+ refreshed.AccessToken.Should().NotBe(issued.AccessToken,
+ "access token must also be regenerated on rotation");
+ }
+
+ // ─── Revoke Propagation ──────────────────────────────────────────────
+
+ [Fact]
+ public async Task RevokedToken_ImmediatelyInactive_CannotRefresh()
+ {
+ var user = new LoginUserInfo { ITCode = "revoke_user" };
+ var issued = await _fx.TokenService.IssueTokenAsync(user);
+
+ await _fx.TokenService.RevokeTokenAsync(
+ issued.RefreshToken, "127.0.0.1", "Security test revocation");
+
+ var result = await _fx.TokenService.RefreshTokenAsync(issued.RefreshToken);
+ result.Should().BeNull("explicitly revoked token must be rejected on refresh");
+ }
+
+ [Fact]
+ public async Task ManualRevoke_DoesNotAffectOtherUsersTokens()
+ {
+ var userA = new LoginUserInfo { ITCode = "user_a" };
+ var userB = new LoginUserInfo { ITCode = "user_b" };
+
+ var tokenA = await _fx.TokenService.IssueTokenAsync(userA);
+ var tokenB = await _fx.TokenService.IssueTokenAsync(userB);
+
+ // Revoke only user_a's token
+ await _fx.TokenService.RevokeTokenAsync(tokenA.RefreshToken, null, "Selective revoke");
+
+ // user_b's token must still be usable
+ var refreshedB = await _fx.TokenService.RefreshTokenAsync(tokenB.RefreshToken);
+ refreshedB.Should().NotBeNull(
+ "revoking one user's token must not affect other users' tokens");
+ }
+
+ // ─── Expired Token Boundary ──────────────────────────────────────────
+
+ [Fact]
+ public async Task ExpiredToken_CannotBeRefreshed_EvenIfNotExplicitlyRevoked()
+ {
+ var expired = new RefreshTokenEntity
+ {
+ Token = "expired_chain_token",
+ ITCode = "expired_user",
+ ExpiresUtc = DateTime.UtcNow.AddSeconds(-1),
+ CreatedByIp = "127.0.0.1"
+ };
+ _fx.SeedToken(expired);
+
+ var result = await _fx.TokenService.RefreshTokenAsync("expired_chain_token");
+ result.Should().BeNull("expired tokens must be rejected regardless of revocation state");
+ }
+
+ public void Dispose() => _fx.Dispose();
+ }
+}
diff --git a/src/WalkingTec.Mvvm.Mvc.Tests/WalkingTec.Mvvm.Mvc.Tests.csproj b/src/WalkingTec.Mvvm.Mvc.Tests/WalkingTec.Mvvm.Mvc.Tests.csproj
new file mode 100644
index 000000000..1230d48f9
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Mvc.Tests/WalkingTec.Mvvm.Mvc.Tests.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net8.0
+ enable
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/src/WalkingTec.Mvvm.Mvc/Auth/JwtAuth/TokenService.cs b/src/WalkingTec.Mvvm.Mvc/Auth/JwtAuth/TokenService.cs
index 771bf417e..97feec2ab 100644
--- a/src/WalkingTec.Mvvm.Mvc/Auth/JwtAuth/TokenService.cs
+++ b/src/WalkingTec.Mvvm.Mvc/Auth/JwtAuth/TokenService.cs
@@ -112,7 +112,10 @@ private string GenerateAccessToken(LoginUserInfo info)
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecurityKey)),
SecurityAlgorithms.HmacSha256);
var claims = new List
- { new(AuthConstants.JwtClaimTypes.Subject, info.ITCode) };
+ {
+ new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
+ new(AuthConstants.JwtClaimTypes.Subject, info.ITCode)
+ };
if (!string.IsNullOrEmpty(info.TenantCode))
claims.Add(new(AuthConstants.JwtClaimTypes.TenantCode, info.TenantCode));
if (!string.IsNullOrEmpty(info.RemoteToken))
diff --git a/test/WalkingTec.Mvvm.Admin.Test/DataPrivilegeApiTest.cs b/test/WalkingTec.Mvvm.Admin.Test/DataPrivilegeApiTest.cs
new file mode 100644
index 000000000..ffb6fb639
--- /dev/null
+++ b/test/WalkingTec.Mvvm.Admin.Test/DataPrivilegeApiTest.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using WalkingTec.Mvvm.Admin.Api;
+using WalkingTec.Mvvm.Core;
+using WalkingTec.Mvvm.Mvc.Admin.ViewModels.DataPrivilegeVMs;
+using WalkingTec.Mvvm.Test.Mock;
+
+namespace WalkingTec.Mvvm.Admin.Test
+{
+ [TestClass]
+ public class DataPrivilegeApiTest
+ {
+ private DataPrivilegeController _controller;
+ private string _seed;
+
+ public DataPrivilegeApiTest()
+ {
+ _seed = Guid.NewGuid().ToString();
+ _controller = MockController.CreateApi(
+ new Demo.DataContext(_seed, DBTypeEnum.Memory), "user");
+ }
+
+ [TestMethod]
+ public void SearchTest()
+ {
+ var rv = _controller.Search(new DataPrivilegeSearcher());
+ Assert.IsTrue(string.IsNullOrEmpty((rv as ContentResult)?.Content) == false);
+ }
+
+ [TestMethod]
+ public void GetTest()
+ {
+ // Seed a DataPrivilege record and query it back
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ context.Set().Add(new DataPrivilege
+ {
+ TableName = "TestTable",
+ UserCode = "getuser",
+ RelateId = "relid1"
+ });
+ context.SaveChanges();
+ }
+
+ var rv = _controller.Get("TestTable", "getuser", DpTypeEnum.User);
+ Assert.IsNotNull(rv);
+ Assert.IsNotNull(rv.SelectedItemsID);
+ Assert.IsTrue(rv.SelectedItemsID.Contains("relid1"));
+ }
+
+ [TestMethod]
+ public void CreateTest()
+ {
+ // DataPrivilegeVM.Validate() checks that UserCode maps to a real FrameworkUser
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ context.Set().Add(new FrameworkUser
+ {
+ ITCode = "dpuser",
+ Name = "DP Test User",
+ Password = PasswordHashHelper.HashPassword("pass")
+ });
+ context.SaveChanges();
+ }
+
+ DataPrivilegeVM vm = _controller.Wtm.CreateVM();
+ vm.Entity = new DataPrivilege
+ {
+ TableName = "TestTable",
+ UserCode = "dpuser"
+ };
+ vm.DpType = DpTypeEnum.User;
+ vm.IsAll = true;
+
+ var rv = _controller.Add(vm).Result;
+ Assert.IsInstanceOfType(rv, typeof(OkObjectResult));
+
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ var records = context.Set().ToList();
+ Assert.AreEqual(1, records.Count);
+ Assert.AreEqual("TestTable", records[0].TableName);
+ Assert.AreEqual("dpuser", records[0].UserCode);
+ Assert.IsNull(records[0].RelateId, "IsAll=true stores a null RelateId row");
+ }
+ }
+ }
+}
diff --git a/test/WalkingTec.Mvvm.Admin.Test/FrameworkGroupApiTest.cs b/test/WalkingTec.Mvvm.Admin.Test/FrameworkGroupApiTest.cs
new file mode 100644
index 000000000..f21a5ca84
--- /dev/null
+++ b/test/WalkingTec.Mvvm.Admin.Test/FrameworkGroupApiTest.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using WalkingTec.Mvvm.Admin.Api;
+using WalkingTec.Mvvm.Core;
+using WalkingTec.Mvvm.Mvc.Admin.ViewModels.FrameworkGroupVMs;
+using WalkingTec.Mvvm.Test.Mock;
+
+namespace WalkingTec.Mvvm.Admin.Test
+{
+ [TestClass]
+ public class FrameworkGroupApiTest
+ {
+ private FrameworkGroupController _controller;
+ private string _seed;
+
+ public FrameworkGroupApiTest()
+ {
+ _seed = Guid.NewGuid().ToString();
+ _controller = MockController.CreateApi(
+ new Demo.DataContext(_seed, DBTypeEnum.Memory), "user");
+ }
+
+ [TestMethod]
+ public void SearchTest()
+ {
+ var rv = _controller.Search(new FrameworkGroupSearcher()).Result;
+ Assert.IsTrue(string.IsNullOrEmpty((rv as ContentResult)?.Content) == false);
+ }
+
+ [TestMethod]
+ public void CreateTest()
+ {
+ FrameworkGroupVM vm = _controller.Wtm.CreateVM();
+ FrameworkGroup v = new FrameworkGroup();
+ v.GroupCode = "001";
+ v.GroupName = "TestGroup";
+ vm.Entity = v;
+ // Add() returns IActionResult (sync), not Task
+ var rv = _controller.Add(vm);
+ Assert.IsInstanceOfType(rv, typeof(OkObjectResult));
+
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ var data = context.Set().FirstOrDefault();
+ Assert.AreEqual(data.GroupCode, "001");
+ Assert.AreEqual(data.GroupName, "TestGroup");
+ // Note: FrameworkGroup : TreePoco : TopBasePoco (no audit fields)
+ }
+ }
+
+ [TestMethod]
+ public void EditTest()
+ {
+ FrameworkGroup v = new FrameworkGroup();
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ v.GroupCode = "002";
+ v.GroupName = "OriginalName";
+ context.Set().Add(v);
+ context.SaveChanges();
+ }
+
+ FrameworkGroupVM vm = _controller.Wtm.CreateVM();
+ var oldID = v.ID;
+ v = new FrameworkGroup();
+ v.ID = oldID;
+ v.GroupCode = "002";
+ v.GroupName = "UpdatedName";
+ vm.Entity = v;
+ vm.FC = new Dictionary();
+ vm.FC.Add("Entity.GroupName", "");
+ // Edit() returns IActionResult (sync), not Task
+ var rv = _controller.Edit(vm);
+ Assert.IsInstanceOfType(rv, typeof(OkObjectResult));
+
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ var data = context.Set().FirstOrDefault();
+ Assert.AreEqual(data.GroupName, "UpdatedName");
+ }
+ }
+
+ [TestMethod]
+ public void GetTest()
+ {
+ FrameworkGroup v = new FrameworkGroup();
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ v.GroupCode = "003";
+ v.GroupName = "GetTestGroup";
+ context.Set().Add(v);
+ context.SaveChanges();
+ }
+
+ var rv = _controller.Get(v.ID);
+ Assert.IsNotNull(rv);
+ Assert.AreEqual(rv.Entity.GroupCode, "003");
+ Assert.AreEqual(rv.Entity.GroupName, "GetTestGroup");
+ }
+
+ [TestMethod]
+ public void BatchDeleteTest()
+ {
+ FrameworkGroup v1 = new FrameworkGroup();
+ FrameworkGroup v2 = new FrameworkGroup();
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ v1.GroupCode = "004";
+ v1.GroupName = "Group1";
+ v2.GroupCode = "005";
+ v2.GroupName = "Group2";
+ context.Set().Add(v1);
+ context.Set().Add(v2);
+ context.SaveChanges();
+ }
+
+ // BatchDelete() returns Task (async)
+ var rv = _controller.BatchDelete(new string[] { v1.ID.ToString(), v2.ID.ToString() });
+ Assert.IsInstanceOfType(rv.Result, typeof(OkObjectResult));
+
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ Assert.AreEqual(context.Set().Count(), 0);
+ }
+
+ rv = _controller.BatchDelete(new string[] { });
+ Assert.IsInstanceOfType(rv.Result, typeof(OkResult));
+ }
+ }
+}
diff --git a/test/WalkingTec.Mvvm.Admin.Test/FrameworkRoleApiTest.cs b/test/WalkingTec.Mvvm.Admin.Test/FrameworkRoleApiTest.cs
new file mode 100644
index 000000000..b3fd87345
--- /dev/null
+++ b/test/WalkingTec.Mvvm.Admin.Test/FrameworkRoleApiTest.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using WalkingTec.Mvvm.Admin.Api;
+using WalkingTec.Mvvm.Core;
+using WalkingTec.Mvvm.Mvc.Admin.ViewModels.FrameworkRoleVMs;
+using WalkingTec.Mvvm.Test.Mock;
+
+namespace WalkingTec.Mvvm.Admin.Test
+{
+ [TestClass]
+ public class FrameworkRoleApiTest
+ {
+ private FrameworkRoleController _controller;
+ private string _seed;
+
+ public FrameworkRoleApiTest()
+ {
+ _seed = Guid.NewGuid().ToString();
+ _controller = MockController.CreateApi(
+ new Demo.DataContext(_seed, DBTypeEnum.Memory), "user");
+ }
+
+ [TestMethod]
+ public void SearchTest()
+ {
+ var rv = _controller.Search(new FrameworkRoleSearcher()).Result;
+ Assert.IsTrue(string.IsNullOrEmpty((rv as ContentResult)?.Content) == false);
+ }
+
+ [TestMethod]
+ public void CreateTest()
+ {
+ FrameworkRoleVM vm = _controller.Wtm.CreateVM();
+ FrameworkRole v = new FrameworkRole();
+ v.RoleCode = "101";
+ v.RoleName = "TestRole";
+ vm.Entity = v;
+ // Add() returns IActionResult (sync), not Task
+ var rv = _controller.Add(vm);
+ Assert.IsInstanceOfType(rv, typeof(OkObjectResult));
+
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ var data = context.Set().FirstOrDefault();
+ Assert.AreEqual(data.RoleCode, "101");
+ Assert.AreEqual(data.RoleName, "TestRole");
+ Assert.AreEqual(data.CreateBy, "user");
+ Assert.IsTrue(DateTime.Now.Subtract(data.CreateTime.Value).Seconds < 10);
+ }
+ }
+
+ [TestMethod]
+ public void EditTest()
+ {
+ FrameworkRole v = new FrameworkRole();
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ v.RoleCode = "102";
+ v.RoleName = "OriginalRoleName";
+ context.Set().Add(v);
+ context.SaveChanges();
+ }
+
+ FrameworkRoleVM vm = _controller.Wtm.CreateVM();
+ var oldID = v.ID;
+ v = new FrameworkRole();
+ v.ID = oldID;
+ v.RoleCode = "102";
+ v.RoleName = "UpdatedRoleName";
+ vm.Entity = v;
+ vm.FC = new Dictionary();
+ vm.FC.Add("Entity.RoleName", "");
+ // Edit() returns IActionResult (sync), not Task
+ var rv = _controller.Edit(vm);
+ Assert.IsInstanceOfType(rv, typeof(OkObjectResult));
+
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ var data = context.Set().FirstOrDefault();
+ Assert.AreEqual(data.RoleName, "UpdatedRoleName");
+ Assert.AreEqual(data.UpdateBy, "user");
+ Assert.IsTrue(DateTime.Now.Subtract(data.UpdateTime.Value).Seconds < 10);
+ }
+ }
+
+ [TestMethod]
+ public void GetTest()
+ {
+ FrameworkRole v = new FrameworkRole();
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ v.RoleCode = "103";
+ v.RoleName = "GetTestRole";
+ context.Set().Add(v);
+ context.SaveChanges();
+ }
+
+ var rv = _controller.Get(v.ID);
+ Assert.IsNotNull(rv);
+ Assert.AreEqual(rv.Entity.RoleCode, "103");
+ Assert.AreEqual(rv.Entity.RoleName, "GetTestRole");
+ }
+
+ [TestMethod]
+ public void BatchDeleteTest()
+ {
+ FrameworkRole v1 = new FrameworkRole();
+ FrameworkRole v2 = new FrameworkRole();
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ v1.RoleCode = "104";
+ v1.RoleName = "Role1";
+ v2.RoleCode = "105";
+ v2.RoleName = "Role2";
+ context.Set().Add(v1);
+ context.Set().Add(v2);
+ context.SaveChanges();
+ }
+
+ // BatchDelete() returns Task (async)
+ var rv = _controller.BatchDelete(new string[] { v1.ID.ToString(), v2.ID.ToString() });
+ Assert.IsInstanceOfType(rv.Result, typeof(OkObjectResult));
+
+ using (var context = new Demo.DataContext(_seed, DBTypeEnum.Memory))
+ {
+ Assert.AreEqual(context.Set().Count(), 0);
+ }
+
+ rv = _controller.BatchDelete(new string[] { });
+ Assert.IsInstanceOfType(rv.Result, typeof(OkResult));
+ }
+ }
+}
diff --git a/test/WalkingTec.Mvvm.Admin.Test/WalkingTec.Mvvm.Admin.Test.csproj b/test/WalkingTec.Mvvm.Admin.Test/WalkingTec.Mvvm.Admin.Test.csproj
index b4134483d..bd37d6c66 100644
--- a/test/WalkingTec.Mvvm.Admin.Test/WalkingTec.Mvvm.Admin.Test.csproj
+++ b/test/WalkingTec.Mvvm.Admin.Test/WalkingTec.Mvvm.Admin.Test.csproj
@@ -11,6 +11,10 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+