117 lines
7.7 KiB
JavaScript
117 lines
7.7 KiB
JavaScript
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('已暂停后台重试'));
|
||
});
|