Merge remote-tracking branch 'origin/develop' into HEAD

# Conflicts:
#	.github/scripts/tests/test_command_descriptions.py
This commit is contained in:
MacRimi
2026-10-16 23:32:05 +02:00
76 changed files with 3714 additions and 309 deletions
+9
View File
@@ -162,6 +162,15 @@ run_case stopped_restore RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_INITIA
[[ $(count 'pct start 101') -eq 1 ]] || fail "stopped CT was not started exactly once"
[[ $(count 'pct shutdown 101 --timeout 60') -eq 1 ]] || fail "stopped CT state was not restored"
run_case scheduled_stopped RUN_HELPER=0 UPDATE_COMMAND='update-custom' \
EXECUTION_MODE=scheduled SCHEDULED_STOPPED_MARKER='fixture-stopped-marker' MOCK_INITIAL_STATE=stopped
[[ $CASE_RC -eq 9 ]] || fail "scheduled_stopped returned $CASE_RC"
[[ $(count 'pct start 101') -eq 0 ]] || fail "scheduled update started a stopped CT"
[[ $(count 'pct exec') -eq 0 ]] || fail "scheduled update executed inside a stopped CT"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "scheduled update ran a custom updater"
grep -qF 'fixture-stopped-marker' "$TEST_ROOT/scheduled_stopped.out" \
|| fail "scheduled stopped result did not include its marker"
run_case locked RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_FLOCK_FAIL=1
[[ $CASE_RC -eq 7 ]] || fail "locked expected 7, got $CASE_RC"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "locked run executed an updater"
+9 -3
View File
@@ -78,7 +78,7 @@ function walk(node) {
if (ts.isVariableDeclaration(node)) {
const name = node.name.getText(tree)
if (wanted.has(name)) definitions.push(`const ${node.getText(tree)};`)
if (["applySchedulePayload", "loadSchedule", "loadBulkUpdate"].includes(name))
if (["LEGACY_STOPPED_SCHEDULE_REASONS", "scheduleReasonCodeFromPersisted", "applySchedulePayload", "loadSchedule", "loadBulkUpdate"].includes(name))
preferenceDefinitions.push(`const ${node.getText(tree)};`)
if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name))
status.push(`const ${node.getText(tree)};`)
@@ -325,11 +325,11 @@ context.selectedVMRef.current = { vmid: 100 }
}
for (const name of ["ScheduleEnabled", "ScheduleConfigured", "ScheduleCron", "SchedulePreset",
"ScheduleTarget", "ScheduleTargets", "ScheduleReleaseDelayDays", "ApplyBackup", "ApplyBackupStorage",
"ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunLog",
"ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunReasonCode", "ScheduleLastRunLog",
"ScheduleLastRunRebootRequired", "ScheduleLastRunRebootPackages", "ExternalCron", "ScheduleError",
"ScheduleLoaded", "BulkError", "BulkLoaded", "BulkEditMode", "BulkConfigured", "BulkTargets", "BulkPersistedTargets"])
preferenceContext[`set${name}`] = value => { preferenceState[name] = value }
assert.equal(preferenceDefinitions.length, 3)
assert.equal(preferenceDefinitions.length, 5)
const preferences = compile(preferenceDefinitions.join("\n") + `
exports.apply = applySchedulePayload; exports.schedule = loadSchedule; exports.bulk = loadBulkUpdate;
`, preferenceContext)
@@ -340,6 +340,12 @@ context.selectedVMRef.current = { vmid: 100 }
assert.equal(preferenceState.ApplyRestart, false, "missing restart cannot inherit a reboot")
preferences.apply({ backup: false, backup_storage: "", restart: false })
assert.equal(preferenceState.ApplyBackupStorage, "")
preferences.apply({ last_run_reason_code: "stopped_container", last_run_reason: "raw stopped reason" })
assert.equal(preferenceState.ScheduleLastRunReasonCode, "stopped_container",
"schedule payload publishes its structured stopped reason")
preferences.apply({})
assert.equal(preferenceState.ScheduleLastRunReasonCode, null,
"a missing reason code cannot leak from the previously selected guest")
preferenceContext.vmModalCacheRef.current.schedule.set(213, { backup: true, backup_storage: "local", restart: true })
const previousSchedule = preferences.schedule(213)
assert.equal(preferenceState.ApplyBackup, true, "use only the selected guest's own cache")
@@ -0,0 +1,102 @@
// Execute the real scheduled-run detail JSX: a known backend reason code is
// localized, while an unknown/missing code keeps its raw diagnostic text.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const root = path.resolve(__dirname, '../../AppImage')
const ts = require(path.join(root, 'node_modules/typescript'))
const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let declaration
let legacyReasons
let reasonCodeHelper
function walk(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'renderScheduleRunDetails') declaration = node.getText(tree)
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'LEGACY_STOPPED_SCHEDULE_REASONS') legacyReasons = node.getText(tree)
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'scheduleReasonCodeFromPersisted') reasonCodeHelper = node.getText(tree)
ts.forEachChild(node, walk)
}
walk(tree)
assert(declaration, 'scheduled run detail renderer must exist')
assert(legacyReasons, 'legacy stopped-reason list must exist')
assert(reasonCodeHelper, 'persisted schedule reason-code helper must exist')
function compile(context) {
const result = ts.transpileModule(`const ${declaration}; exports.render = renderScheduleRunDetails;`, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
reportDiagnostics: true,
})
assert.equal(result.diagnostics.length, 0)
vm.runInNewContext(result.outputText, context)
return context.exports.render
}
function compileReasonCodeHelper() {
const result = ts.transpileModule(`const ${legacyReasons}; const ${reasonCodeHelper}; exports.reasonCode = scheduleReasonCodeFromPersisted;`, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
reportDiagnostics: true,
})
assert.equal(result.diagnostics.length, 0)
const context = { exports: {}, Set }
vm.runInNewContext(result.outputText, context)
return context.exports.reasonCode
}
function flattenText(node) {
if (Array.isArray(node)) return node.map(flattenText).join(' ')
if (!node || typeof node !== 'object') return typeof node === 'string' ? node : ''
return flattenText(node.props?.children)
}
const persistedReasonCode = compileReasonCodeHelper()
assert.equal(persistedReasonCode('stopped_container', 'anything'), 'stopped_container')
assert.equal(persistedReasonCode(null, 'Container is stopped; scheduled update was skipped'), 'stopped_container')
assert.equal(persistedReasonCode(null, 'container was stopped before scheduled update could begin'), 'stopped_container')
assert.equal(persistedReasonCode('future_reason', 'raw diagnostic'), 'future_reason')
assert.equal(persistedReasonCode(null, 'raw diagnostic'), null)
for (const locale of ['en', 'sk']) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`), 'utf8'))
const t = (key, variables = {}) => {
const message = key.split('.').reduce((value, part) => value?.[part], messages)
return String(message ?? key).replace(/\{([^}]+)\}/g, (_, name) => String(variables[name] ?? `{${name}}`))
}
const context = {
exports: {}, t, selectedVM: { vmid: 100, status: 'stopped' }, scheduleLastRunAt: '2026-10-16T05:10:02Z',
scheduleLastRunStatus: 'skipped', scheduleLastRunReasonCode: 'stopped_container',
scheduleLastRunReason: 'container is stopped; scheduled update was skipped',
scheduleLastRunRebootRequired: false, scheduleLastRunRebootPackages: [], scheduleLastRunLog: null,
openScheduleLog() {}, AlertTriangle: 'AlertTriangle', FileText: 'FileText',
require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }), Fragment: 'Fragment' }),
}
const render = compile(context)
const translated = flattenText(render(true))
const expectedCurrentReason = t('vmLxc.scheduled.reasonStoppedContainer')
const expectedHistoricalReason = t('vmLxc.scheduled.reasonStoppedContainerHistorical')
const skippedLabel = t('vmLxc.scheduled.runSkippedStatus')
const expectedCurrentText = locale === 'sk'
? 'Kontajner je vypnutý; plánovaná aktualizácia sa preskočila.'
: 'Container is stopped; scheduled update was skipped.'
const expectedHistoricalText = locale === 'sk'
? 'Kontajner bol v čase plánovanej aktualizácie vypnutý.'
: 'Container was stopped at the scheduled update time.'
assert.equal(expectedCurrentReason, expectedCurrentText,
`${locale}: stopped CT must describe its current stopped state`)
assert.equal(expectedHistoricalReason, expectedHistoricalText,
`${locale}: running CT must describe the stopped state at the prior scheduled run`)
assert(translated.includes(expectedCurrentReason), `${locale}: currently stopped CT must use the present-state catalog`)
assert(!translated.includes(expectedHistoricalReason), `${locale}: currently stopped CT must not use historical wording`)
assert(translated.includes(skippedLabel), `${locale}: skipped run must not claim that no updates are pending`)
assert(!translated.includes(context.scheduleLastRunReason), `${locale}: raw stopped reason must not leak into UI`)
context.selectedVM.status = 'running'
const historical = flattenText(render(true))
assert(historical.includes(expectedHistoricalReason), `${locale}: running CT must use historical wording for a past skipped run`)
assert(!historical.includes(expectedCurrentReason), `${locale}: running CT must not claim it is currently stopped`)
context.scheduleLastRunReasonCode = 'unknown_reason'
const fallback = flattenText(render(true))
assert(fallback.includes(context.scheduleLastRunReason), `${locale}: unknown code must retain the raw diagnostic`)
}
console.log('PASS scheduled reason i18n: EN/SK structured stopped reason and raw fallback')
@@ -119,7 +119,8 @@ raise SystemExit(h.main())
self.write("sleep", "#!/bin/sh\nexit 0\n")
self.write("flock", "#!/bin/sh\nexit 0\n")
names = {"_finalize_lxc_update", "_terminal_lxc_update_completed",
"api_lxc_updates_applied", "_run_scheduled_update"}
"api_lxc_updates_applied", "_run_scheduled_update",
"_scheduled_runner_reported_stopped"}
nodes = [n for n in ast.parse(observed.SERVER.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name in names]
self.scope = self.fixture.scope
@@ -134,6 +135,7 @@ raise SystemExit(h.main())
"_json_list": lambda value: json.loads(value or '[]'),
"_lxc_update_finalizations": {}, "_lxc_update_finalization_lock": threading.RLock(),
"_LXC_UPDATE_FINALIZATION_TTL": 3600,
"_SCHEDULED_STOPPED_EXIT": 9,
"_fast_guest_status": lambda *_: self.state.read_text(),
"_lxc_update_snapshot": lambda *_: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OS"],
@@ -315,6 +317,7 @@ raise SystemExit(h.main())
self.scope['notification_manager'].emit_event.assert_called_once()
def test_scheduled_path_executes_real_runner_and_real_finalization(self):
self.state.write_text("running")
def run(command, **kwargs):
kwargs['env'].update({key: value for key, value in self.env.items()
if key != 'RUN_ID'})
@@ -322,7 +325,7 @@ raise SystemExit(h.main())
self.scope['subprocess'] = SimpleNamespace(run=run, TimeoutExpired=subprocess.TimeoutExpired)
result = self.scope['_run_scheduled_update'](100, {'targets': ['os'], 'target': 'os'})
self.assertEqual(result['status'], 'success', result)
self.assertEqual(self.state.read_text(), 'stopped')
self.assertEqual(self.state.read_text(), 'running')
self.assertEqual(self.fixture.row()['update_check']['count'], 0)
def test_legacy_cli_without_run_id_preserves_update_and_original_state(self):
+156
View File
@@ -1,4 +1,7 @@
import ast
import json
import os
import re
import sys
import subprocess
import tempfile
@@ -312,6 +315,159 @@ class AppCacheWriteThroughContractTests(unittest.TestCase):
self.assertIn("before_snapshot=before", scheduler_block)
class ScheduledStoppedContainerTests(unittest.TestCase):
@staticmethod
def _worker(scope):
source = ast.parse((REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text())
node = next(item for item in source.body
if isinstance(item, ast.FunctionDef) and item.name == "_run_scheduled_update")
exec(compile(ast.Module(body=[node], type_ignores=[]), "scheduled-update", "exec"), scope)
return scope["_run_scheduled_update"]
@staticmethod
def _stopped_marker_reader(scope):
source = ast.parse((REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text())
node = next(item for item in source.body
if isinstance(item, ast.FunctionDef) and item.name == "_scheduled_runner_reported_stopped")
exec(compile(ast.Module(body=[node], type_ignores=[]), "scheduled-marker", "exec"), scope)
return scope["_scheduled_runner_reported_stopped"]
def test_stopped_container_skips_before_runner_or_any_guest_effect(self):
runner = MagicMock()
finalizer = MagicMock(return_value={"recorded": True})
append_log = MagicMock()
scope = {
"time": SimpleNamespace(monotonic=MagicMock(return_value=10.0)),
"uuid": SimpleNamespace(uuid4=lambda: SimpleNamespace(hex="a" * 32)),
"datetime": __import__("datetime").datetime,
"subprocess": SimpleNamespace(run=runner, STDOUT=object(), TimeoutExpired=subprocess.TimeoutExpired),
"_create_lxc_update_log": lambda _vmid, _run_id: ("fixture.log", "/tmp/fixture.log"),
"_fast_guest_status": MagicMock(return_value="stopped"),
"_normalise_schedule_targets": lambda _sched: ["os"],
"_lxc_update_snapshot": lambda _vmid: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OS"],
"_append_lxc_update_log": append_log,
"_prune_lxc_update_logs": MagicMock(),
"_finalize_lxc_update": finalizer,
}
outcome = self._worker(scope)(101, {"target": "os"})
self.assertEqual(outcome["status"], "skipped")
self.assertEqual(outcome["executed_targets"], [])
self.assertIn("container is stopped", outcome["reason"])
self.assertEqual(outcome["reason_code"], "stopped_container")
runner.assert_not_called()
self.assertEqual(finalizer.call_args.kwargs["status"], "skipped")
self.assertEqual(finalizer.call_args.kwargs["source"], "scheduled")
def test_stopped_marker_requires_the_scheduler_token_not_only_exit_code(self):
marker = "PROXMENUX_SCHEDULED_STOPPED:fixture"
reader = self._stopped_marker_reader({"_SCHEDULED_STOPPED_EXIT": 9, "os": os})
self.assertTrue(reader(SimpleNamespace(returncode=9, stdout=marker, stderr=""), None, marker))
self.assertFalse(reader(SimpleNamespace(returncode=9, stdout="", stderr=""), None, marker))
self.assertFalse(reader(SimpleNamespace(returncode=8, stdout=marker, stderr=""), None, marker))
self.assertFalse(reader(SimpleNamespace(returncode=4, stdout=marker, stderr=""), None, marker))
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as log:
log.write("prefix\\n" + marker + "\\n")
log.flush()
self.assertTrue(reader(SimpleNamespace(returncode=9, stdout="", stderr=""), log.name, marker))
def test_runner_race_to_stopped_is_reported_as_skipped_without_reboot_probe(self):
runner = MagicMock(return_value=SimpleNamespace(returncode=9, stdout="", stderr=""))
finalizer = MagicMock(return_value={"recorded": True})
reboot_probe = MagicMock()
stopped_marker = MagicMock(return_value=True)
lxc = SimpleNamespace(
_read_sidecar=lambda _vmid: {"apps": []},
partition_scheduled_release_targets=lambda targets, _apps: (set(), targets),
)
scope = {
"time": SimpleNamespace(monotonic=MagicMock(return_value=10.0)),
"uuid": SimpleNamespace(uuid4=lambda: SimpleNamespace(hex="b" * 32)),
"datetime": __import__("datetime").datetime,
"subprocess": SimpleNamespace(run=runner, STDOUT=object(), TimeoutExpired=subprocess.TimeoutExpired),
"os": os,
"json": json,
"_APPLY_UPDATES_SCRIPT": "/bin/true",
"_SCHEDULED_STOPPED_EXIT": 9,
"_DOCKER_ENGINE_INTEGRATED_COMMAND": "integrated-docker",
"_create_lxc_update_log": lambda _vmid, _run_id: (None, None),
"_fast_guest_status": MagicMock(return_value="running"),
"_normalise_schedule_targets": lambda _sched: ["os"],
"_lxc_update_snapshot": lambda _vmid: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OS"],
"_append_lxc_update_log": MagicMock(),
"_prune_lxc_update_logs": MagicMock(),
"_finalize_lxc_update": finalizer,
"_scheduled_helper_enabled": lambda *_: False,
"_compose_scheduled_update_command": lambda *_: "",
"_inspect_lxc_reboot_requirement": reboot_probe,
"_scheduled_runner_reported_stopped": stopped_marker,
}
with patch.dict(sys.modules, {"lxc_apps": lxc}):
outcome = self._worker(scope)(101, {"target": "os"})
self.assertEqual(outcome["status"], "skipped")
self.assertEqual(outcome["executed_targets"], [])
self.assertIn("before scheduled update could begin", outcome["reason"])
self.assertEqual(outcome["reason_code"], "stopped_container")
self.assertEqual(runner.call_args.args[0], ["bash", "/bin/true"])
self.assertEqual(runner.call_args.kwargs["env"]["EXECUTION_MODE"], "scheduled")
self.assertTrue(runner.call_args.kwargs["env"]["SCHEDULED_STOPPED_MARKER"].startswith(
"PROXMENUX_SCHEDULED_STOPPED:scheduled-"))
stopped_marker.assert_called_once()
reboot_probe.assert_not_called()
stopped_marker.reset_mock()
stopped_marker.return_value = False
runner.return_value = SimpleNamespace(returncode=8, stdout="", stderr="")
reboot_probe.return_value = (None, [], None)
outcome = self._worker(scope)(101, {"target": "os"})
self.assertEqual(outcome["status"], "failure")
self.assertIn("exited with code 8", outcome["reason"])
stopped_marker.assert_called_once()
reboot_probe.assert_called_once()
def test_oci_race_to_stopped_skips_before_the_oci_engine_runs(self):
runner = MagicMock(return_value=SimpleNamespace(returncode=0, stdout="", stderr=""))
finalizer = MagicMock(return_value={"recorded": True})
status = MagicMock(side_effect=["running", "stopped"])
scope = {
"time": SimpleNamespace(monotonic=MagicMock(return_value=10.0)),
"uuid": SimpleNamespace(uuid4=lambda: SimpleNamespace(hex="c" * 32)),
"datetime": __import__("datetime").datetime,
"subprocess": SimpleNamespace(
run=runner,
STDOUT=object(),
DEVNULL=object(),
TimeoutExpired=subprocess.TimeoutExpired,
),
"os": os,
"re": re,
"_create_lxc_update_log": lambda _vmid, _run_id: (None, None),
"_fast_guest_status": status,
"_normalise_schedule_targets": lambda _sched: ["oci_image"],
"_lxc_update_snapshot": lambda _vmid: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OCI image"],
"_append_lxc_update_log": MagicMock(),
"_prune_lxc_update_logs": MagicMock(),
"_finalize_lxc_update": finalizer,
}
outcome = self._worker(scope)(101, {"targets": ["oci_image"]})
self.assertEqual(outcome["status"], "skipped")
self.assertEqual(outcome["target"], "oci_image")
self.assertEqual(outcome["executed_targets"], [])
self.assertEqual(outcome["reason_code"], "stopped_container")
self.assertIn("before scheduled update could begin", outcome["reason"])
self.assertEqual(status.call_count, 2)
runner.assert_not_called()
self.assertEqual(finalizer.call_args.kwargs["status"], "skipped")
class DockerStackNotificationTests(unittest.TestCase):
def _docker_app(self):
return {
@@ -37,6 +37,7 @@ def routes():
_lxc_update_target_labels=lambda *_: [],
_append_lxc_update_log=lambda *_: None,
_prune_lxc_update_logs=lambda *_: None,
_scheduled_runner_reported_stopped=lambda *_: False,
_finalize_lxc_update=Mock(return_value={}),
_inspect_lxc_reboot_requirement=lambda *_, **__: (False, [], None))
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'update-routes', 'exec'), ns)
+30
View File
@@ -0,0 +1,30 @@
// Render the real help component; only its translation provider is replaced.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
const React = require('react');
const { renderToStaticMarkup } = require('react-dom/server');
const source = fs.readFileSync('AppImage/components/apprise-service-help.tsx', 'utf8');
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX } }).outputText;
const english = JSON.parse(fs.readFileSync('AppImage/messages/en/common.json'));
function resolve(catalog, key) { return key.split('.').reduce((value, part) => value?.[part], catalog); }
for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv', 'missing']) {
const catalog = locale === 'missing' ? {} : JSON.parse(fs.readFileSync(`AppImage/messages/${locale}/common.json`));
const context = { exports: {}, require: name => name === '../lib/i18n/provider' ? {
useT: () => key => resolve(catalog, key) ?? resolve(english, key),
} : require(name) };
vm.runInNewContext(js, context);
const html = renderToStaticMarkup(React.createElement(context.exports.AppriseServiceHelp));
assert.ok(html.includes('https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C'));
assert.ok(html.includes('matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com'));
assert.ok(html.includes('https://appriseit.com/services/slack/'));
assert.ok(html.includes('https://appriseit.com/services/matrix/'));
assert.ok(!html.includes('settings.notifications.'), `${locale}: no unresolved labels`);
}
for (const [, key] of source.matchAll(/t\("([^"]+)"\)/g)) assert.ok(resolve(english, key), key);
assert.ok(!source.includes('onChange') && !source.includes('fetch(') && !source.includes('updateChannel'));
const settings = fs.readFileSync('AppImage/components/notification-settings.tsx', 'utf8');
const section = settings.slice(settings.indexOf('<TabsContent value="apprise"'), settings.indexOf('</TabsContent>', settings.indexOf('<TabsContent value="apprise"')));
for (const part of ['<AppriseServiceHelp />', 'renderChannelCategories("apprise")', 'handleTest("apprise")']) assert.ok(section.includes(part));
console.log('PASS: real provider-help JSX, shipped locales and English fallback, no secret mutation, native category/test controls retained');
+137
View File
@@ -0,0 +1,137 @@
"""Real Apprise Slack/Matrix plugins with the HTTP boundary replaced.
No provider account or network is used. These contracts run against both
the minimum bundled Apprise version and the current tested version.
"""
import gc
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
from urllib.parse import urlparse
import apprise
import requests
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts"))
from notification_channels import create_channel
SLACK = "https://hooks.slack.com/services/T00000000/B00000000/fixturesecret1234567890"
MATRIX = "matrixs://notify:fixture-password@matrix.example.test/!room:matrix.example.test"
class AppriseServiceTests(unittest.TestCase):
def setUp(self):
self.calls = []
self.status = 200
self.transport = patch.object(requests.Session, "request", side_effect=self.http)
self.transport.start()
self.addCleanup(self.transport.stop)
self.sleep = patch("notification_channels.time.sleep")
self.sleep.start()
self.addCleanup(self.sleep.stop)
self.addCleanup(gc.collect)
def http(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
response = requests.Response()
response.status_code = self.status
response.url = url
response.reason = "OK" if self.status == 200 else "Rejected"
response._content_consumed = True
host, path = urlparse(url).hostname, urlparse(url).path
if host == "hooks.slack.com":
self.assertTrue(path.startswith("/services/"))
response._content = b"ok" if self.status == 200 else b"invalid_auth"
else:
self.assertEqual(host, "matrix.example.test", "no unexpected destinations")
if self.status != 200:
payload = {"errcode": "M_FORBIDDEN", "error": "Invalid credentials"}
elif path == "/.well-known/matrix/client":
payload = {"m.homeserver": {"base_url": "https://" + host}}
elif path.endswith("/login"):
payload = {"access_token": "fixture-token", "home_server": host, "user_id": "@notify:" + host}
elif path.endswith("/joined_rooms"):
payload = {"joined_rooms": ["!room:" + host]}
elif "/join/" in path:
payload = {"room_id": "!room:" + host}
elif "/send/" in path:
payload = {"event_id": "$fixture"}
elif path.endswith("/logout"):
payload = {}
elif path.endswith("/versions"):
payload = {"versions": ["v1.1"]}
else:
raise AssertionError("Unhandled Matrix request: " + path)
response._content = json.dumps(payload).encode()
return response
def send(self, url):
channel = create_channel("apprise", {"url": url})
self.assertIsNotNone(channel)
return channel.send("ProxMenux probe", "Controlled message", "WARNING")
def test_slack_accepts_the_native_webhook_without_conversion(self):
result = self.send(SLACK)
self.assertTrue(result["success"], result)
self.assertTrue(any(url == SLACK for _, url, _ in self.calls))
self.assertIn("Controlled message", str(self.calls))
def test_slack_apprise_scheme_uses_the_same_transport(self):
result = self.send(SLACK.replace("https://hooks.slack.com/services/", "slack://"))
self.assertTrue(result["success"], result)
self.assertTrue(any(url == SLACK for _, url, _ in self.calls))
def test_matrix_authenticates_and_delivers_to_the_full_room_id_over_https(self):
result = self.send(MATRIX)
self.assertTrue(result["success"], result)
sends = [(url, kwargs) for _, url, kwargs in self.calls if "/send/" in url]
self.assertTrue(sends, self.calls)
self.assertTrue(all(url.startswith("https://matrix.example.test/") for url, _ in sends))
self.assertTrue(all(kwargs.get("verify", True) for _, kwargs in sends))
self.assertIn("Controlled message", str(sends))
self.assertTrue(any(url.endswith("/login") for _, url, _ in self.calls))
def test_test_button_contract_uses_the_same_provider(self):
ok, error = create_channel("apprise", {"url": SLACK}).test()
self.assertTrue(ok, error)
self.assertTrue(self.calls)
def test_rejected_slack_webhook_is_not_reported_as_delivered(self):
self.status = 403
result = self.send(SLACK)
self.assertFalse(result["success"])
self.assertTrue(result["error"])
def test_invalid_matrix_credentials_are_not_reported_as_delivered(self):
self.status = 403
result = self.send(MATRIX)
self.assertFalse(result["success"])
self.assertFalse(any("/send/" in url for _, url, _ in self.calls))
def test_invalid_urls_and_missing_library_fail_before_any_request(self):
channel = create_channel("apprise", {"url": "not-a-url"})
self.assertFalse(channel.send("Probe", "Body")["success"])
channel = create_channel("apprise", {"url": SLACK})
with patch.object(channel, "_load_apprise", return_value=None):
self.assertFalse(channel.send("Probe", "Body")["success"])
self.assertEqual(self.calls, [])
def test_connection_failure_never_claims_delivery(self):
for url in (SLACK, MATRIX):
with patch.object(requests.Session, "request", side_effect=requests.ConnectionError("fixture connection failure")):
result = self.send(url)
self.assertFalse(result["success"])
self.assertTrue(result["error"])
def test_severity_mapping_keeps_native_notification_semantics(self):
channel = create_channel("apprise", {"url": SLACK})
for severity, expected in (("CRITICAL", apprise.NotifyType.FAILURE), ("WARNING", apprise.NotifyType.WARNING),
("INFO", apprise.NotifyType.INFO), ("OK", apprise.NotifyType.SUCCESS),
("SUCCESS", apprise.NotifyType.SUCCESS)):
self.assertEqual(channel._severity_to_notify_type(apprise, severity), expected)
if __name__ == "__main__":
unittest.main()
+204
View File
@@ -0,0 +1,204 @@
"""Exercise the real Figurine installer with isolated files and no network/root access."""
import ast
import importlib.util
import io
from pathlib import Path
import re
import subprocess
import tarfile
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts/post_install/customizable_post_install.sh"
FUNCTION = re.search(
r"^configure_figurine\(\) \{\n.*?^\}", SCRIPT.read_text(), re.M | re.S
).group(0)
VERSION = re.search(r'FIGURINE_VERSION:-([^}]+)', FUNCTION).group(1)
FUNCTION_VERSION = re.search(r'local FUNC_VERSION="([^"]+)"', FUNCTION).group(1)
class FigurineInstallation(unittest.TestCase):
def run_installer(self, arch="amd64", *, kernel="x86_64", dpkg_exit=0,
release=None, download_exit=0, package="valid", existing=True):
with tempfile.TemporaryDirectory(prefix="figurine-test-") as directory:
root = Path(directory)
binary_dir = root / "bin"
profile_dir = root / "profile.d"
binary_dir.mkdir()
profile_dir.mkdir()
(root / "tmp").mkdir()
binary = binary_dir / "figurine"
profile = profile_dir / "figurine.sh"
bashrc = root / "bashrc"
if existing:
binary.write_text("previous binary\n")
binary.chmod(0o755)
profile.write_text("previous profile\n")
bashrc.write_text("# previous bashrc\n")
archive = root / "fixture.tar.gz"
payload = b"#!/bin/sh\nprintf 'fixture Figurine\\n'\n"
if package == "corrupt":
archive.write_bytes(b"invalid tarball")
else:
with tarfile.open(archive, "w:gz") as tar:
name = "deploy/figurine" if package == "valid" else "README"
member = tarfile.TarInfo(name)
member.size = len(payload)
tar.addfile(member, io.BytesIO(payload))
# Relocate every installer filesystem boundary, including its login script.
function = (FUNCTION.replace("/usr/local/bin", str(binary_dir))
.replace("/etc/profile.d", str(profile_dir))
.replace("/root/.bashrc", str(bashrc)))
harness = r'''
translate() { printf '%s' "$1"; }
msg_info2() { :; }
msg_info() { :; }
msg_ok() { :; }
msg_success() { :; }
msg_error() { printf '%s\n' "$*" >&2; }
pmx_journal_context() { :; }
pmx_write_file() { cat > "$1"; }
pmx_append_file() { cat >> "$1"; }
pmx_edit_file() { :; }
chown() { :; }
dpkg() {
[[ "$TEST_DPKG_EXIT" == 0 ]] || return "$TEST_DPKG_EXIT"
printf '%s\n' "$TEST_ARCH"
}
uname() { printf '%s\n' "$TEST_KERNEL"; }
wget() {
[[ "$1" == -qO ]] || return 90
printf '%s\n' "$3" >> "$TEST_ROOT/downloads"
[[ "$TEST_DOWNLOAD_EXIT" == 0 ]] || return "$TEST_DOWNLOAD_EXIT"
cp "$TEST_ROOT/fixture.tar.gz" "$2"
}
register_tool() { printf '%s\n' "$*" > "$TEST_ROOT/registered"; }
'''
harness += function + "\nconfigure_figurine\nexit \"$?\"\n"
environment = {
"PATH": f"{binary_dir}:/usr/bin:/bin",
"HOME": str(root),
"TMPDIR": str(root / "tmp"),
"LC_ALL": "C",
"TEST_ROOT": str(root),
"TEST_ARCH": arch,
"TEST_DPKG_EXIT": str(dpkg_exit),
"TEST_KERNEL": kernel,
"TEST_DOWNLOAD_EXIT": str(download_exit),
}
if release is not None:
environment["FIGURINE_VERSION"] = release
result = subprocess.run(
["/bin/bash", "--noprofile", "--norc"], input=harness,
text=True, capture_output=True, env=environment, timeout=10,
)
return {
"result": result,
"binary": binary.read_bytes(),
"mode": binary.stat().st_mode & 0o777,
"profile": profile.read_text(),
"bashrc": bashrc.read_text(),
"downloads": (root / "downloads").read_text() if (root / "downloads").exists() else "",
"registered": (root / "registered").read_text() if (root / "registered").exists() else "",
"payload": payload,
"binary_path": str(binary),
}
def test_installs_release_matching_debian_userspace(self):
for arch, asset in (("amd64", "amd64"), ("arm64", "arm64"), ("armhf", "arm")):
with self.subTest(arch=arch):
state = self.run_installer(arch)
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
self.assertEqual(state["downloads"],
f"https://github.com/arsham/figurine/releases/download/v{VERSION}/"
f"figurine_linux_{asset}_v{VERSION}.tar.gz\n")
self.assertEqual(state["binary"], state["payload"])
self.assertEqual(state["mode"], 0o755)
self.assertIn(f'{state["binary_path"]} -f "3d.flf" $(hostname)', state["profile"])
self.assertEqual(state["registered"], f"figurine true {FUNCTION_VERSION}\n")
def test_falls_back_to_kernel_when_dpkg_is_unavailable(self):
for kernel, asset in (("x86_64", "amd64"), ("aarch64", "arm64"),
("armv7l", "arm"), ("armv8l", "arm")):
with self.subTest(kernel=kernel):
state = self.run_installer("", kernel=kernel, dpkg_exit=127)
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
self.assertIn(f"figurine_linux_{asset}_v{VERSION}.tar.gz", state["downloads"])
def test_userspace_takes_precedence_over_kernel(self):
state = self.run_installer("armhf", kernel="aarch64")
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
self.assertIn(f"figurine_linux_arm_v{VERSION}.tar.gz", state["downloads"])
def test_fresh_install_creates_binary_banner_and_aliases(self):
state = self.run_installer(existing=False)
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
self.assertEqual(state["binary"], state["payload"])
self.assertIn(' -f "3d.flf" $(hostname)', state["profile"])
self.assertIn("shopt -s expand_aliases", state["bashrc"])
self.assertIn("alias aptup='apt update && apt dist-upgrade'", state["bashrc"])
self.assertEqual(state["registered"], f"figurine true {FUNCTION_VERSION}\n")
def test_unsupported_architecture_fails_before_changes_or_download(self):
for arch in ("i386", "riscv64", "armel", "armv6l", "ppc64el", ""):
with self.subTest(arch=arch):
state = self.run_installer(arch)
self.assertNotEqual(state["result"].returncode, 0)
self.assertIn("Unsupported architecture for Figurine:", state["result"].stderr)
self.assertEqual(state["downloads"], "")
self.assert_preserved(state)
self.assertEqual(state["bashrc"], "# previous bashrc\n")
def assert_preserved(self, state):
self.assertEqual(state["binary"], b"previous binary\n")
self.assertEqual(state["profile"], "previous profile\n")
self.assertEqual(state["registered"], "")
def test_download_failure_preserves_previous_installation(self):
state = self.run_installer(download_exit=8)
self.assertNotEqual(state["result"].returncode, 0)
self.assertIn("Failed to download Figurine", state["result"].stderr)
self.assert_preserved(state)
def test_invalid_packages_preserve_previous_installation(self):
for package, error in (("corrupt", "Failed to extract package"),
("missing", "Binary not found in extracted content.")):
with self.subTest(package=package):
state = self.run_installer(package=package)
self.assertNotEqual(state["result"].returncode, 0)
self.assertIn(error, state["result"].stderr)
self.assert_preserved(state)
def test_release_override_is_preserved(self):
state = self.run_installer("arm64", release="2.1.0")
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
self.assertIn("/v2.1.0/figurine_linux_arm64_v2.1.0.tar.gz", state["downloads"])
def test_existing_installation_is_offered_the_update_once(self):
spec = importlib.util.spec_from_file_location(
"post_install_versions", ROOT / "AppImage/scripts/post_install_versions.py")
versions = importlib.util.module_from_spec(spec)
spec.loader.exec_module(versions)
metadata = versions.parse_post_install_script(SCRIPT)
installed = {"figurine": {"installed": True, "version": "1.1", "source": "custom"}}
updates = versions._detect_updates({}, metadata, installed)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0]["available_version"], FUNCTION_VERSION)
self.assertEqual(updates[0]["function"], "configure_figurine")
installed["figurine"]["version"] = FUNCTION_VERSION
self.assertEqual(versions._detect_updates({}, metadata, installed), [])
tree = ast.parse((ROOT / "AppImage/scripts/flask_proxmenux_routes.py").read_text())
fallback = next(ast.literal_eval(node.value) for node in tree.body
if isinstance(node, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == "TOOL_METADATA"
for t in node.targets))
self.assertEqual(fallback["figurine"]["version"], FUNCTION_VERSION)
if __name__ == "__main__":
unittest.main()