S6LegacyFlowAuthorityMigrationContractTests.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. using System.Text.Json;
  2. using System.Text.Json.Nodes;
  3. using Admin.NET.Plugin.AiDOP.Service.S6;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
  6. /// <summary>
  7. /// S6-LEGACY-SNAPSHOT-MIGRATION-1 契约测试:存量运行中实例的审批权威快照迁移。
  8. ///
  9. /// <para>两层覆盖:</para>
  10. /// <list type="number">
  11. /// <item><b>行为级</b>——<c>ParseNumericRoleRefs</c> / <c>RewriteAuthority</c> 是纯静态方法,
  12. /// 不依赖 DB,可直接喂真实形状的 FlowJson 断言。快照解析与改写是本批最容易悄悄改坏的地方
  13. /// (approverType 语义、token 顺序、只动 approverIds),必须真跑。</item>
  14. /// <item><b>源码契约级</b>——安全谓词(ACTIVE 判据、租户反查、跨租户限定、目标角色唯一性与成员门禁、
  15. /// 回滚来源门禁、并发守卫、留证同事务、append-only、批次边界)依赖
  16. /// <c>ISqlSugarClient</c>,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests),
  17. /// 故按本仓既有惯例做源码断言;真实数据行为由本批 Runtime R1–R10 覆盖。</item>
  18. /// </list>
  19. /// </summary>
  20. public class S6LegacyFlowAuthorityMigrationContractTests
  21. {
  22. private static string Svc() => File.ReadAllText(FindFile(
  23. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Service", "S6", "S6LegacyFlowAuthorityMigrationService.cs"));
  24. private static string Entity() => File.ReadAllText(FindFile(
  25. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Entity", "S6", "AdoS6FlowAuthorityMigrationLog.cs"));
  26. private static string Engine() => File.ReadAllText(FindFile(
  27. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
  28. /// <summary>与真实快照同形:N1=Initiator、N2/N3=Role 数字、另含 SpecificUser / Department 干扰节点。</summary>
  29. private static string SampleSnapshot(string n2Ids = "1329916010002", string n3Ids = "1329916010003") => JsonSerializer.Serialize(new
  30. {
  31. nodes = new object[]
  32. {
  33. new { id = "start", type = "bpmn:startEvent", properties = new { nodeName = "开始" } },
  34. new { id = "N1_SUBMIT", type = "bpmn:userTask", properties = new { nodeName = "提交审核", approverType = "Initiator", approverIds = "", approverNames = "发起人" } },
  35. new { id = "N2_SUP_REVIEW", type = "bpmn:userTask", properties = new { nodeName = "检验主管审核", approverType = "Role", approverIds = n2Ids, approverNames = "过程检验主管" } },
  36. new { id = "GW_RESULT", type = "bpmn:exclusiveGateway", properties = new { nodeName = "质量结果分流" } },
  37. new { id = "N3_QE_DISPOSITION", type = "bpmn:userTask", properties = new { nodeName = "质量处置", approverType = "Role", approverIds = n3Ids, approverNames = "过程检验质量工程师" } },
  38. // 干扰项:数字但语义不是 RoleId
  39. new { id = "X_SPECIFIC", type = "bpmn:userTask", properties = new { nodeName = "指定人", approverType = "SpecificUser", approverIds = "1300000000101", approverNames = "超管" } },
  40. new { id = "X_DEPT", type = "bpmn:userTask", properties = new { nodeName = "部门", approverType = "Department", approverIds = "1300000000002", approverNames = "市场部" } },
  41. new { id = "end", type = "bpmn:endEvent", properties = new { nodeName = "结束" } },
  42. },
  43. edges = new object[] { new { id = "e1", sourceNodeId = "start", targetNodeId = "N1_SUBMIT" } },
  44. });
  45. // ───────────── 行为级:解析 ─────────────
  46. /// <summary>只认 approverType=Role 的数字 token;SpecificUser / Department 的数字不得被当成 RoleId。</summary>
  47. [Fact]
  48. public void Parse_OnlyPicksRoleNodes_NotSpecificUserOrDepartment()
  49. {
  50. var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot());
  51. Assert.Equal(2, refs.Count);
  52. Assert.Equal(new[] { "N2_SUP_REVIEW", "N3_QE_DISPOSITION" }, refs.Select(r => r.NodeId).ToArray());
  53. Assert.DoesNotContain("1300000000101", refs.SelectMany(r => r.NumericTokens)); // SpecificUser 的 UserId
  54. Assert.DoesNotContain("1300000000002", refs.SelectMany(r => r.NumericTokens)); // Department 的 OrgId
  55. }
  56. /// <summary>Initiator 节点 approverIds 为空串,不算 authority。</summary>
  57. [Fact]
  58. public void Parse_IgnoresInitiatorNode()
  59. {
  60. var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot());
  61. Assert.DoesNotContain("N1_SUBMIT", refs.Select(r => r.NodeId));
  62. }
  63. /// <summary>已迁过的快照(全 RoleCode)解析不出数字 token —— 这就是幂等判据。</summary>
  64. [Fact]
  65. public void Parse_AlreadyMigratedSnapshot_YieldsNothing()
  66. {
  67. var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(
  68. SampleSnapshot("ROLE_S6_IPQC_SUPERVISOR", "ROLE_S6_IPQC_QUALITY_ENGINEER"));
  69. Assert.Empty(refs);
  70. }
  71. /// <summary>多 token 逐个识别(即使当前实例是单 token)。</summary>
  72. [Fact]
  73. public void Parse_HandlesMultipleTokens()
  74. {
  75. var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot("111,222,333"));
  76. var n2 = refs.Single(r => r.NodeId == "N2_SUP_REVIEW");
  77. Assert.Equal(new[] { "111", "222", "333" }, n2.NumericTokens.ToArray());
  78. }
  79. /// <summary>畸形 JSON 不得抛异常,返回空(fail-closed:选不中就不改)。</summary>
  80. [Theory]
  81. [InlineData("")]
  82. [InlineData("not json")]
  83. [InlineData("{\"nodes\":\"wrong-type\"}")]
  84. public void Parse_MalformedInput_ReturnsEmpty(string bad)
  85. {
  86. Assert.Empty(S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(bad));
  87. }
  88. // ───────────── 行为级:改写 ─────────────
  89. /// <summary>只改 Role 节点的 approverIds;节点集合、名称、edges、approverNames 一概不动。</summary>
  90. [Fact]
  91. public void Rewrite_ChangesOnlyRoleApproverIds()
  92. {
  93. var before = SampleSnapshot();
  94. var after = S6LegacyFlowAuthorityMigrationService.RewriteAuthority(before, new Dictionary<string, string>
  95. {
  96. ["1329916010002"] = "ROLE_S6_IPQC_SUPERVISOR",
  97. ["1329916010003"] = "ROLE_S6_IPQC_QUALITY_ENGINEER",
  98. });
  99. Assert.NotNull(after);
  100. var diffs = StructuralDiff(before, after!);
  101. Assert.Equal(2, diffs.Count);
  102. Assert.Contains(".nodes[2].properties.approverIds", diffs.Keys);
  103. Assert.Contains(".nodes[4].properties.approverIds", diffs.Keys);
  104. Assert.Equal("ROLE_S6_IPQC_SUPERVISOR", diffs[".nodes[2].properties.approverIds"].after);
  105. Assert.Equal("ROLE_S6_IPQC_QUALITY_ENGINEER", diffs[".nodes[4].properties.approverIds"].after);
  106. }
  107. /// <summary>SpecificUser / Department 的数字即使出现在 tokenMap 里也不得被改。</summary>
  108. [Fact]
  109. public void Rewrite_NeverTouchesNonRoleNodes()
  110. {
  111. var before = SampleSnapshot();
  112. var after = S6LegacyFlowAuthorityMigrationService.RewriteAuthority(before, new Dictionary<string, string>
  113. {
  114. ["1329916010002"] = "ROLE_S6_IPQC_SUPERVISOR",
  115. ["1300000000101"] = "SHOULD_NOT_BE_APPLIED", // SpecificUser 的 UserId
  116. ["1300000000002"] = "SHOULD_NOT_BE_APPLIED", // Department 的 OrgId
  117. });
  118. Assert.NotNull(after);
  119. Assert.DoesNotContain("SHOULD_NOT_BE_APPLIED", after!);
  120. Assert.Contains("\"approverIds\":\"1300000000101\"", after);
  121. Assert.Contains("\"approverIds\":\"1300000000002\"", after);
  122. }
  123. /// <summary>多 token 保持原顺序与原数量;未映射的 token 原样留下。</summary>
  124. [Fact]
  125. public void Rewrite_PreservesTokenOrderAndCount()
  126. {
  127. var after = S6LegacyFlowAuthorityMigrationService.RewriteAuthority(
  128. SampleSnapshot("111,222,333"), new Dictionary<string, string> { ["222"] = "ROLE_B" });
  129. Assert.NotNull(after);
  130. Assert.Contains("\"approverIds\":\"111,ROLE_B,333\"", after!);
  131. }
  132. /// <summary>无可改之处时返回 null,调用方据此不发 UPDATE。</summary>
  133. [Fact]
  134. public void Rewrite_NoApplicableToken_ReturnsNull()
  135. {
  136. Assert.Null(S6LegacyFlowAuthorityMigrationService.RewriteAuthority(
  137. SampleSnapshot(), new Dictionary<string, string> { ["999999"] = "ROLE_X" }));
  138. }
  139. /// <summary>畸形输入返回 null,不抛。</summary>
  140. [Fact]
  141. public void Rewrite_MalformedInput_ReturnsNull()
  142. {
  143. Assert.Null(S6LegacyFlowAuthorityMigrationService.RewriteAuthority("not json", new Dictionary<string, string>()));
  144. }
  145. // ───────────── 源码契约:安全谓词 ─────────────
  146. /// <summary>BizType 范围必须与引擎的严格守卫白名单逐字一致。</summary>
  147. [Fact]
  148. public void Scope_MatchesEngineStrictWhitelist()
  149. {
  150. Assert.Equal(new[] { "IPQC_INSPECTION", "S6_PROCESS_INSPECTION" },
  151. S6LegacyFlowAuthorityMigrationService.TargetBizTypes.ToArray());
  152. var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
  153. Assert.Contains("\"IPQC_INSPECTION\"", set);
  154. Assert.Contains("\"S6_PROCESS_INSPECTION\"", set);
  155. }
  156. /// <summary>ACTIVE 判据不得只看 Status —— 终态实例的 CurrentNodeId 可能仍停在 N3_*。</summary>
  157. [Fact]
  158. public void Selection_RequiresRunningAndPendingTask()
  159. {
  160. var body = Slice(Svc(), "public async Task<List<S6MigrationCandidate>> SelectCandidatesAsync", "public async Task<S6FlowAuthorityMigrationResult> MigrateAsync");
  161. Assert.Contains("x.Status == FlowInstanceStatusEnum.Running", body);
  162. Assert.Contains("t.Status == FlowTaskStatusEnum.Pending", body);
  163. Assert.Contains("if (!hasPending) continue;", body);
  164. }
  165. /// <summary>§57 终态实例必须被 Running 谓词排除,本服务不得出现「顺带把历史也迁了」的路径。</summary>
  166. [Fact]
  167. public void Selection_NeverTouchesTerminalInstances()
  168. {
  169. var body = Slice(Svc(), "public async Task<List<S6MigrationCandidate>> SelectCandidatesAsync", "public async Task<S6FlowAuthorityMigrationResult> MigrateAsync");
  170. Assert.DoesNotContain("FlowInstanceStatusEnum.Approved", body);
  171. Assert.DoesNotContain("FlowInstanceStatusEnum.Rejected", body);
  172. Assert.DoesNotContain("FlowInstanceStatusEnum.Cancelled", body);
  173. }
  174. /// <summary>批次边界:这是「一次性迁移」与「长期 auto-heal」的分界,未来新坏实例必须响亮失败。</summary>
  175. [Fact]
  176. public void Selection_IsBatchScopedByCutoff()
  177. {
  178. var s = Svc();
  179. Assert.Contains("BatchCutoff", s);
  180. Assert.Contains("x.StartTime < cutoff", s);
  181. }
  182. /// <summary>§15 Effective Tenant 必须由业务实体反查,禁止取登录用户租户。</summary>
  183. [Fact]
  184. public void Tenant_ResolvedFromBusinessEntity_NotLoginUser()
  185. {
  186. var s = Svc();
  187. var resolver = Slice(s, "private async Task<long?> ResolveEffectiveTenantAsync", "internal static List<S6NumericRoleRef> ParseNumericRoleRefs");
  188. Assert.Contains("FROM ado_s6_process_inspection_bill WHERE id=@id", resolver);
  189. Assert.Contains("FROM qms_gcjyd WHERE id=@id", resolver);
  190. Assert.DoesNotContain("_userManager", s);
  191. Assert.DoesNotContain("HttpContext", s);
  192. }
  193. /// <summary>§58 同租户 numeric RoleId 是合法引用,必须被挡下不改 —— numeric ≠ bad。</summary>
  194. [Fact]
  195. public void Mapping_SameTenantNumericRoleId_IsNotMigrated()
  196. {
  197. var s = Svc();
  198. Assert.Contains("legacyRole.TenantId == c.EffectiveTenantId", s);
  199. Assert.Contains("非跨租户引用,不在迁移范围", s);
  200. }
  201. /// <summary>§19 目标角色必须恰好一个启用项,不得 First() 随便挑。</summary>
  202. [Fact]
  203. public void Mapping_RequiresExactlyOneEnabledTargetRole()
  204. {
  205. var s = Svc();
  206. Assert.Contains("targets.Count != 1", s);
  207. Assert.Contains("AMBIGUOUS TARGET ROLE", s);
  208. Assert.Contains("x.Status == StatusEnum.Enable", s);
  209. }
  210. /// <summary>§18 目标角色必须至少有一个同租户成员,否则迁完仍解析 0 人。</summary>
  211. [Fact]
  212. public void Mapping_RequiresSameTenantMember()
  213. {
  214. var s = Svc();
  215. Assert.Contains("u.TenantId == c.EffectiveTenantId", s);
  216. Assert.Contains("if (sameTenantMembers == 0)", s);
  217. Assert.Contains("BLOCKED_TARGET_AUTHORITY", s);
  218. }
  219. /// <summary>§59 任一 token 映射不出来 → 整条实例不迁,禁止部分迁移。</summary>
  220. [Fact]
  221. public void Mapping_PartialFailure_AbortsWholeInstance()
  222. {
  223. var body = Slice(Svc(), "private async Task MigrateOneAsync", "/// <summary>挡下的候选同样留证");
  224. // 每个 Block 分支都必须 return,不能继续往下改写
  225. Assert.Contains("result, _ => result.BlockedByTargetAuthority++);\n return;", body);
  226. Assert.Contains("禁止部分迁移", Svc());
  227. }
  228. /// <summary>§20/§21 回滚来源必须恰好 1 行且与当前快照 MD5 一致。</summary>
  229. [Fact]
  230. public void RollbackSource_GateRequiresExactlyOneMatchingVersionRow()
  231. {
  232. var s = Svc();
  233. Assert.Contains("v.FlowId == inst.FlowId && v.Version == inst.FlowVersion", s);
  234. Assert.Contains("versionRows.Count != 1 || usable.Count != 1", s);
  235. Assert.Contains("BLOCKED_ROLLBACK_SOURCE", s);
  236. }
  237. /// <summary>§32 实例表无 version/checksum,UPDATE 必须带改前 MD5 乐观锁且断言 affected==1。</summary>
  238. [Fact]
  239. public void Update_UsesBeforeMd5ConcurrencyGuard()
  240. {
  241. var s = Svc();
  242. Assert.Contains("MD5(FlowJsonSnapshot)=@beforeMd5", s);
  243. Assert.Contains("if (affected != 1)", s);
  244. Assert.Contains("CONCURRENT MODIFICATION", s);
  245. }
  246. /// <summary>§13 留证 INSERT 与快照 UPDATE 必须同一事务。</summary>
  247. [Fact]
  248. public void Evidence_AndUpdate_ShareOneTransaction()
  249. {
  250. var body = Slice(Svc(), "private async Task MigrateOneAsync", "/// <summary>挡下的候选同样留证");
  251. var tranAt = body.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal);
  252. var logAt = body.IndexOf("_logRep.AsInsertable(new AdoS6FlowAuthorityMigrationLog", StringComparison.Ordinal);
  253. var updAt = body.IndexOf("UPDATE ApprovalFlowInstance", StringComparison.Ordinal);
  254. Assert.True(tranAt > 0, "缺少事务边界");
  255. Assert.True(logAt > tranAt, "留证 INSERT 必须在事务内");
  256. Assert.True(updAt > logAt, "先留证再改写");
  257. Assert.Contains("if (!tran.IsSuccess)", body);
  258. }
  259. /// <summary>§12 留证必须 append-only:不得出现任何删除/清空。</summary>
  260. [Fact]
  261. public void Evidence_IsAppendOnly()
  262. {
  263. var s = Svc();
  264. Assert.DoesNotContain("AsDeleteable", s);
  265. Assert.DoesNotContain("_logRep.AsUpdateable", s);
  266. Assert.Contains("append-only", Entity());
  267. }
  268. /// <summary>§11 留证字段齐全 —— 「为什么改/改前是什么/怎么回滚」必须都能回答。</summary>
  269. [Theory]
  270. [InlineData("migration_batch")]
  271. [InlineData("instance_id")]
  272. [InlineData("biz_type")]
  273. [InlineData("biz_id")]
  274. [InlineData("effective_tenant_id")]
  275. [InlineData("before_snapshot")]
  276. [InlineData("after_snapshot")]
  277. [InlineData("before_md5")]
  278. [InlineData("after_md5")]
  279. [InlineData("authority_mapping")]
  280. [InlineData("rollback_source")]
  281. [InlineData("reason")]
  282. [InlineData("outcome")]
  283. [InlineData("created_at")]
  284. public void Evidence_HasRequiredColumn(string column)
  285. {
  286. Assert.Contains($"ColumnName = \"{column}\"", Entity());
  287. }
  288. /// <summary>不得复用 S8 那张每轮全量重写的表 —— 迁移留证一旦被抹掉,回滚依据就消失了。</summary>
  289. [Fact]
  290. public void Evidence_DoesNotReuseS8RepairLog()
  291. {
  292. Assert.DoesNotContain("AdoS8ApprovalFlowRepairLog", Svc());
  293. Assert.Contains("ado_s6_flow_authority_migration_log", Entity());
  294. }
  295. /// <summary>§27 目标表达必须是 RoleCode,不得写目标租户物理 RoleId。</summary>
  296. [Fact]
  297. public void Target_UsesRoleCode_NotTargetLocalRoleId()
  298. {
  299. var s = Svc();
  300. Assert.Contains("tokenMap[token] = code;", s);
  301. Assert.DoesNotContain("tokenMap[token] = target.Id", s);
  302. }
  303. /// <summary>§3/§4 本服务不得触碰引擎、守卫白名单或 resolver —— 它是数据迁移,不是兼容层。</summary>
  304. [Theory]
  305. [InlineData("TenantStrictRoleBizTypes")]
  306. [InlineData("DedicatedEntryOnlyBizTypes")]
  307. [InlineData("ResolveApprovers")]
  308. [InlineData("EnsureRoleAuthorityTenantScoped")]
  309. public void Migration_DoesNotTouchRuntimeGuards(string symbol)
  310. {
  311. Assert.DoesNotContain(symbol + "(", Svc());
  312. }
  313. /// <summary>§29/§30 不得改历史事实表,也不得切 FlowId/FlowVersion。</summary>
  314. [Theory]
  315. [InlineData("UPDATE ApprovalFlowTask")]
  316. [InlineData("UPDATE ApprovalFlowLog")]
  317. [InlineData("UPDATE ApprovalFlowCompletedNode")]
  318. [InlineData("SET FlowId")]
  319. [InlineData("SET FlowVersion")]
  320. public void Migration_NeverWritesHistoryOrFlowIdentity(string forbidden)
  321. {
  322. Assert.DoesNotContain(forbidden, Svc());
  323. }
  324. /// <summary>§31 不得取消旧实例或另起新实例 —— 断言实际调用形态,不误伤文档里提到的 StartFlowCore。</summary>
  325. [Theory]
  326. [InlineData("_flowEngine")]
  327. [InlineData(".StartFlow(")]
  328. [InlineData(".Withdraw(")]
  329. [InlineData("Insertable(new ApprovalFlowInstance")]
  330. public void Migration_NeverRestartsInstance(string forbidden)
  331. {
  332. Assert.DoesNotContain(forbidden, Svc());
  333. }
  334. private static Dictionary<string, (object? before, object? after)> StructuralDiff(string a, string b)
  335. {
  336. var fa = Flatten(JsonNode.Parse(a));
  337. var fb = Flatten(JsonNode.Parse(b));
  338. return fa.Keys.Union(fb.Keys)
  339. .Where(k => !Equals(fa.GetValueOrDefault(k), fb.GetValueOrDefault(k)))
  340. .ToDictionary(k => k, k => (fa.GetValueOrDefault(k), fb.GetValueOrDefault(k)));
  341. }
  342. private static Dictionary<string, object?> Flatten(JsonNode? node, string path = "")
  343. {
  344. var flat = new Dictionary<string, object?>();
  345. switch (node)
  346. {
  347. case JsonObject obj:
  348. foreach (var kv in obj)
  349. foreach (var inner in Flatten(kv.Value, $"{path}.{kv.Key}")) flat[inner.Key] = inner.Value;
  350. break;
  351. case JsonArray arr:
  352. for (var i = 0; i < arr.Count; i++)
  353. foreach (var inner in Flatten(arr[i], $"{path}[{i}]")) flat[inner.Key] = inner.Value;
  354. break;
  355. // 标量统一取原值字符串(不带 JSON 引号),否则断言里要写成 "\"x\"" 影响可读性
  356. case JsonValue val:
  357. flat[path] = val.TryGetValue<string>(out var s) ? s : val.ToJsonString();
  358. break;
  359. default:
  360. flat[path] = node?.ToJsonString();
  361. break;
  362. }
  363. return flat;
  364. }
  365. private static string Slice(string src, string from, string to)
  366. {
  367. var a = src.IndexOf(from, StringComparison.Ordinal);
  368. Assert.True(a >= 0, $"未找到起点:{from}");
  369. var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
  370. return b > a ? src[a..b] : src[a..];
  371. }
  372. private static string FindFile(params string[] parts)
  373. {
  374. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  375. while (dir != null)
  376. {
  377. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  378. if (File.Exists(candidate)) return candidate;
  379. dir = dir.Parent;
  380. }
  381. throw new FileNotFoundException(string.Join("/", parts));
  382. }
  383. }