75b86fd60d
acme:
- acme.sh chmods its tree to owner-only (700/600) every run, which
broke the two-user model: a tree left owner-only by one user made
every acme.sh call of the other exit 2
- normalize_acme_home() reopens group access (sudo chmod g+rwX,
files only — setgid dirs trip RestrictSUIDSGID); _run_acme_preflight
is the choke point before every daemon acme.sh call + startup
- acme service now runs as the daemon user; --log persists the raw CA
transcript; SYS_LOG=6 journals manual issue/renew runs
- timer daily-only: two runs/day landed inside ZeroSSL's 24h
validation backoff (Retry-After: 86400) — a permanent renewal lockout
- _collect_acme no longer raises on cert-list failure; reports
status.error (AcmeState.status) so the certs page can surface it
firewall: re-stamp the applied baseline on live zone mutations
(interfaces/services/rich-rules/masquerade/forward-ports) so cancel-all
reverts to post-mutation state, not a stale install-era snapshot;
set_masquerade syncs the declarative config for existing zones;
add_forward_port records toaddr only with toport
status: apply-all accepts {"force": true} (forwarded to the firewall
apply only); ApplyConfirm force checkbox; applyResultToasts() — the
errors map wins over the 200; ActionButton checks errors before the
success toast; dashboard uses ApplyConfirm
system_import: drift re-imports carry the existing apply-meta; first
import stamps the adopted content as applied (it is the running state)
— no phantom pending changes
nginx: get_config only re-saves when migration actually changed the
config (no more owner/mtime churn on every read)
install: repair mis-owned top-level system dirs (tmpfiles
unsafe-path-transition), warn with a full-repair command for deeper
mis-ownership
daemon/server: loop.get_exception_handler() (aiohttp API fix)
tests: 888 pytest + 24 node passing; ruff clean
272 lines
8.6 KiB
JavaScript
272 lines
8.6 KiB
JavaScript
/**
|
|
* Tests for hoover/components/applyconfirm.js
|
|
*
|
|
* Component-level tests: VNode structure, buildRows logic,
|
|
* and integration behaviour. Run with `node tests/test-applyconfirm.js`.
|
|
*/
|
|
|
|
import { buildRows, isPending, SUBSYSTEM_LIST, applyResultToasts } from '../webui/static/hoover/components/applyconfirm.js';
|
|
|
|
const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key);
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` \u2713 ${name}`);
|
|
passed++;
|
|
} catch (e) {
|
|
console.error(` \u2717 ${name}: ${e.message}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
function assert(cond, msg) {
|
|
if (!cond) throw new Error(msg || 'Assertion failed');
|
|
}
|
|
|
|
function assertEq(a, b, msg) {
|
|
if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`);
|
|
}
|
|
|
|
function assertIncludes(str, substr, msg) {
|
|
if (!str.includes(substr)) throw new Error(msg || `Expected "${str}" to contain "${substr}"`);
|
|
}
|
|
|
|
console.log('Testing ApplyConfirm component\n');
|
|
|
|
// === isPending ===
|
|
test('isPending returns true for needs_apply', () => {
|
|
assertEq(isPending({ needs_apply: true }), true);
|
|
});
|
|
|
|
test('isPending returns true for pending_changes', () => {
|
|
assertEq(isPending({ pending_changes: true }), true);
|
|
});
|
|
|
|
test('isPending returns false when neither flag set', () => {
|
|
assertEq(isPending({}), false);
|
|
});
|
|
|
|
test('isPending returns false for explicit false', () => {
|
|
assertEq(isPending({ needs_apply: false, pending_changes: false }), false);
|
|
});
|
|
|
|
// === SUBSYSTEM_LIST ===
|
|
test('SUBSYSTEM_LIST contains 5 subsystems', () => {
|
|
assertEq(SUBSYSTEM_LIST.length, 5);
|
|
});
|
|
|
|
test('SUBSYSTEM_LIST uses networkd key (not network)', () => {
|
|
assertIncludes(SUBSYSTEM_KEYS.join(','), 'networkd', 'SUBSYSTEM_LIST should contain networkd');
|
|
assert(SUBSYSTEM_KEYS.indexOf('network') === -1, 'SUBSYSTEM_LIST should NOT contain network');
|
|
});
|
|
|
|
test('SUBSYSTEM_LIST keys match daemon response keys', () => {
|
|
const expectedKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'];
|
|
for (const key of expectedKeys) {
|
|
assert(SUBSYSTEM_KEYS.includes(key), `SUBSYSTEM_LIST should contain ${key}`);
|
|
}
|
|
});
|
|
|
|
// === buildRows ===
|
|
test('buildRows returns 5 rows for empty subsystems', () => {
|
|
const rows = buildRows({}, {});
|
|
assertEq(rows.length, 5, 'should have 5 subsystem rows for empty state');
|
|
});
|
|
|
|
test('buildRows marks pending firewall subsystem correctly', () => {
|
|
const data = {
|
|
firewall: {
|
|
needs_apply: true,
|
|
changes: [
|
|
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
|
],
|
|
},
|
|
};
|
|
const rows = buildRows(data, {});
|
|
const fwRow = rows[0];
|
|
assertIncludes(fwRow.props.class, 'pending', 'firewall row should have pending class');
|
|
});
|
|
|
|
test('buildRows changes are VNodes with proper structure', () => {
|
|
const data = {
|
|
firewall: {
|
|
needs_apply: true,
|
|
changes: [
|
|
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
|
],
|
|
},
|
|
};
|
|
const rows = buildRows(data, {});
|
|
const fwRow = rows[0];
|
|
// Status span should contain "1 pending changes"
|
|
const statusSpan = fwRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
|
|
assert(statusSpan, 'should have status span');
|
|
const textChild = statusSpan.ch.find(c => c.tag === '#text');
|
|
assert(textChild && textChild.text.includes('pending changes'), 'status should contain pending changes count');
|
|
});
|
|
|
|
test('buildRows marks pending dnsmasq subsystem correctly', () => {
|
|
const data = {
|
|
dnsmasq: {
|
|
pending_changes: true,
|
|
changes: [
|
|
{ summary: 'DHCP/DNS configuration has unapplied changes', detail: '' },
|
|
],
|
|
},
|
|
};
|
|
const rows = buildRows(data, {});
|
|
const dnsmasqRow = rows[1];
|
|
assertIncludes(dnsmasqRow.props.class, 'pending', 'dnsmasq row should have pending class');
|
|
});
|
|
|
|
test('buildRows shows correct change count text', () => {
|
|
const data = {
|
|
dnsmasq: {
|
|
pending_changes: true,
|
|
changes: [
|
|
{ summary: 'Range 1', detail: '' },
|
|
{ summary: 'Range 2', detail: '' },
|
|
],
|
|
},
|
|
};
|
|
const rows = buildRows(data, {});
|
|
const dnsmasqRow = rows[1];
|
|
const statusSpan = dnsmasqRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
|
|
const textChild = statusSpan.ch.find(c => c.tag === '#text');
|
|
assertEq(textChild.text, '2 pending changes');
|
|
});
|
|
|
|
test('buildRows shows up-to-date for non-pending', () => {
|
|
const data = {
|
|
nginx: { pending_changes: false, changes: [] },
|
|
wireguard: { pending_changes: false, changes: [] },
|
|
};
|
|
const rows = buildRows(data, {});
|
|
const nginxRow = rows[2];
|
|
assert(
|
|
!nginxRow.props.class.includes('pending'),
|
|
'nginx row should not have pending class',
|
|
);
|
|
const statusSpan = nginxRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
|
|
const textChild = statusSpan.ch.find(c => c.tag === '#text');
|
|
assertEq(textChild.text, 'Up to date');
|
|
});
|
|
|
|
test('buildRows shows expand icon and details when expanded', () => {
|
|
const data = {
|
|
firewall: {
|
|
needs_apply: true,
|
|
changes: [
|
|
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
|
{ summary: 'Zone dmz: services changed', detail: '' },
|
|
],
|
|
},
|
|
};
|
|
const rows = buildRows(data, { firewall: true });
|
|
assertEq(rows.length, 6, 'should have 6 items (5 rows + 1 detail section)');
|
|
});
|
|
|
|
test('buildRows hides expand icon when not expanded', () => {
|
|
const data = {
|
|
firewall: {
|
|
needs_apply: true,
|
|
changes: [
|
|
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
|
],
|
|
},
|
|
};
|
|
const rows = buildRows(data, {});
|
|
assertEq(rows.length, 5, 'should only have 5 rows, no detail section');
|
|
});
|
|
|
|
test('buildRows pending flag but no changes treated as up-to-date', () => {
|
|
const data = {
|
|
firewall: { needs_apply: true, changes: [] },
|
|
};
|
|
const rows = buildRows(data, {});
|
|
const fwRow = rows[0];
|
|
assert(
|
|
!fwRow.props.class.includes('pending'),
|
|
'no changes = up to date',
|
|
);
|
|
});
|
|
|
|
test('buildRows detail section contains item VNodes', () => {
|
|
const data = {
|
|
firewall: {
|
|
needs_apply: true,
|
|
changes: [
|
|
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
|
],
|
|
},
|
|
};
|
|
const rows = buildRows(data, { firewall: true });
|
|
const detailSection = rows[1];
|
|
assertIncludes(detailSection.props.class, 'apply-detail-section', 'should be detail section');
|
|
assert(detailSection.ch.length > 0, 'detail section should have children');
|
|
});
|
|
|
|
test('buildRows handles networkd key correctly', () => {
|
|
const data = {
|
|
networkd: {
|
|
pending_changes: true,
|
|
changes: [
|
|
{ summary: 'Network configuration has unapplied changes', detail: '' },
|
|
],
|
|
},
|
|
};
|
|
const rows = buildRows(data, {});
|
|
const networkdRow = rows[4]; // networkd is 5th in list
|
|
assertIncludes(networkdRow.props.class, 'pending', 'networkd row should have pending class');
|
|
});
|
|
|
|
test('buildRows row VNodes have correct tag', () => {
|
|
const data = {
|
|
firewall: { needs_apply: true, changes: [{ summary: 'test', detail: '' }] },
|
|
};
|
|
const rows = buildRows(data, {});
|
|
for (const row of rows.slice(0, 5)) {
|
|
assertEq(row.tag, 'div', 'row should be a div');
|
|
assert(row.props.class.includes('apply-subsystem-row'), 'row should have apply-subsystem-row class');
|
|
}
|
|
});
|
|
|
|
// === applyResultToasts ===
|
|
// apply-all returns 200 with { applied, errors } even when subsystems
|
|
// failed — resp.ok alone is not a success signal; errors must win.
|
|
test('applyResultToasts: errors suppress the success toast', () => {
|
|
const t = applyResultToasts({ applied: ['Network'], errors: { Firewall: 'refused' } }, 'All changes applied');
|
|
assertEq(t.success, null, 'no success toast when errors exist');
|
|
assertIncludes(t.error, 'Firewall — refused');
|
|
});
|
|
|
|
test('applyResultToasts: success toast when applied and no errors', () => {
|
|
const t = applyResultToasts({ applied: ['Firewall', 'Nginx'], errors: {} }, 'All changes applied');
|
|
assertEq(t.error, null);
|
|
assertEq(t.success, 'All changes applied');
|
|
});
|
|
|
|
test('applyResultToasts: no toast when nothing applied and no errors', () => {
|
|
const t = applyResultToasts({ applied: [], errors: {} }, 'All changes applied');
|
|
assertEq(t.error, null);
|
|
assertEq(t.success, null);
|
|
});
|
|
|
|
test('applyResultToasts: multiple errors are joined', () => {
|
|
const t = applyResultToasts({ applied: [], errors: { Firewall: 'a', Nginx: 'b' } }, 'ok');
|
|
assertIncludes(t.error, 'Firewall — a');
|
|
assertIncludes(t.error, 'Nginx — b');
|
|
});
|
|
|
|
test('applyResultToasts: null payload is safe', () => {
|
|
const t = applyResultToasts(null, 'ok');
|
|
assertEq(t.error, null);
|
|
assertEq(t.success, null);
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed > 0 ? 1 : 0); |