Files
Netcatty/domain/groupConfig.test.ts
陈大猫 7272f2564d fix(ssh): per-host skipEcdsaHostKey toggle + advanced algorithm overrides (#1027) (#1116)
* fix(ssh): per-host skipEcdsaHostKey toggle + advanced algorithm overrides (#1027)

#1027 reported an old Huawei S7706 (SSH banner `SSH-2.0--`, empty
software-version field) where the legacy-algorithms toggle still
couldn't get a connection through. The debug log shows the handshake
makes it through every negotiation step, picks `ecdsa-sha2-nistp521`
for the host key, then dies at:

  Handshake failed: signature verification failed

i.e. ssh2's strict RFC verifier rejects the ECDSA signature the
switch produces. OpenSSH on the same machine connects because its
known_hosts is already pinned to an RSA fingerprint, so it never
advertises ECDSA in the first place.

This adds two layers of escape hatch:

A. `host.skipEcdsaHostKey` (one-click) — drops every `ecdsa-sha2-*`
   from the offered host-key list. Forces the fallback to
   ssh-rsa / ssh-dss / ssh-ed25519 that those old stacks implement
   correctly. Wired through ssh / sftp / port-forwarding bridges,
   inheritable from the group default.

B. `host.algorithms` (advanced) — per-category override lists
   (`kex`, `cipher`, `hmac`, `serverHostKey`, `compress`). When a
   category's array is non-empty, it fully replaces the negotiated
   list for that category. Exposed in a collapsible "Advanced
   algorithm overrides" panel on both host and group settings,
   inspired by Tabby's per-profile algorithm UI. Empty arrays
   normalize to "use default" so picking zero algorithms in a
   category doesn't bench the connection with
   "no matching algorithm".

Overrides apply BEFORE `skipEcdsaHostKey` so the latter stays an
unconditional kill switch even if the user explicitly puts
`ecdsa-*` back into the host-key list.

Behavior with default values (neither toggle set, no override list)
is identical to before — zero change for hosts that aren't opted in.

Tests cover the new options on both `buildAlgorithms` and
`buildSftpAlgorithms`, plus group→host inheritance for both fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): address Codex review on #1116 — proper seed + jump-host carry + missing call sites

Four review findings from the Codex pass on #1116, all real:

1. AlgorithmOverridesPanel was seeding the first customization in a
   category from `SUPPORTED_ALGORITHMS_BY_CATEGORY`, which contains
   legacy algorithms (CBC, arcfour, MD5, ssh-dss, group1-sha1...).
   Unchecking a single modern algorithm in a host that had legacy mode
   *off* would silently start offering those legacy algorithms. Now
   seeds from `effectiveDefaultAlgorithms(legacyEnabled)`, mirroring
   what `buildAlgorithms` actually emits at connect time.

   - New `effectiveDefaultAlgorithms` pure helper in
     `domain/sshAlgorithmList.ts` with its own test suite (also asserts
     the modern subset contains no SHA-1 KEX / CBC / arcfour / MD5)
   - `AlgorithmOverridesPanel` takes a `legacyEnabled` prop
   - `isChecked` now reflects the effective-default state for an
     untouched category, so unchecked rows visually represent
     algorithms the connection wouldn't currently advertise

2. The chain-mode jump-host loop in `sshBridge.cjs` was applying the
   target host's `skipEcdsaHostKey` / `algorithmOverrides` to every
   bastion hop, which is wrong when the bastion has its own settings.
   This was actually a pre-existing issue with `legacyAlgorithms` too
   — `NetcattyJumpHost` simply didn't carry any of these fields.

   - `NetcattyJumpHost` gains `legacyAlgorithms`, `skipEcdsaHostKey`,
     `algorithmOverrides`
   - `createTerminalSessionStarters` populates them from each jump
     host's own configuration
   - The jump-host bridge call now reads `jump.* ?? options.*` so a
     hop with its own setting wins, but unset hops still fall back to
     the target's settings (preserves historic chain-wide behavior
     when nothing is overridden)

3. `infrastructure/services/portForwardingService.ts` only forwarded
   `legacyAlgorithms` to the port-forwarding bridge, so a host that
   needed the ECDSA skip or advanced overrides could connect through
   the terminal but its auto-start tunnels would still hit the
   original handshake failure.

   - Forward `skipEcdsaHostKey` and `algorithmOverrides` at the target
     call site and at the jump-host map.

4. `application/state/sftp/useSftpHostCredentials.ts` built
   `NetcattySSHOptions` for `openSftp` without any of the algorithm
   fields — and on inspection it didn't even forward `legacyAlgorithms`
   to begin with, so SFTP panes for legacy-mode hosts were silently
   negotiating with the modern default list. Same gap at the jump-host
   map.

   - Forward all three fields at both the target and the jump-host map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(hosts): make the advanced-algorithm collapsible trigger a real button

The first cut used a plain underlined caption for the
"Advanced algorithm overrides" collapsible trigger. That blends into
the surrounding helper text and doesn't read as an interactive
control — users couldn't tell that the per-category checkbox editor
was reachable at all.

Match the project's existing collapsible-trigger pattern (see
`SerialConnectModal`): full-width ghost Button, label on the left,
ChevronDown / ChevronUp on the right that flips with open state,
controlled via `useState`. Applied in both `HostDetailsPanel` and
`GroupDetailsPanel`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(hosts): rename Legacy Algorithms card to SSH Algorithms; split Backspace into its own section

The card now carries three algorithm controls (Allow Legacy / Skip
ECDSA / Advanced overrides) plus a Backspace Behavior dropdown that
doesn't belong with the rest. Two cleanups:

1. Rename the card from "Legacy Algorithms" to "SSH Algorithms".
   The original title only described the first toggle; the section
   now covers the whole algorithm-negotiation surface, including the
   ECDSA host-key skip and the per-category override editor.
   - i18n key renamed `hostDetails.section.legacyAlgorithms`
     -> `hostDetails.section.sshAlgorithms` in zh-CN / en / ru
   - `HostDetailsPanel` references the new key

2. Move the Backspace Behavior control out of the algorithm card.
   - `HostDetailsPanel`: new dedicated "Terminal Behavior" card
     (TerminalSquare icon) placed between SSH Algorithms and
     Keepalive. New i18n key `hostDetails.section.terminalBehavior`.
   - `GroupDetailsPanel`: no separate Card scaffolding for group
     defaults — moved Backspace to the bottom of the SSH section
     (after Mosh) so it visually separates from the algorithm block
     above without introducing a new card heading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sftp): per-hop algorithm settings in SFTP chain (Codex review on #1116)

`sshBridge.cjs`'s jump loop already reads `jump.* ?? options.*` for
the three algorithm fields, but `sftpBridge.cjs#connectThroughChainForSftp`
was missed in that change and still applied the target host's
`options.legacyAlgorithms` / `skipEcdsaHostKey` / `algorithmOverrides`
to every bastion. A bastion that needed the ECDSA skip or a custom
algorithm list while the target didn't would still fail the SFTP
handshake before reaching the target.

Mirror the sshBridge fix: read each setting from the jump host first,
falling back to the target options when the hop didn't override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): keychain SSH exec + advanced editor seed honor inherited algorithm settings

Two findings from a local Codex review pass on the branch:

1. The keychain "export public key to host" flow opens its own one-off
   SSH connection through `sshBridge#execCommand`, but the connectOpts
   built there didn't include any `algorithms`. ssh2 then negotiated
   with its built-in modern defaults regardless of what the host had
   set, so a host that needs the ECDSA skip (or legacy mode) would
   connect in the terminal but the keychain export would fail with
   the original signature-verification error.

   - `sshBridge.cjs#execCommand` now sets `algorithms` from
     `payload.legacyAlgorithms` / `skipEcdsaHostKey` /
     `algorithmOverrides`, mirroring `startSSHSession`.
   - `useKeychainBackend`'s `execCommand` typing gets the three new
     optional fields.
   - `KeychainManager` forwards the host's `legacyAlgorithms`,
     `skipEcdsaHostKey`, and `algorithms` when invoking the export.

2. The Advanced Algorithm Overrides editor seeded its first
   customization from `form.legacyAlgorithms`, which is the host's
   own field — it doesn't reflect a value the host *inherits* from
   its group's default. A user in a group with `legacyAlgorithms=true`
   editing a host that hadn't explicitly set the flag would see the
   editor seed in modern-only mode, and saving could silently drop
   the legacy algorithms the host actually needed.

   - `HostDetailsPanel` passes `form.legacyAlgorithms ?? groupDefaults?.legacyAlgorithms`.
   - `GroupDetailsPanel` adds an `inheritedLegacyAlgorithms` memo
     resolved from the parent group chain via `resolveGroupDefaults`,
     and the editor uses `form.legacyAlgorithms ?? inheritedLegacyAlgorithms`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(keychain): export-public-key honors group-inherited algorithm settings

Second-pass Codex review on PR #1116 flagged that the keychain
"export public key to host" flow now carries the three algorithm
fields end-to-end, but only reads them from `exportHost.*` directly —
which doesn't reflect values the host inherits from its group's
defaults. A host that left `legacyAlgorithms` / `skipEcdsaHostKey` /
`algorithms` unset but sat inside a group that turned them on would
work fine in the terminal (the terminal starter applies group
defaults before sending the IPC payload) but its keychain export
would silently fall back to ssh2's modern defaults and hit the
original signature-verification failure.

Resolve the effective host with `applyGroupDefaults` +
`resolveGroupDefaults` before the `execCommand` call, then read the
algorithm fields off the effective host. Requires plumbing
`groupConfigs` into `KeychainManager` (added as an optional prop,
forwarded by `VaultView`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(host-details): algorithm-overrides editor reads currently selected group's defaults

Third-pass Codex review caught that the editor seed was reading from
the `groupDefaults` prop, which is whatever group the host belonged
to when the panel opened. If a user switched the host into a different
group inside the panel and then opened the Advanced Algorithm
Overrides collapsible before saving, the editor would seed from the
old group's `legacyAlgorithms` flag and could save the wrong list.

The panel already memoizes `effectiveGroupDefaults` from
`form.group`/`defaultGroup`/`groupConfigs` for exactly this kind of
re-resolution (used by theme/font effective lookups). Read the
inherited flag from there instead of the stale prop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(algorithms): trim UI cipher list to algorithms ssh2 actually supports

Fourth-pass Codex review flagged that `SUPPORTED_CIPHER_ALGORITHMS`
included `blowfish-cbc`, `cast128-cbc`, and the `arcfour*` family,
but OpenSSL 3 disabled those primitives, so ssh2's `canUseCipher`
filter drops them from `SUPPORTED_CIPHER` at startup. Selecting any
of them in the Advanced Algorithm Overrides editor would make
`ssh2.Client.connect()` throw `Unsupported algorithm` synchronously,
turning the override into a "host now unreachable" footgun instead
of a narrowing knob.

Realigned each `SUPPORTED_*_ALGORITHMS` list to ssh2's actual
`SUPPORTED_*` constants:

- `SUPPORTED_CIPHER_ALGORITHMS`: dropped blowfish / cast128 / arcfour;
  added the no-suffix `aes128-gcm` / `aes256-gcm` variants ssh2 also
  accepts.
- `SUPPORTED_KEX_ALGORITHMS`: added `diffie-hellman-group15-sha512`
  and `diffie-hellman-group17-sha512` (present in ssh2 but missing
  from the UI list); reordered to ssh2's canonical order.
- `SUPPORTED_HMAC_ALGORITHMS`: reordered so the ETM/SHA-2 grouping
  matches ssh2's `DEFAULT_MAC` and lookups are predictable.

Locked the invariant in `sshAlgorithmList.test.ts` with a new test
that asserts every UI-offered algorithm is a member of ssh2's
`SUPPORTED_*` for its category. A future ssh2 bump that drops an
algorithm we still expose will fail this test instead of silently
becoming a connect-time error for users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(algorithms): run KEX override through the runtime fixed-DH filter

Codex round-N flagged a gap in `applyAlgorithmOverrides`: when the
user supplies a custom KEX list via the advanced editor, the previous
implementation copied it verbatim into the negotiated `algorithms.kex`
field. The default builder already passes its KEX list through
`filterSupportedFixedDhKex` to drop fixed-DH groups the runtime
doesn't support (notably `diffie-hellman-group1-sha1` on
Electron/BoringSSL, which lacks modp2), but the override path
bypassed that filter — so an Electron user enabling legacy mode and
saving any KEX checkbox state would re-advertise group1-sha1 and the
handshake would crash with "Unknown DH group" instead of failing fast.

Apply the same `filterSupportedFixedDhKex` to the override list. New
test in `sshAlgorithms.test.cjs` exercises a simulated BoringSSL
runtime that lacks modp2 and asserts the override-filtered KEX no
longer includes group1-sha1, while group14-sha1 / group-exchange-sha1
remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(algorithms): run HMAC override through the FIPS MD5 filter

Codex flagged the same runtime-bypass pattern Round-N-1 fixed for KEX,
now in HMAC: `applyLegacyHmacAlgorithms` gates `hmac-md5` behind
`md5Supported()` so FIPS Node builds don't get it, but the UI's
`effectiveDefaultAlgorithms(true)` seed adds `hmac-md5` /
`hmac-md5-96` unconditionally. A user with legacy mode on who saves
any HMAC checkbox change would push those MD5 entries through
`applyAlgorithmOverrides`, which previously copied the override
verbatim — bypassing the FIPS gate and making ssh2 throw
"Unsupported algorithm" before negotiation.

New `filterRuntimeUnsupportedHmac` helper applies the same
`md5Supported()` gate to a user override. `applyAlgorithmOverrides`
routes the HMAC override through it (mirrors how the same function
already routes KEX overrides through `filterSupportedFixedDhKex`).
New test simulates a FIPS-disabled MD5 runtime and asserts MD5
variants drop from the final HMAC list while SHA-1 / SHA-2 entries
remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): jump-host overrides no longer inherit the leaf's algorithm overrides

Codex flagged that the jump-loop fallback I added for chain
convenience applied the target's per-host \`algorithmOverrides\` to
every bastion whose own override wasn't set, which is wrong: a target
restricted to e.g. \`serverHostKey: [\"ssh-rsa\"]\` would lock the hop
to ssh-rsa too and break negotiation against an Ed25519-only bastion.

The fallback IS still correct for \`legacyAlgorithms\` and
\`skipEcdsaHostKey\` — those are append/safety toggles that widen the
offered list, so propagating them to a bastion is safe and matches
the historic chain-wide behavior of \`options.legacyAlgorithms\`
(single-toggle convenience for a chain with one old leaf).

Treat \`algorithmOverrides\` strictly per-host instead. Same change in
both \`sshBridge.cjs\` and \`sftpBridge.cjs\` jump loops, with a
comment block explaining the asymmetry so a future refactor doesn't
"clean up" the distinction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(host-details): surface inherited algorithm overrides in the advanced editor

Codex flagged a real gap in the inheritance model: when a parent
group has set algorithm overrides (e.g. \`algorithms.kex: [...]\`),
a host or child group under it can't simply Reset a category back to
NetCatty's defaults — \`applyGroupDefaults\` treats an unset host
field as "inherit", so the local Reset falls back to the group's
list rather than to ssh2's defaults. Cleanly distinguishing "reset
to NetCatty defaults" from "inherit from group" needs a new schema
field or sentinel, which is a non-trivial design change well beyond
the scope of this PR.

For now, surface the situation in the UI so the user understands
why Reset doesn't behave the way they might expect and where to go
to actually clear the restriction:

- \`AlgorithmOverridesPanel\` accepts an optional \`inheritedFromGroup\`
  prop and, when populated, renders a blue inline notice listing the
  inherited categories and directing the user to the group's
  algorithm settings if they need to opt out.
- \`HostDetailsPanel\` passes \`effectiveGroupDefaults?.algorithms\`.
- \`GroupDetailsPanel\` adds a new \`inheritedAlgorithmOverrides\`
  memo that resolves the same way the existing
  \`inheritedLegacyAlgorithms\` does, and forwards it.
- i18n strings added in zh-CN / en / ru.

Follow-up (out of scope for this PR): if real users do hit this,
introduce a \`host.algorithms = null\` (or explicit
\`algorithmsOverride: boolean\`) sentinel and a Reset-to-defaults
button that uses it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): jump-host skipEcdsaHostKey is per-host; panel seeds from inherited overrides

Three findings from the latest Codex pass:

1 & 2. `skipEcdsaHostKey` on the leaf was still falling back onto
   jump hosts in both `sshBridge.cjs` and `sftpBridge.cjs`. I had
   classified it with `legacyAlgorithms` as a "safety widening" knob,
   but Codex is right that it actually *narrows* the offered host-key
   list by dropping every `ecdsa-sha2-*`. An ECDSA-only bastion (or
   one where the operator pinned ECDSA via known_hosts) would still
   negotiate when ECDSA is offered, but fails when the leaf's skip is
   propagated to it. Same fix as `algorithmOverrides` last round:
   strictly per-host. Only `legacyAlgorithms` keeps the chain-wide
   fallback (append-only — it widens the offer, can never break a
   hop that wasn't already failing).

3. `AlgorithmOverridesPanel` was seeding the first customization of a
   category from the NetCatty/legacy effective default, ignoring any
   list the host inherits from its group for OTHER categories.
   Because `applyGroupDefaults` treats `host.algorithms` as an
   all-or-nothing inherit boundary, the moment the user saved any
   host-local override `{ cipher: [...] }`, the group's
   `{ serverHostKey: ["ssh-rsa"] }` restriction silently dropped from
   the effective host. Now:
   - `toggleAlgorithm`'s first-click seed reads
     `inheritedFromGroup?.[category] ?? effectiveDefault[category]`,
     so customizing one category preserves the group's narrowing on
     the others.
   - `updateCategory` initializes `next` from
     `{ ...inheritedFromGroup, ...value }` so saved overrides carry
     the inherited categories alongside the host's own edits.
   - `isChecked` reflects the inherited list when there's no local
     value, so the visible checkbox state matches what would actually
     be advertised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(host-details): Reset preserves the inherited list instead of silently widening

Codex caught a follow-on bug from the previous "carry inheritance"
fix: pressing Reset on a category that's inherited from the group
deleted that category from \`host.algorithms\` while other host-local
or carried-inherited categories remained. Because
\`applyGroupDefaults\` treats \`host.algorithms\` as all-or-nothing,
the moment any host-local entry exists the group's \`algorithms\`
object stops being inherited as a whole — so the freshly-deleted
category fell back to NetCatty defaults rather than the group's
narrower list. Effective result: Reset on an inherited category
*widened* the offer instead of restoring it.

Reset now persists the inherited list verbatim onto the host when
the group has an override for that category, so "Reset" means "use
what this host would otherwise inherit" in all cases.

Also tightened \`isCustomized\` to suppress the "customized" badge
(and the per-category Reset button) when the host's stored list is
identical to the inherited list — those rows haven't really been
customized by the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(host-details): gate the inherited-notice / checkbox baseline on no host override

Codex caught the asymmetry between the read and write sides of the
panel. The write side (`updateCategory`, `toggleAlgorithm`, Reset)
intentionally carries `inheritedFromGroup` onto the host on the first
edit so the runtime's all-or-nothing inherit boundary in
`applyGroupDefaults` doesn't silently widen the offer. But the read
side (the inherited-notice banner and the `isChecked` baseline for
unfilled categories) was applying inherited values *unconditionally*
— including when the host already had any local `algorithms` object,
which makes `applyGroupDefaults` stop inheriting from the group as a
whole. Net effect on a host that only locally overrode `cipher`: the
UI claimed the group's `serverHostKey` restriction was still in
effect, while the runtime would actually use NetCatty's modern
defaults for that category.

Introduce `inheritedForDisplay`, defined as `value === undefined ?
inheritedFromGroup : undefined`, and route the notice + the
`isChecked` baseline through it. The write side keeps consulting the
unconditional `inheritedFromGroup` so the carry-over still happens on
first edit — that part is what makes the runtime behavior match what
the UI used to advertise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(host-details): legacy/skipEcdsa toggles reflect group-inherited value

Codex caught that the Skip ECDSA toggle in both the host and group
panels read `form.skipEcdsaHostKey` directly, so a host whose group
turned the flag on (and `applyGroupDefaults` therefore applied it to
the runtime SSH negotiation) still saw the toggle rendered as off.
Worse, clicking it computed `!form.skipEcdsaHostKey` — which is `!undefined`
= `true` — so the first click could not actually disable the
inherited setting on a per-host basis; it would just store `true`
explicitly, the same effective state.

The Allow Legacy Algorithms toggle had the same pre-existing issue.
Fix both at once: each toggle now derives its enabled state from
`form.<field> ?? <inherited>` and the onToggle handler flips that same
effective value, so the toggle accurately represents what the runtime
would do and a single click off correctly stores `false`
(which `applyGroupDefaults` then leaves alone, breaking inheritance
for this host).

- `HostDetailsPanel` reads from `effectiveGroupDefaults`.
- `GroupDetailsPanel` adds an `inheritedSkipEcdsaHostKey` memo
  alongside the existing `inheritedLegacyAlgorithms` and uses both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:12:42 +08:00

240 lines
7.8 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { applyGroupDefaults, resolveGroupDefaults, sanitizeGroupConfig } from "./groupConfig.ts";
import { resolveTelnetPassword, resolveTelnetUsername } from "./host.ts";
import type { GroupConfig, Host } from "./models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
...overrides,
});
test("applyGroupDefaults lets a host proxy profile override a group custom proxy", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 3128 },
};
const result = applyGroupDefaults(host({ proxyProfileId: "proxy-1" }), groupDefaults);
assert.equal(result.proxyProfileId, "proxy-1");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults lets a host custom proxy override a group proxy profile", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyProfileId: "group-proxy",
};
const customProxy = { type: "socks5" as const, host: "host-proxy.example.com", port: 1080 };
const result = applyGroupDefaults(host({ proxyConfig: customProxy }), groupDefaults);
assert.equal(result.proxyProfileId, undefined);
assert.deepEqual(result.proxyConfig, customProxy);
});
test("resolveGroupDefaults treats saved and custom proxies as one inherited setting", () => {
const resolved = resolveGroupDefaults("prod/api", [
{
path: "prod",
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
},
{
path: "prod/api",
proxyProfileId: "child-proxy",
},
]);
assert.equal(resolved.proxyProfileId, "child-proxy");
assert.equal(resolved.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile instead of using group proxy", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyProfileId: "group-proxy",
};
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
groupDefaults,
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile when no group fallback exists", () => {
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
{},
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile instead of using group custom proxy", () => {
const groupProxy = { type: "http" as const, host: "group-proxy.example.com", port: 3128 };
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
{ proxyConfig: groupProxy },
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("resolveGroupDefaults keeps a missing group proxy marker when there is no fallback", () => {
const resolved = resolveGroupDefaults(
"prod",
[{ path: "prod", proxyProfileId: "missing-proxy" }],
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(resolved.proxyProfileId, "missing-proxy");
});
test("applyGroupDefaults inherits a missing group proxy marker so connect paths can fail", () => {
const result = applyGroupDefaults(
host({ group: "prod" }),
{ proxyProfileId: "missing-proxy" },
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("resolveGroupDefaults keeps missing child proxy profiles instead of using parent proxy", () => {
const resolved = resolveGroupDefaults(
"prod/api",
[
{
path: "prod",
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
},
{
path: "prod/api",
proxyProfileId: "missing-proxy",
},
],
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(resolved.proxyProfileId, "missing-proxy");
assert.equal(resolved.proxyConfig, undefined);
});
test("applyGroupDefaults preserves explicitly cleared telnet credentials", () => {
const result = applyGroupDefaults(
host({
username: "ssh-user",
password: "ssh-password",
telnetUsername: "",
telnetPassword: "",
}),
{
telnetUsername: "group-telnet-user",
telnetPassword: "group-telnet-password",
},
);
assert.equal(result.telnetUsername, "");
assert.equal(result.telnetPassword, "");
assert.equal(resolveTelnetUsername(result), "");
assert.equal(resolveTelnetPassword(result), "");
});
test("applyGroupDefaults still inherits telnet credentials when host fields are unset", () => {
const result = applyGroupDefaults(
host({
username: "ssh-user",
password: "ssh-password",
}),
{
telnetUsername: "group-telnet-user",
telnetPassword: "group-telnet-password",
},
);
assert.equal(result.telnetUsername, "group-telnet-user");
assert.equal(result.telnetPassword, "group-telnet-password");
assert.equal(resolveTelnetUsername(result), "group-telnet-user");
assert.equal(resolveTelnetPassword(result), "group-telnet-password");
});
test("applyGroupDefaults continues to inherit empty ssh username from the group", () => {
const result = applyGroupDefaults(
host({
username: "",
}),
{
username: "group-ssh-user",
},
);
assert.equal(result.username, "group-ssh-user");
});
test("sanitizeGroupConfig migrates a deprecated fontFamily and clears the override flag", () => {
// Regression guard for codex P2 review on PR #940: groups saved with
// pingfang-sc / microsoft-yahei / comic-sans-ms must shed the
// override so member hosts inherit the global default instead of
// silently falling through to fonts[0] under an enabled override.
const before: GroupConfig = {
path: "team",
fontFamily: "pingfang-sc",
fontFamilyOverride: true,
};
const after = sanitizeGroupConfig(before);
assert.equal(after.fontFamily, undefined);
assert.equal(after.fontFamilyOverride, false);
});
test("sanitizeGroupConfig keeps a still-valid fontFamily untouched", () => {
const before: GroupConfig = {
path: "team",
fontFamily: "jetbrains-mono",
fontFamilyOverride: true,
};
const after = sanitizeGroupConfig(before);
assert.equal(after.fontFamily, "jetbrains-mono");
assert.equal(after.fontFamilyOverride, true);
});
test("applyGroupDefaults inherits skipEcdsaHostKey from the group when host has no value", () => {
const result = applyGroupDefaults(host(), { skipEcdsaHostKey: true });
assert.equal(result.skipEcdsaHostKey, true);
});
test("applyGroupDefaults keeps host-level skipEcdsaHostKey instead of group default", () => {
const result = applyGroupDefaults(
host({ skipEcdsaHostKey: false }),
{ skipEcdsaHostKey: true },
);
assert.equal(result.skipEcdsaHostKey, false);
});
test("applyGroupDefaults inherits algorithm overrides from the group", () => {
const overrides = { serverHostKey: ["ssh-rsa", "ssh-dss"] };
const result = applyGroupDefaults(host(), { algorithms: overrides });
assert.deepEqual(result.algorithms, overrides);
});
test("applyGroupDefaults keeps host algorithm overrides instead of inheriting", () => {
const hostOverrides = { kex: ["curve25519-sha256"] };
const groupOverrides = { kex: ["diffie-hellman-group14-sha256"] };
const result = applyGroupDefaults(
host({ algorithms: hostOverrides }),
{ algorithms: groupOverrides },
);
assert.deepEqual(result.algorithms, hostOverrides);
});