Files
EdgeSSH/tests/ssh-session-failure.test.ts
T
gdz66601 4efa997ad1 fix(ssh): port upstream non-retryable failure handling / 移植上游 SSH 确定性失败处理
Adapt cmliu/CF-Workers-WebSSH b0265afcaf40681066d24cd5d859dc32d73c8863 without importing unrelated network-monitoring changes. Classify asynchronous read-loop failures, stop retries on control messages or close code 4001, and preserve manual reconnect and auxiliary-channel behavior. Add focused backend, reconnect, and browser regressions; accept CRLF in the existing deployment workflow test.
2026-10-02 13:05:19 +08:00

84 lines
3.6 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
// 与 Worker 构建一样解析扩展名省略的导入,在内存中测试真实会话代码。
const result = await build({
stdin: {
contents: "export * from './src/backend/session.ts'; export { SSHPacketBuilder } from './src/ssh/packet.ts';",
resolveDir: fileURLToPath(new URL('..', import.meta.url)),
loader: 'ts',
},
bundle: true, format: 'esm', platform: 'neutral', write: false,
});
const { SSHSession, SSHAuthDefectError, SSHPacketBuilder } = await import(
`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`
);
function fixture(authMethod = 'password', mode?: string) {
const messages: any[] = [];
const closes: number[] = [];
let tcpClosed = false;
const ws = { readyState: WebSocket.OPEN, send: (raw: string) => messages.push(JSON.parse(raw)), close: (code: number) => closes.push(code) };
let controller: ReadableStreamDefaultController<Uint8Array>;
const socket = {
readable: new ReadableStream<Uint8Array>({ start(value) { controller = value; } }),
close() { tcpClosed = true; },
};
const config = { host: '192.0.2.10', port: 22, username: 'root', authMethod, password: 'test-only', privateKey: 'test-only', mode };
const session = new SSHSession(ws, socket, config);
session.phase = 'auth';
session.authRequestSent = true;
return { session, config, messages, closes, push: (data: Uint8Array) => controller!.enqueue(data), tcpClosed: () => tcpClosed };
}
function authFailure(methods: string) {
const value = new TextEncoder().encode(methods);
const payload = new Uint8Array(value.length + 6);
payload[0] = 51;
new DataView(payload.buffer).setUint32(1, value.length);
payload.set(value, 5);
return payload;
}
for (const authMethod of ['password', 'publickey']) {
for (const mode of [undefined, 'forward']) {
test(`async ${authMethod} rejection stops retries in ${mode ?? 'terminal'} mode`, async () => {
const f = fixture(authMethod, mode);
f.push(await SSHPacketBuilder.build(authFailure('publickey,password'), 8, null, 0));
await f.session.readLoop();
assert.deepEqual(f.closes, [4001]);
assert.equal(f.messages.at(-1).retryable, false);
assert.equal(f.messages.at(-1).message, 'SSH authentication failed');
assert.equal(f.tcpClosed(), true);
assert.equal(f.config.password, undefined);
assert.equal(f.config.privateKey, undefined);
});
}
}
test('password authentication still falls back to keyboard-interactive before stopping', async () => {
const f = fixture();
const sent: Uint8Array[] = [];
f.session.sendEncrypted = async (payload: Uint8Array) => { sent.push(payload); };
await f.session.handleAuth(51, authFailure('keyboard-interactive'));
assert.equal(f.session.passwordAuthMethod, 'keyboard-interactive');
assert.equal(sent.length, 1);
assert.equal(f.config.password, 'test-only');
await assert.rejects(f.session.handleAuth(51, authFailure('keyboard-interactive')), SSHAuthDefectError);
});
test('host trust failures stop retries but transport failures keep their existing policy', () => {
for (const message of ['Host key was not accepted', 'SSH host key signature verification failed']) {
const f = fixture();
f.session.fail(new SSHAuthDefectError(message), 'read_error');
assert.equal(f.messages[0].retryable, false);
assert.deepEqual(f.closes, [4001]);
}
const f = fixture();
f.session.fail(new Error('Transport interrupted'), 'read_error');
assert.equal(f.messages[0].retryable, undefined);
assert.deepEqual(f.closes, [1011]);
});