Files
LinkPort/web/tests/persistent-target.test.mjs

117 lines
7.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import {dirname,resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import ts from 'typescript';
import {parse,compileScript} from '@vue/compiler-sfc';
import {createSSRApp,h} from 'vue';
import {renderToString} from '@vue/server-renderer';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
function load(path) {
const module = {exports:{}};
const nativeRequire = createRequire(path);
const require = name => name.startsWith('.') ? load(resolve(dirname(path), /\.(vue|ts)$/.test(name) ? name : `${name}.ts`)) : nativeRequire(name);
let source = readFileSync(path,'utf8');
if (path.endsWith('.vue')) source = compileScript(parse(source,{filename:path}).descriptor,{id:path,inlineTemplate:true}).content;
const output = ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
new Function('require','module','exports',output)(require,module,module.exports);
return module.exports;
}
const {targetDraftPayload,persistentTargetSupport} = load(resolve(root,'domain/targetDraft.ts'));
const {validateTarget} = load(resolve(root,'domain/formValidation.ts'));
const Settings = load(resolve(root,'components/PersistentTargetSettings.vue')).default;
const Status = load(resolve(root,'components/PersistentTargetStatus.vue')).default;
async function settings(value,outbounds=[]) {
let switchProps;
const app = createSSRApp(Settings,{value,outbounds});
app.component('el-form-item',{setup:(_,ctx)=>()=>h('section',ctx.slots.default?.())});
app.component('el-switch',{inheritAttrs:false,setup(_,ctx){switchProps=ctx.attrs;return()=>h('button',{'aria-label':ctx.attrs['aria-label'],disabled:ctx.attrs.disabled},String(ctx.attrs.modelValue));}});
const html = await renderToString(app);
return {html,switchProps};
}
const draft = (type='mysql', extra={}) => ({name:'fixture',type,targetHost:'localhost',targetPort:3306,accounts:[],...extra});
test('legacy missing value stays off; toggle, save payload and reopen preserve it without mutating source',async()=>{
const value = draft();
const initial = await settings(value);
assert.equal(initial.switchProps.modelValue,false);
assert.equal(targetDraftPayload(value).connectionOptions.persistent,false);
initial.switchProps['onUpdate:modelValue'](true);
assert.equal(value.connectionOptions.persistent,true);
const before = JSON.stringify(value);
const payload = targetDraftPayload(value);
assert.equal(JSON.stringify(value),before);
assert.equal(payload.connectionOptions.persistent,true);
const reopened = await settings(JSON.parse(JSON.stringify(payload)));
assert.equal(reopened.switchProps.modelValue,true);
assert.match(reopened.html,/预认证备用池/);
});
test('supported protocol and transport matrix includes probe and unresolved asset',()=>{
for(const type of ['mysql','mongodb','pgsql','redis']) {
assert.equal(persistentTargetSupport(draft(type)).supported,true);
assert.equal(persistentTargetSupport(draft(type)).mode,'session');
assert.equal(persistentTargetSupport(draft(type,{cluster:{mode:'replica-set'}})).mode,'probe');
}
for(const type of ['sqlserver','rabbitmq','http','https','ssh','sftp','tcp']) {
assert.equal(persistentTargetSupport(draft(type)).mode,'probe');
assert.equal(persistentTargetSupport(draft(type,{outbound:{mode:'socks5'}})).supported,true);
}
assert.equal(persistentTargetSupport(draft('unknown',{outbound:{mode:'ssh'}})).supported,false);
for(const mode of ['ssh','k8s']) for(const type of ['mysql','mongodb','pgsql','redis','https','sqlserver','tcp']) {
const support=persistentTargetSupport(draft(type,{outboundId:'asset',cluster:{mode:'cluster'}}),[{config:{id:'asset',outbound:{mode}}}]);
assert.equal(support.supported,true);
assert.equal(support.mode,'tunnel');
}
assert.equal(persistentTargetSupport(draft('mysql',{outboundId:'missing'})).supported,false);
});
test('unknown protocol is disabled with explanation; previously enabled value can be explicitly turned off',async()=>{
const value=draft('unknown');
const disabled=await settings(value);
assert.equal(disabled.switchProps.disabled,true);
assert.match(disabled.html,/不支持此目标协议/);
value.connectionOptions={persistent:true};
const enabled=await settings(value);
assert.equal(enabled.switchProps.disabled,false);
assert.equal(value.connectionOptions.persistent,true);
assert.match(enabled.html,/不会自动关闭/);
enabled.switchProps['onUpdate:modelValue'](false);
assert.equal(value.connectionOptions.persistent,false);
});
test('validation rejects unknown protocol without silently clearing persistent',()=>{
for(const value of [draft('unknown'),draft('mysql',{outboundId:'missing'})]) {
value.connectionOptions={persistent:true};
const issues=validateTarget(value).filter(row=>row.path==='connectionOptions.persistent');
assert.equal(issues.length,1);
assert.equal(issues[0].tab,'advanced');
assert.equal(value.connectionOptions.persistent,true);
}
assert.equal(validateTarget(draft('redis',{connectionOptions:{persistent:true}})).filter(row=>row.path==='connectionOptions.persistent').length,0);
const k8sCluster=draft('mongodb',{cluster:{mode:'replica-set'},outbound:{mode:'k8s'},connectionOptions:{persistent:true}});
assert.equal(validateTarget(k8sCluster).filter(row=>row.path==='cluster').length,1);
assert.match(validateTarget(k8sCluster).find(row=>row.path==='cluster').message,/请选择单节点模式/);
const sshCluster={...k8sCluster,outbound:{mode:'ssh'}};
assert.equal(validateTarget(sshCluster).filter(row=>row.path==='cluster' || row.path==='connectionOptions.persistent').length,0);
});
test('runtime renders all contracted states and cumulative leases, never raw errors or secrets',async()=>{
const targets=['connecting','ready','retrying','rejected','expired','stopped','unknown-secret','constructor','__proto__'].map((state,index)=>({targetId:`target-${index}`,accountId:'account',mode:index%2?'tunnel':'session',state,ready:state==='ready',leases:7,reconnects:2,error:'raw-secret-error',password:'hidden-password'}));
const html=await renderToString(createSSRApp(Status,{targets}));
for(const text of ['正在连接','已就绪','等待重连','已拒绝,暂停重试','凭据已过期','已停止','未知状态','预认证备用池','持续隧道','累计成功复用次数:7','重连次数:2']) assert.ok(html.includes(text),text);
assert.equal((html.match(/未知状态/g)||[]).length,3);
for(const secret of ['raw-secret-error','hidden-password','unknown-secret']) assert.ok(!html.includes(secret));
const empty=await renderToString(createSSRApp(Status,{targets:[]}));
assert.ok(!empty.includes('目标后台持续连接'));
});
test('rejected pauses retries with safe recovery guidance; network retries keep exponential backoff',async()=>{
const target={targetId:'fixture',accountId:'account',mode:'session',ready:false,leases:0,reconnects:1,error:'SCRAM secret raw rejection'};
const rejected=await renderToString(createSSRApp(Status,{targets:[{...target,state:'rejected'}]}));
for(const text of ['已拒绝,暂停重试','避免重复密码登录导致锁号','检查凭据、权限或主机指纹','修正目标配置并保存后按新配置重试']) assert.ok(rejected.includes(text),text);
assert.ok(!rejected.includes('停止并重新启动网关'));
assert.ok(!rejected.includes('1–30'));
assert.ok(!rejected.includes(target.error));
const retrying=await renderToString(createSSRApp(Status,{targets:[{...target,state:'retrying'}]}));
assert.ok(retrying.includes('网络或未知失败按 1–30 秒指数退避重连'));
assert.ok(!retrying.includes('已暂停后台重试'));
});