feat: manage existing credential actions with audited replay

This commit is contained in:
2026-11-01 19:10:37 +08:00
parent baa81c4923
commit 98ad60c0b8
18 changed files with 1350 additions and 53 deletions

View File

@@ -55,3 +55,167 @@ test('时间边界验证、流程范围与无队列保护', () => {
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);
});