Files
orbit/scripts/test-console-state.cjs

222 lines
15 KiB
JavaScript
Raw 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.
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);
});