dsh-app-updater 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/test/render.mjs CHANGED
@@ -359,7 +359,61 @@ tree = rt.render();
359
359
  group("设置面板与列宽手柄");
360
360
  ok(byClass(tree, "appupd-panel").length === 1, "渲染出插件设置面板");
361
361
  const switches = byClass(tree, "appupd-switch");
362
- ok(switches.length === 7, "渲染出 7 个设置开关(新增自动提权)", switches.length);
362
+ ok(switches.length === 7, "渲染出 7 个设置开关(Windows:不含 macOS 专属项)", switches.length);
363
+
364
+ group("设置面板按平台显示开关");
365
+ {
366
+ const macState = baseState({
367
+ platform: "darwin",
368
+ env: { family: "macos", platform: "darwin", osName: "macOS 15(Sequoia)", archLabel: "Apple Silicon(ARM64)" },
369
+ envSummary: "macOS 15(Sequoia) · Apple Silicon(ARM64)",
370
+ engine: { id: "homebrew", available: true, version: "4.5.0" },
371
+ });
372
+ statePayload = macState;
373
+ rt.reset();
374
+ await tick();
375
+ tree = rt.render();
376
+ await tick();
377
+ tree = rt.render();
378
+ const macSwitches = byClass(tree, "appupd-switch");
379
+ ok(macSwitches.length === 5, "macOS 显示 5 个开关(跨平台 3 + macOS 专属 2)", macSwitches.length);
380
+ const labels = byClass(tree, "appupd-field-label").map(textOf);
381
+ ok(!labels.includes("自动提权安装"), "macOS 不显示「自动提权安装」", labels);
382
+ ok(!labels.includes("下载失败时回退 winget"), "macOS 不显示「下载失败时回退 winget」", labels);
383
+ ok(labels.includes("包含非 Homebrew 应用"), "macOS 显示「包含非 Homebrew 应用」", labels);
384
+ ok(labels.includes("包含 App Store 应用"), "macOS 显示「包含 App Store 应用」", labels);
385
+ ok(labels.includes("演练模式") && labels.includes("保留安装包"), "跨平台开关仍然显示", labels);
386
+ }
387
+ statePayload = baseState();
388
+ rt.reset();
389
+ await tick();
390
+ tree = rt.render();
391
+ await tick();
392
+ tree = rt.render();
393
+
394
+ group("macOS 空状态文案");
395
+ {
396
+ const emptyMac = baseState({
397
+ platform: "darwin",
398
+ env: { family: "macos", platform: "darwin" },
399
+ apps: { scannedAt: Date.now(), stats: { total: 0, upgradable: 0, unsupported: 0 }, items: [] },
400
+ });
401
+ statePayload = emptyMac;
402
+ rt.reset();
403
+ await tick();
404
+ tree = rt.render();
405
+ await tick();
406
+ tree = rt.render();
407
+ const emptyText = textOf(byClass(tree, "appupd-empty")[0] || tree);
408
+ ok(emptyText.includes("应用目录") || emptyText.includes("运行日志"), "macOS 空列表给出可执行的下一步,而不是只说「未检测到」", emptyText.slice(0, 120));
409
+ ok(!emptyText.includes("未检测到任何已安装应用"), "macOS 不再显示会产生误解的那句话");
410
+ }
411
+ statePayload = baseState();
412
+ rt.reset();
413
+ await tick();
414
+ tree = rt.render();
415
+ await tick();
416
+ tree = rt.render();
363
417
  ok(byClass(tree, "appupd-grip").length === 4, "渲染出 4 个列宽拖拽手柄", byClass(tree, "appupd-grip").length);
364
418
  const ths = walk(tree).filter((n) => n.type === "th");
365
419
  ok(ths.length === 6, "表头 6 列", ths.length);
@@ -394,6 +448,26 @@ await tick();
394
448
  tree = rt.render();
395
449
  ok(byClass(tree, "appupd-banner").length === 0, "关闭演练模式后警示条消失(宿主回读 state)");
396
450
 
451
+ group("兼容模式(仅回环放行)警示条");
452
+ statePayload = baseState({ admission: { mode: "loopback-legacy-optin", service: false, allowLegacyLoopback: true, warning: null } });
453
+ rt.reset();
454
+ await tick();
455
+ tree = rt.render();
456
+ await tick();
457
+ tree = rt.render();
458
+ ok(byClass(tree, "appupd-banner").length === 1, "兼容模式开启时显示警示条", byClass(tree, "appupd-banner").length);
459
+ ok(byClass(tree, "appupd-banner-warn").length === 1, "兼容模式警示条用警示样式");
460
+ ok(textOf(byClass(tree, "appupd-banner")[0]).includes("兼容模式已开启"), "警示条文案包含「兼容模式已开启」");
461
+
462
+ statePayload = baseState({ admission: { mode: "deny", service: false, allowLegacyLoopback: false, warning: null } });
463
+ rt.reset();
464
+ await tick();
465
+ tree = rt.render();
466
+ await tick();
467
+ tree = rt.render();
468
+ ok(byClass(tree, "appupd-banner").length === 0, "默认(未开启兼容模式)不显示任何警示条");
469
+ statePayload = baseState();
470
+
397
471
  group("下载目录校验");
398
472
  calls.length = 0;
399
473
  statePayload = baseState();
package/test/unit.mjs CHANGED
@@ -20,7 +20,14 @@ import {
20
20
  import { compareVersions, isNewer, looksLikeVersion, splitVersion } from "../lib/version.js";
21
21
  import { parseInstallerManifest, splitSwitchArgs, defaultSilentArgs, resolveInstallArgs, parseBrewCaskVersions, parseBrewOutdated, findBrew, isArchNotFoundError, explainInstallerExitCode, evaluateUpdateResult, findEdgeUpdate, updateHintFor } from "../lib/engine.js";
22
22
  import { psQuote, ELEVATION_CANCELLED } from "../lib/elevate.js";
23
- import { admissionGate, hasConnectionCredentials } from "../lib/index.js";
23
+ import {
24
+ applicationDirs,
25
+ parseCaskCatalog,
26
+ pickCask,
27
+ normalizeAppKey,
28
+ buildMacosItems,
29
+ } from "../lib/macos.js";
30
+ import { admissionGate, hasConnectionCredentials, resolveLegacyLoopbackOptIn, LEGACY_LOOPBACK_ENV } from "../lib/index.js";
24
31
  import {
25
32
  detectEnvironment,
26
33
  describeEnvironment,
@@ -232,8 +239,11 @@ ok(envMac.family === "macos" && envMac.osName.includes("Sequoia") && envMac.arch
232
239
 
233
240
  ok(capabilitiesFor(envWinArm, false).scan === true, "有 winget 时 Windows 可扫描");
234
241
  ok(capabilitiesFor(envWinArm, false).note.includes("winget"), "缺 winget 时给出提示", capabilitiesFor(envWinArm, false).note);
235
- ok(capabilitiesFor(envMac, false).scan === false, "macOS 缺 Homebrew 时不可扫描");
242
+ ok(capabilitiesFor(envMac, false).update === false, "macOS 缺 Homebrew 时不可更新");
243
+ ok(capabilitiesFor(envMac, false).note.includes("Homebrew"), "macOS 缺 Homebrew 时提示装 Homebrew", capabilitiesFor(envMac, false).note);
236
244
  ok(capabilitiesFor(envMac, true).scan === true, "macOS 有 Homebrew 时可扫描");
245
+ ok(capabilitiesFor(envMac, false).scan === true, "macOS 没有 Homebrew 也能扫描(只是不能更新)——「0 个应用」的真正原因不是没 Homebrew");
246
+ ok(capabilitiesFor(envMac, false).update === false, "macOS 没有 Homebrew 时不可更新");
237
247
  ok(capabilitiesFor({ family: "other" }, false).scan === false, "其它系统不可扫描");
238
248
 
239
249
  /* ---------------- Homebrew 解析 ---------------- */
@@ -246,6 +256,124 @@ ok(outdated.get("visual-studio-code") === "1.95.0" && outdated.get("iterm2") ===
246
256
  ok(parseBrewOutdated("not json").size === 0, "坏 JSON 不抛错");
247
257
  ok(findBrew({ HOMEBREW_PREFIX: "/nonexistent-prefix-for-test" }) === null, "找不到 brew 时返回 null");
248
258
 
259
+ /* ---------------- macOS 应用枚举(本次修复的核心) ---------------- */
260
+ group("macOS 应用枚举");
261
+ ok(applicationDirs("/Users/x").includes("/Applications"), "枚举 /Applications");
262
+ ok(applicationDirs("/Users/x").includes("/Users/x/Applications"), "枚举 ~/Applications");
263
+ ok(applicationDirs("/Users/x").includes("/System/Applications/Utilities"), "枚举系统工具目录");
264
+
265
+ const catalogList = [
266
+ {
267
+ token: "visual-studio-code",
268
+ version: "1.144.0",
269
+ name: ["Microsoft Visual Studio Code"],
270
+ artifacts: [{ app: ["Visual Studio Code.app"], target: "/Applications/Visual Studio Code.app" }],
271
+ },
272
+ {
273
+ token: "iterm2",
274
+ version: "3.5.14",
275
+ name: ["iTerm2"],
276
+ artifacts: [{ app: ["iTerm.app"] }],
277
+ },
278
+ {
279
+ token: "raycast",
280
+ version: "1.99.0",
281
+ name: ["Raycast"],
282
+ artifacts: [{ app: ["Raycast.app"] }],
283
+ },
284
+ ];
285
+ const catalog = parseCaskCatalog(catalogList);
286
+ ok(catalog.byToken.get("iterm2").version === "3.5.14", "目录按 token 建索引");
287
+ ok(catalog.byApp.get("iterm.app")[0].token === "iterm2", "目录按 .app 文件名建索引(大小写无关)");
288
+
289
+ const macApps = [
290
+ // 通过 Homebrew 装、且有新版
291
+ { path: "/Applications/Visual Studio Code.app", bundleName: "Visual Studio Code.app", name: "Visual Studio Code", version: "1.140.0", bundleId: "com.microsoft.VSCode", appStore: false, system: false },
292
+ // 通过 Homebrew 装、已是最新
293
+ { path: "/Applications/iTerm.app", bundleName: "iTerm.app", name: "iTerm2", version: "3.5.14", bundleId: "com.googlecode.iterm2", appStore: false, system: false },
294
+ // 拖 DMG 装的,Homebrew 不管
295
+ { path: "/Applications/Raycast.app", bundleName: "Raycast.app", name: "Raycast", version: "1.99.0", bundleId: "com.raycast.macos", appStore: false, system: false },
296
+ // App Store 装的
297
+ { path: "/Applications/Xcode.app", bundleName: "Xcode.app", name: "Xcode", version: "16.0", bundleId: "com.apple.dt.Xcode", appStore: true, system: false },
298
+ ];
299
+ const brews = new Map([["visual-studio-code", "1.140.0"], ["iterm2", "3.5.14"]]);
300
+ const macBuilt = buildMacosItems({ apps: macApps, brews, outdated: new Map(), catalog, settings: { includeStoreApps: true, includeUnmanagedApps: true } });
301
+
302
+ ok(macBuilt.items.length === 4, "4 个 .app 全部列出(旧实现只会有 2 个)", macBuilt.items.map((i) => i.name));
303
+ const vsc = macBuilt.items.find((i) => i.name === "Visual Studio Code");
304
+ ok(vsc && vsc.hasUpdate && vsc.available === "1.144.0", "Homebrew 管理 + 有新版本 → 可更新并给出目标版本", vsc);
305
+ ok(vsc && vsc.updateSupported === true && vsc.id === "brew:visual-studio-code", "可更新的应用带上 brew: token", vsc && vsc.id);
306
+ const iterm = macBuilt.items.find((i) => i.name === "iTerm2");
307
+ ok(iterm && iterm.hasUpdate === false && iterm.reason === null, "Homebrew 管理且已最新 → 不可更新但没有「不支持」理由", iterm);
308
+ const ray = macBuilt.items.find((i) => i.name === "Raycast");
309
+ ok(ray && ray.hasUpdate === false && ray.reason === "not-managed", "非 Homebrew 应用:列出但标注「非 Homebrew 管理」", ray);
310
+ ok(ray && ray.updateSupported === false, "非 Homebrew 应用不会被标成可更新");
311
+ const xcode = macBuilt.items.find((i) => i.name === "Xcode");
312
+ ok(xcode && xcode.reason === "store-managed" && xcode.source === "appstore", "App Store 应用标注由商店管理", xcode);
313
+ ok(macBuilt.items.filter((i) => i.hasUpdate).length === 1, "只有 1 个可更新");
314
+
315
+ // --greedy 能强制升级的 cask(自更新应用)也要露出可用版本
316
+ const greedy = buildMacosItems({ apps: macApps, brews, outdated: new Map([["iterm2", "3.5.15"]]), catalog, settings: {} });
317
+ ok(greedy.items.find((i) => i.name === "iTerm2").available === "3.5.15", "brew outdated --greedy 报告的版本优先", greedy.items.find((i) => i.name === "iTerm2"));
318
+
319
+ // 关掉「包含非 Homebrew 应用」后只剩 Homebrew 管理的
320
+ const onlyBrew = buildMacosItems({ apps: macApps, brews, outdated: new Map(), catalog, settings: { includeUnmanagedApps: false } });
321
+ ok(onlyBrew.items.length === 2 && onlyBrew.items.every((i) => i.source === "homebrew"), "关闭开关后只显示 Homebrew 应用", onlyBrew.items.map((i) => i.name));
322
+
323
+ // 关掉「包含 App Store 应用」
324
+ const noStore = buildMacosItems({ apps: macApps, brews, outdated: new Map(), catalog, settings: { includeStoreApps: false } });
325
+ ok(!noStore.items.some((i) => i.name === "Xcode"), "关闭 App Store 开关后不显示 Xcode");
326
+
327
+ // 没有 cask 目录时降级:仍然列出应用,只是都不认为可更新
328
+ const noCatalog = buildMacosItems({ apps: macApps, brews, outdated: new Map(), catalog: null, settings: {} });
329
+ ok(noCatalog.items.length === 4 && noCatalog.items.every((i) => !i.hasUpdate), "没有 cask 目录也照样列出应用", noCatalog.items.length);
330
+ ok(noCatalog.warnings.some((w) => w.includes("cask 目录")), "没有 cask 目录时给出警告");
331
+
332
+ // Homebrew 装了但 .app 不在标准目录(字体 / 命令行 cask)
333
+ const brewOnly = buildMacosItems({
334
+ apps: [],
335
+ brews: new Map([["font-jetbrains-mono", "2.304"]]),
336
+ outdated: new Map(),
337
+ catalog: parseCaskCatalog([{ token: "font-jetbrains-mono", version: "2.304", name: ["JetBrains Mono"], artifacts: [] }]),
338
+ settings: {},
339
+ });
340
+ ok(brewOnly.items.length === 1 && brewOnly.items[0].id === "brew:font-jetbrains-mono", "没有 .app 的 cask 也能列出", brewOnly.items);
341
+
342
+ /* ---------------- macOS 目录解析 ---------------- */
343
+ group("cask 目录解析");
344
+ const parsedCatalog = parseCaskCatalog([
345
+ { token: "raycast", version: "1.99.0", name: ["Raycast"], artifacts: [{ app: ["Raycast.app"] }] },
346
+ { token: "bad-no-artifacts", version: "1.0" },
347
+ null,
348
+ { version: "9.9" },
349
+ ]);
350
+ ok(parsedCatalog.byToken.size === 2, "跳过没有 token 的条目", parsedCatalog.byToken.size);
351
+ ok(!parsedCatalog.byApp.has("bad-no-artifacts"), "artifacts 里没有 app 的不建 byApp 索引");
352
+ ok(normalizeAppKey("Visual Studio Code.app") === "visualstudiocode", "应用名归一化去掉 .app 与空格");
353
+ ok(normalizeAppKey("VS Code") === "vscode", "应用名归一化保留字母数字");
354
+
355
+ group("同名 .app 的多个 cask:挑对那一个");
356
+ // 真实目录里 iterm2 与 iterm2@nightly 都声明 iTerm.app
357
+ const dupCatalog = parseCaskCatalog([
358
+ { token: "iterm2@nightly", version: "3.6.0", name: ["iTerm2 Nightly"], artifacts: [{ app: ["iTerm.app"] }] },
359
+ { token: "iterm2", version: "3.7.6", name: ["iTerm2"], artifacts: [{ app: ["iTerm.app"] }] },
360
+ ]);
361
+ ok(dupCatalog.byApp.get("iterm.app").length === 2, "两个 cask 都留在候选里", dupCatalog.byApp.get("iterm.app").length);
362
+ ok(
363
+ pickCask(dupCatalog.byApp.get("iterm.app"), { appName: "iTerm2", bundleName: "iTerm.app", brews: new Map() }).token === "iterm2",
364
+ "没有安装记录时优先主 token(不是 @nightly)"
365
+ );
366
+ ok(
367
+ pickCask(dupCatalog.byApp.get("iterm.app"), { appName: "iTerm2", bundleName: "iTerm.app", brews: new Map([["iterm2@nightly", "3.6.0"]]) }).token === "iterm2@nightly",
368
+ "已装的是 nightly 时优先已装的那个"
369
+ );
370
+ // 弃用 / 停用的 cask 排在后面
371
+ const depCatalog = parseCaskCatalog([
372
+ { token: "foo-old", version: "1.0", deprecated: true, artifacts: [{ app: ["Foo.app"] }] },
373
+ { token: "foo", version: "2.0", artifacts: [{ app: ["Foo.app"] }] },
374
+ ]);
375
+ ok(pickCask(depCatalog.byApp.get("foo.app"), { appName: "Foo", bundleName: "Foo.app", brews: new Map() }).token === "foo", "弃用的 cask 不会被选中");
376
+
249
377
  /* ---------------- 提权与安装复核 ---------------- */
250
378
  group("提权执行(PowerShell 助手)");
251
379
  ok(psQuote("C:\\Program Files\\a.exe") === "'C:\\Program Files\\a.exe'", "psQuote 保留 Windows 路径");
@@ -300,25 +428,77 @@ function fakeReq({ loopback = true, headers = {} } = {}) {
300
428
  return { socket: { remoteAddress: loopback ? "127.0.0.1" : "10.0.0.5" }, headers };
301
429
  }
302
430
 
303
- group("connection 准入(市场审核要求)");
431
+ group("connection 准入(市场审核要求:默认拒绝)");
304
432
  ok(hasConnectionCredentials({ headers: { cookie: "dsh=x" } }) === true, "识别浏览器会话 Cookie");
305
433
  ok(hasConnectionCredentials({ headers: { authorization: "Bearer x" } }) === true, "识别 API Cookie/Authorization");
306
434
  ok(hasConnectionCredentials({ headers: {} }) === false, "没有凭据时返回 false");
307
- ok(admissionGate(fakeReq(), fakeRes(), null) === true, "宿主没有 connection 服务时,回环请求放行");
435
+
436
+ /* --- 兼容模式开关:默认关闭,必须是显式动作 --- */
437
+ ok(resolveLegacyLoopbackOptIn(undefined, {}) === false, "没有任何显式开关 → 兼容模式关闭");
438
+ ok(resolveLegacyLoopbackOptIn(null, {}) === false, "配置为 null → 兼容模式关闭");
439
+ ok(resolveLegacyLoopbackOptIn({}, {}) === false, "配置里没有该字段 → 兼容模式关闭");
440
+ ok(resolveLegacyLoopbackOptIn({ allowLegacyLoopback: false }, {}) === false, "配置显式 false → 关闭");
441
+ ok(resolveLegacyLoopbackOptIn({ allowLegacyLoopback: true }, {}) === true, "插件配置 allowLegacyLoopback:true → 开启");
442
+ ok(resolveLegacyLoopbackOptIn(undefined, { [LEGACY_LOOPBACK_ENV]: "1" }) === true, "环境变量 =1 → 开启");
443
+ ok(resolveLegacyLoopbackOptIn(undefined, { [LEGACY_LOOPBACK_ENV]: "true" }) === true, "环境变量 =true → 开启");
444
+ ok(resolveLegacyLoopbackOptIn(undefined, { [LEGACY_LOOPBACK_ENV]: "0" }) === false, "环境变量 =0 → 关闭");
445
+ ok(resolveLegacyLoopbackOptIn(undefined, { [LEGACY_LOOPBACK_ENV]: "off" }) === false, "环境变量 =off → 关闭");
446
+
447
+ /* --- 宿主没有 connection 服务:默认拒绝,只有显式开启兼容模式才放行 --- */
448
+ {
449
+ const res = fakeRes();
450
+ ok(
451
+ admissionGate(fakeReq(), res, null) === false && res.statusCode === 403 && res.headers["X-DSH-Admission"] === "denied",
452
+ "没有 connection 服务 + 回环 + 未开兼容模式 → 403(默认拒绝)"
453
+ );
454
+ }
308
455
  {
309
456
  const res = fakeRes();
310
457
  ok(admissionGate(fakeReq({ loopback: false }), res, null) === false && res.statusCode === 403, "没有 connection 服务时,非回环请求 403");
311
458
  }
459
+ {
460
+ const res = fakeRes();
461
+ ok(
462
+ admissionGate(fakeReq(), res, null, { allowLegacyLoopback: true }) === true &&
463
+ res.headers["X-DSH-Admission"] === "loopback-legacy-optin" &&
464
+ res.headers["X-DSH-Admission-Warning"] === "legacy-loopback-compatibility-enabled",
465
+ "显式开启兼容模式 + 回环 → 放行并标注 opt-in 响应头"
466
+ );
467
+ }
468
+ {
469
+ const res = fakeRes();
470
+ ok(
471
+ admissionGate(fakeReq({ loopback: false }), res, null, { allowLegacyLoopback: true }) === false && res.statusCode === 403,
472
+ "即使开启兼容模式,非回环请求仍然 403"
473
+ );
474
+ }
475
+
476
+ /* --- 宿主有 connection 服务:一律以 admit() 为准 --- */
312
477
  {
313
478
  const res = fakeRes();
314
479
  const connection = { admit: () => ({ peer: { id: "peer-1" } }) };
315
480
  ok(admissionGate(fakeReq(), res, connection) === true && res.statusCode === 0, "准入通过 → 放行且不写响应");
316
481
  }
482
+ {
483
+ const res = fakeRes();
484
+ const connection = { admit: () => undefined };
485
+ ok(admissionGate(fakeReq(), res, connection) === false && res.statusCode === 500, "准入返回空值 → 500(不把看不懂的结果放行)");
486
+ }
487
+ {
488
+ const res = fakeRes();
489
+ const connection = { admit: () => ({ rejection: "401" }) };
490
+ ok(admissionGate(fakeReq(), res, connection) === false && res.statusCode === 403, "准入返回非数字拒绝码 → 403(fail-closed)");
491
+ }
317
492
  {
318
493
  const res = fakeRes();
319
494
  const connection = { admit: () => ({ rejection: 403 }) };
320
495
  ok(admissionGate(fakeReq({ loopback: false }), res, connection) === false && res.statusCode === 403, "准入拒绝 403(不可信来源)→ 403");
321
496
  }
497
+ {
498
+ const res = fakeRes();
499
+ const connection = { admit: () => ({ rejection: 409 }) };
500
+ ok(admissionGate(fakeReq(), res, connection) === false && res.statusCode === 409, "准入拒绝 409(连接被占用)→ 409");
501
+ }
322
502
  {
323
503
  const res = fakeRes();
324
504
  const connection = { admit: () => ({ rejection: 401 }) };
@@ -328,8 +508,34 @@ ok(admissionGate(fakeReq(), fakeRes(), null) === true, "宿主没有 connection
328
508
  const res = fakeRes();
329
509
  const connection = { admit: () => ({ rejection: 401 }) };
330
510
  ok(
331
- admissionGate(fakeReq(), res, connection) === true && res.headers["X-DSH-Admission"] === "loopback-legacy",
332
- "无凭据的回环请求退回旧规则并标注响应头"
511
+ admissionGate(fakeReq({ headers: { cookie: "dsh=x" } }), res, connection, { allowLegacyLoopback: true }) === false &&
512
+ res.statusCode === 401,
513
+ "带凭据未认证时,兼容模式也不放行"
514
+ );
515
+ }
516
+ {
517
+ const res = fakeRes();
518
+ const connection = { admit: () => ({ rejection: 401 }) };
519
+ ok(
520
+ admissionGate(fakeReq(), res, connection) === false && res.statusCode === 401 && res.headers["X-DSH-Admission"] === "denied",
521
+ "无凭据的回环请求默认拒绝(不再退回回环信任)"
522
+ );
523
+ }
524
+ {
525
+ const res = fakeRes();
526
+ const connection = { admit: () => ({ rejection: 401 }) };
527
+ ok(
528
+ admissionGate(fakeReq(), res, connection, { allowLegacyLoopback: true }) === true &&
529
+ res.headers["X-DSH-Admission"] === "loopback-legacy-optin",
530
+ "显式开启兼容模式后,无凭据的回环请求才按旧规则放行"
531
+ );
532
+ }
533
+ {
534
+ const res = fakeRes();
535
+ const connection = { admit: () => ({ rejection: 401 }) };
536
+ ok(
537
+ admissionGate(fakeReq({ loopback: false }), res, connection, { allowLegacyLoopback: true }) === false && res.statusCode === 401,
538
+ "兼容模式的例外只覆盖回环地址,非回环仍然 401"
333
539
  );
334
540
  }
335
541
  {