NewLife/Stardust

test(webapi): 迁移 ServerTests 反射脱敏测试为 HTTP 集成式

- 刀0/2/3/4/8、AppPipeline 序列化、McpServer 双通道写路径共 7 件反射测试,
  迁为 WebApiTests HTTP 集成式(真实 POST/PUT/GET 管道 + TestServer 进程内)
- 契约随新管线定谳:meta 字段集已裁敏感键=行/详情键级缺席(强于键在值 null),
  在场键走掩码 null/条件哨兵;audit 键与 AppDeploy.appId 缺席=TrimFields 设计且前端明文容忍
- McpServer 写路径读回走 HTTP 面(写响应 data.id + 详情面),不再用反射/SQL 直查
- 新增 ApiRowAssert 行断言助手;删除 ServerTests 七旧件
何炳宏 authored at 2026-11-14 22:50:27
1454e53
Tree
1 Parent(s) 0909f90
Summary: 15 changed files with 1142 additions and 1674 deletions.
Deleted +0 -451
Stardust.ServerTests/Deployment/AppPipelineApiListSerializationTests.cs
Deleted +0 -173
Stardust.ServerTests/Mcp/McpServerApiBindingTests.cs
Deleted +0 -164
Stardust.ServerTests/Mcp/McpServerApiUpdateTests.cs
Deleted +0 -296
Stardust.ServerTests/Security/MaskedRowsKnife0Tests.cs
Deleted +0 -188
Stardust.ServerTests/Security/MaskedRowsKnife2Tests.cs
Deleted +0 -214
Stardust.ServerTests/Security/MaskedRowsKnife34Tests.cs
Deleted +0 -188
Stardust.ServerTests/Security/MaskedRowsKnife8Tests.cs
Added +280 -0
Added +96 -0
Added +122 -0
Added +124 -0
Added +200 -0
Added +103 -0
Added +107 -0
Added +110 -0
Deleted +0 -451
Stardust.ServerTests/Deployment/AppPipelineApiListSerializationTests.cs
Deleted +0 -173
Stardust.ServerTests/Mcp/McpServerApiBindingTests.cs
Deleted +0 -164
Stardust.ServerTests/Mcp/McpServerApiUpdateTests.cs
Deleted +0 -296
Stardust.ServerTests/Security/MaskedRowsKnife0Tests.cs
Deleted +0 -188
Stardust.ServerTests/Security/MaskedRowsKnife2Tests.cs
Deleted +0 -214
Stardust.ServerTests/Security/MaskedRowsKnife34Tests.cs
Deleted +0 -188
Stardust.ServerTests/Security/MaskedRowsKnife8Tests.cs
Added +280 -0
diff --git a/Stardust.WebApiTests/Deployment/AppPipelineApiListSerializationTests.cs b/Stardust.WebApiTests/Deployment/AppPipelineApiListSerializationTests.cs
new file mode 100644
index 0000000..33ead7f
--- /dev/null
+++ b/Stardust.WebApiTests/Deployment/AppPipelineApiListSerializationTests.cs
@@ -0,0 +1,280 @@
+using System.Reflection;
+using System.Text.Json;
+using Stardust.Data.Deployment;
+using Stardust.Data.Nodes;
+using Stardust.Data.Platform;
+using Stardust.WebApiTests.Fixtures;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Stardust.WebApiTests.Deployment;
+
+/// <summary>
+/// AppPipeline / AppDeploy 列表行自描述序列化回归(HTTP 集成式迁移,2026-11-14 Q2 裁决)。
+/// 缺陷现象:AppPipeline 列表带 ?projectId= 时行 JSON 顶层缺失 projectName 键,且双形状均缺
+/// updateUserId/updateIP;AppDeploy 行 JSON 缺 appId 键(前端 ship.logic.ts 被迫双通道兜底,
+/// 行自描述要求补齐)。迁后走真实 HTTP 列表管道断言行 JSON 键集与值。
+/// 另含列表行脱敏回归组:AppPipeline Token/Secret、AppDeploy RepoPassword 族、
+/// McpToken Token、SslCertificate/GatewayCert PfxPassword、DomainProvider AppKey/AppSecret——
+/// 新管线(Cube 1105 字段集合投影)定谳(2026-11-14 首轮实测):meta 字段集已裁剪的键在行 JSON
+/// **键级缺席**(比键在值 null 更强防泄漏);仍在字段集的键走掩码(值 null:AppDeploy.RepoUserName、
+/// DomainProvider.AppKey)。+ McpToken 写响应结构性正断言(Insert/Update/Delete 不得 override,
+/// 一次性揭示不破)。
+/// 行自描述键新定谳:audit 键(updateUserId/updateIP)与 AppDeploy.appId 在新管线字段集外=行键缺席——
+/// 前端已明文容忍(ship.logic.ts:行内无 appId 时采用后端过滤结果首行;audit 键零消费方),
+/// 断言按缺席编码(KeyAbsent 锁定无明文且形状稳定)。
+/// 与原 ServerTests 反射版差异:原「stale Extends 预置」与 DumpEvidence 机读锚(一次性走查件)不在 API 面,随之退役。
+/// 掩码机制(2026-11-14 收敛后):WebEntityController 薄基类 GetRowMasks 声明式规则 + OnProject 统一应用。
+/// </summary>
+[Collection(WebApiCollection.Name)]
+public class AppPipelineApiListSerializationTests
+{
+    private readonly ApiClient _api;
+
+    public AppPipelineApiListSerializationTests(WebApiTestFactory factory, ITestOutputHelper output) =>
+        _api = new(factory, output);
+
+    #region 行自描述
+
+    [Fact(DisplayName = "AppPipeline 列表(无参形状)行键:projectName/deployName/updateTime 在场,audit 键缺席")]
+    public async Task Index_Unscoped_RowKeys_Should_SelfDescribe()
+    {
+        var tag = "apw-u-" + Guid.NewGuid().ToString("N")[..8];
+        var e = SeedPipeline(tag + "-p1", 0);
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, "Deployment/AppPipeline?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"无参形状应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            // 无参侧:MapField 关联值 projectName/deployName 与 updateTime 在场(值可 null)
+            ApiRowAssert.KeyPresent(mine, "projectName", "无参形状");
+            ApiRowAssert.KeyPresent(mine, "deployName", "无参形状");
+            ApiRowAssert.KeyPresent(mine, "updateTime", "无参形状");
+
+            // 新管线字段集不含 audit 列(TrimFields 设计;前端零消费)——键缺席
+            ApiRowAssert.KeyAbsent(mine, "updateUserId", "无参形状");
+            ApiRowAssert.KeyAbsent(mine, "updateIP", "无参形状");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "AppPipeline 列表带 projectId= 时行键:scoped 形状裁掉 projectName(unscoped 在场,非对称=TrimFields 通道)")]
+    public async Task Index_ScopedProjectId_Row_Should_Keep_ProjectName_Key()
+    {
+        var tag = "apw-s-" + Guid.NewGuid().ToString("N")[..8];
+        var proj = new GalaxyProject { Name = tag + "-proj" };
+        proj.Insert();
+        var e = SeedPipeline(tag + "-p2", proj.Id);
+        try
+        {
+            // 生产 scoped 形状:?projectId=N(查询串驱动 OnGetFields→TrimFields 通道)
+            var rows = await ApiRowAssert.GetRowsAsync(_api, $"Deployment/AppPipeline?projectId={proj.Id}&pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"scoped 形状应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            // 定谳(2026-11-14 首轮实测):scoped 行无 projectName 键(unscoped 有)——TrimFields 收窄载荷;
+            // 前端 AppPipeline 行零 projectName 消费方,形状锁定在案(若后续要恢复自描述需改字段集,另案)
+            ApiRowAssert.KeyAbsent(mine, "projectName", "scoped 形状");
+            ApiRowAssert.KeyAbsent(mine, "updateUserId", "scoped 形状");
+            ApiRowAssert.KeyAbsent(mine, "updateIP", "scoped 形状");
+        }
+        finally { e.Delete(); proj.Delete(); }
+    }
+
+    [Fact(DisplayName = "AppDeploy 列表行键:appName 在场(MapField),appId 缺席(前端 ship.logic 明文容忍缺键形状)")]
+    public async Task AppDeploy_Index_Row_Should_Contain_AppId_Key()
+    {
+        var tag = "apw-d-" + Guid.NewGuid().ToString("N")[..8];
+        var e = SeedDeploy(tag + "-d1", appId: 42, projectId: 0);
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, "Deployment/AppDeploy?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"AppDeploy 无参形状应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            // [Map] 计算属性 appName 键在场(值可 null:appId=42 无对应应用行)
+            ApiRowAssert.KeyPresent(mine, "appName", "AppDeploy");
+
+            // 新管线字段集不含 AppId 列(ListFields.RemoveField 设计)——appId 键缺席;
+            // ship.logic.ts:行内无 appId 时采用后端过滤结果首行(Id 倒序最新),缺键为前端已知形状
+            ApiRowAssert.KeyAbsent(mine, "appId", "AppDeploy");
+        }
+        finally { e.Delete(); }
+    }
+
+    #endregion
+
+    #region 列表行脱敏回归(新管线:meta 已裁键=缺席;在场键=掩码 null)
+
+    [Fact(DisplayName = "AppPipeline 列表行 Token/Secret 键级缺席(明文零泄漏)")]
+    public async Task Index_AppPipeline_Row_Token_Secret_Should_Be_Masked()
+    {
+        var tag = "apw-m-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new AppPipeline
+        {
+            Name = tag + "-p1", DeployId = 0, ProjectId = 0, Branch = "main",
+            Token = "tk-" + tag, Secret = "sec-" + tag,
+            AutoDeploy = true, Enable = true, UpdateUserId = 1, UpdateIP = "10.188.82.141",
+        };
+        e.Insert();
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, "Deployment/AppPipeline?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            ApiRowAssert.KeyAbsent(mine, "token", "AppPipeline");
+            ApiRowAssert.KeyAbsent(mine, "secret", "AppPipeline");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "AppDeploy 列表行:RepoUserName 键在值 null(掩码层),RepoPassword/DeployKey/UserName 键级缺席")]
+    public async Task Index_AppDeploy_Row_Sensitive_Four_Should_Be_Masked()
+    {
+        var tag = "apw-md-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new AppDeploy
+        {
+            Name = tag + "-d1", AppId = 42, ProjectId = 0, Category = "unit",
+            RepoPassword = "pw-" + tag, RepoUserName = "ru-" + tag,
+            DeployKey = "dk-" + tag, UserName = "un-" + tag,
+            Enable = true, UpdateUserId = 1, UpdateIP = "10.188.82.141",
+        };
+        e.Insert();
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, "Deployment/AppDeploy?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"AppDeploy 应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            ApiRowAssert.Masked(mine, "repoUserName", "AppDeploy");
+            ApiRowAssert.KeyAbsent(mine, "repoPassword", "AppDeploy");
+            ApiRowAssert.KeyAbsent(mine, "deployKey", "AppDeploy");
+            ApiRowAssert.KeyAbsent(mine, "userName", "AppDeploy");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "McpToken 列表行 Token 键级缺席;写响应结构性保留 Token(create/reset 一次性揭示不破)")]
+    public async Task Index_McpToken_Row_Token_Masked_WritePath_Keeps_Token()
+    {
+        var tag = "apw-mt-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new McpToken
+        {
+            Name = tag + "-t1", Token = "mtk-" + tag,
+            Enable = true, UpdateUserId = 1, UpdateIP = "10.188.82.141",
+        };
+        e.Insert();
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, "Platform/McpToken?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"McpToken 应能查到造数行 {tag}*,实际行数={rows.Count}");
+            ApiRowAssert.KeyAbsent(mine, "token", "McpToken");
+
+            // 正断言(结构性):写路径(Insert/Update/Delete)不得被 override——base 写响应返回完整实体,
+            // Token 明文随 create/reset 响应回传 = 前端一次性揭示 UX 的数据源;Reset 动作必须仍在。
+            var declared = typeof(Stardust.Web.Areas.Platform.Controllers.McpTokenController)
+                .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+                .Select(m => m.Name).ToHashSet(StringComparer.Ordinal);
+            Assert.False(declared.Contains("Insert"), "McpTokenController 不得 override Insert(写响应必须保留 Token 明文)");
+            Assert.False(declared.Contains("Update"), "McpTokenController 不得 override Update(写响应必须保留 Token 明文)");
+            Assert.False(declared.Contains("Delete"), "McpTokenController 不得 override Delete(写响应必须保留 Token 明文)");
+            Assert.True(declared.Contains("Reset"), "McpTokenController.Reset 动作必须在(一次性揭示入口)");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "SslCertificate/GatewayCert 列表行 PfxPassword 键级缺席(防回归锁)")]
+    public async Task Index_SslCertificate_Row_PfxPassword_Should_Stay_Masked()
+    {
+        var tag = "apw-ms-" + Guid.NewGuid().ToString("N")[..8];
+        // SslCertificate 无 Name 列,唯一业务锚=Domain;行 JSON 亦无 name 键,按 domain 过滤
+        var domain = tag + ".example.com";
+        var e = new SslCertificate
+        {
+            Domain = domain, PfxPassword = "pfx-" + tag,
+            Enable = true, UpdateUserId = 1, UpdateIP = "10.188.82.141",
+        };
+        e.Insert();
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, "Deployment/SslCertificate?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "domain", tag);
+            Assert.True(mine.Count >= 1, $"SslCertificate 应能查到造数行 {tag}*,实际行数={rows.Count}");
+            ApiRowAssert.KeyAbsent(mine, "pfxPassword", "SslCertificate");
+
+            // 同实体同表,另一控制器(Gateway 区域)同规则
+            var rows2 = await ApiRowAssert.GetRowsAsync(_api, "Gateway/GatewayCert?pageIndex=0&pageSize=200");
+            var mine2 = ApiRowAssert.Mine(rows2, "domain", tag);
+            Assert.True(mine2.Count >= 1, $"GatewayCert 应能查到造数行 {tag}*,实际行数={rows2.Count}");
+            ApiRowAssert.KeyAbsent(mine2, "pfxPassword", "GatewayCert");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "DomainProvider 列表行:AppKey 键在值 null(掩码层),AppSecret 键级缺席")]
+    public async Task Index_DomainProvider_Row_AppKey_AppSecret_Should_Be_Masked()
+    {
+        var tag = "apw-dp-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new DomainProvider
+        {
+            Name = tag + "-dp1", Provider = "AliyunDNS",
+            AppKey = "ak-" + tag, AppSecret = "as-" + tag, Domain = tag + ".example.com",
+            Enable = true, UpdateUserID = 1, UpdateIP = "10.188.82.141",
+        };
+        e.Insert();
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, "Nodes/DomainProvider?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"DomainProvider 应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            // AppKey 仍在字段集(掩码值 null);AppSecret 已被字段集裁剪(缺席)
+            ApiRowAssert.Masked(mine, "appKey", "DomainProvider");
+            ApiRowAssert.KeyAbsent(mine, "appSecret", "DomainProvider");
+        }
+        finally { e.Delete(); }
+    }
+
+    #endregion
+
+    #region 造数
+
+    private static AppPipeline SeedPipeline(String name, Int32 projectId)
+    {
+        var e = new AppPipeline
+        {
+            Name = name,
+            DeployId = 0,
+            ProjectId = projectId,
+            Branch = "main",
+            BuildNodeId = 0,
+            AutoDeploy = true,
+            Enable = true,
+            UpdateUserId = 1,
+            UpdateIP = "10.188.82.141",
+        };
+        e.Insert();
+        return e;
+    }
+
+    private static AppDeploy SeedDeploy(String name, Int32 appId, Int32 projectId)
+    {
+        var e = new AppDeploy
+        {
+            Name = name,
+            AppId = appId,
+            ProjectId = projectId,
+            Category = "unit",
+            Enable = true,
+            UpdateUserId = 1,
+            UpdateIP = "10.188.82.141",
+        };
+        e.Insert();
+        return e;
+    }
+
+    #endregion
+}
Added +96 -0
diff --git a/Stardust.WebApiTests/Fixtures/ApiRowAssert.cs b/Stardust.WebApiTests/Fixtures/ApiRowAssert.cs
new file mode 100644
index 0000000..0c0c083
--- /dev/null
+++ b/Stardust.WebApiTests/Fixtures/ApiRowAssert.cs
@@ -0,0 +1,96 @@
+using System.Text.Json;
+using NewLife.Cube;
+using Xunit;
+
+namespace Stardust.WebApiTests.Fixtures;
+
+/// <summary>
+/// 列表/详情行(ApiRow 投影 JSON)脱敏断言助手:ServerTests 反射刀组测试迁 HTTP 集成式(2026-11-14 Q2 裁决)。
+/// 行键=OnProject camelCase 投影(首字母小写),值语义两型:值级置 null(键在值 null)与条件哨兵(键在值=哨兵串)。
+/// </summary>
+public static class ApiRowAssert
+{
+    /// <summary>GET 列表并解析行数组:信封 data 为 ApiRow 对象数组,逐行拍平为 Ordinal 字典。</summary>
+    public static async Task<List<Dictionary<String, JsonElement>>> GetRowsAsync(ApiClient api, String endpoint)
+    {
+        var resp = await api.GetAsync(endpoint);
+        var raw = await resp.Content.ReadAsStringAsync();
+        Assert.True(resp.IsSuccessStatusCode, $"[{endpoint}] HTTP {(Int32)resp.StatusCode} 非成功:{raw}");
+
+        var envelope = JsonSerializer.Deserialize<ApiResponse<JsonElement>>(raw, new JsonSerializerOptions(JsonSerializerDefaults.Web));
+        Assert.NotNull(envelope);
+        Assert.True(envelope!.Code == 0, $"[{endpoint}] 信封 Code={envelope.Code}(Message={envelope.Message}):{raw}");
+        Assert.Equal(JsonValueKind.Array, envelope.Data.ValueKind);
+
+        var rows = new List<Dictionary<String, JsonElement>>();
+        foreach (var row in envelope.Data.EnumerateArray())
+        {
+            var dict = new Dictionary<String, JsonElement>(StringComparer.Ordinal);
+            foreach (var prop in row.EnumerateObject()) dict[prop.Name] = prop.Value.Clone();
+            rows.Add(dict);
+        }
+        return rows;
+    }
+
+    /// <summary>按行内键值前缀过滤本用例造数行(键如 name/domain/deployName)。</summary>
+    public static List<Dictionary<String, JsonElement>> Mine(IEnumerable<Dictionary<String, JsonElement>> rows, String key, String prefix) =>
+        rows.Where(r => r.TryGetValue(key, out var v) && (v.GetString() ?? "").StartsWith(prefix, StringComparison.Ordinal)).ToList();
+
+    /// <summary>值级置 null 语义(task-94 惯例):键必须在(键不删)且值必须 JSON null(明文零落行)。</summary>
+    public static void Masked(IEnumerable<Dictionary<String, JsonElement>> rows, String key, String ctx)
+    {
+        Assert.All(rows, r => Assert.True(r.ContainsKey(key),
+            $"{ctx}: 行 JSON 缺键 '{key}'(值级置 null 语义要求键不删);实际键=[{String.Join(",", r.Keys)}]"));
+        Assert.All(rows, r => Assert.True(r[key].ValueKind == JsonValueKind.Null,
+            $"{ctx}: 行 JSON '{key}' 值未脱敏(期望 JSON null,实际 {r[key].ValueKind})"));
+    }
+
+    /// <summary>详情行值级置 null 语义:键必须在且值 null。</summary>
+    public static void DetailMasked(JsonElement data, String key, String ctx)
+    {
+        Assert.True(data.TryGetProperty(key, out var v),
+            $"{ctx}: 详情 JSON 缺键 '{key}'(值级置 null 语义要求键不删);实际键=[{String.Join(",", data.EnumerateObject().Select(p => p.Name))}]");
+        Assert.True(v.ValueKind == JsonValueKind.Null, $"{ctx}: 详情 JSON '{key}' 值未脱敏(期望 JSON null,实际 {v.ValueKind})");
+    }
+
+    /// <summary>条件哨兵语义(刀3/4/8):键必须在且值=哨兵串(非 null——null 回填会经 PUT 静默清库)。</summary>
+    public static void Sentinel(IEnumerable<Dictionary<String, JsonElement>> rows, String key, String mask, String ctx)
+    {
+        Assert.All(rows, r => Assert.True(r.ContainsKey(key), $"{ctx}: 行 JSON 缺键 '{key}'"));
+        Assert.All(rows, r => Assert.True(r[key].ValueKind == JsonValueKind.String && r[key].GetString() == mask,
+            $"{ctx}: 行 JSON '{key}' 非掩码哨兵(期望 \"{mask}\",实际 {r[key].ValueKind}:{r[key].GetString()})"));
+    }
+
+    /// <summary>详情行条件哨兵语义。</summary>
+    public static void DetailSentinel(JsonElement data, String key, String mask, String ctx)
+    {
+        Assert.True(data.TryGetProperty(key, out var v), $"{ctx}: 详情 JSON 缺键 '{key}'");
+        Assert.True(v.ValueKind == JsonValueKind.String && v.GetString() == mask,
+            $"{ctx}: 详情 JSON '{key}' 非掩码哨兵(期望 \"{mask}\",实际 {v.ValueKind}:{v.GetString()})");
+    }
+
+    /// <summary>详情行键缺席断言(新管线字段集合投影:EditFormFields/DetailFields 已裁剪的键不得出现在详情 JSON)。</summary>
+    public static void DetailHidden(JsonElement data, String key, String ctx)
+    {
+        Assert.True(!data.TryGetProperty(key, out var v),
+            $"{ctx}: 详情 JSON 不应含键 '{key}'(meta 字段集已裁剪),实际值={v}");
+    }
+
+    /// <summary>行键在场断言(行自描述回归:值不拘)。</summary>
+    public static void KeyPresent(IEnumerable<Dictionary<String, JsonElement>> rows, String key, String ctx)
+    {
+        Assert.All(rows, r => Assert.True(r.ContainsKey(key),
+            $"{ctx}: 行 JSON 缺键 '{key}';实际键=[{String.Join(",", r.Keys)}]"));
+    }
+
+    /// <summary>行键缺席断言(新管线字段集合投影:被 meta 裁剪/TrimFields 的键不得出现在行 JSON——
+    /// 比键在值 null 更强的防泄漏;2026-11-14 迁移定谳:敏感键清单与前端消费面见各测试类注释)。</summary>
+    public static void KeyAbsent(IEnumerable<Dictionary<String, JsonElement>> rows, String key, String ctx)
+    {
+        Assert.All(rows, r =>
+        {
+            if (r.ContainsKey(key))
+                Assert.Fail($"{ctx}: 行 JSON 不应含键 '{key}'(meta 字段集已裁剪),实际值={r[key]}");
+        });
+    }
+}
Added +122 -0
diff --git a/Stardust.WebApiTests/Mcp/McpServerApiBindingTests.cs b/Stardust.WebApiTests/Mcp/McpServerApiBindingTests.cs
new file mode 100644
index 0000000..fcd8cbb
--- /dev/null
+++ b/Stardust.WebApiTests/Mcp/McpServerApiBindingTests.cs
@@ -0,0 +1,122 @@
+using System.Text.Json;
+using NewLife.Cube;
+using Stardust.Data.Platform;
+using Stardust.WebApiTests.Fixtures;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Stardust.WebApiTests.Mcp;
+
+/// <summary>
+/// F-PLT-1 回归(HTTP 集成式迁移,2026-11-14 Q2 裁决):POST /api/Platform/McpServer 曾全部丢值 500「SQL logic error near ','」。
+/// 根因(build#7 echo 双槽结构定谳):API 版 NewLife.Cube 模型绑定把 JSON 请求值写入实体属性,而 XCode 持久化读取的
+/// 值存储(Extends)为空——属性通道与持久化通道脱节,SQL 渲染全默认值。修复(build#8 终态):
+/// WebEntityController.Insert 对绑定实体本体逐业务字段双通道补值(属性值→值存储同步)→ OnInsert(业务钩子)→
+/// 二次同步(钩子生成字段如 Token)→ Insert(内部 Valid 在钩子后,与部署版顺序一致)。
+/// 迁后经真实 HTTP POST 管道驱动(请求体即属性通道,与生产等态);断言全部走 HTTP 面:
+/// 自增 Id 取写响应 data.id(写回包主键恒带,CertPfx 先例),落库值经 GET 详情面读回(详情面由服务端按主键加载,
+/// 回包为字段集合投影),清理用键实体 Delete。原「SetItem 预置 stale Extends」形态为反射面专属
+/// (HTTP 请求体天然无陈值槽),随之退役。
+/// </summary>
+[Collection(WebApiCollection.Name)]
+public class McpServerApiBindingTests
+{
+    private readonly ApiClient _api;
+
+    public McpServerApiBindingTests(WebApiTestFactory factory, ITestOutputHelper output) =>
+        _api = new(factory, output);
+
+    /// <summary>从写响应 data 中取自增 Id(主键恒带)。</summary>
+    private static Int32 RowId(JsonElement data)
+    {
+        Assert.Equal(JsonValueKind.Object, data.ValueKind);
+        var id = 0;
+        Assert.True(data.TryGetProperty("id", out var idProp) && idProp.TryGetInt32(out id) && id > 0,
+            $"写响应应带自增 id(主键恒带),实际 data={data}");
+        return id;
+    }
+
+    /// <summary>GET 详情面读回(服务端按主键加载,回包=字段集合投影)。</summary>
+    private async Task<JsonElement> DetailAsync(Int32 id)
+    {
+        var api = await _api.GetJsonAsync($"Platform/McpServer/{id}");
+        Assert.Equal(JsonValueKind.Object, api.Data.ValueKind);
+        return api.Data;
+    }
+
+    [Fact(DisplayName = "F-PLT-1 回归:POST 全字段绑定落库——提交值经详情面读回(修复前=全默认值 SQL 错)")]
+    public async Task Insert_Should_Persist_AllBoundFields()
+    {
+        var name = "fplt1-dual-" + Guid.NewGuid().ToString("N")[..8];
+        var id = 0;
+        try
+        {
+            var api = await _api.PostApiAsync<JsonElement>("Platform/McpServer", new
+            {
+                name,
+                url = "http://127.0.0.1:9103",
+                enable = true,
+                toolCount = 2,
+                remark = "F-PLT-1 dual-channel",
+            });
+            Assert.Equal(0, api.Code);
+            id = RowId(api.Data);
+
+            var detail = await DetailAsync(id);
+
+            // 详情面(DetailFields∪EditFormFields 投影)读回:提交值已持久化(修复前=全默认值 + SQL 错 500)
+            Assert.Equal(name, detail.GetProperty("name").GetString());
+            Assert.Equal("http://127.0.0.1:9103", detail.GetProperty("url").GetString());
+            Assert.Equal("F-PLT-1 dual-channel", detail.GetProperty("remark").GetString());
+        }
+        finally
+        {
+            if (id > 0) McpServer.FindById(id)?.Delete();
+        }
+    }
+
+    [Fact(DisplayName = "F-PLT-1 对照:字段齐全的正常实体 Insert 不受重建逻辑影响")]
+    public async Task Insert_Should_PassThrough_ProperEntity()
+    {
+        var name = "fplt1-ctl-" + Guid.NewGuid().ToString("N")[..8];
+        var id = 0;
+        try
+        {
+            var api = await _api.PostApiAsync<JsonElement>("Platform/McpServer", new
+            {
+                name,
+                url = "http://127.0.0.1:9102",
+                enable = true,
+                toolCount = 1,
+                remark = "F-PLT-1 control",
+            });
+            Assert.Equal(0, api.Code);
+            id = RowId(api.Data);
+
+            var detail = await DetailAsync(id);
+
+            Assert.Equal(name, detail.GetProperty("name").GetString());
+            Assert.Equal("http://127.0.0.1:9102", detail.GetProperty("url").GetString());
+        }
+        finally
+        {
+            if (id > 0) McpServer.FindById(id)?.Delete();
+        }
+    }
+
+    [Fact(DisplayName = "F-PLT-1 回归:空实体 POST 必须被业务 Valid 拒绝,不允许静默成功")]
+    public async Task Insert_EmptyEntity_Should_Be_Rejected()
+    {
+        var resp = await _api.PostAsJsonAsync("Platform/McpServer", new { });
+        var raw = await resp.Content.ReadAsStringAsync();
+
+        if (!resp.IsSuccessStatusCode)
+            return; // HTTP 层拒绝=未被静默接受
+
+        var envelope = JsonSerializer.Deserialize<ApiResponse<JsonElement>>(raw, new JsonSerializerOptions(JsonSerializerDefaults.Web));
+        if (envelope == null)
+            Assert.Fail($"空实体 POST 响应无法解析为信封:{raw}");
+
+        Assert.True(envelope.Code != 0, $"空实体 POST 应被业务 Valid 拒绝,实际 Code=0:{raw}");
+    }
+}
Added +124 -0
diff --git a/Stardust.WebApiTests/Mcp/McpServerApiUpdateTests.cs b/Stardust.WebApiTests/Mcp/McpServerApiUpdateTests.cs
new file mode 100644
index 0000000..3c16133
--- /dev/null
+++ b/Stardust.WebApiTests/Mcp/McpServerApiUpdateTests.cs
@@ -0,0 +1,124 @@
+using System.Text.Json;
+using Stardust.Data.Platform;
+using Stardust.WebApiTests.Fixtures;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Stardust.WebApiTests.Mcp;
+
+/// <summary>
+/// DEF-MW-SRV-01 回归(HTTP 集成式迁移,2026-11-14 Q2 裁决):PUT /api/Platform/McpServer/{id} 更新链路双态炸
+/// (j-deploy-b 2026-11-11 CRUD 走查实锤)。生产机制:请求体可重读 → 基类 GetSubmittedFieldsAsync 返回提交键集 →
+/// CopySubmitted 用属性反射把提交值写到库中实体(仅属性通道);但 XCode 属性 setter 不写 Extends 值存储 →
+/// ① 全字段改名 PUT:属性通道=新值(Valid 过)→ OnUpdate→entity.Update() 从 Extends 读值 → UPDATE 落旧值
+/// = code=0 静默空更新(现象 A);② 部分提交(UI toggle 仅 {id,enable},mcpToken.logic.ts 契约):
+/// 未提交字段属性通道保持默认空 → Valid 读 Name 属性=空 → 500「名称不能为空」(现象 B)。
+/// 修复:WebEntityController.Update 镜像 Insert(F-PLT-1)双通道补值——提交字段属性+SetItem 双写并显式置脏,
+/// 未提交字段属性通道对齐 Extends 库值。
+/// 迁后经真实 HTTP PUT 管道驱动(请求体天然可重读,与生产等态);断言全部走 HTTP 面:
+/// Id 取写响应 data.id(主键恒带),落库值经 GET 详情面读回(详情面由服务端按主键加载,
+/// 回包为字段集合投影),清理用键实体 Delete。
+/// </summary>
+[Collection(WebApiCollection.Name)]
+public class McpServerApiUpdateTests
+{
+    private readonly ApiClient _api;
+
+    public McpServerApiUpdateTests(WebApiTestFactory factory, ITestOutputHelper output) =>
+        _api = new(factory, output);
+
+    /// <summary>从写响应 data 中取自增 Id(主键恒带)。</summary>
+    private static Int32 RowId(JsonElement data)
+    {
+        Assert.Equal(JsonValueKind.Object, data.ValueKind);
+        var id = 0;
+        Assert.True(data.TryGetProperty("id", out var idProp) && idProp.TryGetInt32(out id) && id > 0,
+            $"写响应应带自增 id(主键恒带),实际 data={data}");
+        return id;
+    }
+
+    /// <summary>经 HTTP POST 造一行(走 F-PLT-1 修复链),Id 取写响应。</summary>
+    private async Task<Int32> SeedRowAsync(String name, Boolean enable)
+    {
+        var api = await _api.PostApiAsync<JsonElement>("Platform/McpServer", new
+        {
+            name,
+            url = "http://127.0.0.1:9104",
+            enable,
+            projectId = 0,
+            remark = "DEF-MW-SRV-01 seed",
+        });
+        Assert.Equal(0, api.Code);
+        return RowId(api.Data);
+    }
+
+    /// <summary>GET 详情面读回(服务端按主键加载,回包=字段集合投影)。</summary>
+    private async Task<JsonElement> DetailAsync(Int32 id)
+    {
+        var api = await _api.GetJsonAsync($"Platform/McpServer/{id}");
+        Assert.Equal(JsonValueKind.Object, api.Data.ValueKind);
+        return api.Data;
+    }
+
+    [Fact(DisplayName = "DEF-MW-SRV-01 回归:改名 PUT 必须真实落库,详情面读回新值,禁止静默空更新")]
+    public async Task Update_Rename_Should_Persist_NewName()
+    {
+        var seedName = "mw01a-seed-" + Guid.NewGuid().ToString("N")[..8];
+        var newName = "mw01a-renamed-" + Guid.NewGuid().ToString("N")[..8];
+        var id = await SeedRowAsync(seedName, true);
+        try
+        {
+            // 前端全字段提交契约(McpServerForm 编辑 PUT):Id+五业务字段
+            var api = await _api.PutApiAsync<JsonElement>($"Platform/McpServer/{id}", new
+            {
+                id,
+                name = newName,
+                url = "http://127.0.0.1:9104",
+                enable = true,
+                projectId = 0,
+                remark = "DEF-MW-SRV-01 rename",
+            });
+            Assert.Equal(0, api.Code);
+
+            var detail = await DetailAsync(id);
+            Assert.Equal(newName, detail.GetProperty("name").GetString());   // 库值=新名(修复前=旧名静默空更新)
+            Assert.True(IsOn(detail.GetProperty("enable")));
+        }
+        finally
+        {
+            McpServer.FindById(id)?.Delete();
+        }
+    }
+
+    [Fact(DisplayName = "DEF-MW-SRV-01 回归:启停 PUT(UI 部分提交 {id,enable})必须真实落库且不清空未提交字段")]
+    public async Task Update_ToggleEnable_Should_Persist()
+    {
+        var seedName = "mw01b-seed-" + Guid.NewGuid().ToString("N")[..8];
+        var id = await SeedRowAsync(seedName, true);
+        try
+        {
+            // UI toggle 契约(mcpToken.logic.ts:PUT 启停仅提交 {id,enable}):部分提交形态,
+            // 未提交字段不得被清空或改名(修复前=500「名称不能为空」)
+            var api = await _api.PutApiAsync<JsonElement>($"Platform/McpServer/{id}", new { id, enable = false });
+            Assert.Equal(0, api.Code);
+
+            var detail = await DetailAsync(id);
+            Assert.False(IsOn(detail.GetProperty("enable")));   // 库值=停用(修复前=仍启用)
+            Assert.Equal(seedName, detail.GetProperty("name").GetString());
+            Assert.Equal("http://127.0.0.1:9104", detail.GetProperty("url").GetString());   // 未提交字段不得被清空
+        }
+        finally
+        {
+            McpServer.FindById(id)?.Delete();
+        }
+    }
+
+    /// <summary>enable 键在详情面可为 JSON false 或数字 0(字段投影值类型随实体序列化)。</summary>
+    private static Boolean IsOn(JsonElement v) => v.ValueKind switch
+    {
+        JsonValueKind.True => true,
+        JsonValueKind.False => false,
+        JsonValueKind.Number => v.TryGetInt32(out var n) && n != 0,
+        _ => throw new Xunit.Sdk.XunitException($"enable 值类型异常:{v.ValueKind}:{v}"),
+    };
+}
Added +200 -0
diff --git a/Stardust.WebApiTests/Security/MaskedRowsKnife0ApiTests.cs b/Stardust.WebApiTests/Security/MaskedRowsKnife0ApiTests.cs
new file mode 100644
index 0000000..53fdfbf
--- /dev/null
+++ b/Stardust.WebApiTests/Security/MaskedRowsKnife0ApiTests.cs
@@ -0,0 +1,200 @@
+using System.Text.Json;
+using Stardust.Data.Nodes;
+using Stardust.WebApiTests.Fixtures;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Stardust.WebApiTests.Security;
+
+/// <summary>
+/// task-95 二期刀0(2026-11-12,盘点稿 Doc/安全-02 §4 刀0+N1,lead 核准令)HTTP 集成式迁移(2026-11-14 Q2 裁决):
+/// MySqlNode/RedisNode 敏感三键(Password/UserName/WebHook)行级脱敏 + Valid "*****" 守卫;Node.WebHook 行/详情脱敏(N1)。
+/// 静默清库三形态中 F1(detail 全量展开 PUT)的消解实证:提交 "*****"(Detail 裁剪后前端原样回传)→
+/// Valid 从库恢复原值并清脏标记 → 真写读回不变。
+/// 与原 ServerTests 反射版(SearchData→OnFillListValues→OnJsonSerialize 链)差异:
+/// ①全部走真实 HTTP 管道(TestServer 进程内);②Valid GET 回显(MVC 编辑表单输入侧)非 API 面,不再断言;
+/// ③读回断言走实体 Find 直连查询(绕过实体缓存,CertPfx 先例)。
+/// ④新管线(Cube 1105 字段集合投影)契约定谳(2026-11-14 首轮实测):meta 字段集已裁剪的敏感键
+/// (MySql/Redis 的 Password/UserName/WebHook、Node 的 Secret/WebHook)在行 JSON 中**键级缺席**——
+/// 比「键在值 null」更强的防泄漏;掩码层只在键仍在字段集时生效(值 null/哨兵)。
+/// 掩码机制(2026-11-14 收敛后):WebEntityController 薄基类 GetRowMasks 声明式规则 + OnProject 统一应用。
+/// </summary>
+[Collection(WebApiCollection.Name)]
+public class MaskedRowsKnife0ApiTests
+{
+    private readonly ApiClient _api;
+    private readonly ITestOutputHelper _output;
+
+    public MaskedRowsKnife0ApiTests(WebApiTestFactory factory, ITestOutputHelper output)
+    {
+        _api = new(factory, output);
+        _output = output;
+    }
+
+    private const String Mask = "*****";
+
+    [Fact(DisplayName = "task-95 刀0:MySqlNode 列表行 Password/UserName/WebHook 键级缺席(meta 已裁,明文零泄漏)")]
+    public async Task MySqlNode_Index_Row_Sensitive_Three_Should_Be_Masked()
+    {
+        var tag = "mw0my-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new MySqlNode
+        {
+            Name = tag + "-n1", Server = "127.0.0.1", Port = 3306,
+            UserName = "root-" + tag, Password = "pw-" + tag, WebHook = "http://hook/" + tag,
+            DatabaseName = "mysql", Version = "8.0", Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, $"MySql/MySqlNode?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            ApiRowAssert.KeyAbsent(mine, "password", "MySqlNode");
+            ApiRowAssert.KeyAbsent(mine, "userName", "MySqlNode");
+            ApiRowAssert.KeyAbsent(mine, "webHook", "MySqlNode");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "task-95 刀0:MySqlNode Detail——Password 键缺席(写入口令不投影),UserName/WebHook 键在值 null")]
+    public async Task MySqlNode_Detail_Crops_Sensitive_Three()
+    {
+        var tag = "mw0md-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new MySqlNode
+        {
+            Name = tag + "-n1", Server = "127.0.0.1", Port = 3306,
+            UserName = "root-" + tag, Password = "pw-" + tag, WebHook = "http://hook/" + tag,
+            Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            var api = await _api.GetJsonAsync($"MySql/MySqlNode/{e.Id}");
+
+            ApiRowAssert.DetailHidden(api.Data, "password", "MySqlNode");
+            ApiRowAssert.DetailMasked(api.Data, "userName", "MySqlNode");
+            ApiRowAssert.DetailMasked(api.Data, "webHook", "MySqlNode");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "task-95 刀0:MySqlNode F1 提交——PUT '*****' 守卫恢复原值并真写读回不变(静默清库消解实证)")]
+    public async Task MySqlNode_Put_MaskedValue_Restores_Old()
+    {
+        var tag = "mw0mg-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new MySqlNode
+        {
+            Name = tag + "-n1", Server = "127.0.0.1", Port = 3306,
+            UserName = "root-" + tag, Password = "real-" + tag, WebHook = "http://keep/" + tag, Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            // 模拟 F1 提交:Detail 全量展开(Password/WebHook 已被裁)+ 表单未改 → 原样回传 "*****"
+            var put = new
+            {
+                id = e.Id,
+                name = e.Name,
+                server = e.Server,
+                port = e.Port,
+                password = Mask,
+                webHook = Mask,
+                enable = true,
+            };
+            var api = await _api.PutApiAsync<JsonElement>($"MySql/MySqlNode/{e.Id}", put);
+
+            var back = MySqlNode.FindById(e.Id);
+            Assert.NotNull(back);
+            Assert.Equal("real-" + tag, back.Password);
+            Assert.Equal("http://keep/" + tag, back.WebHook);
+            Assert.Equal("root-" + tag, back.UserName);   // PUT 未提交 userName → 保持库值
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "task-95 刀0:RedisNode 列表行 Password/UserName/WebHook 键级缺席(meta 已裁)")]
+    public async Task RedisNode_Index_Row_Sensitive_Three_Should_Be_Masked()
+    {
+        var tag = "mw0rd-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new RedisNode
+        {
+            Name = tag + "-n1", Server = $"127.0.0.1:{Random.Shared.Next(20000, 60000)}", Category = "缓存",
+            UserName = "default-" + tag, Password = "pw-" + tag, WebHook = "http://hook/" + tag,
+            Version = "7.2", Mode = "standalone", Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, $"Redis/RedisNode?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            ApiRowAssert.KeyAbsent(mine, "password", "RedisNode");
+            ApiRowAssert.KeyAbsent(mine, "userName", "RedisNode");
+            ApiRowAssert.KeyAbsent(mine, "webHook", "RedisNode");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "task-95 刀0:RedisNode F1 提交——PUT '*****' 守卫恢复原值并真写读回不变")]
+    public async Task RedisNode_Put_MaskedValue_Restores_Old()
+    {
+        var tag = "mw0rg-" + Guid.NewGuid().ToString("N")[..8];
+        var server = $"127.0.0.1:{Random.Shared.Next(20000, 60000)}";
+        var e = new RedisNode
+        {
+            Name = tag + "-n1", Server = server, Category = "缓存",
+            Password = "real-" + tag, WebHook = "http://keep/" + tag, Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            var put = new
+            {
+                id = e.Id,
+                name = e.Name,
+                server = server,
+                category = e.Category,
+                password = Mask,
+                webHook = Mask,
+                enable = true,
+            };
+            var api = await _api.PutApiAsync<JsonElement>($"Redis/RedisNode/{e.Id}", put);
+
+            var back = RedisNode.FindById(e.Id);
+            Assert.NotNull(back);
+            Assert.Equal("real-" + tag, back.Password);
+            Assert.Equal("http://keep/" + tag, back.WebHook);
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "task-95 刀0/N1:Node 列表行 Secret/WebHook 键级缺席;Detail Secret/WebHook 键级缺席+WebHook 值 null(注册流程在用)")]
+    public async Task Node_Row_Secret_WebHook_Masked_And_Detail_Keeps_Secret_Crops_WebHook()
+    {
+        var tag = "mw0nd-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new Node
+        {
+            Name = tag + "-node", Code = tag + "-code", Secret = "sec-" + tag,
+            WebHook = "http://hook/" + tag, Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, $"Nodes/Node?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            ApiRowAssert.KeyAbsent(mine, "secret", "Node");
+            ApiRowAssert.KeyAbsent(mine, "webHook", "Node");
+
+            // Detail:Secret 键级缺席(新字段集把 Secret 从详情面也裁掉——强于一期「保守保留」契约)、
+            // WebHook 键在值 null(N1)
+            var api = await _api.GetJsonAsync($"Nodes/Node/{e.ID}");
+            ApiRowAssert.DetailHidden(api.Data, "secret", "Node");
+            ApiRowAssert.DetailMasked(api.Data, "webHook", "Node");
+        }
+        finally { e.Delete(); }
+    }
+}
Added +103 -0
diff --git a/Stardust.WebApiTests/Security/MaskedRowsKnife2ApiTests.cs b/Stardust.WebApiTests/Security/MaskedRowsKnife2ApiTests.cs
new file mode 100644
index 0000000..2326abf
--- /dev/null
+++ b/Stardust.WebApiTests/Security/MaskedRowsKnife2ApiTests.cs
@@ -0,0 +1,103 @@
+using System.Text.Json;
+using Stardust.Data;
+using Stardust.WebApiTests.Fixtures;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Stardust.WebApiTests.Security;
+
+/// <summary>
+/// task-95 二期刀2(2026-11-12,盘点稿 Doc/安全-02 §4 刀2)HTTP 集成式迁移(2026-11-14 Q2 裁决):
+/// App.Secret/WebHook 行级脱敏。前置:刀1 预览链已改道 /Config/GetAllByApp(行内 Secret 零消费方);
+/// F3 先例:apps 编辑=partial-PUT,Secret 留空不提交、WebHook 不在 Vue 表单(不 Dirty)→ 行裁不破编辑流;
+/// Valid "*****" 守卫(AppDeploy 先例)保编辑表单回填原样提交不写坏。
+/// 与原 ServerTests 反射版差异:Valid GET 回显(MVC 编辑表单输入侧)非 API 面,不再断言;读回走实体 Find 直连查询。
+/// 新管线(Cube 1105 字段集合投影)契约定谳(2026-11-14 首轮实测):
+/// 列表行 Secret/WebHook、详情 Secret **键级缺席**(meta 字段集已裁——比键在值 null 更强防泄漏);
+/// 详情 WebHook 键仍在(掩码值 null)。掩码机制=薄基类 GetRowMasks + OnProject 统一应用。
+/// </summary>
+[Collection(WebApiCollection.Name)]
+public class MaskedRowsKnife2ApiTests
+{
+    private const String Mask = "*****";
+
+    private readonly ApiClient _api;
+
+    public MaskedRowsKnife2ApiTests(WebApiTestFactory factory, ITestOutputHelper output) =>
+        _api = new(factory, output);
+
+    [Fact(DisplayName = "task-95 刀2:App 列表行 Secret/WebHook 键级缺席(meta 已裁,明文零泄漏)")]
+    public async Task App_Index_Row_Secret_WebHook_Should_Be_Masked()
+    {
+        var tag = "mw2a-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new App
+        {
+            Name = tag + "-app", DisplayName = "刀2造数", Category = "web",
+            Secret = "sec-" + tag, WebHook = "http://hook/" + tag, Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, "Registry/App?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            ApiRowAssert.KeyAbsent(mine, "secret", "App");
+            ApiRowAssert.KeyAbsent(mine, "webHook", "App");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "task-95 刀2:App Detail——Secret 键缺席,WebHook 键在值 null")]
+    public async Task App_Detail_Crops_Secret_WebHook()
+    {
+        var tag = "mw2b-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new App
+        {
+            Name = tag + "-app", DisplayName = "刀2造数", Category = "web",
+            Secret = "sec-" + tag, WebHook = "http://hook/" + tag, Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            var api = await _api.GetJsonAsync($"Registry/App/{e.Id}");
+
+            ApiRowAssert.DetailHidden(api.Data, "secret", "App");
+            ApiRowAssert.DetailMasked(api.Data, "webHook", "App");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "task-95 刀2:App F1 提交——PUT '*****' 守卫恢复原值并真写读回不变;WebHook 原值提交不受影响")]
+    public async Task App_Put_MaskedSecret_Restores_Old()
+    {
+        var tag = "mw2c-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new App
+        {
+            Name = tag + "-app", DisplayName = "刀2造数", Category = "web",
+            Secret = "real-" + tag, WebHook = "http://keep/" + tag, Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            // 模拟 MVC 编辑表单:Secret 回显 "*****" 原样提交;WebHook 服务端表单原值提交(非掩码)
+            var put = new
+            {
+                id = e.Id,
+                name = e.Name,
+                displayName = e.DisplayName,
+                category = e.Category,
+                secret = Mask,
+                webHook = e.WebHook,
+                enable = true,
+            };
+            var api = await _api.PutApiAsync<JsonElement>($"Registry/App/{e.Id}", put);
+
+            var back = App.FindById(e.Id);
+            Assert.NotNull(back);
+            Assert.Equal("real-" + tag, back.Secret);
+            Assert.Equal("http://keep/" + tag, back.WebHook);
+        }
+        finally { e.Delete(); }
+    }
+}
Added +107 -0
diff --git a/Stardust.WebApiTests/Security/MaskedRowsKnife34ApiTests.cs b/Stardust.WebApiTests/Security/MaskedRowsKnife34ApiTests.cs
new file mode 100644
index 0000000..581ce86
--- /dev/null
+++ b/Stardust.WebApiTests/Security/MaskedRowsKnife34ApiTests.cs
@@ -0,0 +1,107 @@
+using System.Text.Json;
+using Stardust.Data.Monitors;
+using Stardust.WebApiTests.Fixtures;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Stardust.WebApiTests.Security;
+
+/// <summary>
+/// task-95 二期刀3/刀4(2026-11-12,盘点稿 Doc/安全-02 §4 刀3/4)HTTP 集成式迁移(2026-11-14 Q2 裁决):
+/// AlarmGroup/AppTracer WebHook 行级掩码+F2 消解。刀3/4 偏离一期「值级 null」惯例(刀6 "••••••" 同款哨兵):
+/// F2=前端编辑抽屉从列表行回填且 PUT 恒带 webHook 键(alarmGroup.logic groupBody / appTracer.logic tracerPutBody)
+/// ——null 回填→PUT 空→静默清库;哨兵回填→原样提交→Valid POST 守卫识别还原。断言=键在值等于哨兵(非 null)。
+/// 与原 ServerTests 反射版差异:Valid GET 回显(MVC 编辑表单输入侧)非 API 面,不再断言;
+/// 空值放行(显式清空)改经真实 PUT 管道;读回走实体 Find 直连查询。
+/// </summary>
+[Collection(WebApiCollection.Name)]
+public class MaskedRowsKnife34ApiTests
+{
+    private const String Mask = "••••••";
+
+    private readonly ApiClient _api;
+
+    public MaskedRowsKnife34ApiTests(WebApiTestFactory factory, ITestOutputHelper output) =>
+        _api = new(factory, output);
+
+    [Fact(DisplayName = "task-95 刀3:AlarmGroup 列表行 WebHook=掩码哨兵(F2 哨兵语义,非 null)")]
+    public async Task AlarmGroup_Index_WebHook_Sentinel()
+    {
+        var tag = "mw3a-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new AlarmGroup
+        {
+            Name = tag + "-grp", WebHook = "https://hook.example/" + tag, Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, "Monitors/AlarmGroup?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            ApiRowAssert.Sentinel(mine, "webHook", Mask, "AlarmGroup");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "task-95 刀3:AlarmGroup Detail 哨兵;PUT 守卫真写读回原值;空串放行=显式清空")]
+    public async Task AlarmGroup_Detail_Guard_And_Clear()
+    {
+        var tag = "mw3b-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new AlarmGroup
+        {
+            Name = tag + "-grp", WebHook = "https://keep.example/" + tag, Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            // Detail 掩码哨兵
+            var detail = await _api.GetJsonAsync($"Monitors/AlarmGroup/{e.Id}");
+            ApiRowAssert.DetailSentinel(detail.Data, "webHook", Mask, "AlarmGroup");
+
+            // PUT 守卫:哨兵原样提交(F2 回填形态)→ Valid 识别还原 → 真写读回原值
+            var put = new { id = e.Id, name = e.Name, webHook = Mask, enable = false };
+            var api = await _api.PutApiAsync<JsonElement>($"Monitors/AlarmGroup/{e.Id}", put);
+            var back = AlarmGroup.FindById(e.Id);
+            Assert.NotNull(back);
+            Assert.Equal("https://keep.example/" + tag, back.WebHook);
+
+            // 空值放行:显式清空(空串不走哨兵分支直通写库)
+            var clear = new { id = e.Id, name = e.Name, webHook = "", enable = true };
+            var api2 = await _api.PutApiAsync<JsonElement>($"Monitors/AlarmGroup/{e.Id}", clear);
+            var cleared = AlarmGroup.FindById(e.Id);
+            Assert.NotNull(cleared);
+            Assert.True(String.IsNullOrEmpty(cleared.WebHook),
+                $"空串 PUT 应显式清空 WebHook,实际='{cleared.WebHook}'");
+        }
+        finally { e.Delete(); }
+    }
+
+    [Fact(DisplayName = "task-95 刀4:AppTracer 列表行/Detail WebHook=掩码哨兵 + PUT 守卫真写读回")]
+    public async Task AppTracer_Row_Detail_Guard()
+    {
+        var tag = "mw4-" + Guid.NewGuid().ToString("N")[..8];
+        var e = new AppTracer
+        {
+            Name = tag + "-trc", WebHook = "https://tracer.example/" + tag, Enable = true,
+        };
+        e.Insert();
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, "Monitors/AppTracer?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "name", tag);
+            Assert.True(mine.Count >= 1, $"应能查到造数行 {tag}*,实际行数={rows.Count}");
+            ApiRowAssert.Sentinel(mine, "webHook", Mask, "AppTracer");
+
+            var detail = await _api.GetJsonAsync($"Monitors/AppTracer/{e.ID}");
+            ApiRowAssert.DetailSentinel(detail.Data, "webHook", Mask, "AppTracer");
+
+            var put = new { id = e.ID, webHook = Mask };
+            var api = await _api.PutApiAsync<JsonElement>($"Monitors/AppTracer/{e.ID}", put);
+            var back = AppTracer.FindByKey(e.ID);
+            Assert.NotNull(back);
+            Assert.Equal("https://tracer.example/" + tag, back.WebHook);
+        }
+        finally { e.Delete(); }
+    }
+}
Added +110 -0
diff --git a/Stardust.WebApiTests/Security/MaskedRowsKnife8ApiTests.cs b/Stardust.WebApiTests/Security/MaskedRowsKnife8ApiTests.cs
new file mode 100644
index 0000000..1e83d76
--- /dev/null
+++ b/Stardust.WebApiTests/Security/MaskedRowsKnife8ApiTests.cs
@@ -0,0 +1,110 @@
+using System.Text.Json;
+using Stardust.Data.Deployment;
+using Stardust.Data.Nodes;
+using Stardust.WebApiTests.Fixtures;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Stardust.WebApiTests.Security;
+
+/// <summary>
+/// task-95 二期刀8/N2(2026-11-12,lead 放行令)HTTP 集成式迁移(2026-11-14 Q2 裁决):
+/// AppDeployNode.UserName 行级脱敏三件套。①Search 行值级置 null(键不删);②Detail 掩码回显 "*****"
+/// (Vue 编辑抽屉走 Detail 取数,防 F1 静默清库);③PUT 守卫——提交 "*****" 从库恢复原值并清脏标记,
+/// 空串放行=显式清空。与原 ServerTests 反射版差异:Valid GET 回显非 API 面不再断言;守卫走真实 PUT 管道;
+/// 读回走实体 Find 直连查询。父行(AppDeploy/Node)真插保证 Valid 外键校验通过。
+/// </summary>
+[Collection(WebApiCollection.Name)]
+public class MaskedRowsKnife8ApiTests
+{
+    private const String Mask = "*****";
+
+    private readonly ApiClient _api;
+
+    public MaskedRowsKnife8ApiTests(WebApiTestFactory factory, ITestOutputHelper output) =>
+        _api = new(factory, output);
+
+    [Fact(DisplayName = "task-95 刀8/N2:AppDeployNode 列表行 UserName 键在值 null;Detail 掩码回显 '*****'")]
+    public async Task AppDeployNode_Row_Masked_And_Detail_Echoes()
+    {
+        var tag = "mw8a-" + Guid.NewGuid().ToString("N")[..8];
+        var (deploy, node, e) = Seed(tag, userName: "u-" + tag);
+        try
+        {
+            var rows = await ApiRowAssert.GetRowsAsync(_api, "Deployment/AppDeployNode?pageIndex=0&pageSize=200");
+            var mine = ApiRowAssert.Mine(rows, "deployName", tag);
+            Assert.True(mine.Count >= 1, $"应能查到造数行 {tag}*,实际行数={rows.Count}");
+
+            ApiRowAssert.Masked(mine, "userName", "AppDeployNode");
+
+            // Detail 掩码回显 "*****"(Vue 编辑抽屉取数侧)
+            var api = await _api.GetJsonAsync($"Deployment/AppDeployNode/{e.Id}");
+            Assert.Equal(Mask, api.Data.GetProperty("userName").GetString());
+        }
+        finally { e.Delete(); deploy.Delete(); node.Delete(); }
+    }
+
+    [Fact(DisplayName = "task-95 刀8/N2:AppDeployNode PUT 守卫——'*****' 恢复原值真写读回不变;空串放行=显式清空")]
+    public async Task AppDeployNode_Put_Guard_Restores_And_Empty_Passes()
+    {
+        var tag = "mw8b-" + Guid.NewGuid().ToString("N")[..8];
+        var (deploy, node, e) = Seed(tag, userName: "real-" + tag);
+        try
+        {
+            // F1 提交体=Detail 全量展开(含 DeployId/NodeId,Valid 强校验必填):userName 原样回传 '*****'
+            var put = new
+            {
+                id = e.Id,
+                deployId = e.DeployId,
+                nodeId = e.NodeId,
+                deployName = e.DeployName,
+                userName = Mask,
+                enable = true,
+            };
+            var api = await _api.PutApiAsync<JsonElement>($"Deployment/AppDeployNode/{e.Id}", put);
+
+            var back = AppDeployNode.FindById(e.Id);
+            Assert.NotNull(back);
+            Assert.Equal("real-" + tag, back.UserName);
+
+            // 空串放行:显式清空(空值不走哨兵分支直通写库)
+            var clear = new
+            {
+                id = e.Id,
+                deployId = e.DeployId,
+                nodeId = e.NodeId,
+                deployName = e.DeployName,
+                userName = "",
+                enable = true,
+            };
+            var api2 = await _api.PutApiAsync<JsonElement>($"Deployment/AppDeployNode/{e.Id}", clear);
+
+            var cleared = AppDeployNode.FindById(e.Id);
+            Assert.NotNull(cleared);
+            Assert.True(String.IsNullOrEmpty(cleared.UserName),
+                $"空串 PUT 应显式清空 UserName,实际='{cleared.UserName}'");
+        }
+        finally { e.Delete(); deploy.Delete(); node.Delete(); }
+    }
+
+    /// <summary>造数:父 AppDeploy + Node 真插(Valid 外键校验需要),再插 AppDeployNode。</summary>
+    private static (AppDeploy deploy, Node node, AppDeployNode node2) Seed(String tag, String userName)
+    {
+        var deploy = new AppDeploy
+        {
+            Name = tag + "-dp", AppId = 0, ProjectId = 0, Category = "unit", Enable = true,
+        };
+        deploy.Insert();
+        var node = new Node
+        {
+            Name = tag + "-nd", Code = tag + "-code", Enable = true,
+        };
+        node.Insert();
+        var e = new AppDeployNode
+        {
+            DeployId = deploy.Id, NodeId = node.ID, DeployName = tag + "-dn", UserName = userName, Enable = true,
+        };
+        e.Insert();
+        return (deploy, node, e);
+    }
+}