mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-20 14:37:20 +02:00
An operator running a fork has no honest way to upgrade: upgrade.sh downloads the Release tarball of one hard-coded third-party repository and rsyncs it over the tree, so "upgrade" means "overwrite yourself with somebody else's code", and the safe alternative is to pull by hand and remember the build command. internal/update drives the remote the directory already tracks: fetch, fast-forward, go build into .update-staging, atomic rename of the binary (the previous one kept as cyberstrike-ai.prev), and a .update-state.json so a rollback has a target that is not guessed at. Refusals are the point - local source edits and a diverged branch stop with the file names and commit counts in the error rather than being reset to match, and operator-owned content (roles/skills/tools/agents/data/config.yaml) is put aside before the merge and restored over it, with what was kept listed in keptContent instead of quietly dropped. Consumers: GET/POST /api/system/update* behind update:read / update:apply (mutations are process-global, so they need a global-scope session); a console page at #system-update with an apply button that names why it is blocked, a job polled from the server and a rollback control; the same three actions as -check-update / -update / -update-rollback on the binary; OpenAPI entries for all five endpoints. Permission gating no longer re-enables a control a page disabled for a reason of its own (data-state-disabled), which every state-disabled, permission-gated button in the console was subject to. upgrade.sh becomes a thin shell over the same implementation and only falls back to the tarball path for installs that are not git work trees. Docs and the zh/EN/RU dictionaries are updated. The package runs 13 tests against real temporary git repositories; the HTTP layer, RBAC and the console page each carry their own contract tests.
134 lines
6.3 KiB
JavaScript
134 lines
6.3 KiB
JavaScript
// The platform's permission gate must not re-enable a control a page disabled for a reason
|
|
// of its own.
|
|
//
|
|
// How the bug looked in a real browser: the 一键更新 page rendered an *enabled* apply button
|
|
// directly under the text "有 53 个产品源码文件被本地改过,自动更新不会覆盖它们". Nothing in
|
|
// the page's own tests could see it, because the page really does write `disabled` - the
|
|
// gate then runs over every [data-require-permission] element afterwards and writes
|
|
// `el.disabled = !allowed`, which for an administrator is `false`. Permission says whether a
|
|
// user may ever use a control; it does not say whether the control is ready.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
|
|
const AUTH = fs.readFileSync(path.join(__dirname, 'auth.js'), 'utf8');
|
|
const UPDATE = fs.readFileSync(path.join(__dirname, 'update.js'), 'utf8');
|
|
|
|
function sourceOf(name, src) {
|
|
const at = src.indexOf('function ' + name + '(');
|
|
assert.ok(at >= 0, `${name} is not in auth.js any more - this test is where the gate lives`);
|
|
let depth = 0;
|
|
for (let i = src.indexOf('{', at); i < src.length; i++) {
|
|
if (src[i] === '{') depth++;
|
|
else if (src[i] === '}') {
|
|
depth--;
|
|
if (depth === 0) return src.slice(at, i + 1);
|
|
}
|
|
}
|
|
assert.fail(`${name}: unbalanced`);
|
|
}
|
|
|
|
function element(attrs = {}) {
|
|
const el = {
|
|
hidden: false,
|
|
disabled: false,
|
|
dataset: {},
|
|
_attrs: { ...attrs },
|
|
classList: { classes: new Set(), toggle(c, on) { on ? this.classes.add(c) : this.classes.delete(c); } },
|
|
getAttribute(k) { return this._attrs[k] ?? null; },
|
|
setAttribute(k, v) { this._attrs[k] = String(v); },
|
|
};
|
|
if (attrs['data-state-disabled']) el.dataset.stateDisabled = attrs['data-state-disabled'];
|
|
// Buttons in a DOM have a `disabled` property; a plain object only gets one if the page
|
|
// asked for it, and the gate's `if ('disabled' in el)` is what decides whether state
|
|
// survives - so it is the part worth keeping honest.
|
|
return el;
|
|
}
|
|
|
|
function makeGate(granted) {
|
|
const sandbox = {
|
|
hasPermission: (p) => granted.has(p),
|
|
hasAnyPermission: (list) => list.some((p) => granted.has(p)),
|
|
console,
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(sourceOf('permissionAllowedForElement', AUTH) + '\n' + sourceOf('applyPermissionElement', AUTH), sandbox);
|
|
return sandbox.applyPermissionElement;
|
|
}
|
|
|
|
test('a state-disabled control stays disabled for a user who holds the permission', () => {
|
|
const gate = makeGate(new Set(['update:apply']));
|
|
const el = element({ 'data-require-permission': 'update:apply', 'data-state-disabled': 'true' });
|
|
el.disabled = true;
|
|
gate(el);
|
|
assert.strictEqual(el.disabled, true, 'permission granted must not re-enable a control the page blocked');
|
|
assert.strictEqual(el.getAttribute('aria-disabled'), 'true', 'the assistive state must agree with the real one');
|
|
assert.strictEqual(el.hidden, false);
|
|
});
|
|
|
|
test('a permitted and ready control is enabled, and an unpermitted one is not', () => {
|
|
const gate = makeGate(new Set(['update:apply']));
|
|
const ready = element({ 'data-require-permission': 'update:apply' });
|
|
gate(ready);
|
|
assert.strictEqual(ready.disabled, false);
|
|
assert.strictEqual(ready.getAttribute('aria-disabled'), 'false');
|
|
|
|
// applyPermissionElement returns nothing, so the assertions read the element itself.
|
|
const deniedEl = element({ 'data-require-permission': 'update:apply' });
|
|
makeGate(new Set())(deniedEl);
|
|
assert.strictEqual(deniedEl.hidden, true);
|
|
assert.strictEqual(deniedEl.disabled, true);
|
|
assert.strictEqual(deniedEl.classList.classes.has('rbac-permission-denied'), true);
|
|
});
|
|
|
|
test('every control the update page disables by state carries the marker the gate honors', () => {
|
|
// The other half of the pair: a page that writes `disabled` without the marker silently
|
|
// loses it the moment the gate runs. Asserted over the rendered strings rather than by
|
|
// reading the source, so a refactor that drops the attribute is what fails.
|
|
const sandbox = {
|
|
console,
|
|
escapeHtml: (v) => String(v),
|
|
updateT: (key) => key,
|
|
renderUpdateRestartChoice: () => '',
|
|
renderUpdateChangesTable: () => '<div class="update-changes"></div>',
|
|
isUpdateJobRunning: () => true,
|
|
updateApplyBlockers: (status) => (status.blockingChanges || []).length ? ['blocked'] : [],
|
|
updateChecking: true,
|
|
};
|
|
vm.createContext(sandbox);
|
|
for (const fn of ['renderUpdateApplyBody', 'renderUpdateRollbackBody']) {
|
|
vm.runInContext(sourceOf(fn, UPDATE), sandbox);
|
|
}
|
|
const withBlocker = sandbox.renderUpdateApplyBody(
|
|
{ installed: true, canBuild: true, blockingChanges: [{ path: 'a.go' }], updateAvailable: true, diverged: false }, null);
|
|
const noBlocker = sandbox.renderUpdateApplyBody(
|
|
{ installed: true, canBuild: true, blockingChanges: [], updateAvailable: true, diverged: false }, null);
|
|
const rollbackRunning = sandbox.renderUpdateRollbackBody({ hasRollback: true, rollbackTo: 'abc' });
|
|
|
|
for (const [label, html] of [['apply(blocked)', withBlocker], ['rollback(running)', rollbackRunning]]) {
|
|
const buttons = html.match(/<button[^>]*>/g) || [];
|
|
assert.ok(buttons.length > 0, `${label}: rendered no button`);
|
|
const disabledTags = buttons.filter((t) => / disabled/.test(t));
|
|
assert.ok(disabledTags.length > 0, `${label}: expected a disabled button`);
|
|
for (const tag of disabledTags) {
|
|
assert.match(tag, /data-state-disabled="true"/, `${label}: state-disabled must carry the marker the gate reads: ${tag}`);
|
|
assert.match(tag, /data-require-permission=/, `${label}: the marker only matters on a permission-gated control: ${tag}`);
|
|
}
|
|
}
|
|
|
|
// And the *apply* control of an unblocked install must not advertise itself as blocked,
|
|
// or the gate would keep it dead for someone who can legitimately press it. Other buttons
|
|
// in the same render may still be state-disabled (checking is one), which is why this
|
|
// asserts about the apply button by name.
|
|
const applyTag = (noBlocker.match(/<button[^>]*update-apply-btn[^>]*>/) || [])[0];
|
|
assert.ok(applyTag, 'no apply button rendered when nothing blocks it');
|
|
assert.ok(!/disabled/.test(applyTag), `a ready apply control must not be disabled: ${applyTag}`);
|
|
for (const tag of (noBlocker.match(/<button[^>]*>/g) || [])) {
|
|
if (/ disabled/.test(tag)) {
|
|
assert.match(tag, /data-state-disabled="true"/, `every state-disabled control needs the marker: ${tag}`);
|
|
}
|
|
}
|
|
});
|