diff --git a/test/deployment/test_frontend_review.py b/test/deployment/test_frontend_review.py new file mode 100644 index 0000000..0edf9eb --- /dev/null +++ b/test/deployment/test_frontend_review.py @@ -0,0 +1,59 @@ +"""验证前端审查依赖的只读挂载范围,不启动本机应用。""" + +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch + + +source = Path(__file__).resolve().parents[1] / 'tools' / 'run_frontend_review.py' +spec = importlib.util.spec_from_file_location('frontend_review', source) +review = importlib.util.module_from_spec(spec) +spec.loader.exec_module(review) + + +class DependencyBindingsTest(unittest.TestCase): + root = Path('/var/tmp/smail-review-current') + + def bindings(self, targets): + with patch.object(Path, 'resolve', side_effect=map(Path, targets)): + with patch.object(Path, 'is_dir', return_value=True): + return review.dependency_bindings(self.root) + + def test_local_dependencies_need_no_extra_mount(self): + self.assertEqual(self.bindings([ + str(self.root / 'node-v22.23.2-linux-x64'), + str(self.root / 'frontend/node_modules'), + ]), []) + + def test_shared_dependencies_are_explicit(self): + targets = ['/var/tmp/smail-review-previous/node', + '/var/tmp/smail-review-previous/frontend/node_modules'] + self.assertEqual(self.bindings(targets), sorted(targets)) + + def test_duplicate_mounts_are_combined(self): + target = '/var/tmp/smail-review-previous/dependencies' + self.assertEqual(self.bindings([target, target]), [target]) + + def test_unsafe_targets_are_rejected(self): + for target in ('/opt/smail', '/run/smail', '/var/tmp/unrelated/node', + '/var/tmp/smail-review-previous', + '/var/tmp/smail-review-previous/node:other', + '/var/tmp/smail-review-previous/node other'): + with self.subTest(target=target), self.assertRaises(ValueError): + self.bindings([target]) + + def test_missing_dependency_fails_before_start(self): + with patch.object(Path, 'resolve', side_effect=FileNotFoundError): + with self.assertRaises(FileNotFoundError): + review.dependency_bindings(self.root) + + def test_file_dependency_is_rejected(self): + with patch.object(Path, 'resolve', return_value=self.root / 'node'): + with patch.object(Path, 'is_dir', return_value=False): + with self.assertRaises(ValueError): + review.dependency_bindings(self.root) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index cad4daa..6307718 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -15,6 +15,7 @@ const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } = test('公网 HTTPS 端口独立编辑且保留内部监听与模式切换', () => { const config = { + database: { type: 'sqlite' }, domains: { primary: 'example.com', web: 'mail.example.com', accepted: ['example.com'] }, http: { port: 8001, httpsPort: 8002, httpsEnabled: true, tlsMode: 'direct' } } diff --git a/test/frontend/output-paths.test.mjs b/test/frontend/output-paths.test.mjs index a915c00..2d6a798 100644 --- a/test/frontend/output-paths.test.mjs +++ b/test/frontend/output-paths.test.mjs @@ -96,7 +96,7 @@ test('六个平台默认包含三个插件,构建和发布名称与后端目 assert.match(await read('Makefile'), /package: build_server plugin/) assert.match(await read('.github/workflows/release.yml'), /node deploy\/build-plugins\.mjs --target=/) assert.match(await read('deploy/package.mjs'), /sha256: createHash\('sha256'\)/) - assert.match(await read('deploy/package.mjs'), /files,\n createdAt/) + assert.match(await read('deploy/package.mjs'), /files,\r?\n createdAt/) }) test('安全工作流必须先构建嵌入资源再执行 Go 测试', async () => { diff --git a/test/tools/run_frontend_review.py b/test/tools/run_frontend_review.py index 67071b9..99198ec 100644 --- a/test/tools/run_frontend_review.py +++ b/test/tools/run_frontend_review.py @@ -10,6 +10,20 @@ import subprocess import time +def dependency_bindings(root): + bindings = set() + for relative in ('node-v22.23.2-linux-x64', 'frontend/node_modules'): + target = (root / relative).resolve(strict=True) + if (len(target.parts) < 5 or target.parts[1:3] != ('var', 'tmp') + or not target.parts[3].startswith('smail-review-') + or any(not re.fullmatch(r'[A-Za-z0-9_.-]+', part) for part in target.parts[3:]) + or not target.is_dir()): + raise ValueError('测试依赖必须位于受控审查目录') + if not target.is_relative_to(root): + bindings.add(str(target)) + return sorted(bindings) + + def main(): parser = argparse.ArgumentParser() parser.add_argument('directory') @@ -21,6 +35,8 @@ def main(): assert os.geteuid() == 0 memory = dict(line.split(':', 1) for line in Path('/proc/meminfo').read_text().splitlines()) assert int(memory['MemAvailable'].split()[0]) >= 256 * 1024, '可用内存不足' + # PrivateTmp 会隐藏其他审查目录,只读挂载已验证的依赖,不开放整个临时目录。 + bindings = dependency_bindings(root) command = [ 'systemd-run', '--wait', '--pipe', '--unit=smail-frontend-tests-' + time.strftime('%Y%m%d-%H%M%S'), '-p', 'User=smail', '-p', 'Group=smail', '-p', 'MemoryMax=128M', '-p', 'MemorySwapMax=0', @@ -29,6 +45,7 @@ def main(): '-p', 'PrivateTmp=yes', '-p', 'PrivateNetwork=yes', '-p', 'BindPaths=' + str(root), '-p', 'ProtectSystem=strict', '-p', 'ProtectHome=yes', '-p', 'InaccessiblePaths=/opt/smail /run/smail', '-p', 'NoNewPrivileges=yes', '-p', 'ReadWritePaths=' + str(root), '-p', 'WorkingDirectory=' + str(root), + *[argument for target in bindings for argument in ('-p', 'BindReadOnlyPaths=' + target)], 'python3', str(root / 'run_frontend_review.py'), str(root), '--worker', ] return subprocess.run(command).returncode