Files
baoyu-skills/skills/baoyu-post-to-wechat/scripts/wechat-remote-publish.test.ts
T
Jim Liu 宝玉 3b29f3c57c feat(baoyu-post-to-wechat): add remote-api publishing via SSH SOCKS5 tunnel
Re-implements PR #156 (remote WeChat API publishing for IP-allowlist
constraints) with a fully local code path: spawn a background `ssh -N -D`
SOCKS5 dynamic forward, wrap a `SocksProxyAgent`, and thread it through
the existing token/upload/draft flow. No Python helper, no SCP, no
remote files, no `AppSecret` ever leaving the local process.

Reusing `uploadImagesInHtml` (which replaces the matched `<img>` fullTag
rather than substring-replacing `src`) naturally avoids the original
PR's `html.replace(src, url)` HTML-replacement bug.

Changes:
- New `wechat-remote-publish.ts`: typed-whitelist SSH args, free-port
  allocation, SOCKS readiness polling, signal-handler-backed cleanup.
- New `wechat-http.ts`: minimal `https.request`-backed HTTP helper so a
  `SocksProxyAgent` can be passed through (undici `fetch` ignores agent).
- New `wechat-image-loader.ts`: extracts `loadUploadAsset` for reuse.
- `wechat-api.ts`: thread optional `agent?` through token/upload/draft;
  add `--remote*` CLI flags; dispatch via `withSshTunnel` when remote
  mode is selected by flag or `default_publish_method: remote-api`.
- `wechat-extend-config.ts`: add typed `remote_publish_*` keys with
  account-over-global fallback and value validation.
- Docs: SKILL.md, references/multi-account.md, references/config/
  first-time-setup.md, README.md, README.zh.md.
- Tests: 17 new node:test cases covering config parsing, SSH arg
  whitelisting, free-port allocation, multipart assembly, and HTTP
  agent threading.

Co-authored-by: Dame5211 <1079825614@qq.com>
2026-05-21 00:59:36 -05:00

186 lines
5.5 KiB
TypeScript

import assert from "node:assert/strict";
import net from "node:net";
import test from "node:test";
import {
buildSshArgs,
findFreePort,
normalizeRemoteConfig,
} from "./wechat-remote-publish.ts";
test("normalizeRemoteConfig requires a host", () => {
assert.throws(
() => normalizeRemoteConfig({ host: "" }),
/Remote publish host is required/,
);
assert.throws(
() => normalizeRemoteConfig({ host: " " }),
/Remote publish host is required/,
);
});
test("normalizeRemoteConfig applies user/port defaults and trims host", () => {
const result = normalizeRemoteConfig({ host: " example.com " });
assert.equal(result.host, "example.com");
assert.equal(result.user, "root");
assert.equal(result.port, 22);
assert.equal(result.identityFile, undefined);
assert.equal(result.knownHostsFile, undefined);
assert.equal(result.strictHostKeyChecking, undefined);
assert.equal(result.connectTimeout, undefined);
assert.equal(result.proxyJump, undefined);
});
test("normalizeRemoteConfig preserves explicit user, port, and SSH options", () => {
const result = normalizeRemoteConfig({
host: "example.com",
user: "deploy",
port: 2222,
identityFile: "/home/me/.ssh/id_ed25519",
knownHostsFile: "/home/me/.ssh/known_hosts",
strictHostKeyChecking: "accept-new",
connectTimeout: 15,
proxyJump: "bastion.example.com",
});
assert.equal(result.user, "deploy");
assert.equal(result.port, 2222);
assert.equal(result.identityFile, "/home/me/.ssh/id_ed25519");
assert.equal(result.knownHostsFile, "/home/me/.ssh/known_hosts");
assert.equal(result.strictHostKeyChecking, "accept-new");
assert.equal(result.connectTimeout, 15);
assert.equal(result.proxyJump, "bastion.example.com");
});
test("normalizeRemoteConfig rejects invalid port", () => {
assert.throws(
() => normalizeRemoteConfig({ host: "example.com", port: 0 }),
/Invalid remote publish port/,
);
assert.throws(
() => normalizeRemoteConfig({ host: "example.com", port: 65536 }),
/Invalid remote publish port/,
);
assert.throws(
() => normalizeRemoteConfig({ host: "example.com", port: 1.5 }),
/Invalid remote publish port/,
);
});
test("normalizeRemoteConfig rejects invalid connect timeout", () => {
assert.throws(
() => normalizeRemoteConfig({ host: "example.com", connectTimeout: 0 }),
/Invalid remote_publish_connect_timeout/,
);
assert.throws(
() => normalizeRemoteConfig({ host: "example.com", connectTimeout: -3 }),
/Invalid remote_publish_connect_timeout/,
);
});
test("normalizeRemoteConfig rejects invalid strict host key checking", () => {
assert.throws(
() =>
normalizeRemoteConfig({
host: "example.com",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
strictHostKeyChecking: "maybe" as any,
}),
/Invalid remote_publish_strict_host_key_checking/,
);
});
test("normalizeRemoteConfig falls back to default user when blank string provided", () => {
const result = normalizeRemoteConfig({ host: "example.com", user: " " });
assert.equal(result.user, "root");
});
test("buildSshArgs emits the whitelisted minimum set", () => {
const args = buildSshArgs(
{ host: "example.com", user: "root", port: 22 },
1080,
);
assert.deepEqual(args, [
"-N",
"-T",
"-D", "127.0.0.1:1080",
"-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-p", "22",
"root@example.com",
]);
});
test("buildSshArgs threads optional ssh options in stable order", () => {
const args = buildSshArgs(
{
host: "example.com",
user: "deploy",
port: 2222,
identityFile: "/p/id_ed25519",
knownHostsFile: "/p/known_hosts",
strictHostKeyChecking: "accept-new",
connectTimeout: 12,
proxyJump: "bastion.example.com",
},
1080,
);
assert.deepEqual(args, [
"-N",
"-T",
"-D", "127.0.0.1:1080",
"-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-p", "2222",
"-i", "/p/id_ed25519",
"-o", "UserKnownHostsFile=/p/known_hosts",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=12",
"-J", "bastion.example.com",
"deploy@example.com",
]);
});
test("buildSshArgs does not emit raw ssh options for unknown fields", () => {
const args = buildSshArgs(
{
host: "example.com",
user: "root",
port: 22,
// No extra unknown keys are accepted — typed config is the whitelist.
},
1080,
);
assert.equal(
args.filter((a) => a === "-o" || a.startsWith("--")).length,
3, // ExitOnForwardFailure, ServerAliveInterval, ServerAliveCountMax (and only those)
"buildSshArgs must only emit the three baseline -o options when no extras given",
);
});
test("buildSshArgs rejects invalid SOCKS port", () => {
assert.throws(
() => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 0),
/Invalid SOCKS port/,
);
assert.throws(
() => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 70_000),
/Invalid SOCKS port/,
);
});
test("findFreePort returns a usable loopback port", async () => {
const port = await findFreePort();
assert.ok(port > 0 && port < 65536, `expected valid port, got ${port}`);
await new Promise<void>((resolve, reject) => {
const server = net.createServer();
server.unref();
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.close((err) => (err ? reject(err) : resolve()));
});
});
});