222 lines
15 KiB
JavaScript
222 lines
15 KiB
JavaScript
const { test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const vm = require('node:vm');
|
||
const context = vm.createContext({ URLSearchParams });
|
||
vm.runInContext(fs.readFileSync(require('node:path').join(__dirname, '../internal/transport/httpserver/console/console-core.js'), 'utf8'), context);
|
||
const { listURL, readFilters, ListPage } = context.OrbitConsole;
|
||
|
||
test('精确业务键、筛选和游标跨页保持,不静默扩展查询范围', () => {
|
||
const page = new ListPage();
|
||
page.reset({ business_key: '订单 +%_ ', state: 'NEEDS_REVIEW', created_from: '2026-09-01T00:00:00.000Z' });
|
||
assert.ok(page.accept(page.begin(), { next_cursor: 'opaque+/=' }));
|
||
assert.ok(page.move('next'));
|
||
const query = new URL(listURL('tasks', 'queue-1', page.filters, page.cursor), 'http://localhost').searchParams;
|
||
assert.equal(query.get('business_key'), '订单 +%_ ');
|
||
assert.equal(query.get('state'), 'NEEDS_REVIEW');
|
||
assert.equal(query.get('queue_id'), 'queue-1');
|
||
assert.equal(query.get('cursor'), 'opaque+/=');
|
||
assert.equal(page.limit, 10);
|
||
assert.equal(query.get('limit'), '10');
|
||
assert.equal(new URL(listURL('tasks', 'queue-1', page.filters, page.cursor, 50), 'http://localhost').searchParams.get('limit'), '50');
|
||
assert.ok(page.move('prev'));
|
||
assert.equal(page.cursor, '');
|
||
assert.equal(page.filters.state, 'NEEDS_REVIEW');
|
||
});
|
||
test('查询重置后迟到的旧页面不会覆盖新条件和翻页状态', () => {
|
||
const page = new ListPage();
|
||
const slow = page.begin();
|
||
page.reset({ state: 'FAILED' });
|
||
const latest = page.begin();
|
||
assert.equal(page.accept(slow, { next_cursor: 'wrong' }), false);
|
||
assert.equal(page.loading, true);
|
||
assert.equal(page.move('next'), false);
|
||
assert.equal(page.accept(latest, {}), true);
|
||
assert.equal(page.next, '');
|
||
assert.equal(page.filters.state, 'FAILED');
|
||
});
|
||
test('失败保留原查询与原页,不自动回到无筛选列表', () => {
|
||
const page = new ListPage();
|
||
page.reset({ state: 'RUNNING' });
|
||
page.accept(page.begin(), { next_cursor: 'page-2' });
|
||
page.move('next');
|
||
assert.equal(page.fail(page.begin()), true);
|
||
assert.equal(page.cursor, 'page-2');
|
||
assert.equal(page.history.length, 1);
|
||
assert.equal(page.filters.state, 'RUNNING');
|
||
assert.equal(page.next, '');
|
||
});
|
||
test('时间边界验证、流程范围与无队列保护', () => {
|
||
assert.throws(() => readFilters({ created_from: '2026-09-29T12:00', created_before: '2026-09-29T11:00' }), /结束时间/);
|
||
assert.throws(() => readFilters({ created_from: 'not a date' }), /有效/);
|
||
const f = readFilters({ created_from: '2026-09-29T12:00:00+08:00', business_key: ' key ' });
|
||
assert.equal(f.created_from, '2026-09-29T04:00:00.000Z');
|
||
assert.equal(f.business_key, ' key ');
|
||
assert.throws(() => listURL('tasks', ''), /选择队列/);
|
||
assert.equal(new URL(listURL('workflows', 'ignored'), 'http://localhost').searchParams.has('queue_id'), false);
|
||
});
|
||
|
||
// 用页面原始 HTML 构建轻量 DOM,执行 applications.js 的真实点击与提交处理器。
|
||
// 不引入浏览器依赖;网络边界独立模拟,断言最终请求及用户可见状态。
|
||
function applicationsHarness({ actions = ['tasks:read', 'workers:connect'], revoked = false, loseApply = false } = {}) {
|
||
const decode = text => text.replace(/&(amp|lt|gt|quot|#39);/g, (_, key) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[key]));
|
||
class Element {
|
||
constructor(tag = '', attrs = {}) {
|
||
this.tag = tag; this.attrs = attrs; this.children = []; this.parent = null; this.listeners = {};
|
||
this.value = attrs.value || ''; this.defaultValue = this.value;
|
||
this.checked = 'checked' in attrs; this.defaultChecked = this.checked;
|
||
this.disabled = 'disabled' in attrs; this.hidden = 'hidden' in attrs; this.open = false; this.text = '';
|
||
this.dataset = Object.fromEntries(Object.entries(attrs).filter(([k]) => k.startsWith('data-')).map(([k, v]) => [k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()), v]));
|
||
this.classList = { toggle() {} };
|
||
}
|
||
matches(selector) {
|
||
const id = selector.match(/#([\w-]+)/)?.[1], cls = selector.match(/\.([\w-]+)/)?.[1], tag = selector.match(/^[\w-]+/)?.[0];
|
||
return (!id || this.attrs.id === id) && (!cls || (this.attrs.class || '').split(' ').includes(cls)) && (!tag || this.tag === tag) &&
|
||
(!selector.includes(':checked') || this.checked) && [...selector.matchAll(/\[([\w-]+)\]/g)].every(([, a]) => a === 'open' ? this.open : a in this.attrs);
|
||
}
|
||
querySelectorAll(selector) {
|
||
const all = this.children.flatMap(c => [c, ...c.querySelectorAll('*')]);
|
||
return all.filter(el => selector.split(',').some(s => {
|
||
const parts = s.trim().split(/\s+/); if (!el.matches(parts.pop())) return false;
|
||
let ancestor = el.parent;
|
||
while (parts.length) { const part = parts.pop(); while (ancestor && !ancestor.matches(part)) ancestor = ancestor.parent; if (!ancestor) return false; ancestor = ancestor.parent; }
|
||
return true;
|
||
}));
|
||
}
|
||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||
closest(selector) { return selector.split(',').some(s => this.matches(s)) ? this : this.parent?.closest(selector); }
|
||
set innerHTML(html) { this.html = html; this.children = []; parse(html, this); }
|
||
get innerHTML() { return this.html || ''; }
|
||
set textContent(text) { this.text = String(text); this.children = []; }
|
||
get textContent() { return this.text + this.children.map(c => c.textContent).join(''); }
|
||
get elements() { return this.querySelectorAll('input,select,button,textarea'); }
|
||
reset() { this.elements.forEach(el => { el.value = el.defaultValue; el.checked = el.defaultChecked; }); }
|
||
addEventListener(name, handler) { (this.listeners[name] ||= []).push(handler); }
|
||
async fire(name, target = this) { for (const handler of this.listeners[name] || []) await handler({ target, preventDefault() {} }); }
|
||
showModal() { this.open = true; }
|
||
close() { this.open = false; }
|
||
focus() { document.activeElement = this; }
|
||
}
|
||
function parse(html, root) {
|
||
const stack = [root];
|
||
for (const token of html.matchAll(/<!--[\s\S]*?-->|<[^>]+>|[^<]+/g)) {
|
||
const raw = token[0]; if (raw.startsWith('<!--') || raw.startsWith('<!')) continue;
|
||
const tag = raw.match(/^<(\/?)([\w-]+)/);
|
||
if (!tag) { stack.at(-1).text += decode(raw); continue; }
|
||
if (tag[1]) { if (stack.at(-1).tag === tag[2]) stack.pop(); continue; }
|
||
const attrs = Object.fromEntries([...raw.slice(tag[0].length).matchAll(/([\w:-]+)(?:="([^"]*)")?/g)].map(([, k, v]) => [k, decode(v || '')]));
|
||
const element = new Element(tag[2], attrs); element.parent = stack.at(-1); element.parent.children.push(element);
|
||
if (element.tag === 'option' && element.parent.tag === 'select' && (element.parent.children.length === 1 || 'selected' in attrs)) element.parent.value = element.parent.defaultValue = element.value;
|
||
if (!['input', 'meta', 'link', 'br', 'hr', 'img', 'path'].includes(element.tag) && !raw.endsWith('/>')) stack.push(element);
|
||
}
|
||
}
|
||
const document = new Element('document');
|
||
document.getElementById = id => { const el = document.querySelector(`#${id}`); assert.ok(el, `HTML 缺少 ${id}`); return el; };
|
||
const base = require('node:path').join(__dirname, '../internal/transport/httpserver/console/');
|
||
parse(fs.readFileSync(base + 'index.html', 'utf8'), document);
|
||
const detail = {
|
||
entry: { application: 'sample-service', environment: 'test', namespace: 'default', namespace_id: 'ns-one', application_id: 'app-one', enabled: true },
|
||
queues: [{ id: 'queue-one', name: 'primary' }, { id: 'queue-two', name: 'secondary' }],
|
||
credentials: [{ id: 'cred-one', name: 'main', revision: '7', token_stored: true, expires_at: '2030-01-01T00:00:00Z', previous_valid_until: '2029-01-01T00:00:00Z', ...(revoked ? { revoked_at: '2026-01-01T00:00:00Z' } : {}), permissions: { actions, queues: ['queue-two'] } }]
|
||
};
|
||
const requests = [], window = { addEventListener() {}, confirm: () => true };
|
||
let failed = false;
|
||
const sandbox = vm.createContext({ document, window, location: { origin: 'http://localhost', replace() {} }, performance: { now: () => 0 }, URL, localStorage: { getItem() { return null; } }, fetch: async (path, options) => {
|
||
const body = options.body ? JSON.parse(options.body) : undefined;
|
||
requests.push({ path, body });
|
||
let data;
|
||
if (path === '/console/applications') data = { items: [detail.entry] };
|
||
else if (path === '/console/applications/ns-one') data = detail;
|
||
else if (path === '/console/applications/prepare') data = { version: body.kind === 'SET_PERMISSIONS' ? 3 : 2, operation_id: 'op-fixed', namespace_id: body.namespace_id, credential_id: body.credential_id, input: body };
|
||
else if (path === '/console/applications/apply') {
|
||
detail.credentials[0].revision = '8';
|
||
if (body.input.actions) detail.credentials[0].permissions.actions = [...body.input.actions];
|
||
if (loseApply && !failed) { failed = true; throw new Error('响应连接中断'); }
|
||
data = {};
|
||
} else throw new Error(`意外请求:${path}`);
|
||
return { ok: true, status: 200, json: async () => ({ data: JSON.parse(JSON.stringify(data)) }) };
|
||
} });
|
||
vm.runInContext(fs.readFileSync(base + 'applications.js', 'utf8'), sandbox);
|
||
const get = id => document.getElementById(id);
|
||
return {
|
||
get, requests, detail,
|
||
load: () => window.orbitApplications.load(),
|
||
open: async (action = 'permissions') => { const button = get('access-credentials').querySelector(`[data-access-${action}]`); assert.ok(button, `缺少 ${action} 操作`); await get('access-credentials').fire('click', button); },
|
||
options: () => get('access-permission-options').querySelectorAll('input'),
|
||
submit: () => get('access-form').fire('submit')
|
||
};
|
||
}
|
||
|
||
test('凭据权限弹窗展示完整动作目录,默认保留精确权限和只读队列', async () => {
|
||
const h = applicationsHarness(); await h.load(); await h.open();
|
||
assert.equal(h.get('access-dialog').open, true);
|
||
assert.equal(h.get('access-permission-editor').hidden, false);
|
||
assert.deepEqual(h.options().filter(el => el.checked).map(el => el.value).sort(), ['tasks:read', 'workers:connect']);
|
||
const authSource = fs.readFileSync(require('node:path').join(__dirname, '../platform/auth/token.go'), 'utf8');
|
||
const catalog = [...authSource.match(/var supported = map\[string\]bool\{([^}]+)\}/)[1].matchAll(/"([^"]+)": true/g)].map(([, a]) => a).sort();
|
||
assert.deepEqual(h.options().map(el => el.value).sort(), catalog, '新增服务端动作必须同步精确选择目录');
|
||
assert.match(h.get('access-permission-options').textContent, /高级管理 · 人工恢复与审计/);
|
||
assert.equal(h.get('access-permission-current').textContent, 'tasks:read · workers:connect');
|
||
assert.equal(h.get('access-permission-queues').textContent, 'queue-two');
|
||
assert.equal(h.get('access-permission-queues').querySelectorAll('input,select').length, 0);
|
||
assert.equal(h.get('access-valid-field').hidden, true);
|
||
assert.equal(h.get('access-grace-field').hidden, true);
|
||
assert.equal(h.get('access-token-once-note').hidden, true);
|
||
});
|
||
|
||
test('调整权限提交精确全量集合,响应丢失后原操作重放并刷新详情,不读取 Token', async () => {
|
||
const h = applicationsHarness({ loseApply: true }); await h.load(); await h.open();
|
||
h.options().forEach(el => { el.checked = ['tasks:read', 'schedules:read', 'schedules:manage'].includes(el.value); });
|
||
h.get('access-reason').value = '启用计划管理';
|
||
h.get('access-valid-days').value = '365'; h.get('access-grace-days').value = '30';
|
||
await h.submit();
|
||
const prepared = h.requests.find(r => r.path.endsWith('/prepare')).body;
|
||
assert.deepEqual(prepared, { kind: 'SET_PERMISSIONS', valid_days: 0, grace_days: 0, reason: '启用计划管理', expected_revision: '7', namespace_id: 'ns-one', credential_id: 'cred-one', actions: ['schedules:manage', 'schedules:read', 'tasks:read'] });
|
||
assert.equal(h.get('access-submit').textContent, '重试确认 →');
|
||
assert.equal(h.get('access-backup').hidden, false);
|
||
assert.ok(h.options().every(el => el.disabled));
|
||
const fixed = h.requests.find(r => r.path.endsWith('/apply')).body;
|
||
assert.equal(fixed.version, 3); assert.equal(fixed.token, undefined);
|
||
h.options().forEach(el => { el.checked = true; }); h.get('access-reason').value = '不同的原因';
|
||
await h.submit();
|
||
assert.equal(h.requests.filter(r => r.path.endsWith('/prepare')).length, 1);
|
||
assert.deepEqual(h.requests.filter(r => r.path.endsWith('/apply'))[1].body, fixed);
|
||
assert.equal(h.get('access-form').hidden, true);
|
||
assert.equal(h.get('access-result-title').textContent, '权限已更新,原 Token 继续使用');
|
||
assert.equal(h.get('access-result-config-field').hidden, true);
|
||
assert.equal(h.get('access-result-actions').hidden, true);
|
||
assert.equal(h.get('access-result-config').value, '');
|
||
assert.equal(h.requests.filter(r => r.path === '/console/applications/ns-one').length, 2);
|
||
assert.equal(h.requests.some(r => r.path.includes('/token/')), false);
|
||
assert.deepEqual(h.detail.credentials[0].permissions.queues, ['queue-two']);
|
||
assert.equal(h.detail.credentials[0].expires_at, '2030-01-01T00:00:00Z');
|
||
assert.equal(h.detail.credentials[0].previous_valid_until, '2029-01-01T00:00:00Z');
|
||
});
|
||
|
||
test('空权限、未知旧动作、通配符及重复旧动作不能静默提交', async () => {
|
||
const empty = applicationsHarness(); await empty.load(); await empty.open();
|
||
empty.options().forEach(el => { el.checked = false; }); await empty.submit();
|
||
assert.match(empty.get('access-form-error').textContent, /至少保留一项/);
|
||
assert.equal(empty.requests.some(r => r.body), false);
|
||
for (const actions of [['tasks:read', 'future:manage'], ['*'], ['tasks:read', 'tasks:read']]) {
|
||
const h = applicationsHarness({ actions }); await h.load(); await h.open();
|
||
assert.equal(h.get('access-permission-warning').hidden, false);
|
||
assert.equal(h.get('access-submit').disabled, true);
|
||
assert.equal(h.get('access-permission-current').textContent, actions.join(' · '));
|
||
await h.submit();
|
||
assert.equal(h.requests.some(r => r.body), false);
|
||
}
|
||
});
|
||
|
||
test('撤销凭据禁止打开权限调整,其他操作不携带动作字段', async () => {
|
||
const revoked = applicationsHarness({ revoked: true }); await revoked.load(); await revoked.open();
|
||
assert.equal(revoked.get('access-dialog').open, false);
|
||
const h = applicationsHarness(); await h.load(); await h.open(); await h.open('expiry');
|
||
assert.equal(h.get('access-permission-editor').hidden, true);
|
||
assert.ok(h.options().every(el => el.disabled));
|
||
h.get('access-reason').value = '调整有效期'; await h.submit();
|
||
const prepared = h.requests.find(r => r.path.endsWith('/prepare')).body;
|
||
assert.equal(prepared.kind, 'SET_EXPIRY');
|
||
assert.equal(Object.hasOwn(prepared, 'actions'), false);
|
||
});
|