compressed backups; more webui features

This commit is contained in:
Ludwig Lehnert
2026-08-01 10:29:25 +00:00
parent 69eacc14f3
commit 79cd02695a
25 changed files with 8836 additions and 69 deletions
+99
View File
@@ -0,0 +1,99 @@
import fs from "node:fs";
globalThis.window = globalThis;
const report = await import(new URL("../app/web/report.mjs", import.meta.url));
const typst = await import(new URL("../app/web/vendor/typst/typst.mjs", import.meta.url));
globalThis.Function = () => {
throw new Error("JavaScript dynamic code generation is disabled by CSP");
};
const vendor = new URL("../app/web/vendor/typst/", import.meta.url);
const binary = name => new Uint8Array(fs.readFileSync(new URL(name, vendor)));
typst.$typst.setCompilerInitOptions({
getModule: () => ({module_or_path: binary("compiler.wasm")}),
});
typst.$typst.use(
typst.TypstSnippet.disableDefaultFontAssets(),
typst.TypstSnippet.preloadFontData(binary("LibertinusSerif-Regular.otf")),
typst.TypstSnippet.preloadFontData(binary("LibertinusSerif-Semibold.otf"))
);
const data = {
generatedAt: "2026-08-01T20:27:05+00:00",
storage: {
scannedAt: "2026-08-01T20:20:00+00:00",
scanSeconds: 1.25,
totals: {dataBytes: 1048576, privateBytes: 2048, fslogixBytes: 4096},
groups: [{name: "Forschung & Entwicklung", bytes: 1048576}],
users: [{name: "ludwig.lehnert", privateBytes: 2048, fslogixBytes: 4096, totalBytes: 6144}],
},
groups: {
fetchedAt: "2026-08-01T20:21:00+00:00",
truncated: false,
groups: [{
folder: "Forschung [2026] #1",
sam: "FS_Forschung",
active: true,
userCount: 1,
groupCount: 2,
members: [{
type: "group",
name: "Domänen-Benutzer",
sam: "Domain Users",
members: [{
type: "group",
name: "Forschung und Entwicklung – interne Projektmitglieder",
sam: "Forschung_Entwicklung_Intern",
members: [{type: "user", name: "Ludwig \"Test\" Lehnert", sam: "ludwig.lehnert", members: []}],
}],
}],
}],
},
backup: {
enabled: true,
scheduledHour: 2,
state: "completed",
percent: 100,
transferredBytes: 1054720,
totalBytes: 1054720,
startedAt: "2026-08-01T02:00:00+00:00",
finishedAt: "2026-08-01T02:01:00+00:00",
snapshot: "2026-08-01T020000Z",
message: "Backup completed; 3 snapshot(s) retained",
activeFiles: [],
log: ["DARF NICHT IN DIE PDF"],
},
system: {
hostname: "files.example.test",
serverTime: "2026-08-01T20:27:05+00:00",
checks: {domainTrust: true, sambaConfig: true},
tls: {
subject: {commonName: "files.example.test"},
issuer: {commonName: "Interne CA"},
notAfter: "2027-08-01T00:00:00+00:00",
sans: [["DNS", "files.example.test"]],
},
audit: {secret: "DARF EBENFALLS NICHT IN DIE PDF"},
},
};
const source = report.buildReportSource(data);
if (source.includes("DARF NICHT") || source.includes("DARF EBENFALLS NICHT")) {
throw new Error("log data leaked into Typst source");
}
if (
source.includes("[- ]") ||
!source.includes("columns: (depth * 9pt + 8pt, 44pt, 1fr)") ||
!source.includes("above: 1.8pt, below: 1.8pt")
) {
throw new Error("group hierarchy is not rendered as an indented row grid");
}
const pdf = await typst.$typst.pdf({mainContent: source});
if (!pdf || new TextDecoder().decode(pdf.slice(0, 5)) !== "%PDF-") {
throw new Error("Typst did not produce a PDF");
}
if (process.env.REPORT_SMOKE_OUTPUT) {
fs.writeFileSync(process.env.REPORT_SMOKE_OUTPUT, pdf);
}
console.log("client Typst PDF smoke test passed (" + pdf.length + " bytes)");
+126
View File
@@ -2,6 +2,7 @@ import io
import os
import shutil
import sqlite3
import subprocess
import tempfile
import unittest
from unittest import mock
@@ -188,6 +189,131 @@ class ProgressParsingTests(unittest.TestCase):
self.assertEqual(total, 8)
class GroupArchiveTests(unittest.TestCase):
def test_archive_password_is_required_and_rejects_control_characters(self):
with mock.patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(RuntimeError, "BACKUP_ARCHIVE_PASSWORD"):
backup.archive_password()
with mock.patch.dict(
os.environ, {"BACKUP_ARCHIVE_PASSWORD": "secret\nsecond-line"}, clear=True
):
with self.assertRaisesRegex(RuntimeError, "unsupported characters"):
backup.archive_password()
def test_groups_are_staged_as_encrypted_non_solid_archives(self):
with tempfile.TemporaryDirectory() as tmpdir:
source_root = os.path.join(tmpdir, "groups")
os.makedirs(os.path.join(source_root, "data", "Finance"))
os.makedirs(os.path.join(source_root, "archive", "Former"))
os.makedirs(os.path.join(source_root, "metadata"))
with open(
os.path.join(source_root, "data", "Finance", "report.txt"),
"w",
encoding="utf-8",
) as handle:
handle.write("finance")
with open(
os.path.join(source_root, "archive", "Former", "old.txt"),
"w",
encoding="utf-8",
) as handle:
handle.write("former")
with open(
os.path.join(source_root, "data", "README.txt"),
"w",
encoding="utf-8",
) as handle:
handle.write("preserve me")
with open(
os.path.join(source_root, "metadata", "index.txt"),
"w",
encoding="utf-8",
) as handle:
handle.write("metadata")
commands = []
def fake_run(command, **kwargs):
commands.append((command, kwargs))
archive_path = command[-3]
with open(archive_path, "wb") as handle:
handle.write(b"dummy-7z")
return backup.subprocess.CompletedProcess(command, 0, "", "")
staged_temp = None
try:
with mock.patch.object(backup, "run_command", side_effect=fake_run):
staged_temp, staged_root, count = backup.prepare_group_archives(
source_root, "archive secret", tmpdir
)
self.assertEqual(count, 2)
self.assertTrue(
os.path.isfile(os.path.join(staged_root, "data", "Finance.7z"))
)
self.assertTrue(
os.path.isfile(os.path.join(staged_root, "archive", "Former.7z"))
)
self.assertTrue(
os.path.isfile(os.path.join(staged_root, "data", "README.txt"))
)
self.assertTrue(
os.path.isfile(os.path.join(staged_root, "metadata", "index.txt"))
)
self.assertFalse(
os.path.exists(os.path.join(staged_root, "data", "Finance"))
)
self.assertEqual(len(commands), 2)
for command, kwargs in commands:
self.assertEqual(command[:3], ["7z", "a", "-t7z"])
self.assertIn("-m0=lzma2", command)
self.assertIn("-mx=5", command)
self.assertIn("-mmt=on", command)
self.assertIn("-ms=off", command)
self.assertIn("-mhe=on", command)
self.assertIn("-p", command)
self.assertNotIn("archive secret", command)
self.assertEqual(kwargs["input_text"], "archive secret\n")
self.assertFalse(kwargs["check"])
self.assertTrue(os.path.isdir(kwargs["cwd"]))
finally:
if staged_temp is not None:
shutil.rmtree(staged_temp, ignore_errors=True)
@unittest.skipUnless(shutil.which("7z"), "7z is needed for the archive smoke test")
def test_real_archive_is_encrypted_and_non_solid(self):
with tempfile.TemporaryDirectory() as tmpdir:
source = os.path.join(tmpdir, "Finance")
archive_path = os.path.join(tmpdir, "Finance.7z")
os.mkdir(source)
with open(os.path.join(source, "report.txt"), "w", encoding="utf-8") as handle:
handle.write("classified")
backup.create_group_archive(source, archive_path, "correct secret")
correct = subprocess.run(
[shutil.which("7z"), "l", "-slt", archive_path],
input="correct secret\n",
capture_output=True,
text=True,
check=False,
)
wrong = subprocess.run(
[shutil.which("7z"), "l", "-slt", archive_path],
input="wrong secret\n",
capture_output=True,
text=True,
check=False,
)
self.assertEqual(correct.returncode, 0, correct.stdout + correct.stderr)
self.assertIn("Solid = -", correct.stdout)
self.assertIn("Path = Finance/report.txt", correct.stdout)
self.assertNotEqual(wrong.returncode, 0)
class StateSnapshotTests(unittest.TestCase):
def test_online_snapshot_includes_wal_commits_and_other_state(self):
with tempfile.TemporaryDirectory() as tmpdir:
+25
View File
@@ -1,4 +1,5 @@
import base64
import json
import os
import sqlite3
import tempfile
@@ -30,6 +31,30 @@ def principal(dn, sam, classes, members=(), member_of=(), sid=""):
}
class ReconcileStatusTests(unittest.TestCase):
def test_status_tracks_web_trigger_progress_and_completion_in_utc(self):
with tempfile.TemporaryDirectory() as tmpdir:
status_path = os.path.join(tmpdir, "reconcile-status.json")
status = rs.ReconcileStatus(status_path)
status.begin("web")
status.progress(57.5, "data-permissions", "Syncing permissions", "FS_Finance")
status.complete("Reconciliation completed")
with open(status_path, encoding="utf-8") as handle:
payload = json.load(handle)
self.assertEqual(payload["state"], "completed")
self.assertEqual(payload["trigger"], "web")
self.assertEqual(payload["phase"], "completed")
self.assertEqual(payload["percent"], 100.0)
self.assertEqual(payload["message"], "Reconciliation completed")
self.assertIsNone(payload["currentItem"])
self.assertRegex(payload["startedAt"], r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
self.assertRegex(payload["finishedAt"], r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
self.assertFalse(os.path.exists(f"{status_path}.tmp"))
class GroupFolderNameTests(unittest.TestCase):
def test_sanitizer_preserves_leading_dot(self):
self.assertEqual(rs.sanitize_group_folder_name(".Finance"), ".Finance")
+196
View File
@@ -1,6 +1,8 @@
import datetime as dt
import os
import shutil
import sqlite3
import subprocess
import tempfile
import unittest
from unittest import mock
@@ -34,6 +36,167 @@ class TokenManagerTests(unittest.TestCase):
web_ui.TokenManager("short", 600)
class ReportPayloadTests(unittest.TestCase):
@mock.patch("app.web_ui.backup_payload")
def test_report_snapshot_excludes_all_log_data(self, backup_payload):
backup_payload.return_value = {"state": "completed"}
app = mock.Mock()
app.directory.get.return_value = {"groups": [], "fetchedAt": None}
app.usage.snapshot.return_value = {"groups": [], "users": [], "totals": {}}
app.system_summary.return_value = {
"hostname": "files.example.test",
"checks": {},
"tls": {},
"serverTime": "2026-08-01T12:00:00+00:00",
}
payload = web_ui.App.report(app)
backup_payload.assert_called_once_with(include_log=False)
self.assertNotIn("log", payload["backup"])
self.assertNotIn("audit", payload["system"])
self.assertNotIn("usage", payload["system"])
self.assertEqual(payload["system"]["hostname"], "files.example.test")
@mock.patch("app.web_ui.tail_lines")
@mock.patch("app.web_ui.read_json")
def test_log_free_backup_snapshot_does_not_read_log_file(self, read_json, tail_lines):
read_json.return_value = {
"state": "completed",
"log": ["GEHEIME SICHERUNGSAUSGABE"],
}
payload = web_ui.backup_payload(include_log=False)
self.assertNotIn("log", payload)
tail_lines.assert_not_called()
class AdministrationActionTests(unittest.TestCase):
def test_log_query_flag_defaults_on_and_accepts_explicit_off_values(self):
self.assertTrue(web_ui.query_includes_log({}))
self.assertTrue(web_ui.query_includes_log({"log": ["1"]}))
for value in ("0", "false", "NO", "off"):
self.assertFalse(web_ui.query_includes_log({"log": [value]}))
@mock.patch("app.web_ui.tail_lines", return_value=["backup log"])
@mock.patch("app.web_ui.read_json", return_value={})
def test_backup_payload_exposes_manual_mode_when_automatic_runs_are_off(
self, _read_json, _tail_lines
):
with mock.patch.dict(
os.environ,
{
"BACKUP_DESTINATION": "rsync://backup.example.test/target",
"BACKUP_AUTO_ENABLED": "false",
"BACKUP_START_HOUR": "4",
},
clear=True,
):
payload = web_ui.backup_payload()
self.assertTrue(payload["enabled"])
self.assertTrue(payload["manualEnabled"])
self.assertFalse(payload["automaticEnabled"])
self.assertEqual(payload["scheduledHour"], 4)
self.assertEqual(payload["state"], "waiting")
self.assertEqual(payload["log"], ["backup log"])
@mock.patch("app.web_ui.tail_lines", return_value=["reconcile log"])
@mock.patch("app.web_ui.read_json", return_value={})
def test_reconciliation_payload_has_progress_schedule_and_log(
self, _read_json, _tail_lines
):
payload = web_ui.reconciliation_payload()
self.assertEqual(payload["state"], "waiting")
self.assertEqual(payload["phase"], "waiting")
self.assertEqual(payload["percent"], 0.0)
self.assertTrue(payload["automaticEnabled"])
self.assertEqual(payload["scheduledIntervalMinutes"], 5)
self.assertEqual(payload["log"], ["reconcile log"])
@mock.patch("app.web_ui.tail_lines")
@mock.patch("app.web_ui.read_json", return_value={"state": "completed"})
def test_log_free_reconciliation_snapshot_does_not_read_log_file(
self, _read_json, tail_lines
):
payload = web_ui.reconciliation_payload(include_log=False)
self.assertNotIn("log", payload)
tail_lines.assert_not_called()
@mock.patch("app.web_ui.log")
@mock.patch("app.web_ui.launch_background")
@mock.patch("app.web_ui.lock_is_held", return_value=False)
def test_manual_backup_launches_with_web_trigger(
self, _lock_is_held, launch_background, _log
):
with mock.patch.dict(
os.environ,
{
"BACKUP_DESTINATION": "rsync://backup.example.test/target",
"BACKUP_ARCHIVE_PASSWORD": "archive secret",
},
clear=True,
):
result = web_ui.start_backup_action("EXAMPLE\\alice")
self.assertEqual(result, {"accepted": True, "action": "backup"})
launch_background.assert_called_once_with(
[web_ui.sys.executable, "/app/backup_to_destination.py"],
{"BACKUP_TRIGGER": "web"},
)
@mock.patch("app.web_ui.launch_background")
@mock.patch("app.web_ui.lock_is_held", return_value=False)
def test_manual_backup_requires_archive_password(
self, _lock_is_held, launch_background
):
with mock.patch.dict(
os.environ,
{"BACKUP_DESTINATION": "rsync://backup.example.test/target"},
clear=True,
):
with self.assertRaisesRegex(
web_ui.ActionConflict, "BACKUP_ARCHIVE_PASSWORD"
):
web_ui.start_backup_action("EXAMPLE\\alice")
launch_background.assert_not_called()
@mock.patch("app.web_ui.log")
@mock.patch("app.web_ui.launch_background")
@mock.patch("app.web_ui.lock_is_held", return_value=False)
def test_manual_reconciliation_launches_with_web_trigger(
self, _lock_is_held, launch_background, _log
):
result = web_ui.start_reconciliation_action("EXAMPLE\\alice")
self.assertEqual(result, {"accepted": True, "action": "reconciliation"})
launch_background.assert_called_once_with(
[web_ui.sys.executable, "/app/reconcile_shares.py"],
{"RECONCILE_TRIGGER": "web"},
)
class ReportCompilerTests(unittest.TestCase):
@unittest.skipUnless(shutil.which("node"), "Node.js is needed for the browser Typst smoke test")
def test_vendored_browser_typst_compiles_report_to_pdf(self):
root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
result = subprocess.run(
[shutil.which("node"), os.path.join(root, "tests", "report_pdf_smoke.mjs")],
cwd=root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn("Typst PDF smoke test passed", result.stdout)
class DomainAuthenticationTests(unittest.TestCase):
@mock.patch.dict(os.environ, {"WORKGROUP": "EXAMPLE"})
def test_bare_username_defaults_to_configured_netbios_domain(self):
@@ -462,6 +625,8 @@ class WebPresentationTests(unittest.TestCase):
self.assertNotIn("nav-group", html)
self.assertIn('>Datenbelegung</a>', html)
self.assertIn('>Benutzerbelegung</a>', html)
self.assertIn('href="/reconciliation" data-route="reconciliation">Freigabenabgleich</a>', html)
self.assertIn('href="/report" data-route="report">PDF-Bericht</a>', html)
self.assertNotIn("brand-mark", html)
self.assertNotIn("eyebrow", html + script)
self.assertIn("getUTCHours()", script)
@@ -482,6 +647,37 @@ class WebPresentationTests(unittest.TestCase):
self.assertIn(".shares-split .list", css)
self.assertIn(".shares-split .tree", css)
def test_admin_actions_are_german_and_backup_automatic_setting_is_env_only(self):
script = self.asset("app.js")
self.assertIn("Sicherung jetzt starten", script)
self.assertIn("Freigaben jetzt abgleichen", script)
self.assertIn('api("/api/actions/backup"', script)
self.assertIn('api("/api/actions/reconciliation"', script)
self.assertIn('includeLog ? "/api/reconciliation"', script)
self.assertIn('"/api/backup?log=0"', script)
self.assertIn('"/api/reconciliation?log=0"', script)
self.assertEqual(script.count("if (active || previousActive)"), 2)
self.assertNotIn("BACKUP_AUTO_ENABLED", script)
def test_pdf_report_is_client_side_and_uses_only_log_free_endpoint(self):
script = self.asset("app.js")
report = self.asset("report.mjs")
typst = self.asset(os.path.join("vendor", "typst", "typst.mjs"))
self.assertIn('api("/api/report")', script)
self.assertIn('import("/assets/report.mjs")', script)
self.assertNotIn('api("/api/activity', report)
self.assertNotIn('api("/api/backup', report)
self.assertIn('compiler.pdf({mainContent:', report)
self.assertIn('/assets/vendor/typst/0.6.0-csp1/compiler.wasm', report)
self.assertIn("getUTCHours()", report)
self.assertIn("above: 1.8pt, below: 1.8pt", report)
self.assertIn('["Automatik", backup.automaticEnabled ? "Aktiv" : "Ausgeschaltet"]', report)
self.assertIn("Aktivitätsprotokoll und Sicherungsprotokoll sind nicht Bestandteil", report)
self.assertNotIn("new Function", typst)
self.assertIn("createCspSafeFunction", typst)
def test_samba_audits_only_supported_file_operations(self):
path = os.path.join(os.path.dirname(__file__), "..", "etc", "samba", "smb.conf")
with open(path, encoding="utf-8") as handle: