compressed backups; more webui features
This commit is contained in:
@@ -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)");
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user